day2 JVM 锁机制解密——对象头 MarkWord 与锁升级核心流程

9 阅读12分钟

本文详细解析了 Java 虚拟机中 synchronized 锁的底层实现与优化机制,并将其与基于 AQS 的 Lock 接口 进行了对比。文章核心聚焦于锁的自动升级流程,涵盖了从低竞争环境下的偏向锁轻量级锁,再到涉及系统内核切换的重量级锁的演进全过程。文中深入探讨了 MarkWord 指针 的变化、自旋锁的缓冲作用以及 Monitor 对象 的管理逻辑。此外,作者还介绍了锁粗化、锁消除等编译器优化手段,并针对不同并发强度的应用场景提供了锁选择建议。通过对底层交互细节的剖析,本文揭示了 JVM 如何平衡多线程安全与系统执行效率。

Java 中有两种锁:synchronized 和 Lock,前者是基于对象头 MarkWord 和 Monitor 实现的非公平锁,可以在方法、代码块中使用,加解锁过程由 JVM 实现,而 Lock 是基于 AQS 的完全由 Java 语言实现的锁,对比 synchronized 而言,更为轻量灵活,支持条件等待、公平非公平锁等。

synchronized 加锁和释放锁都由 jvm 主动实现(monitorenter 和 monitorexit),每次加锁都需要找到对象关联的 monitor 对象(利用系统级别互斥方式实现,需要从用户态<->内核态相互切换),进而实现加解锁,同时还需要修改 MarkWord 相关数据,相较而言,成本较高,逻辑较重。

为了解决 synchronized 的问题,jvm 先后提出了 偏向锁、轻量级锁、自旋锁(自适应自旋锁) 等优化手段,以降低 synchronized 逻辑复杂度。

synchronized 锁升级流程:

无锁 → 偏向锁 → 轻量级锁 → 重量级锁(单向升级,不可降级)

申请锁时的完整流程:

  1. 偏向锁阶段:首次获取锁时,将对象头 MarkWord 中记录线程 ID,后续同一线程再次获取锁时,只需比对线程 ID 即可,无需 CAS 操作
  2. 轻量级锁阶段:当有其他线程竞争时,偏向锁升级为轻量级锁,在当前线程栈帧中创建 Lock Record,通过 CAS 将对象头的 MarkWord 替换为指向 Lock Record 的指针
  3. 重量级锁阶段:当轻量级锁 CAS 失败且自旋达到阈值后,升级为重量级锁,此时才会创建 Monitor 对象,将对象头的 MarkWord 替换为指向 monitor 对象的指针,竞争失败的线程进入 Monitor 的 EntryList 阻塞等待,释放锁时从 EntryList 中唤醒线程

各种锁优化机制详解

1. 偏向锁

核心思想:大多数情况下,锁不存在多线程竞争,且总是由同一线程多次获得,为了让线程获取锁的代价更低而引入偏向锁。

工作流制

  • 首次获取锁时,在对象头 MarkWord 中记录线程 ID(使用 CAS 操作)
  • 后续该线程再次进入同步块时,只需检查 MarkWord 中的线程 ID 是否为自己,无需任何同步操作
  • 重入判断:通过检查 MarkWord 中的线程 ID 即可判断是否为重入,不需要额外的锁记录

偏向锁撤销场景

  • 当其他线程尝试获取偏向锁时,需要等待全局安全点(SafePoint)撤销偏向锁
  • 如果原持有线程已退出同步块,则撤销偏向,升级为无锁状态,新线程可重新偏向
  • 如果原持有线程仍在同步块中,则升级为轻量级锁

批量重偏向与批量撤销

  • 当某个类的对象频繁发生偏向撤销,JVM 会进行批量重偏向(rebias)
  • 如果撤销次数超过阈值,则进行批量撤销,该类的所有对象都不再使用偏向锁

偏向锁升级流程:

原持有线程已退出同步块(最常见):

初始状态:
┌─────────────────────────────────────┐
│  对象 MarkWord (偏向锁)             │
│  [Thread-A ID | 1 | 01]             │
└─────────────────────────────────────┘
Thread-A 已退出同步块

Thread-B 尝试获取锁:

步骤1:Thread-B 检查 MarkWord
发现:Thread ID = Thread-A(不是自己)

步骤2:Thread-B 请求偏向锁撤销
JVM 需要在全局安全点(SafePoint)执行撤销操作

步骤3:到达安全点,暂停所有线程
所有线程停在安全点(GC 也是在安全点执行)

步骤4:检查 Thread-A 的状态
遍历 Thread-A 的栈帧,检查是否还在同步块中

结果:Thread-A 已退出同步块

步骤5:撤销偏向锁
┌─────────────────────────────────────┐
│  对象 MarkWord (无锁状态)           │
│  [hashcode | age | 0 | 01]          │
└─────────────────────────────────────┘

