Day09 synchronized锁升级全过程:偏向锁→轻量锁→重量锁
专栏《Java后端工程师进阶之路》工作日每日一更。欢迎关注老梁一个在Java后端摸爬滚打20年的老兵不讲虚的只讲能用的。一、认识Mark Word锁信息的物理存储先看一个灵魂问题JVM把锁状态记在哪里答案堆中的每个Java对象除了它的实例数据还有一个8字节64位JVM下的对象头。对象头中有个叫Mark Word的字段它就是锁信息的物理载体。锁状态最低3位(偏向模式)最低2位(非偏向)无锁0 0 10 1偏向锁1 0 1—轻量级锁—0 0重量级锁—1 0GC标记—1 1JVM用不到8个字节的空间塞进了哈希码、GC年龄、偏向线程ID、锁记录指针、重量级锁指针……像一个极度节俭的老会计把每一条信息都压缩到极致。二、偏向锁假设没有人跟你抢3.1 偏向锁的设计直觉HotSpot的作者做过一个统计大部分synchronized块实际运行时只有一个线程会反复进入。既然没人抢我们为什么还要每次加锁都走一遍CAS这不是浪费CPU吗于是JDK 6引入了偏向锁。它的逻辑极端简单第一次进入synchronized块时把Mark Word里的偏向线程ID设成当前线程以后同一个线程再进来检查一下偏向线程ID是不是自己是就跳过所有加锁操作直接执行业务逻辑注意偏向锁不是一个操作系统层面的锁它本质上是一个假设无竞争的优化。三、轻量级锁CAS接管战场4.1 什么时候升级当另一个线程尝试获取已经偏向了别人的锁时偏向锁的假设破了。JDK 8的行为JVM会在安全点SafePoint暂停持有偏向锁的线程撤销偏向锁然后升级为轻量级锁。JDK 15行为变化偏向锁默认禁用。直接从无锁到轻量级锁。后面我会专门讲为什么。轻量级锁不是操作系统互斥量。它用的是CASCompare-And-Swap自旋线程在当前栈帧中分配一个Lock Record复制Mark Word进去用CAS把对象的Mark Word替换为指向自己Lock Record的指针这一步叫置入锁记录如果CAS成功锁获取成功最低两位变成00如果CAS失败说明有人捷足先登——自旋等待自旋到一定次数还没拿到锁就升级为重量级锁4.2 轻量级锁代码演示import org.openjdk.jol.info.ClassLayout; /** * 轻量级锁演示 —— 两个线程交替竞争 * JVM参数: -XX:-UseBiasedLocking (关闭偏向锁直接走轻量级) * 或者直接用JDK 15默认就没有偏向锁 */ public class LightweightLockDemo { public static void main(String[] args) throws Exception { Object lock new Object(); System.out.println( 初始状态 ); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); // 线程A获取锁 Thread t1 new Thread(() - { synchronized (lock) { System.out.println(\n 线程A获取锁轻量级锁); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); } }, Thread-A); // 线程B也尝试获取 Thread t2 new Thread(() - { synchronized (lock) { System.out.println(\n 线程B获取锁可能升级为重量级); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); } }, Thread-B); t1.start(); t1.join(); // 等A释放 t2.start(); t2.join(); // 等B释放 } }轻量级锁的核心成本是一次CAS操作成功 零系统调用。适合锁被持有时间很短的场景——这也是为什么代码里的synchronized块如果只做字段赋值几乎不影响性能。四、重量级锁操作系统出手了当轻量级锁CAS自旋失败次数达到阈值JDK 6之前是10次或自旋等待CPU核数的一半JDK 6引入自适应自旋后会动态调整JVM认为竞争比较激烈直接升级为重量级锁。重量级锁依托的是操作系统的互斥量mutex这意味着线程被阻塞从用户态切换到内核态操作系统的线程调度器接管等待队列未获取到锁的线程进入BLOCKED状态不再消耗CPU自旋会消耗CPU代价是什么一次系统调用sys_futex的开销。这个操作在热门路径上可能吃掉你15~30微秒。/** * 重量级锁演示 —— 故意制造竞争 * 两个线程同时争一个锁 */ public class HeavyweightLockDemo { private static final Object lock new Object(); public static void main(String[] args) throws Exception { System.out.println( 初始状态 ); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); // 让线程A先持有锁线程B立即竞争 Thread holder new Thread(() - { synchronized (lock) { System.out.println(\n 线程A持有锁 ); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); try { // 故意不释放让竞争充分发生 Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, Lock-Holder); holder.start(); Thread.sleep(100); // 让holder先拿到锁 // 线程B竞争同一个锁 → 触发重量级膨胀 Thread competitor new Thread(() - { synchronized (lock) { System.out.println(\n 线程B终于拿到锁已是重量级锁); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); } }, Competitor); competitor.start(); holder.join(); competitor.join(); } }输出中你会看到Mark Word变化最低两位变成了10Mark Word高位存储的变成了指向ObjectMonitorJVM内部的重量级锁结构的指针。五、完整升级路径图解我用一张经典表格总结整个升级过程锁状态流转 无锁(001) │ ← 首个线程访问JDK 15且偏向锁开启 ▼ 偏向锁(101) ──→ 另一线程尝试获取撤销偏向锁 │ ▼ 轻量级锁(00) ──→ 自旋失败 / 竞争超过阈值 │ ▼ 重量级锁(10) ──→ 锁释放Mark Word恢复但锁对象不再降级关键事实锁只能升级不会降级。一个对象一旦经历了重量级锁即使以后只有一个线程访问它它也不会回到轻量级或偏向。HotSpot的实现就是这样——升级一次终身标记。六、JDK 15为什么干掉偏向锁JEP 374Deprecate and Disable Biased Locking这是一个很多开发不知道的重要变化。从JDK 15开始偏向锁默认-XX:-UseBiasedLocking。原因很直接维护成本太高偏向锁的撤销需要走到安全点SafePoint暂停所有线程。这个过程本身的开销在一些场景下比不加偏向锁还大。现代并发模式不再适用现代Java应用大量使用线程池、Executors同一个锁频繁被不同线程访问。偏向锁的一个线程反复进入假设在很多场景下不成立。轻量级锁已经足够快经过多年的CAS优化和CPU指令集进步特别是x86的CMPXCHG指令无竞争的CAS开销已经接近零。对实际项目的影响如果你的项目在JDK 11偏向锁默认开启而有大量线程池场景可以考虑手动加-XX:-UseBiasedLocking。别小看这个参数我们在一个支付网关项目上实测关闭偏向锁后TPS提升了约6%原因就是省去了大量偏向锁撤销的安全点停顿。七、三条实战建议建议一优化synchronized块的长度比纠结锁类型更有用我见过太多人绞尽脑汁想把synchronized换成StampedLock、换成VarHandle、换成各种花活但就是不看一眼自己的临界区// 坏的写法临界区太长包含IO操作 synchronized (this) { String data httpClient.get(http://external-service/api); // IO阻塞 this.cache.put(key, data); // 只有这句需要保护 } // 好的写法缩小临界区 String data httpClient.get(http://external-service/api); synchronized (this) { this.cache.put(key, data); }synchronized全程被持有200ms和1ms锁升级的概率完全不一样。这比换什么锁类型重要一百倍。建议二JDK 11的项目显式关闭偏向锁试试# JVM启动参数 -XX:-UseBiasedLocking前提是你的应用是线程池模型同一个锁对象被多线程访问。加了这个参数后做一次压测对比很多项目的吞吐量不降反升。建议三用JMX或jstack持续监控BLOCKED线程数# 每5秒抓一次BLOCKED线程 watch -n 5 jstack \$(jps -l | grep YourApp | awk {print \$1}) | grep -A5 BLOCKED如果一个锁导致持续的BLOCKED线程积累说明你的临界区就是有问题别想着换锁解决——去缩短它。synchronized从JDK 1.0的纯重量级锁到JDK 6的偏向/轻量级/重量级三级升级再到JDK 15砍掉偏向锁——这本身就是一个过度优化被现实打脸的故事。你要记住的不是锁升级的每一步细节而是一句话synchronized在无竞争或短时竞争下跟CAS几乎一样快你的调优重点不是换锁是减少锁的持有时间。下篇预告Day 10——AQS框架源码导读ReentrantLock/CountDownLatch的共同秘密。我们将手绘AQS状态流转图拆解CLH队列的入队出队机制看看Doug Lea如何用2000行代码统一了Java的锁世界。