Lesson 12 · 并发编程

synchronized 锁升级:偏向锁 → 轻量级锁 → 重量级锁

高级·⭐ 必问·#并发·#核心·#JVM

第 1 站

从一道面试题开始

面试官微微一笑,抛出了这个经典问题:

"synchronized 和 ReentrantLock 有什么区别?"

90% 的候选人会脱口而出:"synchronized 是重量级锁,ReentrantLock 是轻量级锁"。面试官心里已经给你打了标签——停留在 JDK 5 的认知。

事实上,从 JDK 6 开始,JVM 团队对 synchronized 做了翻天覆地的优化。引入了偏向锁、轻量级锁、锁膨胀等机制,让 synchronized 在大多数场景下的性能已经不输甚至优于 ReentrantLock。HotSpot 虚拟机甚至为此重写了大半个 monitor 模块。

这篇文章,我们就来拆解 synchronized 背后的 锁升级 机制——从一把"笨锁"进化成"智能锁"的全过程。读完之后,你会理解:

  • 对象头里到底藏了什么?
  • 为什么同一个 synchronized 关键字,性能差异可以高达 10 倍?
  • 偏向锁为什么在 JDK 15 被废弃、JDK 18 被彻底移除?

发车之前,先问自己三个问题:

  • Mark Word 有多少位?偏向锁时里面存的是什么?
  • 锁能升级,能降级吗?
  • ObjectMonitor 的 EntryList 和 WaitSet 有什么区别?

带着这些问题,我们从最底层的对象头开始。

第 2 站

对象头结构:锁信息藏在哪里?

在 HotSpot 虚拟机中,每一个 Java 对象在内存中都由三部分组成:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。锁的所有状态信息,都存储在对象头的 Mark Word 里。

对象头的组成:

对象头组成
// 64 位 JVM 下的对象头结构
┌─────────────────────────────────────┐
 Mark Word(8 字节 = 64 位)          ← 锁状态、哈希码、GC 分代年龄
├─────────────────────────────────────┤
 Klass Pointer(4/8 字节)             ← 指向类元数据(开启压缩后 4 字节)
├─────────────────────────────────────┤
 数组长度(仅数组对象,4 字节)        ← 如果是普通对象则没有这部分
└─────────────────────────────────────┘

重点在 Mark Word。它是一个 64 位的位图,不同锁状态下存储的内容完全不同:

64 位 JVM Mark Word 结构(8 字节 = 64 bits) 无锁态: unused (25 bits) + hashCode (31 bits) age(4) 0 01 ← 锁标志位 偏向锁: Thread ID (54 bits) —— 持有偏向的线程 age(4) 1 01 轻量级锁: 指向 Lock Record 的指针 (62 bits) 00 重量级锁: 指向 ObjectMonitor 的指针 (62 bits) 10 GC 标记: forwarding pointer (62 bits) 11 面试关键点 最低 2 位是「锁标志位」:01 = 无锁/偏向、00 = 轻量级、10 = 重量级、11 = GC 标记 偏向锁与无锁共用标志位 01,通过第 3 低位(biased_lock = 1/0)区分
图 164 位 JVM 的 Mark Word 在不同锁状态下存储不同内容,最低 2 位是锁标志位

注意一个细节:偏向锁和无锁的标志位都是 01,它们的区分靠第 3 低位(biased_lock 标志)。这个设计节省了标志位的空间。

第 3 站

偏向锁 (Biased Locking):一个人的独角戏

HotSpot 团队在大量真实应用中发现了一个关键观察:大多数锁在整个生命周期中只被同一个线程访问。比如一个 StringBuffer 对象几乎总是在同一个线程里被 append,一个连接池的内部状态通常由单个管理线程操作。

基于这个观察,偏向锁的设计思路非常直接:既然只有一个线程来,那就把锁"偏向"给它,后续访问连 CAS 都不需要做。

偏向锁的核心:第一次加锁用 CAS 把线程 ID 写入 Mark Word,后续同一线程再次进入同步块时,只需要比较 Mark Word 中的线程 ID 是否是自己——如果是,直接放行,零开销。

偏向锁的获取流程:

