每到二月底,技术社群里问得最多的就不再是什么框架原理了,而是一句“金三银四冲大厂,Java面试题现在该看什么”。我在面试官的位置上坐过,也在候选人那一侧被面过几十轮,给一个比较实在的结论:大厂面试再怎么换风格,Java基础、并发、JVM、Spring、数据存储、分布式这六个板块始终是绕不开的主线。这篇文章不搞那种几百题的流水账,而是把我自己印象里出现频率最高、也最容易翻车的几类 Java 面试题,连同面试官的追问逻辑和参考回答一起拆开讲。按这条线往下过,不敢说稳过,但至少不会踩空。
1. Java基础题:看似简单却最容易翻车的考点
1.1 从“==”到equals再到hashCode:一条线理清楚
基础题在大厂面试里从来不缺席,但很多人恰恰挂在最简单的题目上。先看这个经典问题:“==和equals有什么区别?”
==如果作用于基本数据类型,比较的是值本身;如果作用于引用类型,比较的是两个引用是否指向同一个对象,也就是内存地址。而equals在没有被重写的情况下,行为等同于==,但通常我们会重写它来定义“逻辑相等”。比如两个User对象,只要身份证号相同就算同一个人,这时候就应该重写equals而不是直接比较地址。
面试官问完这一个,十有八九会接一句“那equals和hashCode是什么关系?”这里有个硬规矩:重写equals就必须重写hashCode,而且必须保证如果a.equals(b)为 true,那么a.hashCode()必须等于b.hashCode()。原因其实很实际,HashMap这类集合是靠hashCode定位桶,再用equals去比较桶内元素。如果你只重写equals不重写hashCode,两个逻辑上相等的 key 会落在不同的桶里,HashMap就会存进去两份,这显然就破坏了 Map 的语义。
顺着这条线,面试官还会继续加码:“String 的equals和hashCode是怎么实现的?String 的不变性有什么好处?”String 类内部用一个char数组或者byte数组存字符,它的equals先比较引用、再比较长度、最后逐个字符比较。hashCode则是s[0]*31^(n-1) + s[1]*31^(n-2) + ...这种散列公式,用 31 是因为它是一个奇质数,在乘法运算里冲突概率相对低,而且 JVM 对31 * i有优化,编译器会把它转成(i << 5) - i,位运算比乘法快。String 的不变性让它可以安全地被字符串常量池复用,也保证了作为 HashMap 的 key 时哈希值不会变。这个问题的意义不在背答案,而在考察你有没有把“数据结构”和“语言特性”联系起来思考的习惯。
1.2 HashMap 的底层结构、扩容机制与并发隐患
HashMap 是 Java 面试里当之无愧的“题王”。它的回答版本也在演进:JDK 7 及以前是数组加链表,JDK 8 开始变成数组加链表加红黑树。
默认初始容量是 16,负载因子是 0.75。什么意思?就是当元素个数超过16 * 0.75 = 12时,会触发扩容,容量翻倍到 32。为什么负载因子取 0.75 而不是 1 或者 0.5?这是空间和时间的折中。负载因子越高,空间利用率越高,但哈希冲突概率变大,链表长度增加,查询效率下降;负载因子太低则空间浪费太多。0.75 是官方在大量测试后给的经验值。链表转红黑树的阈值是 8,树转回链表的阈值是 6,之所以不是同一个数,是为了避免元素在边界来回浮动时频繁转换。为什么阈值取 8?本质上是一个概率问题,在随机哈希函数下,链表节点数服从泊松分布,负载因子 0.75 时,桶中链表长度达到 8 的概率已经低于千万分之一,所以 8 是一个“几乎不会出现,但出现了就说明哈希分布极差”的信号。
HashMap 的线程不安全是最常被追问的。JDK 7 里并发 put 可能导致扩容时链表形成环,一旦 get 那个 key 就会死循环,CPU 直接飙满。JDK 8 把头插法改成了尾插法,在一定程度上避免了环的产生,但并发场景下依然有数据覆盖问题:两个线程同时判断某个 key 不存在,同时插入,后写覆盖先写;还有一种情况是 put 过程中检查到容量不够,多个线程同时扩容,导致数据丢失。所以并发场景不要用 HashMap,要用ConcurrentHashMap。JDK 8 的 ConcurrentHashMap 放弃了 JDK 7 的分段锁设计,改为 CAS 加 synchronized 锁住数组的单个槽位,粒度更细,并发度更高。
我自己面试别人的时候,通常会追问一句“为什么 JDK 8 要把扩容后重新散列的算法改成高低位拆分”。JDK 7 扩容是对每个元素重新计算 hash 再确定新位置,JDK 8 优化成根据 hash 值新增的那一位是 0 还是 1,元素要么留在原索引,要么移动到“原索引加旧容量”的位置。这样做既省去了重新计算 hash 的开销,也减少了元素移动次数。这个问题能答上来,说明你确实读过源码而不是只看面经。
2. JVM 与内存模型:拉开差距的核心区
2.1 运行时数据区域与对象创建机制
JVM 这块可以说是大厂面试的分水岭。背完“程序计数器、虚拟机栈、本地方法栈、堆、方法区”这五个名字只是入门,关键是能讲清楚每个区域里到底发生了什么。
虚拟机栈管的是方法调用的过程。每次调用一个方法,JVM 就会创建一个栈帧,栈帧里有局部变量表、操作数栈、动态链接、方法出口。方法嵌套调用的深度是有上限的,线程栈深度超过限制就会抛StackOverflowError。堆则是一切对象实例和数组的分配场所,也是垃圾回收的主战场。方法区在 JDK 8 里被移到了元空间,不再使用虚拟机内存,而是用本地内存,这样设计主要是为了避免永久代的OutOfMemoryError,因为元空间默认只受本机可用内存限制,并且字符串常量池在 JDK 7 时就已经移到了堆里。
对象的创建过程也经常被连环追问:类加载检查、分配内存、初始化零值、设置对象头、执行构造方法。其中分配内存那一步,指针碰撞和空闲列表分别对应“堆内存规整”和“堆内存不规整”两种情况。如果是并发创建对象,JVM 还需要解决线程安全问题,主流做法是 CAS 加失败重试,或者使用线程本地分配缓冲(TLAB)。能聊到 TLAB 的候选人,一般基础就不会太差。
2.2 垃圾回收算法与主流收集器
垃圾回收问的是什么?我认为核心就两个:一是怎么判定对象已死,二是怎么把垃圾高效清掉。
判定对象已死,主流答案是基于可达性分析。从 GC Roots 出发,遍历引用链,不能被到达的对象就可以被回收。GC Roots 包括虚拟机栈中引用的对象、方法区中静态属性引用的对象、常量引用的对象、本地方法栈中 JNI 引用的对象等。这里有个经典坑点:两个对象互相引用,但都没有被外部引用,可达性分析依然能判定它们已死,而引用计数法做不到,所以现代主流的 JVM 都采用可达性分析。顺带一提,面试官很可能继续问“强引用、软引用、弱引用、虚引用的区别”,软引用在内存不足时会被回收,弱引用在下一次 GC 时必被回收,虚引用完全不影响生命周期,只用于对象回收后的通知。
回收算法里,标记-清除会产生大量碎片,标记-复制要浪费一半空间,标记-整理要移动对象。现代的收集器基本都是分代设计:新生代对象存活率低,适合复制算法;老年代对象存活率高,适合标记-整理或者标记-清除。CMS 是第一款并发收集器,它的初衷是减少停顿,但牺牲了 CPU 资源,并且会产生浮动垃圾,最后还因为无法处理碎片化问题在 JDK 9 里被标记为废弃。G1 把堆划分成一个个 Region,可以做到可预测的停顿时间,它的核心思路是维护一个优先级列表,回收价值最大的 Region 优先。再往后就是 ZGC,停顿时间控制在 10 毫秒以内,但越是新东西,面试里问到深度的概率就越低,因为大多数人没有线上压测经验,能答出“G1 的 Region 结构、Mixed GC、Remembered Set”已经足够过关。
2.3 线上 OOM 排查的实战套路
大厂面试基本不满足于让你背概念,经常会把问题包装成场景:“线上突然 OOM,你怎么处理?”这类问题没有唯一答案,但有一套标准动作。
先看报错信息是Java heap space还是Metaspace还是Unable to create new native thread,定位是哪块区域出了问题。然后用jps找到进程号,jstat -gcutil看各代的使用率和 GC 频率,jmap -dump:format=b,file=heap.hprof导出堆快照,再用 MAT 或者 JProfiler 分析。分析的时候先看支配树,找到占用内存最大的那个对象,再看引用链,定位到业务代码的哪一行。如果是内存泄漏,重点关注是否有全局缓存无限增长、数据库连接关闭失败、IO 流未释放、ThreadLocal 没有 remove。如果是内存溢出而非泄漏,一般是大促流量打上来导致对象瞬间暴增,这种时候更常用的手段是调整堆参数、增加机器、优化批量查询逻辑。
我记得有一次真实排查:一个定时任务每天凌晨跑批,数据量增长后频繁 Full GC,每次 Full GC 后老年代还是很快被打满。用jmap看堆,发现一个ArrayList里存了几百万个LogRecord对象。根因是批量处理失败后的重试逻辑里没有分批,重试时把失败批次和积压数据全部加载进内存再处理。最后改成游标式分批加载,同时限制最大重试批次大小,问题就消失了。这类经验在面试里讲出来,远比背诵参数列表有说服力。
3. 并发编程:不只是会用锁,而是理解锁
3.1 volatile 与 synchronized 的底层运作机制
并发这块是三到五年经验的 Java 工程师最常被深挖的地方。先来看最基础也最关键的一道题:“volatile 和 synchronized 有什么区别?”
volatile 有两个语义:保证可见性和禁止指令重排序,但它不保证原子性。可见性靠的是内存屏障加缓存一致性协议,一个线程修改了 volatile 变量后,会强制将修改写回主内存,并让其他线程中该变量的缓存行失效。禁止指令重排序则是在读写 volatile 变量的地方插入内存屏障,防止编译器和 CPU 对指令进行乱序优化。典型的应用场景就是双重检查锁单例模式中的instance字段,没有 volatile 的话,可能出现一个线程拿到半初始化对象的问题,原因在于“分配内存并设置引用”和“调用构造方法初始化对象”这两个步骤可能被重排序。
synchronized 则是对一段代码块或者一个方法加锁。它从 JDK 6 开始做了大量优化,锁的状态从无锁到偏向锁、轻量级锁、重量级锁,是一个逐渐膨胀的过程。偏向锁的意思是“这个锁大概率只被一个线程访问”,于是就在对象头里记录持有者的线程 ID,省掉 CAS;一旦有其他线程竞争,就升级成轻量级锁,通过 CAS 在栈帧中记录锁记录的地址去竞争;竞争激烈时,升级成重量级锁,未获得锁的线程进入阻塞队列,涉及操作系统用户态和内核态切换,代价最大。现在的 JDK 版本已经逐步废弃了偏向锁,但理解这种演变过程仍然很重要,因为它体现了 JVM 的一个核心设计思路:针对“锁竞争程度不同”的情况,用不同代价的方案去适配。
追问的进阶题往往长这样:“一个方法里现在既有读操作又有写操作,全加上 synchronized 会不会性能很差?怎么优化?”这就引出了读写锁、乐观锁、CAS、ConcurrentHashMap 分段设计等一堆知识。我建议回答的时候主动往“锁的粒度”和“并发数据结构”这两个方向靠,面试官会顺着你的话继续深入,答到点上就是加分。
3.2 CAS 的原理与 ABA 问题
CAS,全称 Compare And Swap,可以说是并发编程的基石之一。它的操作是:比较内存中的值和期望值,如果相等,就更新成新值,整个比较加更新在硬件层面是不可分割的。
CAS 避免了锁带来的线程挂起和恢复开销,但它不是没有问题。第一,CAS 如果一直失败,会一直自旋重试,在高并发下会白白消耗 CPU。第二,CAS 只能保证一个共享变量的原子操作,操作多个共享变量时需要加锁或者用 AtomicReference 把它们封装成一个对象。第三,就是著名的 ABA 问题:线程 1 读到值 A,线程 2 把 A 改成 B 又改回 A,线程 1 再 CAS 时发现还是 A,于是认为没有变化,就继续操作了。如果这个过程中其他线程对数据结构产生了影响,就可能出问题。
ABA 问题怎么解决?经典方案是为变量加上版本号,每次修改版本号加 1。Java 里的AtomicStampedReference就是干这个的,它同时持有引用和版本戳。比如一个账户余额,线程 A 看到余额是 100,线程 B 转进 50 变成 150,又转出 50 变回 100,如果没有版本号,线程 A 会以为中间没人动过,这在某些计费场景下会出大问题。用AtomicStampedReference就能感知到版本的变更。不过实践中大部分 ABA 场景其实影响不大,比如店铺库存这种最终还是看当前值的地方,它更关心的是即时状态。这点你主动说出来,反而会显得有真实项目判断力。
3.3 线程池的核心参数与真实调优场景
线程池面试题问得极其高频,核心就是那七个参数:corePoolSize、maximumPoolSize、keepAliveTime、workQueue、threadFactory、handler,还有一个 BlockingQueue 的实现选择。很多人把参数背得很熟,但一追问“你这几个参数在线上怎么定的”就卡住了。
先理一遍流程:提交任务后,当前线程数小于核心线程数,就创建新线程执行;超过核心线程数,任务先进入队列;队列也满了,继续创建线程直到最大线程数;再满,执行拒绝策略。这里最容易被误解的是“corePoolSize 满了之后是扩容线程,而不是等队列满直接拒”。线程数从 core 扩展到 max 的前提是队列已满。
拒绝策略有四种:AbortPolicy是直接抛异常,CallerRunsPolicy是让提交任务的线程自己跑,DiscardPolicy是静默丢弃,DiscardOldestPolicy是丢弃队列里最老的任务。实际项目里我一般推荐CallerRunsPolicy,它不会丢任务,同时也天然提供了一种背压机制:当线程池饱和时,调用方线程被迫执行任务,就自然放慢了提交速度。
参数怎么定?要看任务是 CPU 密集型还是 IO 密集型。CPU 密集型期望核心线程数接近 CPU 核数,通常是N+1;IO 密集型因为大部分时间在等待网络或磁盘,线程数可以多一点,常用公式是N * (1 + 等待时间 / 计算时间)。我曾经调过一个报表导出服务,压测时线程数从 20 加到 50,吞吐量不升反降,因为大量线程同时争抢数据库连接,连接池先成了瓶颈。后来把思路反过来:先定数据库连接池上限,再反推线程池大小,同时把导出任务拆成更细的批次放入队列,最终吞吐量反而翻倍。面试里能讲清楚这类权衡,比单纯背公式有价值得多。
4. Spring 与 Spring Boot:框架题背后的设计思想
4.1 Bean 的生命周期与三级缓存解决循环依赖
Spring 题目现在普遍向源码靠拢。第一个高频题:“Spring 中 Bean 的生命周期是什么?”
简单概括就是:实例化前阶段(BeanPostProcessor 的初始化前置处理)、实例化、属性填充、初始化(InitializingBean、init-method)、使用、销毁。但最好把这个流程挂到具体接口上去理解。比如BeanPostProcessor的postProcessBeforeInitialization和postProcessAfterInitialization分别会在初始化前后被调用,AOP 的代理对象就是在postProcessAfterInitialization这个阶段生成的。@Autowired依赖注入发生在属性填充阶段,而@PostConstruct注解是在属性填充完成之后才执行。理解了这个顺序,你就明白了为什么在构造方法里直接使用@Autowired属性会拿到 null,因为构造方法执行时机早于属性填充。
循环依赖问题也非常经典:“Spring 怎么解决循环依赖?”注意,Spring 解决的是单例 Bean 的 setter 注入循环依赖,构造器注入的循环依赖它解决不了,会直接抛BeanCurrentlyInCreationException。
Spring 的做法是三级缓存:第一级是singletonObjects,存放完整的单例 Bean;第二级是earlySingletonObjects,存放提前暴露的、还没完成属性填充的半成品对象;第三级是singletonFactories,存放 ObjectFactory。为什么需要第三级而不是直接提前创建完整对象?因为大部分 Bean 在创建后期会被动态代理,如果二级缓存直接放原始对象,后续代理对象就没有机会替换了。三级缓存中存的是 ObjectFactory,在真正暴露的时候才决定是返回原始对象还是代理对象,这样既保证了单例语义,又给 AOP 留了口子。答出这一层,面试官通常会比较满意。
4.2 AOP 的底层代理机制与失效场景
AOP 在 Spring 家族里的实践非常广泛,从声明式事务到日志切面、权限控制,再到@Async,底层都离不开动态代理。高频问题就是:“JDK 动态代理和 CGLIB 有什么区别?Spring 里会怎么选?”
JDK 动态代理基于接口,通过Proxy.newProxyInstance生成一个实现同样接口的代理类,调用方法时通过InvocationHandler转发;CGLIB 则通过继承目标类生成子类,重写目标方法。JDK 自带的代理要求目标类必须有接口,而 CGLIB 可以代理没有接口的类。Spring Boot 2.x 之后默认使用的是 CGLIB(也就是通过proxyTargetClass=true),因为现在很多业务类根本不设计接口,直接 CGLIB 更省心。但如果目标方法是 final 的或者类是 final 的,CGLIB 也代理不了,因为子类无法重写 final 方法。
AOP 失效的经典场景是什么?最典型的就是同类内部方法调用。Spring 的代理只在从外部调用 Bean 的方法时生效,如果 Bean 内部一个方法调用另一个带@Transactional的方法,走的是this引用,没有经过代理对象,事务就失效了。解决办法有三个:注入自身代理、拆到另一个 Bean 里、使用AopContext.currentProxy()。这些细节在面试里属于“答完原理之后主动补充的提分点”。还有个容易混淆的点:Spring 的事务和@Async用的是同一个代理机制,所以很多@Async失效的问题,根因和事务失效是一模一样的。
4.3 Spring 事务失效的八大典型场景
“Spring 事务在什么情况下会失效?”这是大厂面试官比较爱出的“场景题”,因为业务项目里事务踩坑太常见了。
我总结下来有八个典型场景。第一,方法不是 public 的,Spring 默认用 CGLIB 代理,代理只能增强 public 方法,非 public 方法的事务注解不生效。第二,同类内部调用,也就是前面说的不走代理。第三,异常被 catch 住吞掉了,事务感知不到异常,自然无法回滚。第四,抛出的异常类型不是 RuntimeException,而是 checked Exception,比如IOException,Spring 默认只对 RuntimeException 回滚,checked 异常不会触发回滚,除非在@Transactional里显式指定rollbackFor。第五,数据库引擎不支持事务,比如 MySQL 用了 MyISAM 表。第六,多线程调用:一个方法内开启子线程去执行另一个事务方法,因为事务和线程绑定,子线程里的事务不会和主线程合并,而且子线程抛异常也不会回滚主线程。第七,事务方法里使用this调用切面相关的内部方法。第八,类没有被 Spring 管理,也就是忘了加@Service之类的注解。
每个场景背后其实都指向同一个核心理解:Spring 事务的本质是 AOP,本质是通过代理拦截目标方法,在方法执行前开启事务、在方法返回或抛出异常时提交或回滚。把这个本质讲清楚,八个场景根本不用背,自己就能推导出来。
5. MySQL 与 Redis:数据层的进阶追问
5.1 索引为什么选 B+ 树,什么时候索引会失效
MySQL 的索引是必考内容,而且角度越来越刁钻。最基本的题目是:“InnoDB 的索引为什么用 B+ 树而不用 B 树或者红黑树?”
B+ 树相比 B 树的优势是数据只存储在叶子节点,内部节点只存键值,所以单页可以容纳更多索引项,树的层级更矮。三层 B+ 树大概可以存储千万级别到亿级别的数据量,加上叶子节点之间有链表连接,做范围查询非常高效。红黑树是二叉树,高度远高于 B+ 树,磁盘 IO 次数多,完全不适合数据库这种以磁盘块为单位的访问模式。哈希索引虽然能 O(1) 做等值查询,但没法做范围查询,也无法利用最左前缀。这是从数据结构特性去理解 MySQL 选型的核心逻辑。
接着就会被问二级索引和回表。InnoDB 是聚簇索引,主键索引的叶子节点直接存整行数据;二级索引的叶子节点存的是主键值。用二级索引查询时,先找到主键,再回聚簇索引查一次,这个过程叫回表。如果查询的列正好全部在二级索引里,就形成了覆盖索引,不需要回表,性能会好很多。这就是为什么查询建议只 select 必要列,而不是无脑 select *,因为一旦带上索引之外的列,覆盖索引就用不上了。
索引失效的场景也是必背清单:对索引列使用函数或者运算;发生隐式类型转换,比如索引列是字符串,查询条件却用了数字;使用模糊匹配且通配符在前面,LIKE '%abc'无法走索引,LIKE 'abc%'可以;使用OR连接非索引列;联合索引不满足最左前缀规则。这些规则背下来是基础,但更关键的是能从“索引本身就是一种有序结构”的角度去理解为什么失效。比如对索引列做运算,本质上是改变了索引列的值,原有排序无法再用,自然就失效了。
5.2 MySQL 的锁机制与事务隔离级别
MySQL 并发控制的相关问题,核心围绕事务隔离级别和锁。事务的四大特性 ACID 人人都能背,关键是隔离级别。
MySQL 默认的隔离级别是REPEATABLE READ,也就是可重复读。它通过多版本并发控制(MVCC)实现快照读,同时用next-key lock解决幻读问题。这里的 next-key lock 是记录锁和间隙锁的组合,它锁定的不只是某一行,还包括这行之前的间隙,这样别的事务就无法在间隙里插入新数据。理解了这一点,就能解释为什么在可重复读级别下,某些范围查询会把不相干的记录也锁住,从而可能引发死锁。
面试时一个更高频的追问是:“READ COMMITTED和REPEATABLE READ的区别是什么?”简单说,前者每次读取都生成新的快照,所以同一事务内两次查询结果可能不同;后者在事务开始时就生成快照,后续读的是同一个快照,所以能重复读。但要注意,可重复读的快照读并不能完全避免幻影读下的写冲突,需要结合 next-key lock 才能做完整防护。这也是为什么很多人说 MySQL 在默认隔离级别下其实已经基本做到了类似串行化隔离级别下才有的防幻读效果。
关于死锁,面试官很喜欢让候选人现场分析。比如两条 update 语句以相反顺序锁定两行数据,就可能形成互相等待。排查死锁的常规动作是执行SHOW ENGINE INNODB STATUS,查看 LATEST DETECTED DEADLOCK 部分,找到持锁的事务和等待锁的事务。业务上规避死锁的一个重要思路是让所有事务以固定的顺序访问表和行。
5.3 Redis 缓存穿透、击穿、雪崩的应对方案
Redis 相关的场景题,最经典的就是缓存三大问题。
缓存穿透是查询一个根本不存在的 key,请求打到数据库上,如果这种请求量很大,数据库压力就会异常。解决思路是缓存空值,把不存在的 key 也缓存一下,但要设置较短的过期时间;更好的是用布隆过滤器,在缓存前面加一道判断,把明显不存在的 key 直接拦截掉。注意布隆过滤器有误判率,它会告诉你“一定不存在”和“可能存在”,误判不会漏掉真实存在的 key,只会把极少数不存在的 key 放过去,这在缓存场景下是可接受的。
缓存击穿是某个热点 key 在过期瞬间,大量并发请求同时打到数据库。处理办法有三个:互斥锁,也就是只让一个请求去重建缓存,其他请求等待;逻辑过期,把 value 里塞一个过期时间字段,异步线程去更新缓存,请求先拿旧值返回;永不过期加后台定时刷新。互斥锁实现简单但存在串行化问题,逻辑过期能做到最终一致且不阻塞请求,但对业务代码有侵入。
缓存雪崩是大量 key 同时过期,或者 Redis 实例宕机,导致请求全部落到数据库。应对策略包括:过期时间加一个随机值,避免同一时刻集体过期;用多级缓存,本地缓存加分布式缓存;Redis 高可用部署,加上熔断降级策略。面试官通常会追问“如果 Redis 挂了你怎么办”,这时候不要只答“等它恢复”,而要把兜底方案讲出来:本地缓存临时顶上、对非核心接口做降级、数据库提前做读写分离扩容。
6. 分布式与场景题:没有标准答案的加分项
6.1 分布式锁:Redis 锁、ZooKeeper 锁与 Redlock 之争
到了这个层面,面试题逐渐变成“开放题”。第一个典型的开放问题是:“你在项目里怎么设计一个分布式锁?”
如果用的是 Redis,最简单的方案是SET key value EX seconds NX,只有键不存在时才能设置成功,过期时间保证锁不会永久持有。但这里面有几个坑。第一个坑是锁的 value 必须带上一个唯一标识,比如 UUID,释放锁时要先比较 value 再删除,否则可能出现线程 A 的锁到期自动释放,线程 B 拿到了新锁,然后 A 又去把 B 的锁删掉的情况。第二个坑是主从切换时的丢锁问题,Redis 主节点宕机后从节点顶上,如果锁还没来得及同步到从节点,锁就丢了。Redlock 算法试图通过向多个独立 Redis 节点加锁来解决这个问题,但它在极端情况下仍有争议,比如发生时钟跳跃时。面试时主动讲出 Redlock 的适用场景和争议点,会让面试官觉得你真的在生产环境里思考过。
如果用 ZooKeeper 做分布式锁,常见做法是创建临时顺序节点,每个客户端注册后得到一个序号,只有序号最小的持有锁,其他客户端监听前一个节点。临时节点的好处是客户端宕机后节点自动消失,不会出现 Redis 锁那种“死锁后只能等过期”的问题。但 ZooKeeper 锁的性能不如 Redis,而且每次加锁都要多次网络交互。实际选型逻辑是:要求高可靠性和自动释放就选 ZooKeeper,追求性能和简单性就选 Redis。这个问题没有唯一答案,你把自己项目的访问量、可用性要求、已有基础设施说清楚,就是完整的回答。
6.2 接口幂等性与重复提交的处理
“怎么保证接口的幂等性”是分布式场景的常客。面试题包装通常是“用户快速点了两次提交,后端重复扣款了,怎么避免”。
幂等性设计的核心思想是让一次和多次请求的效果相同。最简单也最可靠的方法是在数据库层面加唯一约束,比如订单号唯一、支付流水号唯一。第一次插入成功,第二次插入会因为唯一键冲突而失败,业务代码里捕获这个冲突,返回“处理中”或者“已成功”即可。对于更新场景,可以用乐观锁:表里加一个版本号,update 时带上version = ?条件,更新成功后版本号加一,更新行数为 0 就说明版本不匹配,直接丢弃该次请求。
还有一种思路是使用状态机。比如支付单的状态从“待支付”到“支付中”再到“已支付”,是一个不可逆的过程,业务代码里通过update ... where status = '待支付'这样的条件更新来保证同一状态只能被成功推进一次。这种方案的优点是语义清晰,缺点是业务代码里到处都要写状态判断,侵入性强。面试里你只要讲清楚场景匹配哪种方案,比如“下单用唯一键,扣库存用乐观锁,长流程用状态机”,就比只背一个方案要出彩。
6.3 消息队列的可靠性:不丢、不重、有序
消息队列一定是分布式简历里的高频组件。高频题是:“怎么保证消息不丢失?”
回答这个问题要分三段来拆。生产端,使用同步发送并等待 Broker 确认,或者开启事务消息、确认回调,保证消息真正到达 Broker;Broker 端,开启持久化,把消息刷到磁盘,并且对应的队列设置为持久化队列;消费端,关闭自动 ack,改成手动 ack,处理成功后再确认。只要三段每一段都有确认机制,消息就能基本做到不丢。注意消息队列的不丢是有条件的不丢,不同中间件默认配置不一样,比如 Kafka 的配置要关注acks、min.insync.replicas和enable.auto.commit三个参数。
“怎么保证不重复消费?”这才是更难的问题。因为网络重试、消费者宕机再恢复,都会导致同一消息被投递多次。消息中间件只能保证至少一次语义,无法保证刚好一次,所以业务侧必须做幂等,这又绕回了上一个话题:数据库唯一键、Redis setnx、状态机都天然可以用来做消费幂等。
“消息怎么保证顺序性?”Kafka 的答案是分区有序:同一业务 key 的消息发到同一个分区,消费者在分区内顺序处理。但一旦有多个消费者并发消费同一个分区,顺序就会乱。所以实践中通常还要配合“单分区单消费者”或者把并发度收敛,再在业务侧用状态机做兜底。这三类问题串起来答,能明显增加面试的完整度。
7. 面试现场加减分项:聊几个实操心得
7.1 答题节奏与“主动引导”
最后再分享几个我既作为面试官、也作为被面试者总结出来的实操体会。
第一,答题要学会分层。面试官问一个概念,先给一句话结论,再展开细节,不要上来就倒一大堆。比如问“HashMap 和 ConcurrentHashMap 有什么区别”,先说“线程安全的差异和底层实现不同”,再分别展开。提纲挈领的答法让面试官可以随时打断你或者沿着你的话问下去,节奏更可控。
第二,主动暴露“边界”。说完了常规实现,补一句“但是这个方案在某某场景下有局限”,面试官通常会被带着往你准备好的方向问。比如聊 Redis 分布式锁,你主动提主从切换丢锁问题,话题就很自然滑向 Redlock 和 CAP,这些都是你能提前准备的。千万不要只等面试官出招,那样很容易被问到知识盲区。
第三,不会的题要表现推导过程。大厂面试官其实不是非要你每道题都答对,他们更关注你面对不确定问题的反应。遇到不会的题,先把已知的部分拆解出来,再尝试类比到熟悉的东西上。比如让你说一个没接触过的中间件的实现原理,你可以从“它解决什么问题、有什么类似物、数据怎么流转”三个角度推。哪怕推得不够准确,也比沉默或瞎编强。
7.2 简历和项目经验的技术锚点
还有一个容易被低估的环节:项目描述里提到的每个技术名词,都要准备好被追问。写“用了 Redis 缓存”,就要准备缓存穿透、击穿、雪崩、缓存一致性这几个问题;写“用消息队列做了异步解耦”,就要准备可靠性、顺序性、积压处理这些问题。项目里用到了什么技术,面试官默认你会往深了聊,所以不要在简历上堆不认识的名词。
我建议把项目经验按“背景、方案、难点、结果”四段式准备,其中“难点”是重中之重。难点最好是那种能在技术层面体现出取舍的问题,比如“订单量上涨后,数据库查询变慢,你是怎么优化的”。这种问题既考察技术宽度,也考察你是不是真的参与了这个项目。把优化前后的数据对比也准备好,有数据支撑的回答说服力完全不一样。
准备面试本质上就是在整理自己的知识体系。刷题只是引子,真正重要的还是把每个问题的“为什么”想清楚。我自己的体会是,把上面这几条线吃透,不管面试题拿到的是哪套组合,都能找到对应的思考路径。把心态放稳,把基础夯实,金三银四的机会自然会落到有准备的人手里。