JAVA面试题刷了很多,但真正到现场还是容易卡壳,这是不少准备跳槽的朋友跟我抱怨过的事。作为既当过求职者、也当过面试官的人,我总结出一个小规律:基础语法题背一背就能过,但一碰到集合源码、并发原理、JVM调优、数据库索引这些题,如果只是记住了结论而没搞懂机制,三两句就会被问穿。这篇文章是系列第二篇,重点针对Java进阶高频面试题,把核心考点、底层原理和回答思路一起拆开讲,内容密度会比较高,建议收藏后慢慢消化。
1. Java集合源码级面试题:HashMap与ConcurrentHashMap
集合框架是所有Java面试绕不开的板块,其中HashMap几乎成了“必考题”。面试官通常不会直接问“HashMap怎么用”,而是问底层结构、扩容机制、线程安全性。如果能把这一块讲透,基本就能证明你真正读过源码。
1.1 HashMap的底层结构与完整put流程
HashMap在JDK 1.8之后是“数组 + 链表 + 红黑树”的结构,这一点很多人都能背出来。但面试官真正想听的是过程:当你调用map.put(key, value)时,内部发生了什么。
先看key定位的逻辑。HashMap会先调用hash(key)方法,把key的hashCode高16位与低16位做异或,目的是让高位的特征也能参与散列,减少碰撞。然后再用(n - 1) & hash计算数组下标,这里的n是数组长度。为什么用位运算而不是取模?因为当n是2的幂时,(n - 1) & hash等价于hash % n,但位运算性能更高。HashMap初始化时,即使你传的不是2的幂,也会被改成最接近的2的幂。
put流程大致是:先判断数组是否为空,为空则先扩容;然后根据hash定位到数组槽位。如果槽位为空,直接放入新节点。如果槽位不为空,说明发生Hash冲突,此时要么是转换为红黑树后的树节点,要么是一个普通链表节点,需要遍历链表,用equals比较key是否相同,找到相同key就替换value,找不到就在链表尾部插入。插入完成后,检查链表长度是否超过8,并且数组长度是否达到64,如果满足条件就转成红黑树。
这里还有一个高频追问:为什么负载因子是0.75?默认情况下,HashMap容量是16,当元素个数达到12(16 * 0.75)时就会触发扩容。0.75是空间利用率和查询效率的折中。太小了浪费空间,太大了hash冲突变多,链表和红黑树查询成本上升。至于为什么是0.75而不是0.6或0.8,源码注释里有提到泊松分布,简单理解就是在这个值下,桶中链表长度超过8的概率极低,红黑树不会被频繁触发。
面试时可以这样回答:我会先说出底层结构,然后结合put流程展开,主动提到扰动函数、2的幂、负载因子,这样面试官会觉得你不只是背过答案,而是真的理解。
1.2 JDK1.7到1.8的变化,为什么引入红黑树
面试中经常有这样一个对比题:HashMap在JDK 1.7和JDK 1.8里有什么区别?
区别不少,但最核心的是三点:第一,1.7是数组 + 链表,1.8是数组 + 链表 + 红黑树;第二,1.7插入采用头插法,1.8改为尾插法;第三,1.7扩容时所有元素重新计算下标,1.8利用2的幂特性,要么留在原位置,要么移动到“原位置 + 旧容量”的位置。
头插法改尾插法是最值得说的细节。1.7之所以用头插法,是因为后插入的数据更容易被访问到,某些场景下可以提升命中率。但头插法在并发扩容时会出现一个经典问题:链表可能形成环,导致下一次get死循环。1.8改成尾插法后,即使并发扩容,链表相对稳定,不会再形成环。
那为什么还要引入红黑树?链表查询复杂度是O(n),一旦某个桶里的数据特别多,性能退化严重。红黑树可以保证最坏情况下查询也是O(log n)。但红黑树节点占空间更大,维护旋转成本高,所以不能一有冲突就树化。JDK 1.8的规则是:链表长度达到8,且数组长度达到64,才会转成红黑树。如果数组长度没到64,即使链表已经很长,也先选择扩容,这样可以将一条长链分散到新的数组槽位中。
还有一个容易忽略的知识点:树化阈值是8,但退化阈值是6。为什么不是7?因为如果某个桶的链表一直在8和7之间反复抖动,频繁树化和反树化开销很大。中间留一个缓冲,8触发树化,6触发退化为链表,避免临界抖动。
1.3 ConcurrentHashMap的线程安全演进
HashMap本身是线程不安全的,并发环境下要么用Hashtable,要么用ConcurrentHashMap,或者用Collections.synchronizedMap。但现在面试几乎只考察ConcurrentHashMap,因为它在并发场景下的性能最优。
JDK 1.7的ConcurrentHashMap采用分段锁设计,底层是Segment数组,每个Segment继承自ReentrantLock,可以独立加锁。理论上最多支持Segment数量个线程并发写,锁粒度比Hashtable粗得多。但问题在于,Segments数量固定,并且查询一个key需要先定位到Segment再定位到内部的HashEntry,两步查找有一定开销。
JDK 1.8的ConcurrentHashMap放弃了分段锁,改用CAS + synchronized。具体做法是:如果数组槽位为空,用CAS直接放入节点;如果槽位不为空,则对这个槽位的头节点加synchronized锁,锁粒度从“一段”缩小到“一个桶”。这样并发度大大提升,而且synchronized经过锁升级之后,在低竞争场景下性能并不比ReentrantLock差,实现也更简洁。
面试中还常问:为什么1.8不用ReentrantLock替换synchronized?一方面锁粒度已经足够小,用synchronized可以减少死锁和代码复杂度;另一方面JVM对synchronized做了大量优化,比如偏向锁、轻量级锁,这些在低竞争时开销甚至接近于零。
这里必须提醒一句:如果你在项目里把HashMap当共享变量用,多个线程同时put,甚至可能连数据都丢。最轻的解决方案是直接用ConcurrentHashMap,而不是自己对HashMap加synchronized,因为后者锁粒度太大,性能会打折。
2. Java并发编程面试题:从JMM到线程池
并发是Java进阶路上最难跨过的一道坎,也是高级岗位面试的必考范围。很多面试者能把synchronized和volatile区别背得很熟,但题目一旦变换场景,比如问“这个变量要不要加volatile”,就露馅了。建议从JMM底层模型开始理解,再对比锁和线程池。
2.1 JMM与volatile的可见性、有序性
Java内存模型,即JMM,定义了主内存和工作内存的关系。所有变量存储在主内存中,每个线程有自己的工作内存,里面保存了变量的副本。线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存。如果线程A改了变量,线程B如果没有同步,它读到的很可能还是旧值,这就是可见性问题。
volatile的作用主要有两个:保证可见性,即volatile变量的修改会立即被其他线程看到;保证有序性,即禁止指令重排序。但它不保证原子性,这是最容易考的点。
举个例子,两个线程同时执行count++,即使count加了volatile,结果也可能小于20000。因为count++在字节码层面包含“读取-加1-写回”三步,volatile只能保证读取和写入是可见的,不能把三步合并成一个不可分割的操作。
volatile另一个经典应用场景是DCL单例模式:
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 问题:可能指令重排 } } } return instance; } }instance = new Singleton()不是一步完成的,它会先分配内存、初始化对象、把引用指向内存。如果不加volatile,JVM可能重排成“先指向内存,后初始化对象”,另一个线程读到没有初始化的对象,就会出现NPE。
回答这类题时,一定要主动说清“volatile不保证原子性”这个边界。面试官通常会顺着问“那AtomicInteger为什么能保证原子性”,如果你能说出CAS和Unsafe类,印象分会不错。
2.2 synchronized锁升级与ReentrantLock对比
老版本的synchronized是重量级锁,性能不好,很多人习惯用它跟ReentrantLock做对比。但其实JDK 1.6之后,JVM对synchronized做了优化,锁会经历无锁、偏向锁、轻量级锁、重量级锁的升级过程。
这里的关键是:锁只能升级,不能降级。偏向锁适用于只有一个线程访问同步块的场景;一旦出现第二个线程竞争,就升级为轻量级锁,通过自旋来等待;如果自旋失败或竞争线程数超过阈值,就膨胀为重量级锁,阻塞等待。
面试官常问:ReentrantLock和synchronized比有什么优势?答案是支持可中断、支持公平锁、支持非阻塞获取锁、支持多个Condition条件队列。比如lock.tryLock(2, TimeUnit.SECONDS)可以在有限时间拿不到锁时不再傻等,这在处理业务超时时很有用。
不过现实是,即使ReentrantLock功能更丰富,如果你的场景只是简单的互斥,synchronized完全够用,而且写法更简洁,不容易出现忘释放锁的问题。我在实际项目里,除非需要超时或公平性,否则优先用synchronized。
2.3 线程池参数与任务执行流程
线程池属于“必考但很多人答不细”的题。面试官问得最多的就是ThreadPoolExecutor的七个参数:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。
执行流程可以按这个顺序答:先提交任务,如果当前线程数小于核心线程数,就创建新线程执行任务;如果达到了核心线程数,就放入任务队列;如果队列满了,并且当前线程数小于最大线程数,就创建非核心线程;如果最大线程数也满了,就执行拒绝策略。
核心参数怎么定?没有固定答案,但可以从两个角度估算。CPU密集型任务,比如大循环、复杂计算,线程数可以设为CPU核数 + 1,避免频繁上下文切换。IO密集型任务,比如远程调用、数据库操作,线程数可以设为CPU核数 / (1 - 阻塞系数),阻塞系数一般取0.8到0.9之间。更稳妥的方式是先压测,观察CPU利用率和响应时间,再动态调整。
拒绝策略有四种:AbortPolicy直接抛异常,CallerRunsPolicy由调用者线程执行,DiscardPolicy直接丢弃,DiscardOldestPolicy丢弃队列中最老的任务。生产环境一般不建议直接丢弃或抛异常,尤其是涉及交易场景,要么用CallerRunsPolicy让调用方慢慢执行,要么自定义拒绝策略做补偿。
另外,千万别用Executors.newFixedThreadPool()和Executors.newCachedThreadPool()。前者使用无界队列,任务一多可能积压导致内存溢出;后者最大线程数是Integer.MAX_VALUE,高并发下会创建大量线程,非常危险。正确的做法是手动new ThreadPoolExecutor,设置有界队列和合理的拒绝策略。
3. JVM面试连环问:内存区域、类加载、GC
JVM内容是面试中的分水岭。初级岗位可能只问“运行时数据区有哪些”,高级岗位会直接问“线上OOM了你怎么排查”“CMS和G1怎么选”。这一部分不只是背概念,还要有实际解决问题的思路。
3.1 JVM运行时数据区与OOM场景
JVM运行时数据区分为线程私有和线程共享两块。线程私有的有程序计数器、虚拟机栈、本地方法栈;线程共享的有堆和方法区,JDK 1.8之后方法区被元空间取代,使用的是本地内存。
面试里最常见的两个OOM是堆溢出和栈溢出。堆溢出通常是因为对象太多或者对象过大,不断往堆里放对象最终撑满堆,抛出java.lang.OutOfMemoryError: Java heap space。栈溢出多是因为递归没有出口或递归层级太深,抛出StackOverflowError。元空间OOM则常见于动态生成大量类(比如CGLIB),虽然元空间使用本地内存,但也不是无限大。
遇到OOM,常规排查思路是:先拿到堆转储文件,比如用jmap -dump:format=b,file=heap.hprof <pid>导出,然后用MAT或JVisualVM分析大对象和引用链。如果线上不允许停服,可以先查看GC日志,用jstat -gcutil看看老年代是否持续增长,判断是否存在内存泄漏。
3.2 垃圾回收算法与常见收集器
垃圾回收算法有四种基础:标记-清除、标记-复制、标记-整理、分代收集。标记-清除会产生碎片;标记-复制适合存活率低的新生代;标记-整理适合存活率高的老年代。实际收集器都是这些算法的组合。
面试重点还是CMS和G1的对比。CMS是低停顿收集器,全程目标是减少STW时间,但有两个明显缺点:一是并发阶段会产生浮动垃圾,无法彻底回收干净;二是标记-清除会导致内存碎片,老年代空间明明够,却可能出现分配大对象失败,被迫Full GC。G1把堆划分成多个Region,可以有选择地回收垃圾最多的Region,并且能做到可预测停顿时间。G1在JDK 9之后成为默认收集器,也是面试官比较认可的主流方案。
回答GC题目时,最好带上自己的实战经验。比如我调优过的服务,堆设成8G,使用G1,MaxGCPauseMillis=200,同时限定G1HeapRegionSize=4M,之后通过压测观察停顿时间和吞吐量再做微调。这种具体参数描述会让面试官觉得你是真调过,而不是纸上谈兵。
3.3 类加载机制与双亲委派模型
类加载分为加载、验证、准备、解析、初始化五个阶段。双亲委派模型的流程是:类加载器收到加载请求后,不会自己先加载,而是先委派给父加载器,逐层向上请求,只有当父加载器无法完成加载时,才由自己加载。
为什么要双亲委派?两个原因:一是避免类重复加载,同一个类由父加载器加载后,子加载器不需要再加载;二是保证核心类库安全,比如java.lang.String必须由启动类加载器加载,防止用户自定义一个假的String混进JVM。
面试官还可能问:哪些场景打破了双亲委派?典型的如JDBC驱动加载,因为JDBC规范由启动类加载器加载,但具体数据库驱动由类路径下的应用类加载器加载,两者需要配合,所以JDBC使用线程上下文类加载器来突破。Tomcat也需要打破双亲委派,以便每个Web应用都能拥有独立的类库,互不影响。
我经常在面试安全类题目时碰到“能不能自己写一个java.lang.String类放到classpath里?”这种题。答案是能编译,但运行时会报安全异常,因为在加载时会被启动类加载器拦截,不会加载自定义实现。这个问题只要理解了双亲委派,马上就能回答出来。
4. MySQL与Redis高频面试题:索引、事务、缓存
Java开发离不开数据库,所以MySQL和Redis是面试里的大头。很多候选人Java基础不错,但对数据库的理解停留在写SQL层面,一追问索引底层和事务原理就说不下去。
4.1 MySQL索引结构、回表与失效场景
InnoDB的索引结构是B+树,而不是二叉树,也不是B树。B+树的特点是非叶子节点只存储索引键和指针,叶子节点存储真实数据并且通过双向链表串联。这样,一次查询可以定位到叶子节点,天然支持范围查询,而且因为非叶子节点不存数据,单页能放更多索引项,树更矮,IO次数更少。
面试中必问“回表”和“覆盖索引”。假如你在user表的name字段上建了普通索引,select * from user where name = '张三',MySQL会先在name索引树中找到主键id,再用主键id去聚簇索引树中查一整行,这个过程就是回表。如果改成select id, name from user where name = '张三',要查的字段都在二级索引里,不需要回表,这个索引就叫覆盖索引。
索引失效的场景要记熟几个典型的:左模糊匹配like '%abc';对索引列使用函数或表达式;隐式类型转换;使用or且其中一个条件不是索引列;联合索引不满足最左前缀原则;范围查询右侧的列无法继续走索引。
排查索引失效,最直接的办法是使用EXPLAIN SELECT ...。重点看type、key、rows、Extra四个字段。type从好到差依次是const、ref、range、index、ALL,ALL代表全表扫描,通常需要优化。如果Extra里出现Using filesort或Using temporary,说明排序或分组用了临时文件,也要小心。
4.2 事务隔离级别与MVCC实现
MySQL的四种隔离级别是:读未提交、读已提交、可重复读、串行化。读未提交会出现脏读;读已提交解决了脏读但会出现不可重复读;可重复读解决了不可重复读,但理论上还会出现幻读。InnoDB在可重复读级别下,通过MVCC和多版本读再加间隙锁,把幻读问题也控制住了,所以日常开发使用默认的RR级别即可。
MVCC的原理可以这样理解:在InnoDB中,每行记录都隐藏着两个字段,一个是事务ID,一个是回滚指针。事务对某行进行修改时,会把旧版本写入undo log,新版本行头指向旧版本,从而形成版本链。查询时,通过ReadView判断当前事务能看到哪个版本。
读已提交和可重复读的区别就在ReadView的生成时机。RC级别下,每次快照读都会生成一个新的ReadView,所以两次同样查询可能看到不同的数据。RR级别下,事务第一次快照读时就确定了ReadView,后续所有查询都复用这个ReadView,从而保证可重复读。
如果面试官追问“间隙锁”,你可以这样答:InnoDB在RR级别下,对普通索引和范围条件加锁时,不仅锁定匹配的记录,还会锁定这些记录之间的间隙,防止其他事务插入新数据,因此能够避免幻读。但间隙锁也容易导致死锁,所以很多互联网公司会把隔离级别降成RC,再配合其他手段保证一致性。
4.3 Redis缓存三兄弟与分布式锁
缓存题比数据库题更偏实战。穿透、击穿、雪崩是Redis面试里的三兄弟。
缓存穿透:请求的数据在缓存和数据库都不存在,每次都打到数据库,相当于穿透了缓存。解决方案有缓存空值,以及布隆过滤器前置过滤。布隆过滤器可以在查询前判断key是否可能存在,如果不存在就直接返回,减少无效查询。
缓存击穿:某个热点key在同一时刻过期,大量请求同时打到数据库。解决方式最常见的有互斥锁和逻辑过期。互斥锁是让其中一个线程去数据库加载并写缓存,其他线程等待;逻辑过期是把过期时间写到value里,异步更新,逻辑上不设物理过期时间。
缓存雪崩:大量key同时过期,或者Redis服务整体宕机,导致整个请求链路打到数据库。解决方式是给过期时间加随机值,防止同时失效;服务端要做熔断降级;Redis本身需要高可用部署。
分布式锁是Redis的必问题。早年很多人会写SET key value EXPIRE key 30,这是错误的,因为setnx和expire不是原子操作,中间进程崩溃锁就变成永不过期。正确做法是使用一个命令:
SetParams params = SetParams.setParams().nx().ex(30, TimeUnit.SECONDS); boolean ok = redisTemplate.opsForValue().setIfAbsent("lock:order", "clientId", params);释放锁时不能简单delete,需要先比较value是不是自己的clientId,防止误删别人的锁。生产环境建议直接用Redisson,它的看门狗机制会自动给锁续期,避免业务还没执行完锁就过期了。至于RedLock,在多数复杂分布式环境下争议很大,初级候选人可以不主动提,但被问到时要能说出它解决的问题和局限。
5. 框架与中间件面试题:MyBatis、Kafka、分布式一致性
Java服务端开发几乎天天和MyBatis、消息队列打交道。这一部分更贴近实际项目,如果只是用过而没想过原理,很容易被问住。
5.1 MyBatis #{}与${},以及分页插件原理
MyBatis中#{}和${}都能取参数,但语义完全不同。#{}是预编译参数占位符,MyBatis会把它转成?,然后通过PreparedStatement设置参数,可以防止SQL注入。${}是直接字符串替换,把参数值拼进SQL里,存在注入风险。
实际工作中,大部分场景都要用#{}。唯一推荐用${}的情况是动态表名、动态字段名、order by字段这类无法预编译的SQL片段,比如:
SELECT * FROM ${tableName} ORDER BY ${orderColumn} DESC这种情况必须对传入参数做白名单校验,否则一旦被外部传入恶意内容,后果不堪设想。
另一个高频题是PageHelper分页插件原理。PageHelper拦截了MyBatis执行的StatementHandler,在原有SQL基础上拼接LIMIT语句。它会先通过反射获取当前执行的SQL,用方言类生成分页SQL,再执行查询。理解这个原理后,你就知道为什么PageHelper必须紧跟第一条查询语句,中间不能插入其他SQL,因为它基于ThreadLocal保存分页参数。
5.2 Kafka为什么快,以及怎么保证消息可靠
Kafka高吞吐的秘密可以从几个层面回答。第一是顺序写磁盘,Kafka的消息都是追加写入Segment文件,避免了随机IO。第二是零拷贝,消费时使用sendfile系统调用,数据从磁盘直接到网卡,不走用户态拷贝。第三是分区并行,一个topic分成多个partition,生产者可以并行写,消费者可以并行读。第四是批量操作,生产者会把多条消息凑成一个批次再发送,减少网络往返。
消息可靠性要分开三端来说。生产者端需要设置acks=all,并开启重试,保证消息写入所有副本成功。Broker端需要把min.insync.replicas设置合理,配合副本机制,避免leader选举丢消息。消费者端需要关了自动提交offset,或在业务处理成功后再手动提交offset,防止消费到一半进程重启造成消息丢失。
面试中还常问“Kafka怎么保证不重复消费”。Kafka本身只能保证不丢消息,不能完全避免重复消息,所以在业务侧必须做幂等。最简单的幂等方案是给每条消息一个唯一ID,处理前查询是否已处理过;或者用状态机的流转,把重复消息变成无效操作。
5.3 分布式事务与数据一致性
分布式事务是大型系统面试的重点。CAP理论是基础:一致性、可用性、分区容错性三者不能兼得,而网络分区是不可避免的,所以大多数系统只能做最终一致性。
具体方案有几种。2PC(两阶段提交)通过协调者统一决定所有节点提交或回滚,强一致但性能差、协调者单点问题明显。TCC(Try-Confirm-Cancel)把每个业务分成三个动作,性能和灵活性更好,但对业务侵入大。本地消息表是经典方案:业务执行时同时写业务表和消息表,通过后台任务轮询消息表发送到MQ,消费者处理成功后通知回调删除消息。这种方案简单可靠,很多老项目还在用。更现代的方式是使用MQ的事务消息,比如RocketMQ事务消息,在半消息发送成功后执行本地事务,再根据结果提交或回滚消息。
回答数据一致性题目时,要结合自己项目的交易链路。比如下单完成时扣库存、创建订单、发送积分三件事,不能同时成功,我会用本地消息表+MQ最终一致性:核心订单状态落库后,消息一定不丢,积分系统异步消费,消费失败持续重试。如果面试官追问“消息一直失败怎么办”,就答定时任务扫表补偿,最后实在失败进入人工告警。
6. 面试实战经验:怎么把题答出亮点
技术考点讲完了,最后聊聊面试中怎么表达。很多人不是知识储备不够,而是回答方式太直白,面试官问一句答一句,没有展示出思考深度。这部分是我在面试现场见过最多的问题。
6.1 先给结论,再展开原理
我看到过太多候选人被问到“HashMap线程安全吗”时,只回答“不安全”就停了。回答“不安全”是第一步,但还不够。更好的方式是:先给一个简明结论,再说为什么,最后给出替代方案。
比如: “线程不安全。因为并发put时可能出现数据覆盖,JDK 1.7甚至可能因为头插法导致链表成环。所以在并发场景我会用ConcurrentHashMap,它用CAS和synchronized控制并发,并且支持更高的并发度。”
这种回答方式,面试官基本不用再追问第三层,但如果你只答“不安全”,他一定会继续问“为什么,那怎么办”,而你未必准备过。
讲项目时也建议用STAR逻辑:背景、目标、方案、结果。比如“之前线上订单系统经常出现库存超卖,我负责优化,通过引入Redis分布式锁和数据库乐观锁,把超卖次数降为0,QPS从200提升到800”。重点不是背诵,而是让面试官理解你在项目中的角色和取舍过程。
6.2 手写代码的雷区
面试最后经常会有手写代码环节。常见题目包括单例模式、生产者消费者、死锁、LRU缓存、多线程交替打印。写代码时最怕两件事:一是边界条件没考虑,二是方法签名不完整。
比如手写单例,很多人只写DCL,但忘了加volatile。写生产者消费者时,忘记用while(check)而用了if(check),导致被唤醒后没有重新检查条件。写LRU时,可能把双向链表的指针接错。这些小细节在面试官眼里就是代码功底。
我的建议是,面试前专门把这些经典多线程题用纸笔写一遍,不要只看不写。写的时候养成先定义清楚临界资源、锁和条件变量的习惯。如果面试时间允许,可以边写边讲思路:“这里需要加锁保护队列,用条件变量等待和通知。”这种交流方式,比闷头写更能加分。
我个人在实际面试和带团队时,始终觉得技术深度不是靠背诵堆出来的,而是靠“为什么”串起来的。面试题只是一个窗口,真正值钱的,是你面对一个具体问题时,能不能一层层拆开,说出原理、场景和取舍。准备这套题的过程中,重点不是把所有答案背下来,而是要反复问自己“如果换一种实现,会有什么问题”。想通了这些,下次面试不管怎么换问法,你都能接得住。