1. synchronized为什么值得反复聊:它到底在管什么
做了几年Java开发,基本每次面试我都会被问到synchronized,而且问的深度一次比一次狠。从“你用过synchronized吗”到“它的锁升级过程是怎样的”,再到“偏向锁和轻量级锁的区别是什么”“锁消除的触发条件有哪些”,一个问题能拆出五个连环追问。说实话,很多人能背出“无锁、偏向锁、轻量级锁、重量级锁”这几个名词,但真要解释清楚每一步发生的条件、底层数据结构和性能开销,卡壳的非常多。
synchronized之所以值得被反复拿出来聊,是因为它不只是一个简单的加锁关键字。它背后连接的是Java对象头里的Mark Word、操作系统的Monitor机制、JVM的逃逸分析和锁消除优化,以及并发编程中最核心的原子性、可见性、有序性保障。理解synchronized,等于打通了从上层语法到底层实现的整条链路。
这篇文章不打算抄官方文档,我按实际开发和面试复盘两条线来拆解。你能看到锁升级的完整原理,也能看到真正可落地的优化手段,比如减少锁粒度、锁分离、锁消除、锁粗化,以及和ReentrantLock(也就是常说的Lock接口实现)到底怎么选。
适合什么人看?正在准备Java并发面试的开发者、写了几年业务代码但对锁的底层机制还模糊的朋友、以及想优化系统吞吐量但对锁的性能有顾虑的工程师。
2. 先搞清楚对象头里藏着什么:Mark Word与锁状态的关系
2.1 每一个Java对象都自带一把“锁的钥匙孔”
很多人以为synchronized加锁是JVM额外创建了一个锁对象,其实不是。Java对象的锁信息就存在对象头里,具体来说存在Mark Word里。32位虚拟机里Mark Word占32位,64位虚拟机里占64位。它里面存的东西会根据对象状态变化而变化,有时存hashCode,有时存GC分代年龄,有时存锁记录指针,有时存Monitor指针。
无锁状态下,Mark Word里存的是对象的hashCode和分代年龄等信息。一旦进入偏向锁状态,Mark Word里就变成线程ID和偏向时间戳。进入轻量级锁后,Mark Word里存的是栈帧中Lock Record的指针。进入重量级锁后,存的则是Monitor对象的地址。
这个设计很巧妙。它没有为锁单独分配一块内存,而是复用了对象头里的现有空间,用一组标志位来区分当前是什么状态。这也是synchronized能实现锁升级的基础——一个对象从无锁到重量级锁,全程是在同一个Mark Word里做位运算切换。
2.2 hashCode与偏向锁的冲突,很多人没意识到
这里有个细节值得单独说。偏向锁一旦启用,Mark Word里就没有空间存hashCode了。偏向锁的Mark Word存的是线程ID和epoch。如果要调用一个重写了hashCode方法的对象的hashCode,不会触发偏向锁撤销,因为重写的hashCode不走对象头的identity hash code。但如果调用的是Object.hashCode或者System.identityHashCode,那JVM就必须撤销偏向锁,把Mark Word恢复成无锁状态,才能算出hashCode。
在实际开发中,这意味着如果你在一个对象上同时使用了synchronized,并且又基于身份hashCode做了集合存储,就可能出现偏向锁一直被撤销重新偏向的情况。性能测试时这个现象更明显,因为基准测试框架常常会打印对象的identity hash code。
2.3 一个对象可以有多种状态,但同一时刻只能处于一种
对象状态切换的路径是这样的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。这个方向是不可逆的,至少从锁状态上说是这样。不能从重量级锁直接降级回轻量级锁,也不能从轻量级锁直接回到偏向锁。
JVM曾经做过锁降级的尝试,也就是允许重量级锁在安全点释放后降级为无锁或轻量级锁,但这个功能经历了好几个版本的反复调整,早期的实现里有较多性能退化和正确性风险。所以市面上大多数资料讲的还是“锁只能升级,不能降级”的简化模型。对面试而言,你按这个简化模型回答,再把JDK版本的演进情况提一句,基本就够了。
3. 锁升级四步走:从偏向锁到重量级锁的完整链路
3.1 第一步:偏向锁,就是为了“没有竞争”而准备的
偏向锁的核心逻辑是:如果一段同步代码从头到尾只有一个线程在访问,那锁的存在就没有意义。与其每次进入都做CAS操作,不如直接把线程ID记录到Mark Word里。下次该线程再次进入时,只需对比Mark Word里的线程ID是否是自己,相等就直接通过,连CAS都不用做。
偏向锁的获取流程我第一次看源码时觉得挺绕,但拆开其实就几步。线程进入synchronized块时,JVM先判断Mark Word是否处于可偏向状态,也就是看偏向标志位是否为1,锁标志位是否为01。如果可偏向,就CAS尝试把当前线程ID写入Mark Word。写入成功,获取偏向锁成功。写入失败,说明有另一个线程在竞争,这时就需要撤销偏向锁。
3.2 偏向锁撤销没那么简单,安全点是个隐藏代价
偏向锁撤销是整个锁升级链路里最容易被低估的开销。不是简单地改一个标志位,而是需要等待全局安全点,也就是所有线程都暂停在字节码边界。在这个安全点里,JVM会判断持有偏向锁的线程是否还存活。如果线程已经退出竞争,就把对象头恢复成无锁状态,然后允许新线程重新偏向。如果线程还活着且正在执行同步块,就升级为轻量级锁。
这里有一个实际开发中的注意点:偏向锁的撤销有延迟。正因为要等安全点,高竞争场景下偏向锁反而会成为负担。每次撤销都需要STW(Stop The World)停顿,虽然时间很短,但频繁触发会拉高响应延迟。这就是为什么JDK 15之后默认禁用了偏向锁,JDK 18干脆把偏向锁相关代码标记为废弃,JDK 20以后已经被移除。面试如果被问到偏向锁为什么不推荐了,可以从这个角度回答。
3.3 第二步:轻量级锁,用CAS自旋替代系统调用
当第二个线程真正来竞争时,偏向锁撤销后就会进入轻量级锁阶段。轻量级锁的做法是,每个线程在进入同步块时,在栈帧里创建一个Lock Record,然后尝试用CAS把Mark Word更新为指向Lock Record的指针。如果成功,就拿到了轻量级锁。如果失败,说明确实有竞争,线程会自旋一段时间再次尝试。
自旋的本质是忙等,它不释放CPU,反复执行CAS。这样做的好处是避免了线程阻塞和唤醒带来的上下文切换开销。坏处也明显:自旋期间CPU空转,如果持有锁的线程迟迟不释放,自旋线程就会白白消耗CPU。
JVM对自旋做了自适应优化。自适应自旋的意思是,JVM会根据最近一次自旋等待的成功率,动态调整自旋次数。如果上次自旋成功,说明竞争窗口很短,这次就多自旋几次。如果上次自旋失败,说明锁持有时间长,就减少自旋甚至直接放弃。这套机制在现代JDK里是默认开启的,开发者不需要也不能直接配置自旋次数。
3.4 第三步:重量级锁,Monitor与用户态内核态切换
自旋是有上限的,超过阈值还没拿到锁,轻量级锁就会膨胀为重量级锁。重量级锁依赖操作系统的互斥量来实现线程阻塞和唤醒。线程申请锁失败时,会被挂起,进入阻塞队列。锁释放时,系统需要唤醒队列中的线程。这个过程中涉及一次用户态到内核态的切换,再在内核态完成线程调度,然后切回用户态。
内核态切换是重量级锁性能开销大的根本原因。一次上下文切换在多核机器上大概需要几微秒,听起来不多,但如果锁竞争频繁,每秒几十万次锁申请,累积的切换开销就会直接拖垮吞吐量。这也是为什么在低竞争场景下,重量级锁远不如轻量级锁的原因。
Monitor的结构也值得了解。每个Java对象都关联一个Monitor,重量级锁的Mark Word里存的就是Monitor地址。Monitor内部有Owner字段记录持有锁的线程,有EntryList存放阻塞等待的线程,有WaitSet存放调用wait方法后等待的线程。synchronized的wait/notify机制,本质上就是操作Monitor的WaitSet。
3.5 一张表看明白四种锁状态的差异
| 锁状态 | 存储内容 | 获取方式 | 释放方式 | 适用场景 |
|---|---|---|---|---|
| 无锁 | hashCode、分代年龄 | 无 | 无 | 没有竞争 |
| 偏向锁 | 线程ID、epoch | CAS写入线程ID | 等待安全点撤销 | 单线程反复进入 |
| 轻量级锁 | Lock Record指针 | CAS替换Mark Word | CAS还原Mark Word | 竞争不激烈 |
| 重量级锁 | Monitor地址 | 操作系统互斥量 | 唤醒阻塞线程 | 高竞争或锁持有时间长 |
4. 真正能落地的优化手段:别只盯着锁升级
4.1 缩小临界区:让锁持有时间尽可能短
锁升级是JVM帮你做的,但你真正能控制的是代码怎么组织。最基础也最有效的手段就是缩小锁的覆盖范围。锁只包住需要保护的共享资源操作,不要包住IO、网络调用、耗时的计算逻辑。
举个实际例子。我之前优化过一个报表导出接口,原始代码里把整个文件生成过程都包在synchronized里,内容包括查数据库、拼接数据、写Excel、上传OSS。锁持有时间动辄几百毫秒,并发一上来线程全堵在锁上。后来的调整很简单,只对共享的数据源对象加锁,文件生成各做各的,接口耗时从平均800毫秒降到300毫秒,吞吐量翻了接近三倍。
这里有一个容易被忽略的点:缩小临界区不是越小越好。锁的获取和释放本身也有开销,如果临界区只有一两行代码,锁开销占比反而高了。合理的临界区是“把真正的共享操作包住,但不要包住无关逻辑”。
4.2 减少锁粒度:读写分离和分段锁的思路
缩小临界区是让锁内代码变短,减少锁粒度则是让多个线程可以同时进入不同的锁。经典的案例是ConcurrentHashMap在JDK 7里的分段锁设计,把整个Map分成16个Segment,每个Segment各有一把锁,线程操作不同Segment时互不干扰。
在业务开发里的对应做法是锁分离。比如一个订单服务,读多写少场景下,读操作完全不需要加锁,写操作才需要加锁。但要注意,如果读操作不加锁,可能会读到中间状态。这时候更稳妥的方案是使用读写锁ReentrantReadWriteLock,或者用CopyOnWriteArrayList这样的并发容器,读操作无锁,写操作复制一份再改。
锁分离的典型结构是这样的:维护两个锁对象,一个保护读操作,一个保护写操作。读锁可以被多个线程同时持有,写锁互斥。注意,读写锁不是万能的,如果读的比例很高、写的比例很低,它表现很好。如果读写比例接近,锁竞争依然激烈,还可能因为锁升级和降级机制增加复杂度。
4.3 使用原子类和不可变对象:能不用锁就不用锁
synchronized不是唯一的选择。对于简单的计数器、标志位、状态切换,使用AtomicInteger、AtomicLong、AtomicReference等原子类,底层基于CAS实现,比加锁轻量得多。CAS算法在并发量不太高时性能优势明显,没有线程阻塞和唤醒,也就没有上下文切换开销。
但CAS也有它的限制。首先是ABA问题,一个值从A改成B又改回A,CAS会认为它没有变化,解决方案是使用带版本号的AtomicStampedReference。其次是缓存行伪共享问题,多个原子变量如果恰好落在同一个缓存行,一个线程修改其中一个会导致其他线程的缓存行失效,性能会明显下降。解决思路是使用@Contended注解填充Padding,把变量分散到不同缓存行。
再往上一个层面,如果共享对象本身就不可变,那根本不需要加锁。Java里的String是不可变的,所以它天然线程安全。在实际设计里,能用final修饰的字段就尽量final,能通过函数式编程生成新对象就不要修改老对象的状态。
4.4 锁消除与锁粗化:JVM编译器做的“幕后优化”
除了手动优化代码,JVM还会自动做一些优化。最常见的是锁消除。当JVM通过逃逸分析判断一个对象只在线程内部使用,没有被其他线程共享,那synchronized加在这个对象上就毫无意义。JVM会把这个锁直接消除掉,没有加锁也没有释放。
典型例子是StringBuffer的append方法。StringBuffer是线程安全的类,append方法上有synchronized。但如果在方法内部创建StringBuffer,并且它没有被返回或传入其他方法,JVM经过逃逸分析后会判定该对象不会逃逸,于是消除append方法上的锁。这就是为什么很多性能优化建议里说,局部变量用StringBuilder就好,没必要用StringBuffer,JVM虽然能消除锁,但还是多了一层分析开销。
锁粗化和锁消除是相反的操作。锁消除是删掉没必要的锁,锁粗化是把多个连续的加锁解锁合并成一个更大的锁。JVM检测到同一线程反复对同一对象加锁解锁时,会扩大锁的范围,减少重复获取和释放锁的次数。例如在一个循环里每次迭代都对同一个对象加锁,JVM会把锁粗化到整个循环外部。
4.5 避免死锁和锁顺序不一致
死锁是手动加锁时的高发问题。多个线程各自持有一把锁,又都在等待对方手里的锁。解决死锁有几种途径。最简单的是一次性获取所有所需资源,如果不能一次性获取就释放已经持有的。另一个常见方案是规定锁的获取顺序,所有线程必须按相同的顺序加锁。
实际踩过的一个坑是:在一个转账业务里,A账户给B账户转账,线程A锁定了A再锁B,同时线程B给A转账锁定B再锁A,就会出现互相等待。解决方式是让所有转账操作都先锁账户编号小的那个对象,这样两个线程的加锁顺序一致,就不会形成循环等待。如果使用ReentrantLock,还可以通过tryLock加超时机制来避免无限等待,拿不到锁就放弃或重试。
5. synchronized与ReentrantLock怎么选:性能和功能的权衡
5.1 功能层面的差异,决定了使用场景
synchronized是JVM层面实现的,ReentrantLock是JDK层面基于AbstractQueuedSynchronizer(AQS)实现的。功能上ReentrantLock更丰富:支持公平锁、支持非阻塞获取锁、支持超时获取锁、支持多个Condition队列、支持中断响应。synchronized在JDK 6之后做了大量优化,但在功能上依然比ReentrantLock少。
具体来说,synchronized的wait/notify只能和同一个Monitor的等待集合交互,如果某个锁上有多个等待条件,比如“队列有任务”和“队列已空”两个条件,synchronized只能用一个等待集合处理,开发时得靠额外的状态字段来区分。ReentrantLock配合Condition可以创建多个等待队列,逻辑会清晰很多。
5.2 性能层面,现代JDK下差距其实不大
JDK 6之前,synchronized性能明显弱于ReentrantLock。但JDK 6引入偏向锁和轻量级锁后,synchronized在低竞争场景下性能反而不输ReentrantLock,因为ReentrantLock的加锁解锁需要CAS操作,而synchronized在偏向锁状态下连CAS都不需要。
在高竞争场景下,两者的差距取决于很多因素。一个是锁粒度,一个是临界区大小,还有一个是线程数量。没有绝对的“谁快谁慢”。我自己做过的基准测试数据显示,在低竞争场景(1-2个线程)下,synchronized和ReentrantLock吞吐量接近,synchronized略优。在高竞争场景(8个线程以上)下,ReentrantLock在可中断和超时场景下体验更好,但纯加锁性能差距在5%以内。
5.3 选择建议:默认synchronized,特殊需求再上ReentrantLock
我的建议是:默认优先使用synchronized。原因有三个:代码简洁,自动释放锁避免忘记解锁;现代JVM优化成熟;大部分业务场景用不到高级功能。只有遇到以下几种需求时才考虑ReentrantLock:需要公平锁、需要可中断获取锁、需要超时获取锁、需要多个Condition队列。
这里有一个常见的误解:公平锁一定比非公平锁好。其实公平锁因为需要维护FIFO队列,吞吐量反而更低,但避免了线程饥饿。非公平锁允许新来的线程插队,吞吐量更高,但可能出现长时间等待的线程一直被插队。选择哪种取决于业务能否容忍个别线程等待时间过长。我做过一个消息分发系统,为了保证高优先级任务能够及时获得锁,就用了非公平锁模式。
6. 面试实战:一套能聊半小时的synchronized回答框架
6.1 常规开头:讲清楚synchronized的三种用法
面试官问你synchronized时,不要一上来就背锁升级。先讲加锁的三种形态:修饰实例方法、修饰静态方法、修饰代码块。这三者的本质区别在于锁对象不同。修饰实例方法锁的是this对象,修饰静态方法锁的是Class对象,修饰代码块锁的是括号里指定的对象。
这个开头能展现你对基础知识的扎实掌握。接下来要补充的是:synchronized保证的三大特性。原子性,代码块内的操作要么全部执行要么全部不执行;可见性,加锁后线程对变量的修改在释放锁时刷新到主内存,获取锁时重新加载;有序性,加锁保证了代码块的执行顺序不会被重排序破坏。
6.2 转折深入:从锁升级讲到偏向锁的演进
讲完基础知识后,主动抛出锁升级机制,这是最有技术含量的一部分。从无锁到偏向锁到轻量级锁再到重量级锁,每一步的触发条件和底层数据结构都说清楚。尤其要说清楚偏向锁为什么在JDK 15后默认禁用。这里可以补充一个细节:偏向锁在高竞争场景下会因为频繁撤销而引入安全点停顿,反而成为性能瓶颈。
讲到这里,面试官基本上会开始追问细节。比如“轻量级锁的CAS具体是做什么的”“重量级锁为什么慢”“偏向锁撤销为什么要等安全点”。只要掌握前面三个章节的内容,这些追问都能接住。
6.3 加分进阶:把锁消除、锁粗化和实际案例结合起来
进阶部分是展示你真实做过性能优化的证明。不要只说“JVM有锁消除和锁粗化”,要举具体的代码场景。比如“在方法内部new StringBuffer并局部使用时,JVM会通过逃逸分析消除锁”“循环内对同一把锁反复加锁时,JVM会将锁粗化到循环外”。
说到实际优化案例时,最好带上量化数据。不要只说“优化后变快了”,要说“优化后接口TP99从420ms降到150ms,吞吐量从每秒200提升到700”。数据能证明你不仅懂原理,还有真实项目经验。
6.4 终极追问:如果让你设计一个锁管理策略,你会怎么做
这个问题考察综合设计能力。可以围绕几个维度回答:根据系统吞吐量指标评估锁竞争程度;优先使用内置并发容器如ConcurrentHashMap;采用读写分离和分段锁;需要公平性时使用ReentrantLock;所有锁必须按统一顺序获取避免死锁;配合监控工具观察锁竞争的情况,比如使用JFR查看Monitor Wait事件和Thread Lock阻塞时间。
这部分回答的关键是体现你有闭环思维:从问题分析到方案选型,从落地实施到效果验证。
6.5 面试中不建议说的几种回答
有几种回答会让面试官觉得你理解不深。第一种是一上来就背“synchronized是重量级锁”,这是典型的过时认知,JDK 6之后锁升级机制已经改变了这一情况。第二种是“synchronized和ReentrantLock只是性能有差别”,这忽略了功能维度的差异,Condition、公平锁、超时获取这些功能才是关键区别。第三种是“无脑用synchronized就行”,虽然我前面推荐默认用synchronized,但完全说不出何时要换成ReentrantLock,会让面试官怀疑你的技术深度。
7. 关于synchronized优化的几句实在话
做性能优化这几年,我最大的体会是:不要为了优化而优化。先看竞态,再谈优化。如果一段代码每天只执行几十次,无论加锁还是不加锁都没区别。如果一段代码每秒执行上万次并且有明显锁竞争,才有必要去分析锁的粒度和锁的选择。
另外一个经验是,JDK版本直接影响synchronized的行为。同样是偏向锁,JDK 8、JDK 11、JDK 17的表现可能完全不同。测试时一定标明JDK版本,不要拿着JDK 8的测试结果去推断JDK 17的生产环境表现。我之前调过一个服务,本地JDK 8测试自旋效果很好,上到生产JDK 17环境后因为偏向锁默认关闭,行为差异很大,排查了半天才定位到是锁策略变了。
最后分享一个小工具。排查锁竞争时,可以用JDK自带的jcmd或者JFR来查看Monitor Wait和Thread Lock相关的事件。结合线程转储,能看到哪些线程在等待哪把锁,等待时间是多少。有了这些数据,再决定是用锁分离、读写锁还是直接替换成并发容器。盲目优化不如先度量,这个原则在锁优化上尤其适用。