步骤6:恢复线程运行

步骤7:Thread-B 重新尝试获取锁
有两种可能:
a) 如果对象仍可偏向,Thread-B 获取偏向锁(重偏向)
b) 如果对象不可偏向,Thread-B 获取轻量级锁

原持有线程仍在同步块中(升级为轻量级锁):

初始状态:
┌─────────────────────────────────────┐
│  对象 MarkWord (偏向锁)             │
│  [Thread-A ID | 1 | 01]             │
└─────────────────────────────────────┘
Thread-A 正在执行同步块

Thread-B 尝试获取锁:

步骤1:Thread-B 检查 MarkWord
发现:Thread ID = Thread-A(不是自己)

步骤2:Thread-B 请求偏向锁撤销

步骤3:到达安全点

步骤4:检查 Thread-A 的状态
遍历 Thread-A 的栈帧

结果:Thread-A 仍在同步块中!

步骤5:升级为轻量级锁
5.1 在 Thread-A 的栈帧中创建 Lock Record
    ┌─────────────────────────────┐
    │  Thread-A 栈帧              │
    │  ┌───────────────────────┐  │
    │  │ Lock Record           │  │
    │  │ Displaced Mark Word:  │  │
    │  │ [hashcode|age|0|01]   │  │ ← 保存原始信息
    │  └───────────────────────┘  │
    └─────────────────────────────┘

5.2 修改对象 MarkWord 指向 Lock Record
    ┌─────────────────────────────────────┐
    │  对象 MarkWord (轻量级锁)           │
    │  [Lock Record 指针 | 00]            │
    └─────────────────────────────────────┘

步骤6:恢复线程运行

步骤7:Thread-B 尝试获取轻量级锁
7.1 在自己栈中创建 Lock Record
7.2 尝试 CAS 替换对象 MarkWord
7.3 CAS 失败(因为 Thread-A 持有)
7.4 开始自旋等待

多线程激烈竞争(直接膨胀为重量级锁):

特殊情况:偏向锁可以直接膨胀为重量级锁

触发条件:
1. 原线程仍在同步块中
2. 有多个线程同时竞争
3. 或者调用了 wait() / notify() 方法

流程:

步骤1:检测到多线程竞争或 wait() 调用

步骤2:到达安全点

步骤3:直接创建 Monitor 对象
┌────────────────────────────────┐
│  Monitor                       │
│  Owner: Thread-A               │
│  EntryList: [Thread-B, C, D]   │
│  WaitSet: []                   │
└────────────────────────────────┘

步骤4:修改对象 MarkWord
┌─────────────────────────────────────┐
│  对象 MarkWord (重量级锁)           │
│  [Monitor 指针 | 10]                │
└─────────────────────────────────────┘

步骤5:竞争线程进入 EntryList 阻塞等待

2. 轻量级锁

核心思想:在没有多线程竞争的情况下,通过 CAS 操作减少重量级锁使用操作系统互斥量产生的性能消耗。

工作流程

  • 线程在执行同步块前,在栈帧中创建 Lock Record(锁记录)
  • 将对象头的 MarkWord 复制到 Lock Record 中(Displaced Mark Word)
  • 使用 CAS 尝试将对象头的 MarkWord 替换为指向 Lock Record 的指针
  • 重入处理:同一线程再次获取锁时,会创建新的 Lock Record,但 Displaced Mark Word 为 null,通过 Lock Record 数量判断重入次数
  • 解锁时,使用 CAS 将 Displaced Mark Word 替换回对象头,如果失败说明存在竞争,需要膨胀为重量级锁

加锁逻辑:

加锁前:
┌──────────────┐
│   对象头     │
│  MarkWord    │
│ [hash|age|01]│
└──────────────┘

加锁中:
线程栈                           堆中对象
┌─────────────────┐           ┌──────────────┐
│  Lock Record    │           │   对象头     │
│  ┌───────────┐  │           │  MarkWord    │
│  │Displaced  │  │  ←─复制─  │ [hash|age|01]│
│  │Mark Word  │  │           └──────────────┘
│  │[hash|age] │  │
│  └───────────┘  │
│  ┌───────────┐  │
│  │   obj     │──┼─→ 指向对象
│  └───────────┘  │
└─────────────────┘

加锁后(CAS 成功):
线程栈                           堆中对象
┌─────────────────┐           ┌──────────────┐
│  Lock Record    │  ←────────┤   对象头     │
│  ┌───────────┐  │   指针    │  MarkWord    │
│  │Displaced  │  │           │ [ptr | 00]   │
│  │Mark Word  │  │           └──────────────┘
│  │[hash|age] │  │  ← 保存了原始 MarkWord
│  └───────────┘  │
│  ┌───────────┐  │
│  │   obj     │──┼─→ 指向对象
│  └───────────┘  │
└─────────────────┘