偏向锁获取 · 伪代码
// 第一次进入 synchronized 块
if (markWord.bias_pattern == 1 && markWord.threadId == 0) {
    // 对象可偏向,且还没有偏向任何线程
    CAS(markWord.threadId, 0, currentThreadId);  // CAS 写入当前线程 ID
    return; // 加锁成功,几乎无开销
}

// 后续进入 synchronized 块
if (markWord.threadId == currentThreadId) {
    return; // 直接放行!不需要任何原子操作
}

// 其他线程来竞争 → 偏向锁撤销 → 升级为轻量级锁
revokeBias();

关键优势在于 零 CAS 开销。传统加锁每次进入同步块都需要 CAS 操作,偏向锁只需要第一次 CAS,之后就是普通的内存读比较——这比 CAS 快一个数量级。

JDK 15 废弃、JDK 18 移除偏向锁

偏向锁的设计初衷是好的,但它带来了巨大的代码复杂度。HotSpot 中与偏向锁相关的代码超过 2 万行,涉及 GC、类加载、锁撤销等多个子系统的协调。

JEP 374(JDK 15)正式废弃了偏向锁,默认关闭(-XX:-UseBiasedLocking)。JDK 18 之后彻底移除。原因是:

  • 现代应用更多是多线程并发,"只有一个线程访问"的场景变少
  • 偏向锁撤销涉及 SafePoint,可能导致 STW 暂停
  • 维护成本高,收益递减——轻量级锁本身已经很快了

面试时要说:偏向锁在 JDK 15+ 已被废弃,JDK 18 彻底移除,现代 JVM 从轻量级锁起步。

第 4 站

轻量级锁 (Lightweight Lock):短暂竞争的最优解

当偏向锁遇到竞争(另一个线程也来加锁),或者 JDK 15+ 直接跳过偏向锁阶段,锁就会进入轻量级锁状态。

轻量级锁的设计假设是:锁的竞争是短暂的——线程 A 马上就会释放,线程 B 只需要稍微"自旋"等一下就拿到了。这种情况下,不值得动用操作系统级别的线程挂起(这涉及用户态到内核态的切换,代价高昂)。

核心机制是 Lock Record(锁记录),它位于线程栈帧中:

BasicLock · 栈帧中的锁记录
// 每个线程栈帧中都有若干 Lock Record
// 用于在轻量级锁阶段保存 Mark Word 的备份
struct BasicLock {
    volatile markOop displaced_header;  // 保存对象原始的 Mark Word
};

// 加锁流程:
// 1. 在当前线程栈帧中创建一个 Lock Record
// 2. 把对象的 Mark Word 复制到 Lock Record 的 displaced_header
// 3. CAS 尝试将 Mark Word 替换为指向 Lock Record 的指针
// 4. CAS 成功 → 拿到锁(锁标志位变为 00)
// 5. CAS 失败 → 存在竞争 → 可能膨胀为重量级锁

轻量级锁的完整获取与释放序列:

线程栈帧 方法调用帧 Lock Record displaced_header 局部变量 ① 复制 Mark Word 对象(堆上) Mark Word (64 bits) 原始值: hashCode|age|01 Klass Pointer 实例数据 ② CAS 替换 Mark Word → 锁记录指针 加锁序列 ① 复制 Mark Word 到 Lock Record ② CAS(MarkWord, 原始值, LockRecordPtr) CAS 成功 → 标志位变 00,拿到锁 CAS 失败 → 竞争,膨胀为重量级锁 解锁序列 ③ CAS(MarkWord, LockRecordPtr, 原始值) CAS 成功 → 释放锁,恢复 Mark Word CAS 失败 → 有线程在等,唤醒它们
图 2轻量级锁通过 CAS 操作在线程栈帧和对象头之间交换 Mark Word,避免了操作系统级别的线程挂起
轻量级锁适用场景
线程交替执行(竞争短暂),同步块执行时间短。
性能比重量级锁高约 5 倍(无用户态/内核态切换)。
第 5 站

锁膨胀 (Lock Inflation):从轻到重的临界点

当轻量级锁的 CAS 操作失败——意味着有另一个线程也在尝试获取同一把锁,竞争已经从"短暂"变成了"持续"。此时 JVM 会执行锁膨胀,将轻量级锁升级为重量级锁

膨胀过程不可逆(在绝大多数 JDK 版本中),这就是"锁升级"中"升级"的含义——一旦变重,就回不去了。

