资讯详情

资讯详情

Java面试深度解析:从集合源码到JVM调优的十大核心考点

这一两年我面了不少候选人也被别人面过无数轮发现真正能拉开差距的从来不是你背了多少面试题而是你能不能把一个知识点讲清楚、讲出背后的设计逻辑和取舍方案。网上流传的Java面试题动辄几百道但大量都是流水账式的背诵材料背完了换一个问法就懵。所以我把这十年来反复出现、真正能考察出水平的Java面试问题挑出来做一份深度解析。这些问题覆盖了集合源码、JVM内存与GC、并发编程、Spring框架、数据一致性、手撕算法、系统设计等核心方向既有基础原理也有高频追问和实际踩坑记录。不管是准备校招还是社招不管面的是中小厂还是大厂这份内容都值得认真过一遍。我写这篇的目的很直接让你看完不仅能答上问题还能理解面试官为什么这么问并且拿到可以直接复用的回答思路和代码模板。这些内容不是背给我听的是讲给你自己听的。1. 十大经典题型全景与考查意图拆解先把十大问题列出来。这些题目是我在大量面试记录里统计出来的高频题它们基本覆盖了Java工程师面试的绝大部分核心考点。序号经典问题核心考点高频追问方向1String、Integer等基础类型的底层原理常量池、不可变性、缓存机制new String创建几个对象、Integer比较2HashMap底层实现与put流程哈希算法、红黑树、扩容为什么8转红黑树、并发问题3ArrayList扩容机制与Fail-Fast动态数组、modCount扩容因子为什么是1.5、和LinkedList怎么选4JVM内存区域与对象创建过程运行时数据区、对象头哪些区域线程私有、OOM发生在哪5GC Roots与垃圾回收算法可达性分析、分代回收什么时候触发Full GC、如何调优6双亲委派模型与类加载类加载过程、打破双亲委派能不能自己写java.lang.String7volatile与并发编程三大特性可见性、有序性、原子性DCL为什么需要volatile8synchronized锁升级与锁对比偏向锁、轻量级锁、重量级锁和ReentrantLock怎么选9线程池七大参数与任务执行流程核心线程数、队列、拒绝策略核心线程数怎么算、为什么禁用Executors10手撕代码单例、排序、链表反转编码能力与边界思维快排优化、DCL单例、递归反转说实话这十大问题背后其实就三个考察维度。第一是基础功底看你有没有真正读过源码理解底层的设计意图第二是实战经验看你有没有在真实项目里踩过坑、做过取舍第三是表达逻辑能不能把复杂的东西用简单的语言讲清楚。这三点才是面试官真正想判断的。我观察到很多候选人容易陷入一个误区只背结论不追原理。比如问HashMap知道“数组加链表加红黑树”但问为什么加载因子是0.75为什么链表长度到8才转红黑树就答不上来了。这种回答方式在初级岗位还能勉强过关到中高级岗位基本是送命题。所以后面的解析我不会只给标准答案我会把每一个问题背后的设计思想、数学考量、源码佐证和常见追问全部摊开讲。你在准备时也建议按这个思路来先理解再到源码里验证最后自己复述一遍直到能流畅讲出来为止。2. 基础类型与字符串高频基础题的底层原理深度解析2.1 String的不可变性到底保证了什么String可以说是Java里被问得最多的类没有之一。常见问法是“String为什么是final的”“new String(abc)创建了几个对象”“String str abc和new String(abc)有什么区别”先说不可变性。String类被final修饰内部用private final char value[]存储字符所有修改操作substring、concat、replace都是返回新对象。这一点带来的最大好处是安全。字符串经常用作哈希表的key、网络连接的地址、类加载的类名如果它可变HashMap的key哈希值变了会导致查找失效类加载路径被篡改会直接威胁JVM安全。所以immutable不是设计上的偶然是必须如此。然后是创建对象的数量问题。String str abc时如果常量池里已经有“abc”那直接返回常量池引用不创建新对象如果没有就在常量池创建。而new String(abc)一定会先在堆上创建一个String对象但构造入参的“abc”字面量会触发常量池的创建或复用。所以如果常量池原本没有“abc”new String(abc)会创建两个对象一个在常量池一个在堆里。如果常量池已有则只创建一个堆对象。这个问题看似简单其实是考察对JVM字符串常量池机制的理解。还有一个必考的点是intern方法。调用intern时如果常量池有相同内容的字符串就返回常量池引用否则把当前字符串内容放到常量池并返回引用。有个经典面试题String s1 new String(a) new String(b); s1.intern(); String s2 ab; 问s1 s2的结果。这个在JDK 7以后返回true因为在JDK 7以后intern会把堆中首次出现的字符串对象引用直接注册到常量池而不是复制一份。这个细节能区分出候选人是不是真的研究过版本差异。我建议大家回答这类问题时把“常量池存储位置”和“JDK版本差异”一起讲出来这比干巴巴说“创建两个对象”要有说服力得多。面试官想听的不是一个背下来的答案而是你确实知道JVM底层是怎么处理字符串的。2.2 Integer缓存陷阱与自动装箱拆箱再说一个极其容易翻车的基础题Integer的比较。直接看代码Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false Integer e new Integer(127); Integer f new Integer(127); System.out.println(e f); // false原因在于Integer内部有一个缓存池默认缓存-128到127之间的Integer对象。用Integer a 127这种方式赋值时编译器会自动装箱调用Integer.valueOf(127)这个方法会直接返回缓存池里的对象所以a和b是同一个引用。而128超出了默认缓存范围valueOf会new一个新对象所以c和d是两个不同对象。new Integer则完全不走缓存池必然不相等。追问往往在这为什么缓存范围是-128到127因为这个范围的整数值使用频率最高JVM规范也建议缓存低频小整数而127刚好是8位有符号二进制的最大值2^7-1选这个值兼顾了内存开销和命中率。而且这个上限可以通过JVM参数-XX:AutoBoxCacheMax调整只是几乎没人动它。做比较的正确方式永远是用equals。这里有个从源码层面看都很微妙的坑Integer的equals确实比较的是int值但如果你写的代码里一边是Long一边是Integerequals直接返回false因为类型不同。另外如果数值超过int范围比如比较两个Long直接用判断的是引用而不是数值极其容易踩雷。所以我一直强调包装类型比较一律用equals不要用这也是阿里巴巴Java开发手册里的强制规定。Integer还有一个很值得说的考点是valueOf的源码实现它会先判断数值是否在缓存区间如果在这个区间就直接返回IntegerCache.cache数组里的对象否则new一个新对象。这个设计本质上是“享元模式”在JDK源码里的经典应用。你在回答时能把设计模式点出来面试官会觉得你有从代码抽象到设计思想的意识而不是只会背题。3. 集合框架核心考点HashMap与ArrayList的实战源码解析3.1 HashMap的put流程、哈希扰动与扩容机制HashMap是Java面试中的绝对C位几乎每一轮技术面都会问到。我见过最差的回答是只背出“数组加链表”最好的回答是能把整个put流程、哈希扰动、树化阈值、扩容时机全部讲清楚并且每一个点都解释得明明白白。先看put的大流程。put时先对key做hash运算这里的hash不是直接用key.hashCode()而是用扰动函数将高位16位与低位16位做异或即h key.hashCode() ^ (key.hashCode() 16)。这么做的理由是数组桶的下标是通过(n - 1) hash计算出来的当数组长度n较小时实际参与运算的只有hash的低位高位信息丢失容易产生碰撞。把高16位扰动到低16位就能让散列分布更均匀。这个设计在数据量不大时效果尤为明显。为什么要用运算而不是取模因为HashMap的桶数设计为2的幂次方n-1的二进制就是全1与hash做按位与时结果刚好等价于hash % n但位运算比取模快得多。同时这也是为什么HashMap要求容量必须是2的幂否则(n - 1) hash无法均匀分布。接着说链表转红黑树。为什么阈值是8而不是7或9源码注释给了一个泊松分布的参考在负载因子0.75、随机哈希的理想情况下链表长度达到8的概率已经是非常小的千万分之六左右。所以8是经验值和概率统计的折中既要避免链表过长影响查询性能又不能频繁转换红黑树增加维护成本。而红黑树节点是链表节点的两倍大小所以只有在链表足够长时才值得用空间换时间。至于为什么还要有个6的退化阈值这是为了防止树和链表在阈值边界频繁互转6和8之间留了缓冲区间。扩容是另一个大考点。HashMap默认初始容量16负载因子0.75意思是元素个数超过1216*0.75时触发扩容每次扩容成原来的两倍。扩容不是简单地把数组搬过去而是要重新计算每个元素在新数组中的位置。这里有个经典结论因为数组扩容是翻倍新数组长度n-1的二进制比原来多了一个1所以元素在新桶的位置要么在原位置要么是“原位置旧容量”。这个结论在JDK 7中使用头插法实现时很麻烦JDK 8改用尾插法并且直接用e.hash oldCap判断高位是否为0为0留原位置为1则移动oldCap位效率很高。讲HashMap必须提并发问题。JDK 7及以前多线程并发put时可能触发扩容扩容采用头插法在链表元素迁移时会产生环形链表下次get时陷入死循环。JDK 8改成尾插法后死循环问题得到解决但数据丢失、脏读、size统计错误这些并发安全问题依然存在。所以并发场景不要用HashMap要么用ConcurrentHashMap要么用Collections.synchronizedMap做兜底。3.2 ArrayList扩容、Fail-Fast与LinkedList的选择困境ArrayList的考点相对简单但也很容易翻车。底层是Object数组默认容量10当add时发现容量不够会扩容成原来的1.5倍即newCapacity oldCapacity (oldCapacity 1)然后调用Arrays.copyOf把旧数组内容复制到新数组。ArrayList的扩容是有代价的因为涉及数组复制所以如果能预估数据量最好初始化时直接指定容量new ArrayList(1000)避免频繁扩容。Fail-Fast机制也是必问点。ArrayList的modCount字段记录结构性修改次数迭代器在迭代时会检查modCount是否和初始值一致不一致就抛出ConcurrentModificationException。注意不是所有修改都会触发只有结构性修改比如add、remove、clear而set不会。这个设计本质是一种快速失败的防御机制它的目的是让错误尽早暴露而不是保证并发安全。换句话说Fail-Fast不是解决并发问题的方案只是fail-fast detection。关于ArrayList和LinkedList怎么选这是老生常谈的送分题。ArrayList基于数组随机访问快O(1)但中间插入删除要移动元素O(n)LinkedList基于双向链表头部插入删除O(1)但随机访问要遍历O(n)。实际开发中绝大多数场景选ArrayList就够了因为对CPU缓存友好内存连续迭代性能好。LinkedList反而因为每个节点还要维护前后指针内存占用更大。面试官问这个题更多是想看你是真的理解数据结构还是背概念所以最好结合具体业务场景来讲比如“我做过一个日志采集模块数据是尾部追加为主随机读为辅所以选ArrayList”。3.3 ConcurrentHashMap的锁粒度演进讲完HashMap和ArrayList我建议把ConcurrentHashMap一起准备。现在面试官特别喜欢在HashMap后面紧接着问“如果让你设计一个线程安全的HashMap你怎么做”这就是考察有没有真正理解锁的粒度。JDK 7的ConcurrentHashMap用Segment分段锁默认16段每段是一个独立的小HashMap相当于把锁粒度细化到Segment级别不同Segment之间互不干扰。JDK 8干脆废除了Segment直接用CAS加synchronized锁Node节点锁粒度细化到单个桶。put时先通过CAS尝试插入空桶如果失败说明有竞争就对桶头节点加synchronized。这个设计把并发度提升到了一个很高的水平同时synchronized在JDK 6以后做了锁升级优化性能其实不差。扩容是ConcurrentHashMap最复杂的部分JDK 8引入了多线程协助扩容机制扩容时把任务拆分成多个transfer任务其他线程发现正在扩容可以加入帮忙迁移数据每个线程负责一段索引区间全部完成后才把table引用切换到新数组。这个机制在面试里属于加分项能讲出来基本就能证明你真的读过并发源码。4. JVM与内存面试中最容易翻车的追问点4.1 运行时数据区与对象创建全过程JVM内存区域的题目几乎必考。如果连堆、虚拟机栈、方法区、程序计数器、本地方法栈都分不清楚第一轮基本就挂了。但光背名字不行得深入一层。先理清楚线程私有的区域有三个程序计数器、Java虚拟机栈、本地方法栈。程序计数器记录当前线程执行的字节码行号用于分支、循环、跳转、异常恢复和线程切换后恢复执行位置不会OOM。虚拟机栈是线程私有的每个方法调用创建一个栈帧栈帧里包含局部变量表、操作数栈、动态链接、方法出口。栈深度超过JVM允许的值就报StackOverflowError比如无限递归。本地方法栈是为Native方法服务的。线程共享的区域是堆和方法区。堆是对象分配的主要区域也是GC的主要战场分新生代和老年代。方法区在JDK 8之后被元空间取代元空间使用本地内存存类元信息、常量、静态变量等默认大小只受本地内存限制这解决了旧版永久代容易OOM的问题。对象创建的过程是另一个高频追问。来捋一遍首先类加载检查看类是否已被加载、解析、初始化然后为对象分配内存分配方式有指针碰撞和空闲列表两种取决于堆是否规整接着初始化零值把对象的字段都设成默认值然后设置对象头包含Mark Word、类指针、GC分代年龄等信息最后执行构造方法。这里有个很值得说的细节Java栈上分配是HotSpot的一个优化手段如果对象满足逃逸分析条件且足够小会在栈上分配而不进堆这样对象随方法结束自动销毁减少GC压力。但这个优化在多数面试场景里是被忽略的你能提出来会显得更有深度。4.2 如何判断对象可回收与GC Roots判断对象是否可回收的主流算法是可达性分析不是引用计数。引用计数有个比较经典的缺陷循环引用。A引用BB也引用A两者引用计数都不是0但它们已经不可能再被外部访问到引用计数法无法回收它们。而可达性分析从GC Roots出发沿着引用链遍历凡是不可达的对象都判定为可回收。GC Roots包括哪些虚拟机栈中引用的对象、本地方法栈中JNI引用的对象、方法区中类的静态属性引用的对象、方法区中常量引用的对象、Java虚拟机内部的引用比如基本数据类型对应的Class对象、常驻的异常对象、系统类加载器还有被synchronized持有的对象。面试时要能把这几类答全最好再加一句“JIT编译器在局部变量被复用后会影响GC Roots的判定”这能体现你真的研究过。怎么判断GC Roots本身也不会被回收答案是GC Roots是由虚拟机本身维护的和Java程序员的对象图没有关系。这块理解清楚后回答什么对象不能被回收这类问题就会自然很多。4.3 垃圾回收算法与常见收集器实战选型垃圾回收算法从标记-清除、标记-复制、标记-整理一路演变过来。标记-清除有碎片问题标记-复制适合存活率低的新生代标记-整理适合存活率高的老年代。新生代用复制算法划分成Eden区和两个Survivor区比例默认8:1:1每次Minor GC把Eden和一个Survivor的存活对象复制到另一个Survivor然后清空这两块。如果Survivor装不下就通过担保机制直接把对象晋升到老年代。常见的垃圾收集器要能讲清楚。Serial是单线程适合客户端和小内存应用。Parallel Scavenge更注重吞吐量适合后台运算。CMS以最短停顿为目标采用标记-清除会产生内存碎片JDK 9后被标记为废弃。G1是当前主流把堆划分成Region通过维护一个优先列表来回收垃圾最多的Region可以指定预期的停顿时间。ZGC则用染色指针和读屏障实现了几乎不影响业务的超低停顿JDK 15以后慢慢成为新的主力JDK 21的虚拟线程配合ZGC已经是服务端标配了。面试官问“什么情况触发Full GC”时常见的答案有老年代空间不足、元空间不足、分配担保失败、System.gc等。但很多人不知道的是CMS垃圾收集器在并发标记阶段如果老年代存量达到某个阈值也会触发一次FullGC来做碎片整理这属于CMS特有的行为。另外要注意Stop The World是所有收集器都逃不掉的只是停顿时间长短的区别。最后说一下调优的实用思路。生产环境我一般先通过-XX:PrintGCDetails -XX:PrintGCDateStamps把GC日志收集出来再结合GCViewer或在线工具观察GC频率、停顿时间和内存变化。调优不是一上来就调参数而是根据观测结果推测当前瓶颈在堆大小、分配速率还是回收算法。如果是对象频繁进入老年代要看Survivor空间是不是太小、晋升阈值是不是太高。如果是FGC频繁先检查是否存在内存泄漏或者大对象分配。我见过很多人一上来就把Xmx调到64G以为万事大吉结果FGC一次几十秒整个应用直接雪崩这就是典型的只看参数不看业务特征。5. 并发编程volatile、synchronized与线程池的高频追问5.1 volatile的可见性与有序性以及它不保证原子性volatile是并发面试的常客基本问法是“volatile能保证什么不能保证什么为什么”volatile能保证可见性和有序性。可见性是通过缓存一致性协议实现的具体是MESI协议。当volatile变量被修改时会强制把新值写回主内存并让其他CPU核心的缓存行失效。有序性是通过内存屏障实现的JMM规定volatile写之前的普通读写不能重排序到写之后volatile读之后的普通读写不能重排序到读之前。但volatile不保证原子性。经典的例子是i它包含读取、加一、写回三步volatile只保证每次读取都能拿到最新值但三个操作之间可能会被其他线程穿插。所以有人用volatile修饰计数器做并发统计时结果总是不对这不是因为volatile失效了而是因为它根本不解决复合操作的原子性问题。要保证原子性就需要synchronized、Lock或者AtomicInteger这类原子类。还有一个高频追问场景是双重检查锁单例DCLpublic class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里的volatile非常关键。new Singleton()在字节码层面不是一个原子操作它包含分配内存、初始化对象、把引用指向内存三个步骤JIT编译器可能把后两步重排序。如果一个线程在引用地址已经赋值但对象还没初始化完成时去读就会拿到一个“半初始化”的对象直接使用就会出问题。volatile禁止了new操作中写操作与后续写操作之间的重排序确保引用赋值一定在对象初始化完成之后。这个知识点很多工作三五年的开发都讲不透能讲清楚基本就能证明并发功底。5.2 synchronized锁升级过程与锁对比synchronized的锁升级是这几年的高频考点。锁升级路径是无锁状态在经过偏向锁、轻量级锁、重量级锁后最终停在重量级锁而且锁只能升级不能降级。先讲清楚几个状态。偏向锁是指不存在多线程竞争时锁会偏向第一个获取它的线程之后该线程再次获取锁不需要任何CAS操作。如果出现另一个线程竞争偏向锁会被撤销升级为轻量级锁。轻量级锁通过CAS自旋尝试获取锁适合锁持有时间短、竞争不激烈的场景因为自旋会占用CPU如果自旋次数过多反而不划算。重量级锁是基于操作系统的互斥量实现的线程会进入阻塞状态涉及用户态和内核态的切换开销最大但适合锁持有时间长、竞争激烈的场景。为什么JDK 15以后默认禁用了偏向锁因为这个优化在某些场景下不仅没有优势反而带来额外的撤销开销尤其是大量使用并发集合和锁的应用中偏向锁的撤销STW问题反而拖累性能。它本身的设计目标是优化只有一个线程访问同步块的场景但现代应用的并发度远超当年收益已不明显。这个演进过程去了解背后的原因比死记锁状态要有意义得多。synchronized和ReentrantLock的区别也比较常问。synchronized是JVM层面的关键字自动加锁解锁支持锁升级ReentrantLock是JUC提供的高级锁必须手动lock和unlock通常配合try-finally使用。ReentrantLock的优势是支持可中断的等待锁、公平锁、多个条件变量、超时获取锁。如果面试官问“你平时用哪个”我会说单机场景优先synchronized因为写法简单、不易出错需要公平锁或超时控制时用ReentrantLock。这体现出你了解工具边界而不是盲目选型。5.3 线程池七大参数与核心线程数计算线程池的经典问法是“线程池有哪些参数核心线程数怎么定为什么阿里规范强制不让你用Executors”先背参数corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime非核心线程的存活时间、unit时间单位、workQueue任务队列、threadFactory线程工厂、handler拒绝策略。执行流程是当提交任务时如果当前线程数小于核心线程数就新建线程执行否则放入队列如果队列满了且线程数小于最大线程数就新建非核心线程如果队列满了且线程数已经到达最大线程数就走拒绝策略。这个流程面试时最好画成流程图但要说得清楚每个判断的先后顺序。核心线程数怎么定这是个典型的“没有标准答案”的问题。CPU密集型任务核心线程数一般设置为CPU核数1或CPU核数因为这种任务几乎不等待线程多了反而增加上下文切换。IO密集型任务因为线程经常在等待IO可以设置成CPU核数的两倍或更多常见公式是CPU核数/(1-阻塞系数)其中阻塞系数是0.8到0.9。但这不是死的生产环境更靠谱的做法是先按经验值设置再通过压测动态调整。我之前做过一个文件解析服务先是按公式算了个值压测后发现线程数翻倍时吞吐量反而下降原因是出现了锁竞争和内存占用过高后来调到约CPU核数*1.5才算稳定。拒绝策略有四种AbortPolicy直接抛异常CallerRunsPolicy由提交任务的线程自己执行DiscardPolicy静默丢弃DiscardOldestPolicy丢弃队列里最旧的任务。实际开发中为了保证数据不丢我一般自定义拒绝策略把任务持久化到本地数据库等系统恢复后再重放。另外线程池大小、队列长度和拒绝策略必须结合业务一起设计不能机械地套模板。比如秒杀场景要快速拒绝超出预期的请求日志异步写入场景则倾向于无限队列加满时丢弃因为日志丢了总比系统崩了强。还必须提醒一个非常经典的坑阿里巴巴开发手册明确禁止用Executors创建线程池因为newFixedThreadPool和newSingleThreadExecutor用的是无界LinkedBlockingQueuenewCachedThreadPool的最大线程数是Integer.MAX_VALUE。前者可能导致任务无限堆积导致OOM后者会无限创建线程导致线程数量爆炸和OOM。我在实际项目中见过有人用Executors.newFixedThreadPool处理大量消息某次下游服务变慢后队列里堆积了几百万个任务直接把内存压爆了。所以线程池必须通过ThreadPoolExecutor手动创建队列要设置容量上限拒绝策略要自己定。6. Spring与MyBatis-Plus框架面试题里的高频陷阱6.1 Spring循环依赖为什么用三级缓存解决Spring的循环依赖问题几乎年年必考。所谓循环依赖就是A依赖BB依赖A。Spring通过三级缓存来解决三个缓存分别是一级缓存singletonObjects存放完整的单例Bean二级缓存earlySingletonObjects存放提前暴露的早期Bean三级缓存singletonFactories存放Bean的对象工厂。核心流程是A创建时发现需要注入B就去创建BB创建时需要注入A此时从三级缓存中找到A的工厂通过提前暴露的ObjectFactory获取A的早期引用放到二级缓存B完成注入B创建完成后A再拿到B完成自己的注入。整个过程的关键在于Bean在实例化后、属性填充前会先把一个ObjectFactory放入三级缓存这样别的Bean可以在它还没完成初始化的时候就拿到它的引用。为什么需要三级缓存而不是二级这个问题特别经典。二级缓存只保存早期引用也能解决循环依赖但三级缓存存在的真正原因是Spring要支持AOP代理。如果一个Bean需要被代理那么它在早期暴露时就必须是代理对象否则后面再替换成代理别的Bean持有的引用就还是原始对象切面逻辑就失效了。所以三级缓存存的不是对象而是ObjectFactory在工厂里调用SmartInstantiationAwareBeanPostProcessor来决策是否要提前生成代理。换句话说三级缓存是给Bean的后置处理器留了一个介入的窗口。如果面试官问你“构造器注入为什么解决不了循环依赖”答案是构造器注入在实例化阶段就要求依赖到位但此时Bean还没放入三级缓存没有提前暴露的途径所以必然失败。而Setter注入和字段注入是在实例化之后才填充属性可以依赖三级缓存完成。6.2 事务失效的若干场景事务失效的题比事务原理出现频率更高因为坑太多了。最常见的失效场景是自调用。比如一个Service类里方法A调用了本类的另一个方法BB上有Transactional但A调用B走的是this.B()这相当于绕过Spring代理直接调用目标对象的方法事务注解完全不生效。解决方式有三种把B拆到另一个Service里让代理生效注入ApplicationContext然后通过getBean拿代理对象调用或者用AopContext.currentProxy。但最干净的做法还是拆分自调用本身就是设计上的坏味道。还有一种场景是异常被吞了。Transactional默认只在RuntimeException和Error时回滚检查异常不会触发回滚。如果方法里catch了异常没有抛出事务照样不会回滚。我见过一个做对账的程序业务代码catch住异常后return了一个失败结果结果异常数据被写进了库账怎么都对不上。正确做法要么让异常抛出去要么在catch里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。另外还可以通过rollbackFor Exception.class来指定任何异常都回滚但这会牺牲对检查异常的容忍度需要平衡。其他容易失效的坑包括方法被final修饰导致CGLIB无法生成代理类没有被Spring管理也就是没有加Service等注解数据库表引擎不支持事务比如MyISAM就没有事务多个线程各自开启新连接事务不在同一个线程的同一个连接上。如果你能在回答时把这些场景串起来归纳成“代理不生效、异常没抛、传播行为配置错、底层不支持”四类面试官会觉得你有体系化的总结能力。6.3 MyBatis-Plus的实体类生成SQL与逻辑删除陷阱MyBatis-Plus是近年来的热门框架尤其在国内企业项目里几乎成了标配。面试官问MP一般不是问基础CRUD而是问自动填充、逻辑删除、乐观锁、分页插件这些细节。先说说“根据Java实体类生成创建表的SQL语句”这个需求。MP本身不提供实体类反向生成建表语句的功能但市面上有几套方案一是用MyBatis-Plus Generator逆向生成的表结构格式化文档二是用flyway-db支持从实体类自动生成迁移脚本三是直接在测试环境用SchemaTool或自定义注解读取实体字段和注解生成DDL。我实际操作的时候比较推荐用Spring Boot的Schema初始化在配置里加上spring.sql.init.schema-locations配合一个自定义的EntityScanner在启动时扫描带有TableName注解的实体类反射解析字段类型和约束拼接CREATE TABLE语句。字段映射规则有个坑Java的Long对应bigintString要看长度决定varchar还是textLocalDateTime对应datetimeBigDecimal对应decimal枚举类对应varchar。精度映射必须和业务约定对齐否则同一张表在不同环境生成的DDL会不一致部署时就炸了。再说逻辑删除这个是MP使用中最容易出问题的地方。逻辑删除的核心是update时自动把deleted字段置为1查询时自动带上deleted 0条件。听起来很方便但有两个经典坑。第一个坑是唯一索引冲突比如用户表用手机号做唯一索引逻辑删除后再次注册同一个手机号因为旧数据还在表里唯一索引直接冲突。解决方式是把deleted字段和业务唯一字段做联合唯一索引或者用删除时间代替布尔删除标识。第二个坑是关联查询时如果忘记在SQL里加deleted条件会被逻辑删除的数据关联出来。MP的自动逻辑删除只对MP提供的方法生效自己写的XML SQL不会自动追加条件这一点必须牢记。还有数据权限的问题这也是我遇到过的场景。MP提供了一套数据权限插件Interceptor可以拦截SQL并在解析阶段注入权限条件比如“只能看到本部门的数据”就是在原SQL上追加AND dept_id ?。行级权限的实现原理是解析SQL的Select对象在where条件后追加权限片段核心是把权限规则抽象成可配置的表达式否则每个接口手写条件会累死人。面试时可以提一下这套方案的思路因为现在很多系统都有数据权限需求。7. 手撕代码题排序、单例与链表反转的高分写法7.1 冒泡排序到快速排序的进阶路线手撕排序几乎是中国式技术面试的保留节目。冒泡排序、快速排序、归并排序是最常出现的三类。我先说冒泡因为热搜词里也频繁出现而且别以为冒泡简单就不准备很多候选人写着写着就漏了优化。最基础的冒泡是这样public static void bubbleSort(int[] arr) { for (int i 0; i arr.length - 1; i) { for (int j 0; j arr.length - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } }但如果面试官让你优化你需要能说出“如果某一轮冒泡没有发生交换说明数组已经有序可以提前终止”。引入一个flag变量内层循环发生交换才置位每一轮结束后检查flag没有置位就break。这是从时间复杂度O(n^2)到最好情况O(n)的优化。快速排序是手撕题里的常客标准写法采用分治和双指针public static void quickSort(int[] arr, int left, int right) { if (left right) return; int base arr[left]; int i left, j right; while (i j) { while (i j arr[j] base) j--; if (i j) { arr[i] arr[j]; i; } while (i j arr[i] base) i; if (i j) { arr[j] arr[i]; j--; } } arr[i] base; quickSort(arr, left, i - 1); quickSort(arr, i 1, right); }这段代码的核心是挖坑填数加分治。每次选定一个基准值base这段代码取最左从右往左找比base小的数填入左边的坑从左往右找比base大的数填入右边的坑最后把base归位。面试时要能解释为什么最后两个while里都加了i j防止越界。如果面试官追问快速排序的最坏情况就是每次基准值都选到最大或最小值退化为O(n^2)所以有随机化快排和三数取中的优化。蓝桥杯这类竞赛场景里排序题通常还会考到归并排序的稳定性和逆序对统计。归并排序的merge过程天然能统计逆序对因为右边数组的数插入时左边剩余元素数量就是跨越这两个数组的逆序对数量。这个点属于竞赛选手的加分项社招里一般不会问这么深但如果写了归并排序能顺便提一句稳定性优势印象分会提高不少。7.2 DCL单例、静态内部类与枚举单例单例模式手撕题考察的是并发安全和写法规范我见过不少人写出来的代码在并发场景下是有问题的。最稳妥的答案是DCL加volatile前文已经分析过volatile的作用。静态内部类方式是另一种推荐写法public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }它的原理是类加载机制保证了线程安全Holder类只有在getInstance真正被调用时才会被加载类的静态字段初始化由JVM保证只会执行一次所以天然是懒加载加线程安全而且不依赖synchronized和volatile。注意这里的前提是Singleton构造函数不能有危险的外部依赖否则类加载阶段就可能抛异常。枚举单例虽然在做题时不常写但它是Effective Java推荐的写法因为它不仅能解决线程安全还能防止反射和序列化破坏单例。反射调用私有构造器在枚举面前会抛异常序列化反序列化枚举也是返回同一个实例。面试时能把这个点说出来会体现出你对单例的理解不止停留在锁层面。7.3 链表反转的递归与迭代写法链表反转几乎和HashMap一样高频。迭代法很简单三指针维护prev、cur、nextpublic ListNode reverseList(ListNode head) { ListNode prev null; ListNode cur head; while (cur ! null) { ListNode next cur.next; cur.next prev; prev cur; cur next; } return prev; }递归写法也要会public ListNode reverseListRecursive(ListNode head) { if (head null || head.next null) return head; ListNode newHead reverseListRecursive(head.next); head.next.next head; head.next null; return newHead; }递归写法理解难度稍微大一点核心是假设后面的链表已经反转成功那么只需要把当前节点挂到它的后继节点后面。递归解法比较考验对递归函数返回值的理解面试官大概率会追问“递归方法返回的是谁”以及“head.next.next head这行代码到底做了什么”。能把这两个问题讲清楚的候选人编码能力基本不会差。8. 综合场景题缓存一致性、数据一致性与防爬设计8.1 缓存与数据库一致性怎么做“先更新数据库还是先删缓存”是综合场景题的常客也是真实项目里天天要面对的问题。Cache Aside Pattern是目前最常用的方案读的时候优先读缓存读不到就读数据库然后把数据写回缓存写的时候先更新数据库再删除缓存。为什么是删除缓存而不是更新缓存因为更新缓存需要额外考虑并发操作的原子性和缓存构建成本删除缓存让下次读请求来重建更简单也更可靠。但这套模式有一个经典的坑先删缓存再更新数据库可能出现缓存里被并发读线程写入了旧数据。所以业内有一个补偿方案叫延迟双删先删除缓存更新数据库休眠一小段时间比如500毫秒再删一次缓存。这个休眠时间要大于一次完整的读请求加写缓存的时间否则第二次删除可能发生在别人刚写完缓存之前等于白删。延迟双删不能根治问题只能降低发生概率真正实现强一致还是依赖分布式锁或事务消息。另一个设计细节是缓存过期时间。我在实际项目里喜欢给所有缓存设置一个合理的过期时间比如热点配置5分钟用户基础信息30分钟因为任何一致性方案在宕机、网络抖动面前都可能失效有过期时间就有一层兜底。如果没有过期时间数据一旦不一致就是永久性的后面排查起来非常痛苦。这块我在面试回答时一定会强调因为面试官真正关心的是你有没有对这种分布式系统的经典问题有过真实思考和权衡。8.2 分布式场景下如何保证数据最终一致性“Java怎么保证数据一致性”这个热搜词非常贴近真实项目。分布式系统里谈一致性先要明确是强一致还是最终一致。强一致一般用分布式事务常见的实现有2PC、TCC、SEATA的AT模式。TCC模式有三个阶段Try预留资源、Confirm确认提交、Cancel取消回滚适合对一致性要求高的核心链路但实现复杂度高。AT模式通过代理数据源自动生成undo log业务代码侵入小但性能开销比TCC大且对隔离性有要求。实际生产中大多数互联网业务场景用的是最终一致性而不是强一致。最终一致性的经典实践是本地消息表加定时任务在一个本地数据库事务里同时写入业务数据和消息记录然后通过定时任务扫描消息表把消息发送给下游系统或MQ。如果发送失败消息状态仍是待发送任务会重试。本地消息表的优势是把“业务操作”和“消息发送”放在同一个事务里从根本上避免了“业务成功、消息没发”的问题。后面演进成事务消息比如RocketMQ提供的事务消息把check逻辑下沉到MQ本质上还是同一个思路。我遇到过最头疼的问题是消息重复消费。即使事务消息解决了发送的原子性消费端至少一次投递还是会带来重复。解决方式不是靠消息中间件保证不重复而是在消费端做幂等。常用的幂等方案包括数据库唯一索引、Redis防重表、状态机前置判断。比如订单支付回调里先查订单状态如果已经是已支付就返回成功不做二次处理这就是业务层面的幂等。面试时讲一致性如果能把“发送可靠、消费幂等、状态机约束”这三点串起来就比只背CAP、BASE理论要扎实得多。8.3 Controller层防爬虫与接口安全设计防爬虫这个方向在热搜词里出现说明很多人实际工作中遇到过被爬的困扰。接口层防爬的核心思路就一句话把自动化工具和真人用户区分开。第一层是流量特征识别。通过User-Agent、Referer、访问频率、IP维度做基础过滤。UA为空或UA是Python-requests、curl等工具的直接拒绝同一个IP单位时间内超过阈值就限流。但这一层只能防住低水平的爬虫稍微用点心的人会随机UA、换IP所以还得往上加东西。第二层是行为验证。核心接口加图形验证码或滑块验证登录接口加短信验证码。高价值业务比如抢购、下单要做更严的风控比如设备指纹、账号行为模型、IP信誉库。Controller层面还可以对请求参数做签名校验客户端生成一个签名服务端验签签名规则使用密钥加时间戳加参数组合的HMAC防止请求被篡改和重放。我做过一个对外接口用时间戳加nonce防重放nonce存Redis并设置过期时间超过有效期或重复出现就丢弃请求。这个方案非常简单但效果很好。第三层是限流框架选型。单机场景可以用Guava RateLimiter做令牌桶限流集群场景要上Redis令牌桶或Sentinel。Sentinel对接口粒度的流量控制做得很好还支持熔断降级能防止爬虫流量冲击系统。我在项目里用Sentinel给核心接口配过QPS限流和线程数隔离效果非常明显恶意流量进来后业务接口依然稳定。这块面试时能把方案分层讲会显得你对安全设计有全局视野。9. 学习路线与面试准备的避坑指南9.1 Java工程师怎么规划学习路线“Java学习路线”这个热搜词背后大概率是正在迷茫期的初级开发者。我根据自己的成长经验把学习路线拆成四个阶段。第一阶段是Java基础加数据库。“Java基础”不是看几本书就完了而是要把集合源码、并发包、IO模型、JVM基础这些都过一遍。数据库最少要精通MySQL的事务隔离级别、索引原理和SQL优化这是后端开发的命根子。第二阶段是框架加工程化Spring Boot、MyBatis-Plus、Maven、Git、Linux基本操作都要熟练最好能独立做一个包含登录、权限、CRUD的完整项目把整个链路走通。第三阶段是中间件和分布式Redis、MQ、Elasticsearch、微服务体系这个阶段要通过项目实战来学只看理论很难真正掌握。第四阶段是源码和架构能读Spring和MyBatis的源码能设计高可用系统。每个阶段都应该有作品支撑。面试官最不信任的就是那种“我读过Redis源码”但一个Redis问题都答不上来的候选人。学习一定要伴随着输出写技术笔记、画架构图、做demo项目然后把项目部署到服务器上贴上地址。我当面试官时会优先看候选人的博客和项目链接一个有持续输出习惯的候选人学习能力和表达能力通常都不会差。9.2 面试回答的三个常见毛病与表达技巧第一个毛病是把面试题当背诵题。问HashMap就说数组加链表加红黑树问JVM就堆栈方法区各来一句全是大词没有细节没有为什么。面试官的追问一深入就卡壳。改进的方法是准备每个知识点时强制问自己三个问题它解决了什么问题它为什么这么设计它的代价是什么这三个问题想清楚了知识才是自己的。第二个毛病是不分对象乱答。面试官问你了解JVM吗你上来就讲G1回收器和ZGC但他其实只是想考察基本的内存模型。回答要有层次先讲核心结论观察面试官的反应再决定要不要展开到细节这个节奏感很重要。如果面试官不打断你说明你的方向是符合预期的如果频繁追问细节说明他希望你讲得更具体。回答一定要有逻辑顺序比如先整体再局部先原理再实践。第三个毛病是答错了不敢承认。技术面试中答错不丢人硬撑着不认错才丢人。有一次候选人把CMS和G1的回收过程说反了我指出后他立刻改口但改口的过程前后矛盾。其实更好的做法是坦诚说“这块我记得不太牢可能是这样我回去再确认下”然后再补充你已经掌握的相关内容。面试官反而会觉得这个人实在、有成长空间。9.3 一些简历和项目经验的加分写法项目经验怎么写直接决定了面试官第一个技术问题问什么。很多人简历上写“负责用户模块开发”面试官就只能问一些泛泛的基础题。更好的写法是把技术难点写出来比如“基于Redis实现分布式登录态解决集群环境下的Session共享问题”、“通过MQ异步解耦订单消息推送峰值QPS从200提升到800”。这样面试官就会顺着你的技术点深挖你准备的素材也能派上用场。还有一个容易忽略的点项目里遇到过的线上故障一定要写。故障最能体现问题排查和系统思考能力。比如线程池使用不当导致OOM、缓存穿透把数据库打挂、逻辑删除导致数据唯一性失败这些我都在真实项目里遇到过。准备2到3个这样的小故事讲清楚背景、原因、解决方案、后续优化你的面试竞争力会有一个明显的提升。简历篇幅控制也很重要一页半到两页是比较合理的范围。项目不要写超过三个选最能体现能力的方向写并且每个项目都包含“背景、职责、技术栈、难点、成果”五要素。无效的自我评价比如“吃苦耐劳、学习能力强”尽量不写因为它们无法验证面试官也不关心。10. 复盘与最后的实用建议如果让我给一个总的学习建议那就是不要追求把所有面试题都背下来而是要学会追问每个知识点背后的设计动机。HashMap为什么加载因子0.75因为要均衡时间空间String为什么不可变因为安全与缓存线程池为什么不让用Executors因为无界队列会压垮系统。每一个设计背后都有工程权衡这些权衡才是面试官真正感兴趣的东西。再分享一个小技巧准备面试时把每个核心知识点写成一套“自问自答”的脚本。比如JVM这块我准备的脚本是“先讲内存划分再讲对象创建再讲GC判定与收集器最后用日志输出和调优案例收尾”。这样面试时不至于慌按脚本顺序讲即可。脚本里的每一条都要用自己的话写不要直接抄博客否则被追问细节会露馅。面完之后不管结果如何都建议当天或第二天做一次复盘把没答上来的问题记录下来回归源码和官方文档查证。我当年就是这样从一个小厂跳到现在的每一次面试都当成学习机会而不是纯粹的测试。要记住面试题只是一个入口面试官真正想确认的是你有没有解决问题的能力和自驱力而这两样靠的是一次次真实的编码、排障和复盘慢慢沉淀出来的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →