面试这关,八股文绕不开。哪怕是干了三五年的老手,跳槽前也得把JVM、集合、并发、Spring这些基础再过一遍,更不用说刚入门的新人。网上各种面试题汇总遍地都是,但大部分只是列问题、贴答案,背完了也不知道面试官到底在问什么。这篇我把Java面试里最核心的几个模块拆开讲清楚,每个考点不只告诉你“答案是什么”,还会讲“为什么会有这个设计”“面试官为什么爱问这个”“回答的时候怎么延伸”。零基础的同学可以把它当入门地图,有经验的可以拿来做体系梳理,看完这篇再去刷题,思路会顺很多。
1. 面试必考的JVM:先搞懂内存和类加载
JVM是Java面试的压轴戏,十场面试九场会问。很多新手一听到JVM就头大,觉得抽象、离日常开发远。其实原理弄清楚之后,它反而比业务代码好理解得多,因为JVM的设计逻辑非常清晰:一切都是为了让Java程序跑得更快、更稳。
1.1 运行时数据区域:哪些线程私有,哪些线程共享
JVM在运行Java程序时会把它管理的内存划分成几个区域,这个划分是面试的第一道开胃菜。记住一个口诀:两私有、三共享。程序计数器、虚拟机栈是线程私有的;堆、方法区、运行时常量池是线程共享的。
程序计数器是当前线程执行字节码的行号指示器,它占用的空间极小,而且是唯一不会出现OutOfMemoryError的区域。虚拟机栈存的是栈帧,每一个方法调用对应一个栈帧的入栈和出栈,栈帧里包含局部变量表、操作数栈、动态链接、方法出口等信息。这个区域对应我们平时说的栈内存,递归调用太深就会抛StackOverflowError。堆几乎存了所有的对象实例,也是垃圾回收的主战场。方法区存类信息、常量、静态变量、JIT编译后的代码等。
面试官通常会往上加难度:堆和栈有什么区别?这是一个特别经典的追问。回答的时候要从两个维度说。管理方式上,栈是自动分配和释放的,方法执行完栈帧就销毁;堆需要垃圾回收器来回收。空间大小上,栈通常比堆小很多,默认1MB左右;堆可以设置到几个GB。存储内容上,栈存基本类型变量、对象引用、方法调用上下文;堆存真正的对象数据。再补一句:栈是线程隔离的,堆是线程共享的,共享就会涉及并发安全,所以new出来的对象在多线程环境下要小心处理,这句话能把话题引向并发方向。
1.2 类加载机制与双亲委派模型
一个类从被加载到可以被使用,要经过加载、验证、准备、解析、初始化五个阶段。加载阶段把Class文件读进内存,生成Class对象;验证阶段检查字节流是否符合规范;准备阶段为静态变量分配内存并设置默认值;解析阶段把符号引用替换为直接引用;初始化阶段执行静态代码块和静态变量的赋值。
面试官最喜欢在这个环节追加“双亲委派模型”。这个模型讲的是:当一个类加载器收到加载请求时,它不会自己先去加载,而是先把这个请求委派给父类加载器处理,每一层都往上抛,最后到启动类加载器。只有父类加载器反馈自己无法加载时,子类加载器才会尝试自己加载。
为什么要这样设计?两个原因。安全:比如自己写一个java.lang.String,它不会去加载你写的那个版本,而是交给启动类加载器加载JDK自带的String,防止核心API被篡改。一致性:保证同一个类在整个JVM里只被加载一次,避免出现同一个类被不同加载器加载导致类型不兼容的问题,典型的就是ClassCastException。
如果面试官继续追问“能不能打破双亲委派”,回答思路是:可以通过继承ClassLoader并重写loadClass方法来实现,但loadClass默认会走双亲委派逻辑,所以要打破它就不要用super.loadClass,而是直接改findClass或重写整个loadClass流程。然后可以提一下Tomcat的类加载器就打破了双亲委派,它为了给每个Web应用维护独立的类库版本,先把请求交给Web应用自己的类加载器,这算是一个加分回答。
1.3 GC与分代收集:面试最爱问的那几个问题
垃圾回收这块,首先得明白判断对象是否存活有两种方法。引用计数法思路简单,给对象加一个计数器,被引用加1,失效减1,减到0就判定死亡。但它解决不了循环引用问题:A引B、B引A,两边计数器都不归零,对象永远回收不掉。所以主流JVM用的是可达性分析算法,从GC Roots出发向下搜索走过的路径叫引用链,没有引用链的对象就是不可达的,可以被回收。GC Roots包括虚拟机栈中引用的对象、方法区中静态属性引用的对象、常量引用的对象、JNI引用的对象。
接下来的重点是三种基础回收算法。标记-清除先标记出可回收对象再统一回收,缺点是有内存碎片,大对象可能找不到连续空间。复制算法把内存分成两块,只使用一块,回收时把存活对象复制到另一块,然后把当前块整个清空,不会有碎片,但浪费了一半空间,而且存活对象多时复制成本高。标记-整理类似标记-清除,但回收后会把存活对象向一端移动,解决碎片问题,代价是移动开销。
HotSpot把堆分成新生代和老年代,新生代又细分成Eden区和两个Survivor区(默认比例8:1:1)。新生代对象“朝生夕灭”,适合复制算法;老年代对象存活率高,用标记-清除或标记-整理。面试问到这里,可以主动讲一下对象晋升的流程:对象先分配在Eden区,Minor GC后存活对象进入Survivor区,每熬过一次GC年龄加1,达到阈值(默认15)进入老年代。另外大对象会直接进入老年代,避免在新生代反复复制。
如果面试官再往深挖,会问CMS和G1的差别。CMS基于标记-清除,并发收集,停顿短,但会产生碎片;G1把堆分成多个Region,可预测停顿时间,兼顾年轻代和老年代回收,JDK9之后成了默认收集器。答到这里基本就能让面试官满意了,因为这些内容已经涉及到你在实际项目里怎么调优、怎么选型了。
2. 集合框架面的核心:HashMap原理必须倒背如流
集合这块,问得最多的绝对是HashMap,没有之一。从底层结构到put流程、扩容机制、线程安全性,面试官能一连串问十几分钟。搞定HashMap,集合这块就站住了一半。
2.1 HashMap底层结构与put流程
JDK1.8之后,HashMap的底层结构是数组加链表加红黑树。数组叫Node桶,每个桶上可以挂一个链表;当链表长度超过阈值(默认8)且数组长度大于等于64时,链表会转成红黑树,目的是把查询时间复杂度从O(n)降到O(log n)。
现在面试最常问的就是“描述一下put一个key-value的完整流程”。这个一定要说得顺利:
- 对key计算hash值:(h = key.hashCode()) ^ (h >>> 16)。为什么高位异或低位?因为数组长度是2的幂,计算下标时用的是(n - 1) & hash,高位参与异或能让散列更均匀,降低hash碰撞概率。
- 通过(n - 1) & hash算出数组下标。
- 如果数组为空,先触发resize初始化。
- 如果下标位置上没有元素,直接new一个Node放进去。
- 如果有元素,先判断key是否相等,相等就直接覆盖value。
- 如果不相等,判断当前节点是否为红黑树节点,是就走红黑树的插入逻辑;否则遍历链表,找到相同key就覆盖,没找到就尾插新节点。插入后如果链表长度达到8,调用treeifyBin尝试转红黑树,转之前还会检查数组长度是否达到64,没达到就优先扩容而不是树化。
- 插入完成后,如果size加1之后超过threshold(容量乘负载因子),触发扩容。
这套流程背下来不难,关键是要能回答追问。比如为什么链表转红黑树的阈值是8?这是基于泊松分布得出的一个统计结果。源码注释里给了数据:在负载因子0.75下,链表长度达到8的概率只有约千万分之六,所以正常情况下很少触发树化。8这个值是为了在“空间成本”和“时间成本”之间取得平衡。树化是防止恶意hash攻击的兜底手段,而不是常态。
2.2 扩容机制和2的幂次方设计
HashMap扩容听起来简单:数组不够了就扩大到原来的两倍。但真正问起来有很多细节。
先看为什么容量总是2的幂次方。因为下标计算用的是(n - 1) & hash,如果n是2的幂,n - 1的二进制全是低位1,那么与运算就等价于hash对n取模,但位运算比取模快得多。同时,扩容后元素在新数组中的位置只有两种:位置不变,或者原位置加旧容量。这取决于新增的那一位二进制是0还是1。这样rehash的成本很低,元素均匀分布的概率也高。
扩容的流程要能说清楚:先算新容量和新阈值,然后用新容量new一个新数组,把旧数组里的元素搬过去。搬的时候,如果是链表,会拆成低位链和高位链分别放进新数组的原位置和原位置加旧容量的位置;如果是红黑树,会先拆分,如果拆分后节点数小于6,退化成链表。这正好对应另一个高频问题:红黑树什么时候退化成链表?答案是扩容拆分后节点数小于等于6时。为什么不是8?因为如果阈值也是8,树化和退化会在边界反复震荡,来回切换成本太高,取6留出缓冲空间。
2.3 并发下的HashMap与ConcurrentHashMap演进
HashMap是线程不安全的,这个结论要从多线程put时可能发生覆盖、扩容期间可能出现死循环这些角度去说明。JDK1.7的扩容采用头插法,并发扩容时可能形成环形链表,读取时死循环CPU飙满。JDK1.8改成尾插法修复了这个问题,但并发覆盖的问题仍然存在,所以并发场景应该用ConcurrentHashMap。
ConcurrentHashMap是面试里的另一座大山,重点说它怎么保证线程安全。JDK1.7的是分段锁设计:把数据分成一段一段,每段都有一把锁,锁粒度是Segment,默认16段。并发度就是段数,理论上16个线程可以同时写不同段的数据。JDK1.8放弃了分段锁,直接用Node数组加CAS加synchronized:put的时候,如果对应槽位为空,用CAS去插入,不用加锁;如果槽位不为空,就对链表头节点或红黑树根节点加synchronized锁。锁粒度从“一段”细化到“一个桶”,并发度大幅提高。
回答到这儿可以补一句:JDK1.8之所以不用分段锁,是因为Segment本身继承ReentrantLock,锁粒度太粗,而且内存占用也大。CAS加synchronized既保留了细粒度锁的优势,又减少了加锁的开销,这也解释了为什么现在的版本性能更好。
3. 并发编程:不止会背八股,还要能说清设计动机
并发是Java面试的分水岭,基础题人人都能答,深入问下去就筛掉一批人。这部分内容要么不背,要背就必须连设计动机一起背,否则面试官换个问法就露馅了。
3.1 volatile到底解决了什么问题,又解决不了什么
volatile有三个考点:可见性、禁止指令重排、不保证原子性。
先说可见性。Java内存模型规定,每个线程有自己的工作内存,操作变量时先拷贝主内存的副本到自己工作内存,操作完再刷回主内存。普通变量的更新对其他线程不可见,因为刷回时机不确定。volatile修饰的变量,写操作会立即刷回主内存,读操作会强制从主内存读取,相当于告诉线程“这个变量别缓存,每次都去看主内存”。注意:可见性本质是缓存一致性协议在硬件层面提供的保证,JMM再通过内存屏障把这个语义暴露给程序员。
再说禁止指令重排。编译器和CPU为了优化性能,可能会调整指令执行顺序,单线程下没问题,多线程下可能出问题。volatile通过在读写操作前后插入内存屏障,限制编译器重排,保证程序串行语义一致。有一个很经典的DCL双重检查锁单例问题,由于创建对象不是原子操作,极端情况下其他线程会拿到一个半初始化的对象,给 instance 加上volatile就能解决,因为禁止了“分配内存、调用构造器、赋值引用”这三步的重排序。
最后是不保证原子性。volatile不能保证复合操作的原子性,最典型的例子是count++。这个操作包含读取、加1、写回三个步骤,即使每一步都可见,两个线程同时读到一个旧值再各自加1写回,最后结果也会少一次递增。这也是为什么说“volatile适合做状态标记,不适合做计数器”。
3.2 synchronized与Lock的选择逻辑
聊并发锁,既要会对比,也要会回答“你项目里怎么选”。
synchronized是JVM内置的关键字,Lock是JDK提供的接口,区别集中在四点上:
- 锁的获取和释放:synchronized由字节码指令monitorenter和monitorexit自动管理,异常时JVM会自动释放锁;Lock必须手动lock和unlock,一般放在try-finally里保证释放。
- 可中断性:synchronized在获取锁时不可中断,如果拿不到锁就只能一直阻塞;Lock的lockInterruptibly可以响应中断。
- 非阻塞获取锁:synchronized只能阻塞等待;Lock的tryLock可以尝试获取,拿不到就返回false,避免死等。
- 公平性:synchronized是非公平锁;ReentrantLock构造器传true可以启用公平锁。
但面试官真正想听的,其实是synchronized的锁升级过程。无锁 → 偏向锁 → 轻量级锁 → 重量级锁,这个路径就是“让大多数场景下不要真的有线程阻塞”。偏向锁适用于只有一个线程访问同步块的场景;一旦有竞争,升级为轻量级锁,通过CAS自旋尝试获取,避免线程切换;自旋失败或竞争激烈时膨胀为重量级锁,依靠操作系统互斥量阻塞线程。这个机制说明JVM在“减少锁开销”这件事上做了大量优化。JDK15之后偏向锁被废弃,回答时如果提到这一点,会显得你关注新版本变化。
选型上我的建议是:能用synchronized就用synchronized,它简单可靠,而且JVM后续版本还在持续优化;需要超时控制、可中断或者有大量读少写场景需要读写锁时,才考虑Lock体系。
3.3 线程池参数与拒绝策略
线程池是并发模块必考内容,核心是七个参数和一个执行流程。
七个参数分别是:核心线程数corePoolSize、最大线程数maximumPoolSize、空闲线程存活时间keepAliveTime、时间单位unit、任务队列workQueue、线程工厂threadFactory、拒绝策略handler。
执行流程要按顺序说清楚:提交任务时,当前工作线程数小于corePoolSize,就创建新线程执行任务;大于corePoolSize,就放进工作队列排队;队列满了,线程数小于maximumPoolSize,就创建临时线程执行任务;队列满且线程数到达最大值,就触发拒绝策略。
拒绝策略有四种:AbortPolicy直接抛异常,这是默认的;CallerRunsPolicy由提交任务的线程自己执行,起到限流作用;DiscardPolicy静默丢弃;DiscardOldestPolicy丢弃队列里最老的任务再重新提交。
线程池的核心线程数设置没有唯一答案,但要能给出合理思路。CPU密集型任务,核心线程数设置为CPU核数加1比较合适,因为CPU密集型很少等待,线程多了反而增加切换开销。IO密集型任务,核心线程数可以设置为CPU核数的两倍,因为IO等待期间CPU是空闲的,可以多安排线程占用它。更精确的公式是CPU核数除以(1 - 阻塞系数),阻塞系数通常在0.8到0.9之间。回答时强调具体问题具体分析、压测验证更重要,比背死数字更有说服力。
4. Spring框架问到底:IoC、AOP与Bean生命周期
Spring几乎是Java后端岗位的标配,面试问到Spring的深度,直接决定你是“会用的”还是“懂原理的”。这一章,把核心概念拆到最底层。
4.1 IoC容器的本质:控制权反转到底反转了什么
IoC全称Inversion of Control,控制反转。很多新手背了定义也不知道在说什么。用生活例子解释一下:你开一家餐厅,以前食材、厨师、菜单全自己管,每天累死。现在你把餐厅交给一个中央厨房管理,你只需要说自己要什么菜,中央厨房统一调度厨师、备好食材、把菜端上来。中央厨房就是IoC容器,你需要什么就注入什么。开发者不用自己new对象、不用管依赖关系,容器负责创建对象、组装对象、管理对象生命周期。
反转的是什么?是对象创建和依赖管理的控制权。以前是代码主动new依赖,现在是容器把依赖“注入”进来,所以也叫DI依赖注入。
用Spring的时候,最直观的体验是:你只需要在类上标注@Component或在配置类里声明@Bean,容器启动时就会帮你创建这个类的实例。创建的时候如果发现这个Bean依赖其他Bean,容器会递归地先把依赖的Bean创建好再注入进来。这就是控制反转的落地。面试官如果问“IoC和DI的关系”,回答是:IoC是设计思想,DI是实现IoC的方式,Spring通过依赖注入让容器和被管理的对象解耦。
4.2 AOP底层:JDK动态代理与CGLIB
AOP,面向切面编程,核心用途是抽离日志、事务、权限这些横切逻辑,避免它们侵入业务代码。面试必问的是它的底层实现:JDK动态代理和CGLIB。
JDK动态代理要求目标类实现接口,它基于Java反射机制,在运行时生成一个代理类,代理类实现目标类同样的接口,在调用目标方法前介入逻辑。CGLIB不要求实现接口,它通过继承目标类生成子类,子类重写父类方法来实现增强。Spring默认规则是:目标类实现了接口就用JDK动态代理,没有实现接口就用CGLIB。
面试加分点是性能差异。JDK动态代理自Java8之后性能已经很接近CGLIB,而且它生成代理对象的速度更快,因为基于接口的代理类生成逻辑更简单。CGLIB因为是字节码增强生成子类,创建代理对象时有额外开销,但调用方法时性能也不差。Spring Boot 2.x之后默认强制使用CGLIB,不管有没有接口,这样做的原因是为了统一行为、避免代理类型不一致带来的排查困难。
还有一个高频追问:JDK动态代理为什么只能代理接口?因为代理类本身是运行时生成的Proxy子类,Java是单继承,Proxy已经占掉了一个父类位置,只能通过实现接口来代理目标。这个限制是语法层面决定的。
4.3 Bean生命周期和循环依赖
Bean生命周期这道题,要是能把Spring源码里那几哥后处理器串起来,回答质量会高不少。
完整生命周期大致是:实例化(对应构造器或工厂方法)→ 属性填充(依赖注入)→ 执行前置处理器方法(BeanPostProcessor的postProcessBeforeInitialization)→ 执行初始化方法(InitializingBean的afterPropertiesSet或自定义init-method)→ 执行后置处理器方法(postProcessAfterInitialization)→ 使用 → 销毁(DisposableBean的destroy方法或自定义destroy-method)。
面试官让你配一张这样的表并不要求你一字不差背源码,但至少要知道:实例化不等于初始化。实例化只是把对象new出来,此时属性还没填充,初始化才是业务上“准备好了”的节点。AOP代理一般发生在后置处理器阶段,所以拿到代理Bean是在postProcessAfterInitialization之后。
Spring三级缓存解决循环依赖也是个必考考点。一级缓存存成品Bean,二级缓存存提前暴露的早期Bean,三级缓存存ObjectFactory工厂对象。A依赖B、B依赖A时,A实例化后先把自己的ObjectFactory放进三级缓存,然后填充属性时发现需要B,就去创建B;B实例化后在填充属性时发现需要A,从三级缓存拿到A的ObjectFactory,调用getEarlyBeanReference得到早期引用,放进二级缓存,然后完成B的创建;B创建完,A拿到B的引用继续完成初始化,最后把成品A放进一级缓存。
这中间有个关键限制:Spring只能解决setter注入和字段注入的循环依赖,不能解决构造器注入的循环依赖,因为构造器注入在实例化阶段就必须拿到依赖,根本没法提前暴露早期引用。回答时主动说出这个限制,面试官会觉得你真懂而不是背概念。
5. 八股文怎么背才算“入门到精通”
背知识是一回事,把知识变成面试得分能力又是一回事。这一章不讲具体知识点了,聊聊准备面试的方法论。这部分是我在看了大量新人面试反馈后总结出来的,比多背一百道题更有用。
5.1 知识串联,先建体系再记细节
很多零基础的同学背八股文失败,不是因为记性差,是因为知识点是散的。今天背一个HashMap,明天背一个JVM垃圾回收,相互之间没有关联,过一周全忘。正确的做法是先画知识地图,再往里填细节。
我当时准备面试时,先列了一张“Java面试能力地图”,分成六大块:Java基础与集合、JVM、并发、Spring家族、MySQL、Redis。每一块先记住“这个模块在解决什么问题”,再记“核心概念有哪些”,最后才记具体细节和数字。比如JVM这块,先记住“JVM是为Java程序提供运行时环境的虚拟机,核心工作是加载类、分配内存、回收垃圾”,然后才是“内存分哪几块、垃圾回收有哪几种算法”。这样即使面试时某个细节忘了,也能根据大框架现场推导,不会开口就是一句“我不会”。
这里强烈建议用费曼学习法:把每周学的知识用自己的话讲给同事、同学听,或者写成一篇文章发出来。当你能把一个问题用大白话讲得让零基础的人也听懂时,这个知识点就真正属于你了。写面试总结博客就是很有效的练习方式,我现在回头看自己当年写的笔记,很多内容正是这样一点点从“背下来”变成“理解透”的。
5.2 用“是什么、为什么、怎么做”组织答案
背会了知识,还要会表达。面试官问一道题,最怕听到两种回答:一种是只背结论,不会展开;另一种是东扯西扯,没有逻辑。正确的方式是按“是什么、为什么、怎么做”三段式来组织答案。
拿“为什么HashMap是线程不安全的”来举例:
是什么:HashMap在多线程环境下,put操作可能丢失数据,扩容可能导致死循环(JDK1.7),所以是线程不安全的。
为什么:多个线程同时put时,如果计算出的下标一致,并且这个位置上原本没有值,两个线程可能同时插入成功,后者覆盖前者,数据就丢了。扩容时,多个线程同时rehash,可能导致链表成环。
怎么做:并发场景用ConcurrentHashMap,它通过CAS加synchronized保证线程安全,或者对HashMap做外部同步。
这个结构的好处是:就算你的结论不够深入,面试官也能看到你有完整的思考框架。几乎所有八股题都可以套这个结构。JVM类的问题套“是什么(内存结构是什么样子)、为什么(为什么这样设计)、怎么做(怎么调优)”,Spring类的问题套“是什么(IoC是什么)、为什么(解决什么问题)、怎么做(容器怎么实现)”,逻辑一下子就通了。
5.3 面试时答不出的处理方式
最后聊一个实战技巧。面试中一定会遇到不会的题,任何“精通”的人都会遇到,关键看你怎么处理。千万不要直接说“我不会”就冷场,也不要不懂装懂瞎编。正确的做法是:把问题拆成自己知道的子问题,先回答相关部分,再诚实说明盲区在哪。
面试官问“JVM的ZGC了解吗”,如果不够熟悉,可以这样回答:“ZGC是基于Region的内存布局,目标是极低停顿时间,JDK11开始引入,核心思路是不分代、使用彩色指针等技术实现并发收集。具体到染色指针的细节我研究得还不深,但这部分内容我正在系统补,有兴趣的话我可以再聊聊G1的设计思路。”这样既展示了知识边界,又显示了学习能力和关联思考能力,比硬编一个答案体面得多。
6. 一道典型面试题的完整拆解演示
光讲方法论容易空洞,我拿一道面试出现频率极高的题做个完整演示,大家感受一下前面的“什么是、为什么、怎么做”框架是怎么落地的。这道题是:线程池是怎么保证核心线程不被销毁的?
看到这道题,先不要急着背答案。用三层框架思考:是什么、为什么、怎么做。
是什么:线程池里有一个Worker内部类,每个Worker持有自己的线程,它通过一个while循环不断从工作队列里获取新任务来执行。核心线程不被销毁,关键就在于这个循环和阻塞队列。
为什么:因为核心线程的存活策略跟普通线程不同。线程池给Worker配置了allowCoreThreadTimeOut标志,默认是false。这个标志为false时,核心线程在从队列里拿任务时,用的是take()方法,而take()是阻塞方法——队列为空时,线程会阻塞等待而不是退出循环。只有当这个标志为true时,核心线程才会受keepAliveTime控制,空闲超时后退出。非核心线程拿任务用的是poll(timeout),它设置了排队时间上限,超时拿不到任务就返回null,线程退出。
怎么做:线程池在runWorker方法里包了一个while循环,循环条件就是task不为空,或者通过getTask()拿到了新任务。Worker维护着状态,只要还在线,就不断执行新任务。整个机制的本质是“让线程没事干的时候不要退出,而是阻塞等待新任务到来”,用阻塞代替销毁,节省了重复创建线程的开销。
这道题答到这里,已经包含了参数细节、源码逻辑、设计动机三个层次,面试官再追问什么都能接住。你会发现,八股文背得再多,不如掌握一个通用框架。把每道高频题都按这个思路过一遍,面试时的表现会有质的提升。
Java面试考察的从来不是背题能力,而是你对自己技术栈的理解深度。把JVM、集合、并发、Spring这些核心模块的原理吃透,再用一套自己的逻辑串起来,面试官反而会记住你是一个“有体系”的人。这套方法我指导过很多人实践,效果都很明显。希望这篇整理能帮你在准备面试的路上少走点弯路。