膨胀的核心步骤:

inflate() · 锁膨胀伪代码
ObjectMonitor* inflate(Thread* self, oop obj) {
    // 1. 为对象创建一个 ObjectMonitor(如果不存在)
    ObjectMonitor* monitor = new ObjectMonitor();

    // 2. 将对象的 Mark Word 从"指向 Lock Record"改为"指向 ObjectMonitor"
    //    锁标志位从 00 变为 10
    obj->set_mark(markOop::encode(monitor));

    // 3. 将之前持有轻量级锁的线程放入 ObjectMonitor 的 Owner 字段
    monitor->set_owner(lockRecordOwner);

    // 4. 将当前竞争失败的线程放入 EntryList(阻塞队列)
    monitor->enter(self);  // 线程被 park(),进入内核态阻塞

    return monitor;
}
为什么锁不能降级?

理论上,当竞争消失后,重量级锁可以"降级"回轻量级锁以恢复性能。但实际上,HotSpot 的实现中锁降级非常有限。原因:

  • 降级逻辑复杂——需要确认 EntryList 为空、没有线程在 wait() 等
  • 降级本身有开销——CAS 修改 Mark Word、清理 ObjectMonitor
  • 经验数据表明:竞争激烈过的锁,未来大概率还会竞争

JDK 8u20 之后引入了有限的锁降级,但只在安全点(SafePoint)批量进行,不是实时降级。

第 6 站

重量级锁 (Heavyweight Lock):ObjectMonitor 全解析

重量级锁的底层实现是 HotSpot 的 ObjectMonitor 对象。它管理着三组线程队列,并通过操作系统的 Mutex 和 Condition Variable 来实现线程的阻塞与唤醒。

ObjectMonitor 内部结构 Mark Word → 指向 ObjectMonitor ObjectMonitor _owner: Thread* (当前持有锁的线程) _count: int (重入计数器) _cxq: ObjectWaiter* (竞争链表,新来线程先入此) _EntryList: ObjectWaiter* (阻塞队列,等待获取锁) _WaitSet: ObjectWaiter* (已获取锁但调用了 wait() 的线程) _recursions: int (递归/重入次数) Thread-B Thread-C (已 wait()) 三个队列的区别 cxq: 刚被 park 的线程 (FIFO 入队,性能优化) EntryList: 从 cxq 转移 等待被 unpark 竞争锁 WaitSet: 持有锁后调了 wait(),等 notify() 唤醒
图 3ObjectMonitor 管理三个队列:cxq 接收新阻塞线程、EntryList 排队竞争、WaitSet 存放 wait() 线程

来看 synchronized 编译后的字节码——每个 synchronized 块会被翻译为 monitorentermonitorexit 指令:

字节码 · synchronized 编译结果
// Java 源码
synchronized (obj) {
    count++;
}

// 编译后的字节码(javap -c 输出)
  0: aload_1           // 加载 obj 到操作数栈
  1: dup               // 复制一份引用(用于 finally 中的 monitorexit)
  2: monitorenter     // 尝试获取 monitor 锁
  3: aload_0
  4: dup
  5: getfield #2      // 读取 count
  8: iconst_1
  9: iadd
 10: putfield #2      // 写回 count++
 13: aload_1
 14: monitorexit      // 正常退出,释放 monitor 锁
 15: goto 23
 18: astore_2          // 异常处理入口
 19: aload_1
 20: monitorexit      // 异常退出,也要释放锁!
 21: athrow            // 重新抛出异常
 23: return

注意字节码里有两个 monitorexit:一个是正常路径(第 14 行),一个是异常路径(第 20 行,由编译器自动生成的 finally 块)。这就是为什么 synchronized 能保证即使抛出异常,锁也一定会被释放。

ObjectMonitor 关键行为
操作行为涉及队列
加锁(monitorenter)如果 _owner 为空则获取;否则 park 线程cxq → EntryList
解锁(monitorexit)释放 _owner,从 EntryList 唤醒一个线程EntryList 出队
wait()释放 _owner,当前线程进入 WaitSet→ WaitSet
notify()从 WaitSet 移一个线程到 EntryListWaitSet → EntryList
notifyAll()将 WaitSet 所有线程移到 EntryListWaitSet → EntryList
第 7 站

