ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java并发之synchronized锁升级:从Mark Word到重量级锁的完整拆解

Java并发之synchronized锁升级:从Mark Word到重量级锁的完整拆解 如果你准备过Java并发的面试题synchronized锁升级基本是绕不开的一题。坦白说很多人在背完“无锁、偏向锁、轻量级锁、重量级锁”四个状态后仍然说不出它们到底怎么切换、有什么代价、生产中怎么观察。这篇文章以synchronized的锁升级为唯一主题从对象头Mark Word一路拆到偏向锁撤销、自旋、锁膨胀再用JOL做一个可复现的实验最后把面试追问和生产避坑一起讲清楚。无论你是正在准备后端开发面试还是业务代码里被锁竞争熬得焦头烂额这篇都能帮你少走弯路。1. 从对象头开始理解锁升级的完整链路1.1 Mark WordJVM把锁状态藏在哪里要聊锁升级绕不开对象内存布局。HotSpot中一个普通Java对象在堆里主要由三块组成对象头、实例数据、对齐填充。对象头又分为Mark Word和Klass Pointer64位JVM下Mark Word固定占8字节Klass Pointer在开启压缩指针时占4字节。锁相关的所有信息都写在Mark Word里。Mark Word本质是一块被反复复用的存储。同一段bit位在不同状态下翻译出来的含义完全不同无锁时可能存对象的identity hashcode和GC分代年龄偏向锁时存线程ID和epoch轻量级锁时存线程栈中Lock Record的指针重量级锁时存ObjectMonitor的指针。这就是为什么不同锁状态的mark word长度相同、含义却千差万别。64位JVM下Mark Word的低两位用于表示lock状态高位还有一个biased_lock位配合区分临界状态锁状态biased_locklockMark Word内容无锁001身份哈希值、GC分代年龄等偏向锁101持有线程ID、epoch、GC分代年龄轻量级锁不适用00指向线程栈中Lock Record的指针重量级锁不适用10指向ObjectMonitor的指针GC标记不适用11标记阶段使用注意无锁和偏向锁的lock位都是01区别在biased_lock位。所以面试里常说的“无锁最后三位是001、偏向锁最后三位是101”指的就是这个结构。搞懂这张表锁升级的底层逻辑就建立起来了。1.2 第一次加锁从无锁到偏向锁偏向锁解决的是“同一个线程反复拿同一把锁”的场景。很多业务代码中的synchronized块实际只会被一个线程访问很少存在真正的多线程竞争。如果每次加锁都走CAS甚至操作系统互斥量成本太高。偏向锁的做法很简单粗爆在mark word里记下持有线程的ID后续这个线程再进入直接比对ID一致就认为锁归自己连CAS都不做。第一次加锁流程大致是这样检查mark word当前是否可偏向如果可偏向就通过CAS把当前线程ID写进mark word。CAS成功说明拿到了偏向锁失败说明已有别的线程持有了偏向锁此时需要做偏向锁撤销。偏向锁是可重入的同一个线程再次进入临界区时检查到线程ID一致就放行不会修改mark word也不会额外做CAS。这里有个很出名的细节HotSpot默认在JVM启动后4秒才开始启用偏向锁通过BiasedLockingStartupDelay参数控制。原因是JVM启动初期类加载、JIT编译、对象分配非常频繁各类竞争路径上偏向锁的撤销反而会成为负担。所以你在测试时要么sleep 4秒以上要么直接加上-XX:BiasedLockingStartupDelay0。1.3 轻量级锁CAS登场当偏向锁被撤销或者对象因为先调用了hashCode而无法进入偏向锁加锁路径就会走到轻量级锁。轻量级锁针对的典型场景是“多个线程错峰访问同一个锁同一时刻基本只有一个线程持有”。它不再赌只有一个线程而是赌“排队的人不多且持锁时间很短”。获取轻量级锁的关键动作有两个。第一线程在自己的栈帧中分配一个Lock Record并把当前对象头mark word的副本保存进去这个副本叫做Displaced Mark Word。第二通过CAS把对象头的mark word替换成指向这个Lock Record的指针。CAS成功说明锁到手了CAS失败说明有其他线程已经把mark word改掉了于是开始自旋或者直接膨胀为重量级锁。轻量级锁用CAS替代操作系统级的线程阻塞在临界区短、竞争概率低时非常高效。但它的前提是“竞争不激烈”一旦两个线程同时真实地抢锁并且自旋也没能拿到锁锁就会膨胀。后面这段才进入真正的重量级玩法。1.4 重量级锁synchronized的底牌重量级锁依赖HotSpot内部的ObjectMonitor。ObjectMonitor结构里有一组关键字段_owner记录当前持有者线程_recursions记录重入次数_cxq和_EntryList保存等待队列_WaitSet保存调用wait后释放锁的线程。当一个线程获取锁失败会进入等待队列通过ObjectMonitor::EnterI执行真正的阻塞逻辑底层通常依赖操作系统层面的互斥机制或者线程park/unpark。这种做法的优点是有完整的等待队列、支持wait/notify机制缺点是线程从用户态到内核态的切换、挂起与唤醒成本非常高。锁膨胀成重量级锁之后状态就“粗”了下来。后面即使没有激烈竞争也不太会降回轻量级锁或偏向锁。释放后对象头会回到无锁状态但Monitor实例通常会回到系统缓存里复用下次再遇到竞争时膨胀路径会更快。面试里常说的“锁只能升级不能降级”重点就是指重量级锁不会自动降回轻量级或偏向。2. 为什么要有锁升级设计者的取舍2.1 为什么不是一把锁走天下如果JVM只提供一种重量级锁代码倒是简单了但几乎所有synchronized都要付出操作系统级的互斥代价。实际业务里大量锁对象要么只被单一线程访问要么竞争频率极低极端场景是“一个线程反复进入同一个synchronized块”。针对不同竞争烈度使用不同策略才是锁升级的核心思路默认赌不竞争发现竞争后逐步加戏直到竞争真的激烈了才上重量级锁。这种渐进式设计本质上是一套自适应策略。用偏向锁覆盖“无竞争单线程反复加锁”成本几乎为零用轻量级锁覆盖“轻微竞争临界区短”成本是一次CAS用重量级锁兜底“真竞争长临界区”牺牲线程切换换来公平排队。每一步的收益都是针对前面场景的代价优化而不是简单的功能叠加。2.2 自旋与自适应自旋拿CPU换唤醒阻塞和唤醒涉及操作系统调度开销很大。如果临界区只需要几微秒那么把一个线程挂起再恢复的时间可能比实际执行临界区的耗时还长。所以在轻量级锁阶段抢锁失败的线程不会立刻阻塞而是先空转几圈反复尝试CAS这就是自旋。早期JVM的自旋次数是固定的比如PreBlockSpin默认10次。后来JVM引入了自适应自旋根据上一次同一个锁上自旋后成功获得锁的概率动态调整下一次自旋次数。如果最近通过自旋成功拿到锁的比例高JVM就多自旋一会儿如果总是转半天也拿不到就直接走阻塞避免CPU空烧。自旋在单核场景是没有意义的因为一个线程空转时根本没法腾出CPU让别的线程执行。多核环境下自旋是典型的“用CPU换延迟”但如果锁竞争太激烈、临界区太长就会出现CPU飙高但任务没进展的现象。生产上看到某个线程CPU占用异常高除了死循环锁自旋也是一个排查方向。2.3 锁消除和锁粗化JIT层面的另两招锁升级讲的是运行时对象头的状态变化但HotSpot在JIT编译阶段还有两个容易混淆的优化锁消除和锁粗化。锁消除依赖逃逸分析。如果JVM判断synchronized锁住的对象根本没有逃出当前线程比如在一个方法内new出来的对象那就直接把这个锁去掉。JDK 8中对应参数是-XX:EliminateLocks默认开启。注意这种优化只是编译器的合法猜测它不会破坏程序语义因为对象本身没有线程共享的可能。锁粗化则相反把多个相邻的加锁区块合并成一个更大的锁临界区减少重复加锁解锁的次数。典型的场景是循环里多次向同一个容器追加内容如果编译器认为反复加锁解锁的代价超过持锁时间就会粗化成整段循环只锁一次。锁消除和锁粗化都是JIT按启发式规则决定的开发者不能把线程安全寄托在这上面该同步的地方仍然要同步。2.4 hashCode为什么会破坏偏向锁这是一个极容易踩坑的细节一个对象只要计算过identity hashcode就再也无法进入偏向锁。原因在于Mark Word空间有限无锁状态下hashcode占用了很大一部分bit位而偏向锁需要把线程ID写进相同的区域两者物理上不能共存。更准确地说在无锁可偏向状态下如果先调用了obj.hashCode()hash值会被写入mark word的无锁区域biased_lock位不能再变后续这个对象加锁时只能走轻量级锁路径。反过来如果对象已经处于偏向锁状态再计算hashcodeJVM会先撤销偏向锁把mark word恢复成无锁态或者干脆膨胀到重量级锁来安放hash值。这个细节在JOL实验里会被非常直观地打脸。2.5 偏向锁撤销与批量重偏向的真相偏向锁撤销不像看起来那么轻量。如果一个偏向锁正被某个线程持有另一个线程来抢JVM需要等待一个安全点暂停持有者线程然后判断持有者是否已经退出临界区。如果退出了直接撤销并可能让新线程重新偏向如果还没退出就把锁升级为轻量级锁或重量级锁。安全点期间的停顿会影响应用延迟这就是偏向锁隐藏的成本。为了减少频繁撤销JVM对同一个类做了批量重偏向和批量撤销。当同一个类的对象发生偏向锁撤销次数累计超过20次会触发批量重偏向JVM把该类的epoch加1让旧的线程ID连同旧epoch一起失效之后新线程可以重新进行偏向。如果撤销次数继续累计超过40次就会触发批量撤销JVM认为这个类的锁竞争已经很激烈不再适合偏向之后这个类新建的对象直接禁用偏向锁。两个阈值分别是BiasedLockingBulkRebiasThreshold和BiasedLockingBulkRevokeThreshold生产环境通常保持默认即可。3. 用JOL亲手复现一遍锁升级3.1 准备环境JDK 8 JOL实验前先把环境准备好。测试锁升级最好使用JDK 8因为偏向锁在JDK 8默认开启、默认延迟4秒正好可以完整观察JDK 15开始偏向锁被标记为废弃JDK 18开始默认关闭并不适合做传统锁升级演示。需要一个叫JOLJava Object Layout的工具包专门用来打印对象内存布局。引入方式很简单dependency groupIdorg.openjdk.jol/groupId artifactIdjol-core/artifactId version0.17/version /dependency运行时记得带上JVM参数。查看偏向锁状态时用-XX:UseBiasedLocking -XX:BiasedLockingStartupDelay0想强制关闭偏向锁观察轻量级锁路径时用-XX:-UseBiasedLocking -XX:BiasedLockingStartupDelay0可以用VM.current().details()在代码里打印当前JVM的详细设置确认参数生效。JOL的核心API并不复杂核心就是ClassLayout.parseInstance(obj).toPrintable()。3.2 先看无锁和偏向锁长什么样写一个最简单的demoimport org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.vm.VM; public class LockUpgradeDemo { public static void main(String[] args) throws Exception { System.out.println(VM.current().details()); Object obj new Object(); System.out.println(加锁前无锁状态); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); synchronized (obj) { System.out.println(加锁中); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } System.out.println(释放锁之后); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } }使用-XX:UseBiasedLocking -XX:BiasedLockingStartupDelay0运行时加锁前的输出末尾是001这是无锁状态。加锁中的输出末尾变成101对应偏向锁状态并且在mark word里能看到明显的线程ID写入。更关键的是释放锁之后再次打印仍然显示101。因为偏向锁不会在释放时主动复位它默认保留偏向状态方便同一个线程下次再进入。3.3 关闭偏向锁观察轻量级锁把运行参数改成-XX:-UseBiasedLocking -XX:BiasedLockingStartupDelay0还是跑同一段代码。加锁中mark word的末尾会变成00也就是轻量级锁。单线程加锁时CAS几乎必然成功所以不会膨胀稳定落在轻量级锁路径。区分一下关闭偏向锁之后任何synchronized都会走轻量级锁的Lock Record CAS流程而开启偏向锁时如果对象不可偏向加锁也会走到这里。所以“00状态”不止出现在关闭偏向锁的场景试过多次实验后会意识到锁状态的具体取值取决于运行时各种条件叠加。3.4 制造竞争观察重量级锁膨胀重量级锁需要真正的竞争。写两个线程抢同一个锁import org.openjdk.jol.info.ClassLayout; import java.util.concurrent.TimeUnit; public class HeavyLockDemo { private static final Object OBJ new Object(); public static void main(String[] args) throws Exception { Thread a new Thread(() - { synchronized (OBJ) { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(A持锁期间对象头状态); System.out.println(ClassLayout.parseInstance(OBJ).toPrintable()); } }); Thread b new Thread(() - { synchronized (OBJ) { System.out.println(B获取到锁); } }); a.start(); Thread.sleep(100); b.start(); a.join(); b.join(); } }A持有锁3秒B在100毫秒后开始抢。此时A仍然在临界区内B无法通过轻量级锁自旋获得成功最终会膨胀成重量级锁。A在锁内打印时mark word末尾通常能看到10状态。由于线程调度有不确定性如果第一次没看到10可以适当拉长A的sleep时间多跑几次。这里有个值得强调的现象AB竞争结束后你再让主线程回头打印这个对象它可能已经回到无锁状态但下次一旦竞争对象会更快地膨胀。锁升级对同一个对象更像是“路径记忆”一旦见过了大场面就很难再回到小清新路径。3.5 实验中最容易翻车的三个细节第一4秒延迟。忘了加-XX:BiasedLockingStartupDelay0代码一上来就加锁打印你永远看不到101。这不是代码错了是偏向锁还没“醒”。第二先调hashCode。在加锁前先执行obj.hashCode()再看加锁状态会发现稳定显示00而不是101原因前面已经讲过hashcode和偏向锁在mark word里互斥。第三打印行为本身会影响实验结果。JOL打印耗时不少尤其锁内打印会拉长临界区改变竞争时序。所以实验结论要多次重复验证不要一次打印就下死结论。4. 面试追问与生产实践避坑4.1 面试怎么答才不像背八股锁升级相关的面试题很固定但很多候选人只答了状态名。真正有区分度的是说出来“为什么”和“代价”。几个高频问题可以参考面试题建议回答方向synchronized在JDK 1.6之后做了哪些优化引入偏向锁、轻量级锁、自旋/自适应自旋、锁消除、锁粗化偏向锁和轻量级锁的区别偏向锁只写线程ID、后续加锁不执行CAS轻量级锁需要CAS把对象头替换为Lock Record指针什么时候发生锁膨胀偏向锁撤销后发生真实竞争或轻量级锁自旋失败锁升级能降级吗重量级锁不会主动降回轻量级或偏向锁轻量级锁释放后回到无锁状态偏向锁撤销为什么会慢需要遇到安全点暂停持有线程再执行撤销逻辑hashCode为什么影响偏向锁无锁态hashcode和偏向锁线程ID占用同一片mark word区域面试时能讲出“偏向锁撤销依赖安全点”“Mark Word是复用存储”“自旋是拿CPU换上下文切换”这三句话比背三遍状态机更有用。如果你还能顺带提起JDK 15后偏向锁被标记废弃面试官大概率会认可你有工程意识。4.2 JDK版本演进偏向锁为什么被弃用面试里越来越多会问版本演进。JDK 15通过JEP 374将偏向锁标记为废弃JDK 18开始HotSpot默认不再启用偏向锁。这不是说synchronized变弱了反而说明随着JIT和硬件发展轻量级锁路径已经足够快偏向锁带来的收益不再划算。偏向锁的问题集中在撤销成本上。撤销偏向锁需要等待安全点安全点停顿对延迟敏感的应用很伤。另外现代服务器普遍多核应用并发度相比JDK 6时代高了很多“一个锁只由一个线程访问”的场景占比在下降。代码本身的高竞争环境下偏向锁不只是没有收益还会因为反复撤销拖后腿。新版JDK默认关掉它是为了让默认行为更可预测。4.3 生产环境调参和性能排查怎么做先说结论绝大多数应用不要动锁升级相关参数。BiasedLockingStartupDelay只是测试时用BiasedLockingBulkRebiasThreshold和BiasedLockingBulkRevokeThreshold在默认值下经过大量验证手工改动未必能带来收益。线上真遇到锁竞争严重优先做三件事先定位锁的粒度能不能拆细再看临界区能不能缩短最后评估能否用无锁数据结构替代。比如计数场景用LongAdder、读写分离用ReentrantReadWriteLock或StampedLock、分片思想改造热点对象。调JVM参数是最后手段而且必须建立在压测数据上。从JDK 8迁移到JDK 17或21时还要特别注意老版本上偏向锁带来的性能红利在新版本默认关闭后会消失但现代GC和JIT也在持续变强最终性能差异只能靠真实业务压测判断不能拍脑袋。4.4 Synchronized和Lock到底怎么选很多人觉得Lock一定比synchronized快其实不一定。synchronized有JIT优化、偏向锁、轻量级锁低竞争甚至单线程反复加锁时成本可能比Lock低。Lock的优势不在速度而在控制能力公平锁、超时等待、可中断、多个Condition条件队列这些是synchronized不具备的。我的选择经验很简单只做互斥和简单同步、追求代码简洁用synchronized需要超时获取锁、可中断等待、多条件队列或者公平调度用ReentrantLock读多写少的共享数据优先考虑ReadWriteLock热点计数用原子类或LongAdder。如果是面试被问到底层原理ReentrantLock背后的AQS核心是CLH变体和volatile state和synchronized的Monitor阻塞模型并不一样不要混为一谈。最后分享一点实操体会我第一次跑JOL实验时结果完全反直觉以为会看到偏向锁却反复出现轻量级锁后来发现是忘了等4秒延迟又有一次加了hashCode调用整个结论直接被推翻。这些坑踩过一遍比背十遍八股管用得多。锁升级这套机制确实复杂但核心就一句话JVM在赌你的锁没人抢赌输了再加钱上更重的方案。把Mark Word结构搞懂再结合实际版本跑一次实验你自然能理解为什么锁升级被称为Java并发中最经典的优化策略之一。
返回列表