🔥 一个 synchronized 背后,JVM 到底做了什么?

引子:从一行代码开始

csharp 复制代码
Object lock = new Object();
synchronized (lock) {
    // 临界区
}

当多个线程执行这段代码时,JVM 并不是简单地"让它们排队"。 它会根据竞争程度,动态选择最合适的同步策略------从零开销到操作系统介入,全程自动演进。

这个过程,就是 偏向锁 → 轻量级锁 → 重量级锁 的升级链路。 而这一切,都围绕着 Java 对象头中的 Mark Word 展开。


第一步:对象刚创建 ------ 无锁状态

每个 Java 对象在内存中都有一个 对象头(Object Header),其中最关键的是 Mark Word(64 位 JVM 占 8 字节)。

初始状态下,Mark Word 存储对象的 hashCode、分代年龄等信息:

ini 复制代码
| hashCode (25 bits) | age (4) | biased_lock=0 | lock=01 |

此时,对象处于 无锁(Normal) 状态。


第二步:第一个线程进入 ------ 偏向锁(Biased Locking)

假设线程 A 首次执行 synchronized(lock)

JVM 发现:

  • 对象无锁;
  • 无竞争(只有线程 A 访问);
  • JVM 启用了偏向锁(JDK 8 默认开启)。

于是,JVM 尝试将线程 A 的 ID 写入 Mark Word:

scss 复制代码
| threadID (54 bits) | epoch (2) | age (4) | biased_lock=1 | lock=01 |

✅ 从此以后,只要线程 A 再次进入同步块,JVM 只需比对 threadID,匹配就直接放行------无需任何 CAS、无需 OS 介入,开销几乎为零。

🎯 偏向锁的设计哲学:大多数同步代码,其实只有一个线程在反复执行。


第三步:第二个线程到来 ------ 撤销偏向,升级轻量级锁

现在,线程 B 也执行 synchronized(lock)

JVM 检查 Mark Word,发现:

  • biased_lock=1
  • threadID ≠ B → 存在竞争!

于是,JVM 在安全点(Safepoint) 暂停线程 A,检查它是否还在同步块中:

  • 如果已退出 → 直接撤销偏向,恢复无锁;
  • 如果仍在执行 → 撤销偏向锁,进入轻量级锁流程。

轻量级锁怎么做?

  1. 线程 B 在自己的 Java 栈帧中创建一个 Lock Record;
  2. Lock Record 保存对象原来的 Mark Word(称为 Displaced Mark);
  3. CAS 尝试将对象头 Mark Word 替换为指向 Lock Record 的指针;
  4. 如果成功,线程 B 获得锁;
  5. 如果失败(比如线程 A 也在竞争),则自旋重试(默认 10 次)。

此时 Mark Word 变为:

ini 复制代码
| ptr_to_LockRecord (62 bits) | lock=00 |

✅ 轻量级锁仍在用户态完成,避免昂贵的 OS 线程挂起。


第四步:高并发竞争 ------ 膨胀为重量级锁

如果线程 C 也加入竞争,且线程 B 自旋多次仍无法获得锁,JVM 会做出关键决策:

"用户态竞争成本已高于内核态挂起,是时候升级了。"

于是,JVM 执行 锁膨胀(Inflation):

  1. 在堆中创建一个 ObjectMonitor 对象(C++ 实现);
  2. 将对象头 Mark Word 改为 指向 ObjectMonitor 的指针;
  3. Mark Word 变为:

ObjectMonitor 的核心结构:

arduino 复制代码
class ObjectMonitor {
  void* _owner;               // 当前持有锁的线程
  ObjectWaiter* _EntryList;   // 阻塞队列:等待获取锁的线程
  ObjectWaiter* _WaitSet;     // 调用 wait() 的线程集合
  ...
};
  • 线程 B 成为 _owner
  • 线程 C 被封装为 ObjectWaiter,加入 _EntryList
  • JVM 调用 OS 的 futexpthread_mutex_lock,将线程 C 挂起(park),不再占用 CPU。

✅ 此时,锁已从"自旋等待"变为"操作系统级阻塞",适合高并发、长临界区场景。


第五步:锁释放与线程唤醒

当线程 B 执行完临界区,执行 monitorexit

  1. JVM 将 ObjectMonitor 的 _owner = null
  2. 检查 _EntryList 是否非空;
  3. 如果有等待线程(如线程 C),从 _EntryList 中取出一个;
  4. 调用 OS 的 futex_wake(),唤醒线程 C;
  5. 线程 C 被调度执行,重新竞争 ObjectMonitor → 成功获得锁。

🌟 关键点:线程不是"轮询"锁是否释放,而是被操作系统精准唤醒。


第六步:内存可见性 ------ JMM 的隐性契约

你可能以为 synchronized 只是"排队",但它还做了更重要的事:

保证释放锁前的修改,对后续获得锁的线程可见。

这是 Java 内存模型(JMM) 的核心规则之一:

对同一个 Monitor,unlock happens-before 后续的 lock。

底层如何实现?

  • monitorexit 时,JVM 插入 内存屏障(Memory Barrier),强制将 CPU 缓存中的修改 flush 到主内存;
  • monitorenter 时,使当前 CPU 缓存失效,从主内存重新加载共享变量。

✅ 所以,线程 C 进入同步块后,一定能读到线程 B 修改的最新值。


第七步:CAS 的角色 ------ 轻量级锁的引擎

在整个过程中,CAS(Compare-And-Swap) 是轻量级锁的核心:

  • 它是一条 CPU 原子指令,由硬件保证不可分割;
  • 形式:CAS(address, expected, new)
  • 轻量级锁通过 CAS 尝试替换 Mark Word,避免 OS 介入;
  • 如果失败,就重试(自旋)------这就是 无锁并发(Lock-Free) 的思想。

⚠️ 但 CAS 不适合复合操作(如"扣库存 + 发消息"),此时仍需重量级锁或事务。


总结:一条完整的执行链路

css 复制代码
线程 A 首次进入 → 偏向锁(Mark Word 记录 threadID)
       ↓
线程 B 竞争 → 撤销偏向 → 轻量级锁(CAS + Lock Record)
       ↓
线程 C 加入 → 自旋失败 → 膨胀为重量级锁(ObjectMonitor)
       ↓
线程 B 退出 → 唤醒 _EntryList 中的线程 C
       ↓
线程 C 获得锁 → 通过 JMM 看到最新内存值
相关推荐
SamDeepThinking2 小时前
有了 AI IDE 之后,为什么还还要 CLI?
后端·ai编程·cursor
yinke小琪2 小时前
线程池七宗罪:你以为的优化其实是在埋雷
java·后端·面试
我不是混子2 小时前
Spring Boot启动时的小助手:ApplicationRunner和CommandLineRunner
java·后端
用户723905105692 小时前
Java并发编程原理精讲
后端
在钱塘江3 小时前
Elasticsearch 快速入门 - Python版本
后端·python·elasticsearch
道可到3 小时前
Java 面试通关笔记 01|拆箱与装箱:你真的理解了吗?
后端·面试
C++chaofan3 小时前
通过Selenium实现网页截图来生成应用封面
java·spring boot·后端·selenium·测试工具·编程·截图
Java水解3 小时前
MySQL常用客户端工具详解
后端·mysql
Olaf_n3 小时前
SpringBoot自动装配
spring boot·后端·程序员