锁升级全景图:状态机总览

现在把前几站的内容串联起来,看完整的锁升级状态机:

无锁 标志位: 01 偏向锁 标志位: 01 biased=1 轻量级锁 标志位: 00 重量级锁 标志位: 10 首次加锁 另一线程 竞争 JDK 15+ 跳过偏向锁,直接轻量级 CAS 失败 (膨胀) 锁升级是单向的 —— 膨胀不可逆(绝大多数 JDK 版本) 各级锁的相对性能(单线程基准 ≈ 100%) 无锁: ~100% 偏向锁: ~97% 轻量级锁: ~85% 重量级锁: ~55% * 以上为典型场景近似值,具体取决于 JVM 版本和竞争模式
图 4锁升级状态机:从无锁到重量级锁的完整升级路径,膨胀不可逆
锁升级触发条件
无锁 → 偏向锁:第一次加锁,CAS 写入线程 ID
偏向锁 → 轻量级锁:另一个线程尝试加锁(撤销偏向)
轻量级锁 → 重量级锁:CAS 替换 Mark Word 失败(检测到真实竞争)
第 8 站

总结与面试应对

最后,把 synchronized 和 ReentrantLock 做一个全面对比——这是面试中的高频追问:

synchronized vs ReentrantLock
特性synchronizedReentrantLock
实现层面JVM 内置(字节码指令)JDK 层面(java.util.concurrent)
锁释放自动释放(字节码保证)必须手动 unlock()(通常在 finally 中)
可中断不可中断lockInterruptibly() 支持中断
超时获取不支持tryLock(timeout) 支持
公平锁不支持(非公平)new ReentrantLock(true) 支持
条件变量一个(Object.wait/notify)多个(Condition.await/signal)
JDK 6+ 性能锁升级后接近 ReentrantLock基于 AQS + CAS
可读性简洁,关键字语法稍复杂,需要 try-finally
面试标准回答模板

面试官问:"synchronized 和 ReentrantLock 有什么区别?"

建议回答结构:

  • 先纠正误区:JDK 6 之后 synchronized 不再是重量级锁,引入了偏向锁、轻量级锁、锁膨胀机制
  • 讲锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,升级不可逆
  • 讲功能差异:ReentrantLock 提供可中断、超时、公平锁、多条件变量等高级功能
  • 讲使用建议:优先用 synchronized(简洁 + 自动释放 + JVM 持续优化);需要高级功能时用 ReentrantLock
  • 加分项:提到 JDK 15 废弃偏向锁(JEP 374),说明你关注技术演进

JDK 15+ 移除偏向锁后的影响:

JDK 版本演进 · 偏向锁的终结
// JDK 6 ~ 14:完整的锁升级链路
// 无锁 → 偏向锁 → 轻量级锁 → 重量级锁

// JDK 15(JEP 374):偏向锁默认关闭
// -XX:-UseBiasedLocking 成为默认值
// 实际升级链路变为:无锁 → 轻量级锁 → 重量级锁

// JDK 18+:偏向锁代码彻底移除
// 不再支持 -XX:+UseBiasedLocking
// 如果仍在使用偏向锁优化,需要评估是否迁移到 ReentrantLock

// 实践建议:
// ① 新项目直接用 JDK 17+,不需要关心偏向锁
// ② 老项目升级到 JDK 17 时,跑一遍 JMH 基准测试
// ③ 如果发现锁竞争密集型代码性能下降,考虑换成 ReentrantLock

核心知识点回顾

  • Mark Word:64 位位图,根据锁状态存储不同内容(hashCode / 线程 ID / Lock Record 指针 / ObjectMonitor 指针)
  • 偏向锁:将线程 ID 写入 Mark Word,后续零开销进入;JDK 15 废弃,JDK 18 移除
  • 轻量级锁:Lock Record + CAS,适用于短暂竞争;比重量级锁快约 5 倍
  • 锁膨胀:CAS 失败触发,轻量级锁升级为重量级锁,创建 ObjectMonitor
  • ObjectMonitor:Owner + EntryList + WaitSet + cxq,管理阻塞和等待线程
  • 锁升级单向:绝大多数 JDK 版本中膨胀不可逆
  • 选型建议:优先 synchronized;需要可中断/超时/公平/多条件时用 ReentrantLock