JVM 对 Synchronized 关键字的实现是怎样的?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
字节码层面理解:面试官想知道你是否清楚
synchronized在字节码层面是怎么体现的——monitorenter/monitorexit指令、ACC_SYNCHRONIZED标志位,这些是最基本的。 -
底层机制深度:
synchronized的加锁解锁本质上依赖 Monitor(管程),而 Monitor 又和 Java 对象头的 Mark Word 紧密关联。这条线能不能串起来,是区分 "背答案" 和 "真懂" 的关键。 -
锁升级机制:JDK 6 引入了偏向锁 → 轻量级锁 → 重量级锁的升级过程。这是面试的高频追问点,也是很多人答不清楚的地方。
核心答案
JVM 对 synchronized 的实现,从上到下可以分三层来看:
| 层面 | 实现方式 |
|---|---|
| 字节码层 | 同步代码块用 monitorenter / monitorexit 指令;同步方法用 ACC_SYNCHRONIZED 标志位 |
| 对象头层 | 利用对象头 Mark Word 存储锁状态、线程 ID、锁记录指针等信息 |
| Monitor 层 | 最终依赖 ObjectMonitor(C++ 实现)完成真正的加锁解锁 |
核心一句话:synchronized 基于 Monitor(管程) 实现,而每个 Java 对象天生就关联着一个 Monitor。
深度解析
一、字节码层面的实现
synchronized 有两种用法,对应的字节码指令不一样:
1. 同步代码块
public void method() {
synchronized (this) {
// 临界区
}
}
编译后的字节码:
monitorenter // 进入 Monitor,获取锁
// 临界区代码
monitorexit // 退出 Monitor,释放锁
monitorexit // 异常路径的退出(编译器自动生成)
monitorenter:尝试获取对象的 Monitor。如果 Monitor 的计数器为 0,说明没人加锁,当前线程获取成功,计数器 +1。如果当前线程已经持有该 Monitor(可重入),计数器再 +1。如果被其他线程持有,当前线程阻塞等待。monitorexit:将计数器 -1,减到 0 时释放 Monitor。注意编译器会自动生成一个异常路径的monitorexit,确保即使同步块抛异常也能释放锁。
2. 同步方法
public synchronized void method() {
// 临界区
}
同步方法不需要 monitorenter / monitorexit 指令,而是在方法表(method_info)的访问标志(access_flags)中设置 ACC_SYNCHRONIZED(0x0020)。JVM 调用方法时检查到这个标志位,就会自动做加锁解锁。
二、Java 对象头与 Mark Word
每个 Java 对象在 JVM 中都有一段 "对象头"(Object Header),这是 synchronized 能够实现锁机制的基础。
上图展示了 Java 对象在内存中的三部分布局。其中对象头里的 Mark Word 是锁机制的核心,它在不同锁状态下存储的内容不同(以 64 位 JVM 为例):
| 锁状态 | Mark Word 存储内容 | 标志位 |
|---|---|---|
| 无锁 | 对象哈希码、GC 分代年龄 | 001 |
| 偏向锁 | 线程 ID、Epoch、GC 分代年龄 | 101 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 |
| 重量级锁 | 指向 Monitor(ObjectMonitor)的指针 |
10 |
| GC 标记 | 空 | 11 |
Mark Word 最后 2-3 位是锁标志位,JVM 通过读取这几位就知道当前对象处于什么锁状态。
三、Monitor:真正的锁实现
Monitor 可以理解为一种 同步工具,每个 Java 对象天生就关联着一个 Monitor。在 HotSpot 中,Monitor 由 C++ 类 ObjectMonitor 实现,核心字段:
_owner:指向当前获得锁的线程。线程拿到锁,就把自己的引用写到这里。_count:记录锁被重入的次数。每次monitorenter+1,monitorexit-1,减到 0 释放锁。_EntryList:当锁被占用时,其他尝试获取锁的线程会进入这个队列阻塞等待。锁释放时,JVM 从这个队列中唤醒一个线程。_WaitSet:当线程调用Object.wait()时,会释放锁并进入这个集合等待。被notify()/notifyAll()唤醒后,线程从_WaitSet移到_EntryList,重新竞争锁。
线程加锁解锁的交互过程:
- 线程进入
_EntryList排队 - 获取到锁,
_owner设为当前线程,_count+1 - 如果调用
wait(),线程进入_WaitSet等待,释放锁 - 被唤醒后重新回到
_EntryList竞争锁 - 同步代码执行完毕,
_count-1,减到 0 释放锁,_owner置空
四、锁升级(JDK 6+)
JDK 6 之前,synchronized 只有一种锁——重量级锁,每次加锁都要走 Monitor,涉及用户态到内核态的切换,性能很拉。JDK 6 引入了 锁升级机制,根据竞争程度自动选择最优的锁策略:
锁升级是单向的(通常不能降级),随着竞争越来越激烈,锁会从轻量逐步升级到重量级。
第一级:偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由 同一个线程 反复获取。偏向锁的设计思路就是:既然总是你来,那我连 CAS 都省了,直接把你的线程 ID 写到 Mark Word 里,下次你来,我看一眼 ID 是你,直接放行。
- 获取锁时,检查 Mark Word 中的线程 ID 是否是当前线程
- 如果是,直接进入同步块(几乎零开销)
- 如果不是,通过 CAS 尝试将线程 ID 替换为当前线程
- CAS 成功,获得偏向锁;失败,说明存在竞争,撤销偏向锁,升级为轻量级锁
第二级:轻量级锁
当有第二个线程来竞争锁时,偏向锁撤销,升级为轻量级锁。轻量级锁的核心是 CAS + 自旋:
- 在当前线程栈帧中创建一个 "锁记录"(Lock Record)
- CAS 尝试将对象头 Mark Word 替换为指向锁记录的指针
- 成功,获得轻量级锁
- 失败,说明有竞争,线程开始自旋(循环重试 CAS)
- 自旋一定次数后还是拿不到,升级为重量级锁
轻量级锁适用于线程交替执行同步块、持有锁时间很短的场景。自旋的代价是消耗 CPU,所以如果锁持有时间长,自旋就是浪费。
第三级:重量级锁
自旋失败(或者 JVM 判断当前竞争过于激烈),锁升级为重量级锁。此时 Mark Word 中存储的就是指向 ObjectMonitor 的指针,加锁解锁都要走 Monitor,涉及线程阻塞和唤醒(操作系统层面的互斥量),开销最大。
锁升级总结对比:
| 锁类型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 偏向锁 | 单线程反复获取 | 几乎零开销 | 有竞争时撤销成本高 |
| 轻量级锁 | 短时间轻度竞争 | CAS 自旋,避免阻塞 | 自旋消耗 CPU |
| 重量级锁 | 激烈竞争、长持有时间 | 不消耗 CPU 自旋 | 线程阻塞,上下文切换开销大 |
五、编译器层面的优化
除了锁升级,JIT 编译器还做了两个很重要的优化:
锁消除(Lock Elimination)
public void method() {
// sb 是局部变量,不可能被其他线程访问
// 但 StringBuffer 的方法都用了 synchronized
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
}
JIT 通过 逃逸分析 发现 sb 不会逃逸出方法(其他线程拿不到),那加锁毫无意义,直接在编译阶段把 synchronized 去掉。这就是锁消除。
锁粗化(Lock Coarsening)
public void method() {
// 连续对同一个对象加锁解锁,JIT 会把锁粗化到外面
synchronized (lock) { doSomething1(); }
synchronized (lock) { doSomething2(); }
synchronized (lock) { doSomething3(); }
// 优化后等价于:
synchronized (lock) {
doSomething1();
doSomething2();
doSomething3();
}
}
连续对同一个对象反复加锁解锁,JIT 会把多次锁操作合并成一次,减少加锁解锁的开销。
面试高频追问
-
synchronized和ReentrantLock的区别?synchronized是 JVM 层面的关键字,ReentrantLock是 API 层面的类synchronized不需要手动释放锁,ReentrantLock必须在finally中unlock()ReentrantLock支持公平锁、可中断锁、多条件变量(Condition)synchronized支持锁升级优化,ReentrantLock依赖 AQS- 两者都是可重入锁
-
synchronized是可重入的吗?怎么实现的?是的。重量级锁通过
ObjectMonitor的_count计数器实现,每次monitorenter计数器 +1,monitorexit计数器 -1。偏向锁通过 Mark Word 中的线程 ID 判断,轻量级锁通过栈帧中的 Lock Record 数量计算重入次数。 -
偏向锁在 JDK 15 被废弃了?
是的。JDK 15 起,偏向锁默认关闭(
-XX:-UseBiasedLocking),JDK 18 正式移除。原因是现代应用中无竞争的单线程场景越来越少,偏向锁撤销的成本(需要等到安全点 Safepoint)反而成了负担。
常见面试变体
- "
synchronized的底层实现原理是什么?" - "Java 对象头包含哪些信息?和锁有什么关系?"
- "什么是锁升级?偏向锁、轻量级锁、重量级锁分别是什么?"
- "
synchronized和volatile的区别?"
记忆口诀
锁升级:偏向(单线程贴 ID)→ 轻量(CAS 自旋)→ 重量(Monitor 阻塞),只升不降。
实现三层面:字节码有指令(monitorenter / monitorexit),对象头有 Mark Word,底层有 ObjectMonitor。
总结
JVM 对 synchronized 的实现分三层:字节码层面用 monitorenter / monitorexit 指令,对象头层面利用 Mark Word 存储锁状态,底层依赖 ObjectMonitor 完成加锁解锁。JDK 6 引入的锁升级机制(偏向锁 → 轻量级锁 → 重量级锁)加上锁消除、锁粗化等 JIT 优化,让 synchronized 的性能在大多数场景下已经不逊于 ReentrantLock。
