2026年Java面试题汇总,网上到处都在传“八股文没用了”,但真到了面试现场,你会发现问的还是那些底层原理——只是问法越来越刁钻。不再问“HashMap的原理是什么”,而是问“HashMap在并发扩容时会不会形成环,JDK 8之后为什么不会”;不再问“线程池的几个参数”,而是直接在白板上扔一段生产代码,问你队列满了以后会发生什么。这是我整理的第一期,围绕Java基础集合、JVM、并发编程、Spring框架四个大方向展开,每个问题都附上答题思路和追问备选。后续系列还会继续补充MySQL、Redis、消息队列、分布式等模块,争取做成一份可以长期迭代的面试知识库。
我写这份汇总的初衷很简单:面试准备最怕的就是“看了很多题,但不知道背的重点在哪”。所以我不只列问题和标准答案,还会把面试官真正想听到的层次讲清楚——哪些是基础分,哪些是加分项,哪些是答不好直接挂的致命点。文中的题目全部来自我本人以及身边同事近两年在一线互联网公司和中小厂的真实面试场景,覆盖面偏向2025-2026年的出题趋势,建议配合源码一起看,不建议死记硬背。
1. 基础集合框架:HashMap是永远绕不开的C位
集合类可以说是Java面试的入场券,基本没人能跳过。而集合里面,HashMap的出场率高得离谱,几乎每一轮技术面都会涉及。高频程度肉眼可见:HashMap、ConcurrentHashMap、ArrayList、LinkedList,这是四个核心,其中HashMap一家就占了接近一半的提问量。
1.1 HashMap底层结构与扩容机制细节
面试官通常从一个最简单的问法切入:“说一下HashMap的底层数据结构。”很多人的回答止步于“数组加链表,JDK 8之后转红黑树”,这句话只能拿基础分。你需要接着往下延伸:Node数组的初始长度是16,负载因子默认0.75,也就是说当元素个数达到12的时候触发扩容,扩容之后容量翻倍到32。链表转红黑树的条件是链表长度到达8,并且数组长度不小于64,两个条件缺一个都不会转,而是优先通过扩容来缩短链表。
这里面的逻辑值得多想一步:为什么树化的阈值是8而不是16或者4?因为链表节点和树节点的内存占用不同——普通Node占用的内存比TreeNode小很多,短链表形态下链表反而更省内存、遍历更快。只有当链表足够长,树化才划算。8这个阈值在数学上遵循一个泊松分布,在理想哈希算法下,链表长度达到8的概率只有千万分之六,所以在绝大多数场景下红黑树并不会真正登场。
扩容这个话题最有挖掘空间。面试官会追问:扩容发生在什么时机?扩容之后元素是怎么迁移的?JDK 7和JDK 8的迁移方式有什么区别?这里的关键加分点是,JDK 8之后不再逐个节点重新计算哈希,而是通过(e.hash & oldCap)判断元素属于原位置还是原位置加旧容量。原因是数组长度始终是2的n次幂,扩容后新容量相当于在二进制高位多了一个1,元素的索引要么不变,要么增加一个旧容量值。这也是HashMap能保持高性能扩容的底层原因。
再往下深挖,面试官可能会抛出一个经典陷阱:JDK 7的HashMap在并发环境下扩容可能形成循环链表,导致get操作死循环,为什么JDK 8之后解决了?因为JDK 7用的是头插法,多线程同时触发rehash时链表会逆序,在并发迁移中可能把引用关系彻底打乱,形成环。JDK 8改用尾插法,元素相对位置不变,从机制上消除了死循环的可能。但你如果只回答到这里,面试官很可能跟一句:“那JDK 8的HashMap在并发环境下就是安全的吗?”正确答案是不安全,虽然不会死循环了,但数据覆盖、size计数不准确的问题依然存在,并发场景必须用ConcurrentHashMap。
1.2 ConcurrentHashMap的锁粒度演进
ConcurrentHashMap的考察重点放在锁策略上。JDK 7的实现是分段锁,默认16个Segment,每段各自管理一组桶,不同段之间可以并行写。JDK 8直接抛弃了分段锁,改用CAS加synchronized,锁的粒度从Segment级别细化为单个桶级别。这个演进方向非常明确:锁的粒度越来越细,并发度越来越高。
JDK 8的put流程值得自己画一遍:首先判断数组当前有没有初始化,没有就通过CAS操作进行初始化,注意这里的初始化会有并发竞争,多个线程可能同时发现数组为空,但CAS能保证只有一个线程真正执行创建动作,其他线程会自旋重试。接下来计算出数组下标位置,如果此时该位置是空的,就用CAS直接把节点写进去,这是完全没有锁的路径。如果该位置已经有节点了,才会对当前桶的头节点加synchronized锁,然后走链表或红黑树的写入逻辑。你会发现ConcurrentHashMap的设计哲学很有意思:尽量在无竞争或极轻竞争的场景下用CAS,真正发生哈希冲突时才加锁,而且只锁单个桶。
还有一个高频追问点是size()方法的统计方式。并发环境下直接用count计数肯定不准确,所以ConcurrentHashMap引入了baseCount加CounterCell数组来分摊计数压力。每个线程先尝试CAS增加baseCount,如果CAS竞争激烈失败,就随机选一个CounterCell数组里的槽位去做累加。这样做的效果是,即使有大量线程同时写,计数器也不至于互相阻塞。
1.3 字符串、ArrayList与vFail-Fast机制
字符串相关的题目也是老演员了,但出题角度在变化。以前喜欢问“String为什么是final的”,现在更多会问“new String("abc")创建了几个对象”“StringBuffer和StringBuilder的区别底层是怎么体现的”。答前者的时候,建议从安全、线程安全、字符串常量池设计三个角度说明,只答“不可变是为了安全”显得太干。创建对象个数的问题,本质是在考字符串常量池:如果常量池里已经有“abc”,new String("abc")只创建一个new出来的堆对象;如果常量池里没有,还要先在常量池创建一个,再创建一个堆对象,一共两个。引申到intern()方法时,要注意JDK 7之后常量池移到堆里,intern的返回逻辑也变了。
ArrayList考察最多的就是“和LinkedList的区别”。常规回答是数组和双向链表的内存结构不同,随机访问和插入删除性能不同。但加分答法要提到内存局部性和CPU缓存的影响:ArrayList底层是连续内存空间,遍历时能充分利用CPU缓存的预取机制,在数据量大时随机访问性能优势明显;LinkedList每个节点是散落的内存碎片,还需要额外的前后指针开销。
面试官比较爱设陷阱的是fail-fast机制。ArrayList在迭代过程中如果有线程修改了modCount值,会抛出ConcurrentModificationException。这个机制看起来“安全”,实际上并不是强一致保障,底层逻辑很简单——迭代器每次获取下一个元素前都会检查expectedModCount != modCount。追问通常是“如何避免”,标准回答是用CopyOnWriteArrayList,它的迭代器操作的是创建时的快照数组,修改操作是在副本上进行的,迭代器永远感知不到变化,所以不会抛异常。
2. JVM原理:从内存区域到调优实战
JVM知识在二面、三面中占比异常高,尤其在一线互联网公司的面试中,基本是必考板块。而且这个方向非常容易看出一个候选人有没有真实调优经验——你说你用过G1,面试官现场追问几个参数含义和调优场景,答不出来立刻穿帮。
2.1 运行时内存区域划分与对象分配路径
Java运行时内存区域是老生常谈,但要从“Areas of JVM”升级到“对象的一生”角度回答。一个对象new出来之后,绝大多数情况下先在伊甸园分配,伊甸园快满了触发Minor GC,存活对象移动到幸存者区S0和S1之间倒腾,每熬过一次GC年龄加一,默认到达15次就往老年代晋升。这里要注意大对象直接进入老年代,这是由参数-XX:PretenureSizeThreshold控制的,但对象太大,即使设置了阈值,也有可能出现直接在老年代分配的情况。
内存区域之间的线程关系也是高频考点。虚拟机栈、本地方法栈、程序计数器是线程私有的,堆和方法区是线程共享的。JDK 8的一个重大变化就是方法区退出历史舞台,被元空间取代,“永久代”这个叫法只存在于JDK 7及以前。元空间和永久代有本质区别:永久代属于JVM管理的内存,大小受JVM限制;元空间用的是本地内存,不再触发Full GC,而且默认情况下大小几乎不受限制。这个设计变化带来的实际好处是,很多开发者不再被PermGen Space错误折磨。
还有一个常考的冷门知识点是“什么情况下会触发StackOverflowError,什么情况下会触发OutOfMemoryError”。前者通常是方法递归层数过深,线程栈帧把虚拟机栈挤爆了;后者是堆区或元空间无法再分配内存。有些面试官会追问:无限递归会导致堆内存溢出吗?答案是绝大多数情况不会,因为递归每层都会创建新栈帧,栈先爆掉。
2.2 垃圾回收算法与主流收集器对比
垃圾回收相关的考点又碎又多,但只要抓住一条主线就好理解了:回收算法经历了从标记-清除、复制、标记-整理到分代收集的演进,收集器则从Serial、Parallel发展到CMS再到今天的G1和ZGC。
标记-清楚最核心的问题有两个:第一是STW时间太长,第二是内存碎片化,频繁Full GC的场合下碎片化尤其致命。复制算法完美解决了碎片问题,代价是浪费部分内存空间,所以新生代幸存者区的划分就用了这个思路,但复制算法在老年代不可用,因为老年代存活率高,复制代价太大。标记-整理算法避免了上述两个缺点,但代价是移动对象需要更新所有引用,过程复杂且停顿时间依然不短。
收集器的选择是面试官判断你有没有真实项目经验的试金石。Parallel是JDK 8默认的收集器,优势是高吞吐,适合后台计算型任务;CMS是追求低停顿的鼻祖,但它在并发清除阶段会跑CPU,而且存在“并发模式失败”,一旦发生就退化为Serial Old,性能一落千丈;G1从JDK 9开始变成默认收集器,核心思路是划分成很多大小相等的Region,通过跟踪每个Region的回收价值,优先回收垃圾最多的区域,所以它叫Garbage First,而不是全堆回收。
追问G1时,面试官经常问:“G1的RSet是什么?”这个问题很多人答不上。RSet是Region级别的记忆集,记录了哪些Region引用了当前Region的对象。因为G1回收某个Region时,不能只扫描该Region,还得知道谁引用了它,RSet就是用来记住这些外部引用的数据结构。有了RSet,回收单个Region时就不用全堆扫描了,这是G1能实现可预测停顿的核心机制之一。
ZGC是Java 17之后开始频繁出现在高端岗位题目中的角色。它的最大特点是停顿时间控制在10毫秒以内,而且堆大小不管设成几十G还是几百G,停顿时间都几乎不变。原理核心是染色指针和读屏障,这个实现很复杂,中小公司面试一般不会深挖,但如果你的岗位级别是高级工程师以上,建议把ZGC和G1的对比整理清楚。
2.3 类加载机制与双亲委派模型
类加载的考点稳定集中在:类加载的过程是什么、有哪些类加载器、双亲委派模型是什么以及为什么需要它。加载、验证、准备、解析、初始化,这是JVM加载一个类的五个阶段。很多人会把“准备阶段”搞混,这里专门提一句:准备阶段是给类的静态变量分配内存并设置默认值,不是用户代码里写的初值。比如static int a = 20,准备阶段a的值是0,把20赋值给a发生在初始化阶段。
双亲委派模型比较反直觉,需要把它讲透。应用类加载器收到一个类加载请求,不会自己先加载,而是先交给父加载器,一直向上传递到启动类加载器,只有父加载器无法完成加载时,子加载器才尝试自己加载。所有加载请求从下往上传递,实际加载过程是从上往下进行。这个机制的核心价值是避免同一个类被加载多次,保证核心类库的安全——如果开发者自己写了一个java.lang.String,理论上根本不会被加载,因为启动类加载器已经加载过真正的String了。
面试官如果追加追问“有没有可能打破双亲委派模型”,标准的切入点是线程上下文类加载器,Tomcat等Web容器就是典型例子。Tomcat管理多个应用时,每个应用需要隔离的类库版本,如果严格执行双亲委派,不同应用的同名类就会被父加载器加载后共享,无法实现隔离。所以Tomcat的WebAppClassLoader会优先自己加载WEB-INF/classes下的类,这就是“逆双亲委派”。另一个经典案例是SPI机制,JDBC驱动加载就是通过Thread.currentThread().getContextClassLoader()来打破委派链条的。
2.4 内存泄漏定位与JVM调优参数实战
这部分是可以通过一道场景题来考:线上应用频繁Full GC,响应时间飙升,你怎么排查?这个问题考察的是完整的问题定位链路,没有真实经验的人很容易答得支离破碎。
我建议的排查路径是这样的:先通过jstat -gcutil <pid> 1000观察GC情况,确认Full GC的频率和耗时是否异常;如果确认有问题,用jmap -dump:format=b,file=heap.hprof <pid>导出堆转储文件,这一步要注意,生产环境大堆导出文件非常吃磁盘,务必提前确认空间;然后用MAT或JProfiler分析,重点看对象直方图的Retained Size,找大对象,或者通过支配树看哪个对象持有了大量引用。最常见的泄漏场景无非那么几种:静态集合类中存了对象但没有清除条件、各种连接资源没关闭、ThreadLocal里的Entry没有remove、监听器没有反注册。
JVM调优参数没必要背全套,但要掌握核心几组。堆大小固定的三件套-Xms -Xmx要一致,防止运行时动态扩容带来的抖动;新生代比例-Xmn建议设置成堆的3/8左右,GC日志参数-Xloggc:/path/gc.log -XX:+PrintGCDetails是排查问题的基本依赖。真正到了调优现场,最常用的命令其实是jstat和jmap,大部分问题靠这两个命令就能定位到八九成。
3. 并发编程:从线程池到AQS的完整链路
并发几乎是Java高级工程师面试的“分水岭”模块。基础岗位还在问synchronized和volatile的区别,高级岗位就已经开始死磕AQS和并发工具的内部实现了。
3.1 线程池七个参数与执行流程源码级分析
线程池最常见的问法是“说一下线程池的执行流程”,网上流传的答案一般是:先判断核心线程是否满了,再判断队列是否满了,再判断最大线程数,最后拒绝。这个回答能过基础面,但经过细化才能过深水区。真正的源码流程是,execute方法提交任务后,如果当前工作线程数小于核心线程数,直接新增一个核心线程执行;注意这里和前几年的旧版本逻辑有变化,现在的版本是优先判断工作线程数是否达到corePoolSize,没达到就新建线程。如果已经达到,尝试将任务放入阻塞队列。队列成功接收任务,那就等有空闲线程再去取任务执行。队列满了,再判断当前线程数是否小于maximumPoolSize,小于就创建救急线程,临时线程执行完当前任务后会去队列里面取任务,如果队列任务也被清空,并且等待时间超过keepAliveTime,线程就会被回收。如果线程数已经达到上限并且队列也满了,才会执行拒绝策略。
线程饥饿的概念也经常被顺带问起。单线程执行器是典型的例子:一个单线程线程池里面,如果第一个任务阻塞了,后面所有任务都会排队等待,这在声明“异步执行”的地方特别容易踩坑。
拒绝策略有四种:AbortPolicy直接抛异常,这是默认的;CallerRunsPolicy让提交任务的线程自己执行,适合降低任务提交速度,也让任务不丢失;DiscardPolicy和DiscardOldestPolicy都是丢任务,后者还会把队列头部的旧任务丢掉,腾出空间给新任务。真实业务中最好不用默认策略,不然高峰期任务一多,直接抛异常,上游体验非常差,一般建议用CallerRunsPolicy,或者自定义拒绝策略做降级处理。
线程池参数怎么设置,是每个面试者都要准备的场景题。CPU密集型就看核心数设核数加1到2,IO密集型建议设置成核数的两倍左右,更精确的做法是通过公式计算:线程数 = 核数 * (1 + 等待时间/计算时间)。真实经验是,IO密集型线程池可以直接用这个公式估算一个初始值,再用压测微调,别指望理论值一步到位。
3.2 synchronized锁升级与ReentrantLock底层对比
synchronized的锁升级过程是这几年热度只增不减的考点。整个过程是无锁、偏向锁、轻量级锁、重量级锁。无锁状态就是对象头里的Mark Word没有记录锁信息;偏向锁的意思是,同一个线程第一次获取锁后,Mark Word会记录这个线程的ID,下次这个线程再进来直接判断是自己的锁,都不用走CAS,这就是“偏向”的含义;一旦出现第二个线程竞争,偏向锁撤销,升级为轻量级锁,这个锁通过CAS操作把对象头的Mark Word复制到线程栈帧的锁记录中,然后尝试把对象头修改为指向锁记录的指针;如果CAS失败,说明竞争激烈,膨胀为重量级锁,此时未获取锁的线程进入阻塞队列,操作系统的互斥量参与其中。
ReentrantLock和synchronized的对比题目出现频率一样高。机制上两者都支持可重入,可重入的底层含义是同一把锁可以被同一个线程多次获取,每获取一次lock次数加一,unlock一次减一,减到零才真正释放。synchronized是JVM层面的锁,在对象头做文章,而ReentrantLock是基于AQS框架的类实现,通过state变量记录重入次数。功能上,ReentrantLock多出来的能力包括:可中断(lockInterruptibly)、可超时(tryLock(TimeoutUnit))、公平锁(构造参数传true)、可以有多个等待条件(newCondition)。需要特别指出的是,公平锁在真实高并发场景应用不多,因为排队唤醒的开销比非公平锁大很多,通常业务上对响应时间有强要求的场景才会考虑。
3.3 volatile、CAS与ThreadLocal的内存泄漏真相
volatile的考察核心是“可见性和有序性,不保证原子性”。它通过给变量加Lock前缀指令,写入时强制刷新到主内存,读取时从主内存拉取最新值。另外,volatile还有一个常被忽略的作用:禁止重排序。双重检查锁单例模式就是利用volatile防止对象发布时指令重排带来的半初始化问题——instance = new Singleton()在字节码层面有三个步骤:分配内存、初始化对象、把引用赋值给静态字段。如果不加volatile,第二和第三步可能被重排,其他线程会拿到一个引用已赋值但内部尚未初始化的对象,用起来直接空指针。
CAS的三大问题必须能说全。ABA问题的含义是,一个值从A改成B又改回A,当前线程以为数据没有变化,实际上中间被改过。Java里提供了AtomicStampedReference,用版本号标识每次修改,解决ABA。自旋问题在激烈竞争下,CAS会一直循环失败,白白浪费CPU周期,分段锁和条带化机制就是为了降低竞争频率。第三是只能保证单个变量的原子操作,无法作用于代码块,这种场景还是得靠锁。
ThreadLocal内存泄漏问题是典型的“知道原理才能解释的题目”。ThreadLocal的底层结构是一个ThreadLocalMap,这个Map被Thread持有。Map的key是ThreadLocal对象,用弱引用持有,但value是强引用。如果线程存活且ThreadLocal对象不再被外部引用,key会被GC回收,但value链条依然存在,这就产生了key为null的Entry——内存泄漏。正道解法是每次用完ThreadLocal后调用remove方法清除Entry,这也是规范要求的标准用法,比如在finally块和remove成对出现。
3.4 AQS核心机制与常用并发工具
AQS(AbstractQueuedSynchronizer)是并发包的灵魂组件。面试官喜欢直接问“AQS怎么实现的”,主体结构是三大块:一个volatile类型的state字段表示共享资源状态,一个FIFO双向队列存放等待线程,还有独占模式和共享模式两种获取方式。
独占模式是lock的底层逻辑,线程tryAcquire失败后,会被包装成Node放入等待队列尾部,然后通过LockSupport.park挂起。前面排队的线程释放锁后,执行unpark唤醒后续节点。共享模式对应的是CountDownLatch、Semaphore、ReadWriteLock这些工具,state的含义各不相同。比如CountDownLatch初始state为N,每次countDown减一,变为0时所有等待线程被唤醒;Semaphore的state就是剩余的许可证数量。
理解AQS之后,面试官会展开追问:“ReentrantReadWriteLock里面,为什么可以支持锁降级但不能支持锁升级?”这跟死锁风险强相关。如果有两个线程同时持有读锁,都想升级为写锁,但写锁需要等读锁全部释放,两边相互等待,就死锁了。锁降级是安全的,因为持有写锁说明没有其他线程有写锁,此时获取读锁再释放写锁,数据一致性是能保证的。
4. Spring框架高频追问:生命周期与循环依赖
Spring系列在Java岗位面试中的地位仅次于Java基础,如果岗位职责包含业务开发,那Spring的追问基本是二面重头戏。
4.1 Bean生命周期完整链路与扩展点
Bean的生命周期题目答得好不好,往往直接决定技术面的分段位。因为这道题能反映一个开发者是不是真的读过Spring的源码。
我建议的回答链路从实例化开始:Spring通过推断构造方法找到候选构造器并实例化对象,然后进行属性填充,也就是Autowire处理@Autowired和@Resource这些依赖注入,然后依次调用BeanPostProcessor的postProcessBeforeInitialization方法、@PostConstruct注解、InitializingBean的afterPropertiesSet方法,接着是自定义init-method,再来是postProcessAfterInitialization方法——注意这里执行了AOP的代理逻辑,在这之后Bean才具备被代理包装的能力。所有初始化完成后,Bean就绪,进入容器。销毁阶段会执行DisposableBean的destroy方法和自定义destroy-method。
加分段位的点是扩展接口的触发时机。ApplicationContextAware和BeanNameAware在属性填充之后会触发对应回调,前面还可以追问BeanFactoryPostProcessor和BeanDefinitionRegistryPostProcessor的区别——前者是容器本身配置的增强器,后者额外支持注册新的BeanDefinition,这些都是在Bean实例化之前的阶段运行的。回答时最好从扩展点出发,拉一条完整的时间线,能一口气顺下来的人,面试官通常会很满意。
4.2 AOP底层机制:JDK动态代理与CGLIB
Spring AOP的考察重点集中在两种代理的选型逻辑。默认情况下,如果目标类实现了接口,Spring偏好使用JDK动态代理,底层通过Proxy.newProxyInstance生成一个实现相同接口的代理对象,核心InvocationHandler通过反射调用目标方法;如果目标类没有实现接口,则使用CGLIB,通过生成目标类的子类,覆写业务方法实现增强,所以CGLIB被代理的类和方法不能是final的。
Spring Boot 2.x之后,AOP默认使用的是CGLIB,特别是Spring Boot 2.2以后官方建议用CGLIB代理,因为JDK动态代理要求目标对象必须实现接口,在实际业务类越来越不依赖接口的开发模式下,常出现代理类型不匹配的坑。值得追问的是Spring AOP只能作用于Spring容器管理的Bean,并且只能拦截public方法,因为代理对象本质上是在Bean创建时通过后置处理器包装了一层,方法调用必须走代理对象才能触发拦截器链,而private方法根本没机会走代理链路。
切入点表达式的常见写法也建议记几个:execution(public * com.example.service.*.*(..))表示匹配service包下所有类的所有public方法,@annotation(com.example.aspect.LogAnnotation)表示匹配带指定注解的方法。切面还可以做优先级控制,通过@Order注解调整多个切面的执行顺序,执行顺序体现在before中最先执行、after中最晚执行。
4.3 Spring事务失效的九种场景排查
@Transactional失效问题属于开发必问场景题,答得好会迅速拉高面试官对你的实战认可度。这里想强调一点,绝大多数失效原因是同一个东西——Spring事务是通过AOP代理实现的,你调用的必须是代理对象的方法,事务注解才会生效。
具体失效场景我建议按类整理。自己类内部方法调用:A方法调用同类中的B方法,但B加了@Transactional,因为调用发生在内部,没有走代理对象,事务不生效,解决办法是注入自身代理、拆到不同类,或者用AopContext.currentProxy。方法不是public时失效,因为@Transactional只对public方法生效,非public方法Spring默认不生成事务代理。异常被捕获处理了导致失效,事务感知的是RuntimeException或者Error,普通异常不触发,但业务代码把异常catch住没往外抛,Spring就根本感知不到。异常类型问题,checked异常默认不触发回滚,需要rollbackFor = Exception.class。数据库引擎不支持事务,MySQL的MyISAM引擎就没有事务能力,等于白切。方法被final修饰,CGLIB无法覆写final方法,必然无法代理。传播行为设置错误,比如把传播行为设为NOT_SUPPORTED,当前方法会以非事务方式执行。多线程环境下,事务边界在线程A创建,但出错发生在子线程B,子线程中的异常不影响父线程事务的回滚。
4.4 循环依赖与三级缓存机制解析
循环依赖在Spring中是一个既带着“为什么”又带着“怎么解决”的综合题。典型的构造器循环依赖直接无解,因为实例化阶段就需要对方,Spring没法解决这个问题。能被解决的是setter注入形式的循环依赖,解决它的三兄弟是一级、二级、三级缓存。
三级缓存的机制是解决循环依赖的钥匙,要理解它最好从“非循环依赖的正常流程”看。A实例化完成后,如果A依赖B,先把A准备创建的实例放到三级缓存里,也就是一个ObjectFactory工厂对象,这个工厂的getObject方法负责返回A的早期引用——准确说是A的早期对象,它可能还没有完成属性填充。当B创建时发现需要A,此时A不在二级缓存但能在三级缓存中找到,Spring会调用A的ObjectFactory拿到早期引用,提前注入给B。B由此完成创建,B的全部初始化流程结束后,Spring再回头处理A,A拿到B的代理对象,继续完成自己的初始化。
为什么说是三级缓存,而不是两级?这里是整道题的深水区。假设只有二级缓存,所有刚实例化的Bean都直接放入二级缓存,那么即使没有循环依赖,也要先创建对象再放到二级缓存,可是这样做会提前暴露尚未完成初始化的Bean,对AOP处理的时机产生副作用。用三级缓存存储ObjectFactory的话,只有在真正发生循环依赖、且需要提前注入时,才会调用工厂方法创建早期引用。所以三级缓存的核心价值是“延迟了早期引用的生成”,把AOP代理的时机尽可能往后推,保证不破坏正常创建流程。这个点能讲明白的, собеседник的印象分会很不一样。
5. 面试实战复盘:高频追问与答题节奏控制
讲了那么多知识点,最后聊点战场上的经验。很多人技术深度过关,但面试表现力不行,问题就出在对追问的预判不够,以及答题结构的取舍上。
5.1 用STAR结构化回答源码阅读类问题
源码类问题最容易答得又长又乱。面试官问“你怎么看HashMap的扩容”,你从源码第一行开始背,面试官很快会失去耐心。比较好用的策略是“STAR式的技术答法”:先一句话给结论,讲清楚这个机制的背景和目的,接着提炼关键实现路径,讲两三个核心步骤,而不是逐行背诵,再丢出一个比较深刻的细节,比如“JDK 8扩容时不需要重新计算hash,是通过与oldCap相与来区分高低位链表”,最后补充一句这个设计在哪些场景会出问题。这样输出出来,面试官既确认你真的读过源码,也不会认为你在背题。
应试方面还有一个大忌:不懂装懂。我当时见过一个候选人,被问到线程池的原理,他把核心线程说成“固定数量的线程总是在跑”,问他如果核心线程空闲会怎样,他答“那就浪费了”,细问才发现他根本没想过keepAliveTime只作用于非核心线程。这个属于知识点模棱两可,死记硬背很容易被连续追问测出来。
5.2 持续更新的错题本与阶段复盘方法
面试题汇总类的资料很多,但大部分人买回去就吃灰了。我的经验技巧是建立自己的“错题本”,不是把答案抄一遍,而是记录当时答不出来的切入点,以及面试官追问后得到的思路补充。每道错题背后都代表一个知识盲区,隔一周回头再看一次,基本能形成长期记忆。
另一套方法就是分阶段做复习切片。基础阶段只刷高频集合类和JVM核心题,目标是能不看资料写出完整答案;进阶阶段做场景化题目,比如“线上频繁Full GC你怎么排查”“定时任务集群并发重复执行怎么解决”;战场阶段就是做模拟追问,找同事或者自己录音,推演面试官可能针对每道题展开的后手。等到面试官出题目时,你脑子里浮现的不再是对着文档背答案,而是一张完整的技术知识图谱。
6. 热身好题精选:十道你未必答得全的对错题
最后放一组适合用来热身和自测的题目,能在短时间内检验自己的基本面。
第一道:一个类的实例变量在并发环境中被多个线程修改,不加volatile也不加锁,其他线程看到的值是否一定和最新值一致?答案是不一定,JMM不保证变量在多线程下的可见性。
第二道:Java 8的HashMap在扩容时,同一个桶上的链表元素顺序是否完全保持不变?答案是元素在数组中的位置会基于(e.hash & oldCap)分成高低两半,高位部分的顺序会保留,但链表整体实际会被拆分成两条链。
第三道:synchronized修饰的静态方法和实例方法,锁的是同一个对象还是不同对象?答案是不同对象。静态方法锁的是Class对象,实例方法锁的是当前实例对象。
第四道:ThreadLocal在并发环境下,不同线程往ThreadLocal里放的值,会互相覆盖吗?答案是不会,每条线程持有的ThreadLocalMap是线程私有的,存的值是线程隔离的。
第五道:Spring的@Transactional加在private方法上,会生效吗?答案是通常不生效,因为事务代理无法拦截private方法,而且方法本身都无法被子类覆写。
第六道:如果Spring Bean的构造方法里调用了另一个Bean的方法,此时会发生循环依赖问题吗?答案是有可能,构造器阶段发生的循环依赖Spring无法解决,会直接报错。
第七道:volatile修饰的变量能保证i++的线程安全吗?答案是不能,i++是读改写三步操作,volatile只保证中间读取的值是最新的,但整体复合操作不具备原子性。
第八道:JVM在JDK 8默认使用的垃圾收集器是哪两个?答案是新生代Parallel Scavenge和老年代Parallel Old。
第九道:CountDownLatch和CyclicBarrier的核心区别是什么?一个是门闩一次性使用,计数器减到0就放行,等完就没了;另一个是栅栏可以循环复用,所有线程到达后同时开始下一轮。
第十道:对一个资源操作频繁的场景,是无锁+CAS好,还是直接加锁好?答案是不一定,CAS在竞争不激烈时开销低,但高竞争下自旋消耗CPU,此时加锁反而更稳定,这也是很多中间件在极端高并发下选用分段加锁的根本原因。
这十道题里如果全对,说明基础框架已经足够扎实,可以直接进入下一阶段刷分布式和数据库的高频题了。我自己做技术复盘时,最深刻的感受是:面试准备不是背会题目,而是通过题目验证自己对底层机制的理解颗粒度。别再停留在“知道结论”的层面,试着去解释“为什么是这个结论”,这个习惯一旦养成,应付2026年的Java岗位面试,会轻松很多。后续的汇总也会继续往MySQL、Redis、消息队列、分布式架构方向推进,欢迎在留言区补充你在真实面试中遇到的题目。