多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

synchronized锁升级与优化实战:从偏向锁到重量级锁

synchronized锁升级与优化实战:从偏向锁到重量级锁 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会判断持有偏向锁的线程是否还存活。如果线程已经退出竞争就把对象头恢复成无锁状态然后允许新线程重新偏向。如果线程还活着且正在执行同步块就升级为轻量级锁。这里有一个实际开发中的注意点偏向锁的撤销有延迟。正因为要等安全点高竞争场景下偏向锁反而会成为负担。每次撤销都需要STWStop 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、epochCAS写入线程ID等待安全点撤销单线程反复进入轻量级锁Lock Record指针CAS替换Mark WordCAS还原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又改回ACAS会认为它没有变化解决方案是使用带版本号的AtomicStampedReference。其次是缓存行伪共享问题多个原子变量如果恰好落在同一个缓存行一个线程修改其中一个会导致其他线程的缓存行失效性能会明显下降。解决思路是使用Contended注解填充Padding把变量分散到不同缓存行。再往上一个层面如果共享对象本身就不可变那根本不需要加锁。Java里的String是不可变的所以它天然线程安全。在实际设计里能用final修饰的字段就尽量final能通过函数式编程生成新对象就不要修改老对象的状态。4.4 锁消除与锁粗化JVM编译器做的“幕后优化”除了手动优化代码JVM还会自动做一些优化。最常见的是锁消除。当JVM通过逃逸分析判断一个对象只在线程内部使用没有被其他线程共享那synchronized加在这个对象上就毫无意义。JVM会把这个锁直接消除掉没有加锁也没有释放。典型例子是StringBuffer的append方法。StringBuffer是线程安全的类append方法上有synchronized。但如果在方法内部创建StringBuffer并且它没有被返回或传入其他方法JVM经过逃逸分析后会判定该对象不会逃逸于是消除append方法上的锁。这就是为什么很多性能优化建议里说局部变量用StringBuilder就好没必要用StringBufferJVM虽然能消除锁但还是多了一层分析开销。锁粗化和锁消除是相反的操作。锁消除是删掉没必要的锁锁粗化是把多个连续的加锁解锁合并成一个更大的锁。JVM检测到同一线程反复对同一对象加锁解锁时会扩大锁的范围减少重复获取和释放锁的次数。例如在一个循环里每次迭代都对同一个对象加锁JVM会把锁粗化到整个循环外部。4.5 避免死锁和锁顺序不一致死锁是手动加锁时的高发问题。多个线程各自持有一把锁又都在等待对方手里的锁。解决死锁有几种途径。最简单的是一次性获取所有所需资源如果不能一次性获取就释放已经持有的。另一个常见方案是规定锁的获取顺序所有线程必须按相同的顺序加锁。实际踩过的一个坑是在一个转账业务里A账户给B账户转账线程A锁定了A再锁B同时线程B给A转账锁定B再锁A就会出现互相等待。解决方式是让所有转账操作都先锁账户编号小的那个对象这样两个线程的加锁顺序一致就不会形成循环等待。如果使用ReentrantLock还可以通过tryLock加超时机制来避免无限等待拿不到锁就放弃或重试。5. synchronized与ReentrantLock怎么选性能和功能的权衡5.1 功能层面的差异决定了使用场景synchronized是JVM层面实现的ReentrantLock是JDK层面基于AbstractQueuedSynchronizerAQS实现的。功能上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相关的事件。结合线程转储能看到哪些线程在等待哪把锁等待时间是多少。有了这些数据再决定是用锁分离、读写锁还是直接替换成并发容器。盲目优化不如先度量这个原则在锁优化上尤其适用。
返回列表