锁膨胀逻辑:

阶段1:轻量级锁状态
┌─────────────┐
│   对象头    │
│ MarkWord    │
│ [LR ptr|00] │ ← 指向线程 ALock Record
└─────────────┘

线程 A: 持有锁
线程 B: 尝试获取锁 → CAS 失败 → 自旋


阶段2:自旋超时,开始膨胀
┌─────────────┐          ┌──────────────┐
│   对象头    │          │   Monitor    │
│ MarkWord    │          │ ┌──────────┐ │
│ [LR ptr|00] │          │ │  Owner   │ │
└─────────────┘          │ │ Thread A │ │
                         │ └──────────┘ │
线程 B 创建 Monitor ────→ │ ┌──────────┐ │
                         │ │EntryList │ │
                         │ │ Thread B │ │
                         │ └──────────┘ │
                         └──────────────┘


阶段3:膨胀完成
┌─────────────┐          ┌──────────────┐
│   对象头    │   指向   │   Monitor    │
│ MarkWord    │ ───────→ │ ┌──────────┐ │
│ [Mon ptr|10]│          │ │  Owner   │ │
└─────────────┘          │ │ Thread A │ │
                         │ └──────────┘ │
线程 A: 持有锁           │ ┌──────────┐ │
线程 B: 在 EntryList    │ │EntryList │ │
        等待             │ │ Thread B │ │
                         │ └──────────┘ │
                         └──────────────┘


阶段4:线程 A 解锁
线程 A 尝试 CAS 恢复 MarkWord
期望值: [LR ptr|00]
实际值: [Mon ptr|10]  ← 不匹配!
CAS 失败 → 检测到膨胀 → 调用 Monitor.exit()

┌─────────────┐          ┌──────────────┐
│   对象头    │   指向   │   Monitor    │
│ MarkWord    │ ───────→ │ ┌──────────┐ │
│ [Mon ptr|10]│          │ │  Owner   │ │
└─────────────┘          │ │   null   │ │ ← 释放 Owner
                         │ └──────────┘ │
                         │ ┌──────────┐ │
                         │ │EntryList │ │
                         │ │ Thread B │ │ ← 唤醒 Thread B
                         │ └──────────┘ │
                         └──────────────┘

适用场景:线程交替执行同步块,竞争不激烈的情况。

3. 自旋锁/自适应自旋锁

核心思想:避免线程在获取锁失败后立即挂起,而是执行忙循环(自旋),因为很多时候持有锁的线程会很快释放锁。

工作机制

  • 当轻量级锁 CAS 失败时,不立即升级为重量级锁,而是自旋等待
  • 自旋次数有限制,JDK 1.6 之前默认 10 次,可通过 -XX:PreBlockSpin 参数设置
  • 自适应自旋:根据同一个锁上一次自旋的时间及锁拥有者的状态动态调整自旋时间
    • 如果上次自旋成功获得锁,则本次可能增加自旋时间
    • 如果某个锁很少通过自旋获得,则可能省略自旋过程

注意事项:自旋会占用 CPU 时间,如果锁占用时间长,自旋反而浪费资源。

4. 重量级锁

核心思想:基于操作系统的互斥量(Mutex)实现,涉及用户态和内核态的切换。

工作机制

  • 创建 Monitor 对象,包含三个队列:
    • EntryList:等待获取锁的线程队列
    • WaitSet:调用 wait() 方法的线程队列
    • Owner:当前持有锁的线程
  • 竞争失败的线程进入 EntryList 阻塞等待(park)
  • 释放锁时从 EntryList 中唤醒一个线程(notify)或唤醒所有线程(notifyAll)
  • wait() 的线程进入 WaitSet,需要 notify/notifyAll 唤醒后重新竞争锁

解锁流程:

synchronized 解锁流程:
┌─────────────────────────────────────────┐
│  线程退出 synchronized 块               │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│  释放 Owner(Owner = null)             │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│  检查 EntryList 是否为空?              │
└──────────────┬──────────────────────────┘
               │
       ┌───────┴───────┐
       │               │
      空              非空
       │               │
       ▼               ▼
   ┌───────┐    ┌─────────────────┐
   │ 结束  │    │ 从 EntryList    │
   └───────┘    │ 选择一个线程    │
                └────────┬────────┘
                         │
                         ▼
                ┌─────────────────┐
                │ 唤醒该线程      │
                │ (unpark)        │
                └────────┬────────┘
                         │
                         ▼
                ┌─────────────────┐
                │ 线程竞争锁      │
                │ (可能成功或失败)│
                └─────────────────┘

