一、从一次惨痛的宕机说起
三年前我接手过一个支付系统的维护任务,上线后的第一个周末,凌晨三点监控报警:订单状态在并发场景下出现了“已支付”和“未支付”同时存在的脏数据。那是一个典型的秒杀场景:用户点击支付后,系统先扣减库存,再更新订单状态,最后发送消息通知。代码里只用了最简单的HashMap缓存订单状态,配合一个没有同步的计数器。当1000个并发请求同时涌入,HashMap直接变成了环形链表,CPU飙升到100%,整个应用挂死。复盘时发现,写这段代码的同事并非不懂线程安全,而是“觉得百万并发离自己很远”。这种心态恰恰是面试官最想击碎的。
面试中讲线程安全,如果只背八股文“volatile保证可见性,synchronized保证原子性”,面试官听完会礼貌性点头,然后给你一个“缺乏深度”的评分。真正的加分项是:你能把一个看似简单的线程问题,用真实的生产事故把它背后的内存模型、锁升级、死锁检测讲透。下面我就用这个支付案例贯穿始终,从原理到实战,帮你构建一套面试官听了会眼睛发亮的回答框架。
二、线程安全的三座大山:原子性、可见性、有序性
先回到那个宕机的根本原因。线程安全不是“加个锁就好”这么粗暴,它本质上是你要同时管理好三个维度:原子性(操作不可分割)、可见性(一个线程的修改对其他线程立即可见)、有序性(防止指令重排带来的意外语义)。那个支付案例里,用了没有同步的i++更新库存,i++在字节码层面是三条指令:读-改-写,线程A读到了100,线程B也读到了100,各自加1后写回,结果库存只减了1,这就是原子性失效。同时,订单状态被线程A写入了“已支付”,但线程B因为CPU缓存的原因,看到的还是“未支付”,这是可见性问题。最后,JVM可能会为了性能将“更新订单”和“扣减库存”重排序,导致订单已支付但库存没扣,这又是有序性问题。
面试官特别喜欢追问:“你凭什么说synchronized就能同时解决这三个问题?”正确的回答是:synchronized在进入同步代码块时,会强制刷新工作内存到主内存(可见性),退出时强制写回(有序性),并且互斥保证了原子性。但如果你只用volatile,它只能保证可见性和有序性,无法保证复合操作的原子性。很多候选人会混淆这一点,面试官一旦听到“volatile可以用来做计数器”,基本就判断你基础不扎实。
三、用真实案例还原锁的选择困境
回到支付系统,我的修复方案一开始用了synchronized修饰整个扣库存方法。上线后压力测试通过,但生产环境发现RT(响应时间)从5ms飙升到200ms。原因很简单:synchronized是悲观锁,无论是否冲突,都会阻塞其他线程。在秒杀场景下,大量线程同时争抢同一把锁,导致线程频繁挂起、恢复,操作系统上下文切换成了瓶颈。这就是锁粗化的负面效果。后来我改用了ConcurrentHashMap的原子性操作replace(key, oldValue, newValue) + CAS自旋,将RT降回了10ms以内。
在面试中,你要能根据场景主动选择合适的锁机制,而不是只会无脑加synchronized。比如:读多写少用ReadWriteLock,写操作少但竞争激烈用StampedLock的乐观读,复杂状态流转用Lock。举一个我实际遇到的例子:一个配置中心需要实时更新,但几乎99%是读请求。我用了ReadWriteLock,写锁有20ms延迟,但读锁完全无阻塞,每秒支撑了10万次读取。面试官听完后一定会追问:ReadWriteLock底层的AQS如何实现读锁共享?这时你就可以顺势深入AQS的state设计:高16位表示读锁计数,低16位表示写锁持有线程的重入次数,两个状态通过CAS原子更新,读锁可同时被多个线程持有,写锁只能独占。
四、深入底层:锁升级与偏向锁被淘汰的真相
很多面试题会问“synchronized在JDK 1.6之后的优化”,但99%的回答只会背诵“偏向锁→轻量级锁→重量级锁”。你如果能解释清楚为什么偏向锁在JDK 15被默认禁用,面试官绝对会高看你一眼。偏向锁的核心思想:假设只有一个线程频繁获取锁,那就直接把锁对象的Mark Word指向该线程ID,无需CAS操作。但在高并发应用中,线程一旦切换,就需要撤销偏向锁,撤销过程需要等待全局安全点(STW),这在高并发系统里简直是灾难。我参与过的一个消息中间件项目,就因为偏向锁的撤销导致频繁的Stop-The-World,GC日志里看到大量“Biased Locking revocation”暂停耗时超过100ms。后来我们直接-XX:-UseBiasedLocking关闭偏向锁,延迟反而稳定了。
锁升级的完整路径应该是:无锁→偏向锁(JDK 15后默认不启用)→轻量级锁(CAS自旋)→重量级锁(OS互斥量)。面试时你可以用一个计数器例子来演示:当10个线程并发累加,synchronized会先尝试自旋(轻量级锁),如果自旋超过一定次数(默认10次或自适应),锁会升级为重量级锁,线程进入等待队列。这里有个隐藏知识点:自旋会消耗CPU,适合锁持有时间短的场景;重量级锁适合锁持有时间长、竞争激烈的场景。你甚至可以提到“自适应自旋”,JVM会根据上次自旋成功的概率动态调整自旋次数,这是JVM一个很精细的工程优化。
五、那些年我们踩过的死锁坑
死锁是面试中的高频考点,但很多候选人只会背诵“四个必要条件:互斥、占有且等待、不可剥夺、循环等待”。你如果能讲一个真实发生的死锁案例,并且给出排查方法,面试官会认为你具备生产级问题解决能力。我遇到过的一个典型场景是:线程A先锁order表再锁user表,线程B先锁user表再锁order表,两者同时执行时死锁。更隐蔽的是,两个方法分别在不同的Service中,代码审查时很难发现跨方法的锁顺序。
解决死锁的常见思路有:统一锁顺序(所有线程按固定次序获取锁)、使用tryLock带超时(Lock接口的tryLock(time, unit)方法,获取不到就放弃并释放已有锁)、死锁检测(JDK自带的jstack + VisualVM)。我曾经在线上用jstack导出了一次死锁现场,日志里明确打印了“Found one Java-level deadlock”,然后看到了三个线程互相等待的详细信息。面试官如果追问你“如何手动触发死锁”,你可以写一段简单代码:一个线程先锁A再锁B,另一个线程先锁B再锁A,然后Thread.sleep确保交叉发生,最后用jstack验证。这种动手能力比任何理论都加分。
六、面试必杀技:用代码说话
面试官问“线程安全”时,最好的策略是打开IDE现场写一段代码,边写边解释。比如我经常演示一个“简单的计数器”:
public class UnsafeCounter { private int count = 0; public void increment() { count++; } // 问题:count++不是原子操作 }
然后我改成两种方案:方案A用synchronized,方案B用AtomicInteger。我会指出AtomicInteger底层是Unsafe的compareAndSwapInt,通过硬件级别的CAS指令保证原子性,没有锁切换的开销,但可能出现ABA问题。提到ABA就可以引出AtomicStampedReference的版本号机制。如果你再提到JMH基准测试证明:在低竞争下AtomicInteger比synchronized快20倍,但在高竞争下synchronized由于锁升级后的阻塞反而更省CPU,面试官绝对会刮目相看。
另一个面试高发区是ThreadLocal的内存泄漏。我曾遇到一个Web应用,每次请求都创建一个ThreadLocal存储用户信息,但线程池中的线程复用后,ThreadLocal的value没有被remove,导致上一次请求的用户信息泄漏给下一次请求。这就是为什么ThreadLocal用完一定要调用remove(),特别是在线程池环境中。更深入一点,ThreadLocalMap的Entry是弱引用,如果Key被GC回收,value会永远无法被访问,形成内存泄漏。你可以主动告诉面试官:在阿里巴巴Java开发手册中,明确规定“必须回收自定义的ThreadLocal变量”。
七、AQS:并发工具的基石
如果你能讲清楚AQS(AbstractQueuedSynchronizer),面试基本稳了。ReentrantLock、CountDownLatch、Semaphore、CyclicBarrier都是基于AQS实现的。AQS的核心是一个volatile int state和一个CLH(FIFO)等待队列。以ReentrantLock为例,state为0表示无锁,1表示有人持有锁,大于1表示重入次数。非公平锁的实现:tryAcquire直接CAS抢锁,抢不到就进队列;公平锁则需判断前驱节点是否在队列头部。面试官很喜欢问:“你平时用ReentrantLock,能说说它和synchronized的区别吗?”标准回答是:synchronized是隐式锁,由JVM管理,支持锁升级;ReentrantLock提供了可中断、可超时、多个Condition条件等高级功能。但你要加上一句:在性能上,JDK 1.6之后两者在低竞争场景下几乎无差别,高竞争场景下ReentrantLock的灵活性更高。
八、面试中必须避开的陷阱
有些所谓的“线程安全”做法其实是反模式。比如用Hashtable而不考虑性能,或者用String作为锁对象。String常量池会带来可怕的副作用:当两个地方用了内容相同的String作为锁,它们实际上是同一把锁,会导致完全不相关的代码块互相同步。正确做法是new Object()或专门的锁对象。另一个陷阱是double-checked locking(双重检查锁)单例模式,不加volatile时,由于指令重排,第一个线程new Instance()时可能先赋值了引用,对象还没初始化,第二个线程读到非null引用却使用了一个半构造的对象。这就是为什么单例里instance必须加volatile。面试官如果听到你把这个场景讲清楚,会认为你彻底理解了happens-before原则。
九、从线程安全到系统设计
面试最后,面试官往往会问:“如果让你设计一个高并发的库存扣减系统,你怎么保证线程安全?”一个好的回答不是“加锁”,而是结合业务场景分层设计。比如:用Redis原子自减做库存扣减(因为Redis是单线程模型,天然线程安全),扣减成功后异步发消息落到MySQL做最终一致性。这叫做“用系统架构规避线程安全问题”。而Java层面的原子类只适合做本地计数器。如果你还能提到乐观锁的ABA问题在金额扣减场景下特别危险——因为ABA可能导致余额凭空多出一倍,所以必须用版本号或时间戳——面试官会更加认可你的工程思维。
最后,送你一句我认为面试线程安全时最加分的金句:“线程安全不是代码问题,而是数据共享策略问题。如果数据根本不共享,那就没有线程安全问题。”这句话直指本质,能让面试官觉得你的思考已经超越了语法层面。现在你准备好了吗?下次面试,把这些案例和原理融进你的回答里,你会看到面试官的眼睛在发光。