注意:WaitSet 中的线程不参与此流程!

性能优化思路总结

提升 synchronized 性能的核心思路:

  1. 减少阻塞等待:通过自旋避免线程挂起和唤醒的开销
  2. 减少 Monitor 使用:通过偏向锁和轻量级锁避免重量级锁的系统调用
  3. 锁粗化:将多个连续的加锁、解锁操作合并为一次
  4. 锁消除:通过逃逸分析,如果判断一段代码中的锁不可能被其他线程访问,则消除锁

锁的选择建议

  • 低竞争场景:偏向锁效果最好(但 JDK 15+ 已默认禁用)
  • 中等竞争场景:轻量级锁 + 自旋锁组合效果好
  • 高竞争场景:直接使用重量级锁,避免自旋浪费 CPU
  • 需要更灵活控制:考虑使用 Lock(ReentrantLock)

附录:修正点、问题解答与补充内容

❌ 错误修正

  1. 错误:"jvm 先后提出了轻量级锁、自旋锁(自适应自旋锁)、偏向锁等模式"

    • 修正:正确的演进顺序应该是偏向锁 → 轻量级锁 → 重量级锁,自旋是轻量级锁的优化手段,不是独立的锁类型
  2. 错误:"其他线程会被挂起,并放入 waitSet 等待"

    • 修正:竞争锁失败的线程应该进入 EntryList,而不是 WaitSet。WaitSet 是专门存放调用了 wait() 方法的线程
  3. 错误:"轻量级锁就是想解除 synchronized 对 monitor 对象的依赖"

    • 修正:轻量级锁确实避免了 Monitor 对象的创建,但如果竞争失败,最终还是会膨胀为重量级锁并创建 Monitor

❓ 问题解答

  1. 问题:"怎么判断重入解锁?"

    • 解答
      • 偏向锁重入:直接通过 MarkWord 中的线程 ID 判断,无需额外记录
      • 轻量级锁重入:每次重入创建一个新的 Lock Record(Displaced Mark Word 为 null),通过栈中 Lock Record 的数量判断重入次数,解锁时依次弹出 Lock Record
  2. 问题:"如果是持有锁过程中有竞争,直接升级为重量级锁?未持有锁时竞争,升级为轻量级锁?"

    • 解答
      • 偏向锁持有期间有竞争:需要等待全局安全点,撤销偏向锁并升级为轻量级锁(如果原线程还在同步块中)
      • 偏向锁未持有时有竞争:撤销偏向,升级为无锁或轻量级锁
      • 轻量级锁竞争:先自旋尝试获取,自旋失败后膨胀为重量级锁

➕ 补充内容

  1. 锁升级的单向性

    • 锁只能升级,不能降级(JDK 某些版本实验性支持锁降级,但默认关闭)
    • 这是为了避免频繁的锁状态切换带来的开销
  2. 偏向锁的废弃

    • JDK 15 开始默认禁用偏向锁(-XX:-UseBiasedLocking
    • JDK 18 完全废弃偏向锁
    • 原因:现代应用中多线程竞争更常见,偏向锁的撤销成本较高,收益不明显
  3. MarkWord 结构

    • 32 位 JVM:MarkWord 占 4 字节
    • 64 位 JVM:MarkWord 占 8 字节
    • 存储内容根据锁状态变化:hashcode、GC 分代年龄、锁标志位、线程 ID、指向 Lock Record 的指针、指向 Monitor 的指针等
  4. 锁粗化与锁消除

    • 锁粗化:JIT 编译器会将多个连续的加锁解锁操作合并为一次
    • 锁消除:通过逃逸分析,消除不可能存在竞争的锁
  5. Monitor 对象的三个关键区域

    • EntryList:等待获取锁的线程队列
    • WaitSet:调用 wait() 的线程队列
    • Owner:当前持有锁的线程引用
  6. synchronized 与 Lock 的对比

    • synchronized:自动加解锁,JVM 层面优化,支持锁升级
    • Lock:手动加解锁,更灵活,支持公平锁、可中断、超时等待、多条件变量
  7. 相关 JVM 参数

    • -XX:+UseBiasedLocking:启用偏向锁(JDK 15+ 默认禁用)
    • -XX:BiasedLockingStartupDelay=0:JVM 启动后立即启用偏向锁
    • -XX:PreBlockSpin=10:自旋次数
    • -XX:+UseSpinning:启用自旋锁(JDK 1.6+ 默认开启)
  8. 轻量级锁加锁、膨胀流程

  9. synchronized 解锁流程

  10. MarkWord 在各种锁模式下保存的数据

    • 偏向锁模式下保存线程 id
    • 轻量级锁模式下保存锁记录对象指针
    • monitor 模式下保存 monitor 对象指针
  11. 偏向锁升级流程