📌 人工智能开发 :基于Spring AI的智能对话系统设计:Java全栈实现RAG与工具调用
第5题:说一下 synchronized 关键字的底层原理?
📚 回答:
- 核心考点 :
synchronized的底层原理是大厂面试中并发编程板块的"压轴题"。面试官不仅期望你掌握 Monitor 对象模型和 CAS 竞争机制,更要深入理解 对象头 Mark Word 的锁状态编码 、锁升级全过程的字节级细节 、cxq/EntryList/WaitSet 三条队列的协作关系 、park/unpark 与操作系统线程调度的交互 ,以及 可重入性的栈帧追踪机制。真正的大厂面试官会通过追问"锁膨胀时 _cxq 和 _EntryList 的转移策略""wait/notify 为什么必须在同步块内调用"来区分"背过八股"和"理解原理"的候选人。
1. 字节码层面的入口------monitorenter 与 monitorexit
-
1.1 反编译证据
通过
javap -verbose -c反编译同步代码块,可以清晰看到 JVM 在同步边界插入的指令 citation:13:javapublic void syncBlock(); Code: 0: aload_0 1: getfield #2 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter // ← 同步块入口:获取 Monitor 7: aload_0 8: invokevirtual #3 // Method doSomething:()V 11: aload_1 12: monitorexit // ← 正常退出:释放 Monitor 13: goto 21 16: astore_2 17: aload_1 18: monitorexit // ← 异常退出:释放 Monitor(确保不泄露) 19: aload_2 20: athrow 21: return Exception table: from to target type 7 13 16 any 16 19 16 any关键发现 citation:13:
- 编译器生成了 两个
monitorexit------一个用于正常路径(offset 12),一个用于异常路径(offset 18); Exception table确保任何异常都会跳转到 offset 16,执行第二个monitorexit;- 这是 JVM 的刚性保证:无论同步块如何退出(正常、return、break、异常),锁一定会被释放。
- 编译器生成了 两个
-
1.2 同步方法的隐式实现
对于
synchronized修饰的方法,JVM 不生成monitorenter/monitorexit,而是在方法的 ACC_SYNCHRONIZED 访问标志位上置位。调用方法时,JVM 自动检查该标志并获取/释放锁 citation:4。同步方式 字节码实现 锁对象 同步代码块 monitorenter+monitorexit显式指定对象 实例方法 ACC_SYNCHRONIZED标志this静态方法 ACC_SYNCHRONIZED标志Class对象
2. Monitor 对象的内部结构------HotSpot 源码级解析
synchronized 重量级锁的核心是 HotSpot 虚拟机中的 ObjectMonitor 对象(C++ 实现),其关键字段如下 citation:6citation:4:
cpp
// hotspot/share/runtime/objectMonitor.hpp
class ObjectMonitor : public CHeapObj<mtInternal> {
volatile markOop _header; // 对象头备份(Displaced Mark Word)
volatile void* _object; // 指向锁对象本身
volatile intptr_t _recursions; // 重入计数(0 表示首次进入)
volatile Thread* _owner; // 持有锁的线程(NULL 表示无锁)
volatile void* _WaitSet; // 调用 wait() 的线程队列(WaitNode 双向链表)
volatile void* _cxq; // 竞争队列(Contention Queue,单向链表,LIFO)
volatile void* _EntryList; // 入口队列(双向链表,FIFO)
volatile int _WaitSetLock; // 保护 WaitSet 的锁
volatile int _Responsible; // 负责唤醒的线程
volatile intptr_t _succ; // 后继线程(启发式唤醒优化)
volatile int _Spinner; // 自旋计数
volatile intptr_t _SpinFreq; // 自旋频率统计
volatile intptr_t _SpinClock; // 自旋时钟
volatile int _SpinDuration; // 自旋持续时间(自适应)
};
核心字段详解 citation:6citation:4:
| 字段 | 类型 | 作用 |
|---|---|---|
_header |
markOop | 锁膨胀时备份的对象头 Mark Word,解锁时恢复 |
_object |
void* | 指向被锁定的 Java 对象,用于反向查找 |
_recursions |
intptr_t | 重入计数。线程每次进入同步块 +1,退出 -1,为 0 时真正释放锁 |
_owner |
Thread* | 锁持有者。CAS 原子操作修改,NULL 表示锁空闲 |
_WaitSet |
WaitNode* | 等待队列 。调用 wait() 的线程被封装为 WaitNode 放入此处,需 notify() 唤醒 |
_cxq |
ObjectWaiter* | 竞争队列 (Contention Queue)。获取锁失败的线程先进入此处,单向链表,LIFO(栈) |
_EntryList |
ObjectWaiter* | 入口队列 。从 _cxq 中筛选或直接从竞争失败的线程移入,双向链表,FIFO |
_succ |
intptr_t | 启发式优化。记录可能被唤醒的线程,减少不必要的唤醒操作 |
_SpinDuration |
int | 自适应自旋。根据历史竞争情况动态调整自旋时间 |
3. 锁获取全过程------从 monitorenter 到 _owner
当线程执行 monitorenter 时,JVM 的 InterpreterRuntime::monitorenter 方法被调用,核心逻辑如下 citation:6citation:4:
步骤 1:检查锁状态(快速路径)
cpp
// 1. 检查对象头 Mark Word
markOop mark = obj->mark();
if (mark->is_neutral()) { // 无锁状态(001)
// 尝试 CAS 将 Mark Word 替换为指向 Lock Record 的指针(轻量级锁)
// 或设置为偏向锁(101)
}
步骤 2:偏向锁/轻量级锁处理
- 如果是偏向锁且线程 ID 匹配 → 直接返回(零开销);
- 如果是轻量级锁 → CAS 替换 Mark Word 为 Lock Record 指针;
- 如果 CAS 失败 → 进入锁膨胀流程。
步骤 3:锁膨胀为重量级锁
cpp
// 创建或获取 ObjectMonitor
ObjectMonitor* monitor = inflate(thread, obj);
// 进入 monitor 的 enter 方法
monitor->enter(thread);
步骤 4:Monitor::enter 的竞争逻辑
cpp
void ObjectMonitor::enter(TRAPS) {
Thread * const Self = THREAD;
// 4.1 尝试 CAS 获取锁(快速路径)
if (CAS(&_owner, NULL, Self) == NULL) {
_recursions = 1; // 首次进入,重入计数设为 1
return;
}
// 4.2 检查是否重入(已持有锁的线程再次进入)
if (_owner == Self) {
_recursions++; // 重入计数 +1
return;
}
// 4.3 自旋尝试(自适应自旋)
if (TrySpin(Self) > 0) {
_recursions = 1;
return;
}
// 4.4 进入竞争队列 _cxq(慢路径)
ObjectWaiter node(Self);
node.TState = ObjectWaiter::TS_CXQ;
// 将 node 插入 _cxq 头部(LIFO 栈)
for (;;) {
ObjectWaiter * front = _cxq;
node._next = front;
if (CAS(&_cxq, front, &node) == front) break;
}
// 4.5 挂起线程(park)
Self->set_current_pending_monitor(this);
park(); // ← 调用操作系统 pthread_cond_wait 或 WaitForSingleObject
}
关键设计 :_cxq 使用 LIFO(栈) 结构,新竞争者插入头部。这是基于"最近活跃的线程缓存更热"的假设,但可能导致老线程饥饿 citation:6。
4. 锁释放全过程------从 monitorexit 到 unpark
当线程执行 monitorexit 时,JVM 调用 InterpreterRuntime::monitorexit,核心逻辑 citation:6:
步骤 1:重入检查
cpp
void ObjectMonitor::exit(bool not_suspended, TRAPS) {
Thread * const Self = THREAD;
// 1.1 检查是否是锁持有者
if (_owner != Self) {
// 异常情况:未持有锁却调用 monitorexit → IllegalMonitorStateException
}
// 1.2 重入计数递减
if (_recursions != 0) {
_recursions--; // 重入退出,不真正释放锁
return;
}
}
步骤 2:真正释放锁
cpp
// 2.1 将 _owner 置为 NULL(CAS 操作)
for (;;) {
if (CAS(&_owner, Self, NULL) == Self) break;
}
// 2.2 检查是否有等待线程
if (_cxq != NULL || _EntryList != NULL) {
// 2.3 唤醒策略:cxq → EntryList 转移 + 唤醒头节点
ObjectWaiter * w = _EntryList;
if (w != NULL) {
// 从 EntryList 头部取出一个线程唤醒
ExitEpilog(Self, w); // unpark(w->_thread)
} else {
// EntryList 为空,将 _cxq 整体转移到 _EntryList
// 然后唤醒 EntryList 头部线程
QMode == 0: _EntryList = _cxq; _cxq = NULL;
w = _EntryList;
ExitEpilog(Self, w);
}
}
唤醒策略详解 citation:6:
| QMode 参数 | 策略 | 说明 |
|---|---|---|
| QMode = 0(默认) | _cxq → _EntryList 整体转移,FIFO 唤醒 |
最公平,但转移有开销 |
| QMode = 1 | 直接唤醒 _cxq 头部(LIFO) |
性能最好,但最不公平 |
| QMode = 2 | 直接唤醒 _cxq 头部,且 _cxq 中线程直接竞争锁 |
跳过 EntryList,减少一次排队 |
| QMode = 3 | _cxq 逆序后插入 _EntryList |
将 LIFO 转为 FIFO,平衡公平与性能 |
| QMode = 4 | _cxq 直接插入 _EntryList 头部 |
保持 LIFO,新竞争者优先 |
默认 QMode = 0 的流程:
释放锁时:
_cxq: [T5] → [T4] → [T3] → NULL(LIFO,T5 最新)
_EntryList: [T2] → [T1] → NULL(FIFO)
→ 转移 _cxq 到 _EntryList 尾部
_EntryList: [T2] → [T1] → [T3] → [T4] → [T5] → NULL
→ 唤醒 _EntryList 头部 T2
5. 可重入性的栈帧追踪机制
synchronized 的可重入性不是通过调用栈比对实现的,而是通过 _recursions 计数器 citation:6:
java
public synchronized void outer() {
inner(); // 同一线程,_recursions 从 1 → 2
}
public synchronized void inner() {
// _recursions = 2,无需再次获取锁
}
底层实现:
- 线程首次进入
outer()→ CAS_owner为当前线程,_recursions = 1; - 进入
inner()→ 检查_owner == Self→_recursions++→ 变为 2; - 退出
inner()→_recursions--→ 变为 1,不释放锁; - 退出
outer()→_recursions--→ 变为 0,CAS_owner为 NULL,真正释放。
为什么不会死锁?
- 如果不可重入:线程 A 持有锁后调用另一个同步方法 → 发现锁被占用(自己)→ 阻塞等待自己释放 → 死锁;
- 可重入设计:通过
_recursions识别"是自己",直接放行,避免死锁 citation:4。
6. wait/notify 的底层实现------_WaitSet 与 _cxq/_EntryList 的协作
-
6.1 wait() 的实现
cppvoid ObjectMonitor::wait(jlong millis, bool interruptable, TRAPS) { // 1. 检查是否持有锁 if (_owner != Self) throw IllegalMonitorStateException(); // 2. 创建 WaitNode 并加入 _WaitSet ObjectWaiter node(Self); node.TState = ObjectWaiter::TS_WAIT; AddWaiter(&node); // 加入 _WaitSet 双向链表 // 3. 释放锁(_recursions = 0, _owner = NULL) exit(true, Self); // 4. 挂起线程(park) park(); // 等待被 notify/unpark // 5. 被唤醒后,重新竞争锁(进入 _cxq 或 _EntryList) enter(Self); }关键设计 :调用
wait()时必须持有锁,因为wait()内部需要先释放锁再挂起。如果不加同步块,释放锁时可能抛出IllegalMonitorStateExceptioncitation:6。 -
6.2 notify() 的实现
cppvoid ObjectMonitor::notify(TRAPS) { // 1. 检查是否持有锁 if (_owner != Self) throw IllegalMonitorStateException(); // 2. 从 _WaitSet 头部取出一个 WaitNode ObjectWaiter * iterator = DequeueWaiter(); if (iterator == NULL) return; // _WaitSet 为空,无操作 // 3. 将 WaitNode 转移到 _cxq 或 _EntryList // 默认策略:转移到 _EntryList(保证被唤醒线程优先竞争) iterator->TState = ObjectWaiter::TS_ENTER; AddWaiterToEntryList(iterator); // 4. unpark 唤醒线程 unpark(iterator->_thread); }notify() vs notifyAll() citation:6:
notify():从_WaitSet移出一个线程到_EntryList,唤醒一个;notifyAll():将_WaitSet中所有 线程移到_EntryList,全部唤醒。
-
6.3 为什么 wait/notify 必须在同步块内?
- 语义要求 :
wait()需要先释放锁,如果不持有锁就调用release,会抛出IllegalMonitorStateException; - 竞态条件保护 :
while (condition) { wait(); }这种模式需要锁保护条件检查的原子性。如果不在同步块内,条件检查和wait()之间可能被其他线程修改条件,导致"虚假唤醒"问题 citation:6。
- 语义要求 :
7. 非公平锁的饥饿问题与缓解策略
-
7.1 非公平性来源
synchronized 是非公平锁,来源于两个设计 citation:6:
- _cxq 的 LIFO 结构:新竞争者插入头部,老线程沉底;
- 唤醒后直接与外部线程竞争 :被唤醒的线程从
park恢复后,需要重新竞争锁,此时外部新线程可能刚好 CAS 成功,"插队"获取锁。
-
7.2 饥饿场景
线程 A 持有锁 → 释放 → 唤醒 _EntryList 头部的线程 B 同时线程 C 刚好到达 → CAS _owner → 成功获取锁 线程 B 从 park 恢复 → CAS _owner → 失败 → 再次进入 _cxq 循环往复,线程 B 始终无法获取锁(饥饿) -
7.3 缓解策略
- 默认 QMode = 0 将
_cxq整体转移到_EntryList尾部,一定程度上缓解了 LIFO 的不公平; - 如果业务要求严格公平,应使用
ReentrantLock(true)(公平锁),但会牺牲 10%~20% 的吞吐量 citation:3。
- 默认 QMode = 0 将
8. 锁膨胀的触发条件与优化参数
| 触发条件 | 说明 | 优化参数 |
|---|---|---|
| CAS 自旋失败 | 轻量级锁 CAS 替换 Mark Word 失败 | -XX:PreBlockSpin(JDK6 前固定自旋次数) |
| 多个线程竞争 | 通常超过 2 个线程同时竞争 | 自适应自旋(JDK6+) |
| 调用 wait()/notify() | 需要 ObjectMonitor 的等待队列 | 无 |
| 调用 hashCode() | 占用 Mark Word 空间,禁用偏向锁 | 无 |
| 批量撤销达到阈值 | 偏向锁撤销 40 次后永久禁用 | -XX:BiasedLockingDecayTime |
关键 JVM 参数 citation:13:
bash
# 启用/禁用偏向锁(JDK 15+ 默认禁用)
-XX:+UseBiasedLocking
-XX:-UseBiasedLocking
# 偏向锁启动延迟(JDK 8 默认 4000ms,避免启动时竞争)
-XX:BiasedLockingStartupDelay=0
# 批量重偏向阈值(默认 20)
-XX:BiasedLockingBulkRebiasThreshold=20
# 批量撤销阈值(默认 40)
-XX:BiasedLockingBulkRevokeThreshold=40
# 打印锁升级日志
-XX:+PrintBiasedLockingStatistics
9. 面试官追问与高分回答模板
-
追问 1:"synchronized 的底层原理是什么?"
低分回答:"通过 monitor 实现,用 CAS 竞争锁,失败就 park。"(太浅)
高分回答:
"synchronized 的底层原理可以从三个层面理解:
- 字节码层面 :JVM 在同步块入口插入
monitorenter,出口插入两个monitorexit(正常和异常路径),通过 Exception table 确保锁在任何情况下都能释放。 - JVM 层面 :重量级锁依赖 HotSpot 的
ObjectMonitor对象,核心字段包括_owner(持有者)、_recursions(重入计数)、_cxq(竞争队列,LIFO)、_EntryList(入口队列,FIFO)、_WaitSet(等待队列)。获取锁时先 CAS_owner,失败则自旋,再失败则进入_cxq并park;释放锁时_recursions减到 0 后 CAS_owner为 NULL,然后从_EntryList或_cxq唤醒线程。 - 操作系统层面 :
park调用pthread_cond_wait(Linux)或WaitForSingleObject(Windows),涉及用户态→内核态切换。这也是重量级锁开销大的根本原因。
此外,JDK 6 后引入了偏向锁、轻量级锁、锁消除、锁粗化、自适应自旋等优化,使 synchronized 在大多数场景下性能已接近 ReentrantLock。" citation:4citation:6citation:13
- 字节码层面 :JVM 在同步块入口插入
-
追问 2:"Monitor 的 _cxq 和 _EntryList 有什么区别?为什么需要两个队列?"
高分回答:
"
_cxq和_EntryList是 Monitor 中两条功能不同的竞争队列:_cxq(Contention Queue) :获取锁失败的线程首先进入此处,单向链表,LIFO(栈)。新竞争者插入头部,基于'最近活跃线程缓存更热'的假设,但可能导致老线程饥饿。_EntryList(Entry List) :从_cxq中筛选或转移过来的线程,双向链表,FIFO 。锁释放时优先从_EntryList头部唤醒线程,比直接从_cxq唤醒更公平。
为什么需要两个队列?
- 性能分离 :
_cxq作为"快速缓冲区",新竞争者快速入队,无需维护复杂结构;_EntryList作为"调度队列",按 FIFO 顺序唤醒,减少饥饿。 - 转移策略 :默认 QMode = 0 时,释放锁会将
_cxq整体转移到_EntryList尾部,将 LIFO 转为 FIFO,平衡性能与公平。 - QMode 调优 :QMode = 1 直接唤醒
_cxq头部(性能最好但不公平);QMode = 3 将_cxq逆序后插入_EntryList(最公平但开销大)。" citation:6
-
追问 3:"wait() 和 notify() 的底层实现是什么?为什么必须在同步块内调用?"
高分回答:
"
wait()的底层实现:- 检查当前线程是否持有锁(
_owner == Self),否则抛出IllegalMonitorStateException; - 将当前线程封装为
WaitNode加入_WaitSet双向链表; - 释放锁(
_recursions = 0,_owner = NULL); - 调用
park()挂起线程; - 被
notify()唤醒后,从_WaitSet移到_EntryList或_cxq,重新竞争锁。
notify()的底层实现: - 检查锁持有者;
- 从
_WaitSet头部取出一个WaitNode; - 将其状态改为
TS_ENTER并加入_EntryList; - 调用
unpark()唤醒线程。
必须在同步块内调用的原因: wait()需要先释放锁,不持有锁就释放会抛异常;while (condition) { wait(); }模式需要锁保护条件检查的原子性,防止虚假唤醒和竞态条件。" citation:6
- 检查当前线程是否持有锁(
-
追问 4:"synchronized 是可重入锁,底层怎么实现的?"
高分回答:
"synchronized 的可重入性通过
ObjectMonitor的_recursions字段实现:- 线程首次获取锁 → CAS
_owner为当前线程,_recursions = 1; - 同一线程再次进入同步块 → 检查
_owner == Self→_recursions++(无需再次 CAS); - 每次退出同步块 →
_recursions--; - 当
_recursions减到 0 → CAS_owner为 NULL,真正释放锁。
如果 synchronized 不可重入,线程 A 持有锁后调用另一个同步方法,会发现锁被自己占用而阻塞,永远无法释放,导致死锁。_recursions的设计完美解决了这个问题。" citation:4citation:6
- 线程首次获取锁 → CAS
-
追问 5:"synchronized 是非公平锁,怎么解决饥饿问题?"
高分回答:
"synchronized 的非公平性来源于两个设计:
_cxq是 LIFO 栈,新竞争者总是插队到头部;- 被唤醒的线程从
park恢复后需要重新竞争锁,外部新线程可能刚好 CAS 成功。
缓解策略:
- 默认 QMode = 0:释放锁时将
_cxq整体转移到_EntryList尾部,将 LIFO 转为 FIFO,一定程度上缓解饥饿; - 如果业务要求严格公平,应使用
ReentrantLock(true),但会牺牲 10%~20% 吞吐量; - 避免长时间持有锁,减少其他线程等待时间。
注意:在大多数业务场景中,非公平锁的吞吐量优势远大于饥饿风险,因此 synchronized 和 ReentrantLock 默认都是非公平的。" citation:3citation:6
-
追问 6:"JDK 6 后 synchronized 做了哪些优化?锁升级的过程是怎样的?"
高分回答:
"JDK 6 前 synchronized 只有重量级锁,性能差。JDK 6 引入了五大优化:
- 偏向锁:单线程重复获取锁时零开销,CAS 设置线程 ID 后后续直接进入;
- 轻量级锁:低竞争时 CAS 自旋,避免阻塞;
- 锁消除:逃逸分析发现对象不会逃逸出线程时,直接消除锁;
- 锁粗化:连续对同一对象加锁时合并为更大的同步块;
- 自适应自旋 :根据历史竞争情况动态调整自旋次数。
锁升级路径 :
无锁(001)→ 偏向锁(101):第一个线程获取时 CAS 设置线程 ID;
偏向锁 → 轻量级锁(00):其他线程竞争时撤销偏向,CAS 替换 Mark Word 为 Lock Record 指针;
轻量级锁 → 重量级锁(10):CAS 自旋失败或等待线程过多,膨胀为 ObjectMonitor。
注意:JDK 15+ 默认禁用偏向锁,因为现代应用多线程竞争激烈,偏向锁的收益已不明显。" citation:2citation:4citation:13
10. 方案选型速查表
| 业务场景 | 推荐方案 | 核心理由 |
|---|---|---|
| 简单同步代码块 | synchronized |
语法简洁,JVM 自动优化 |
| 需要超时/中断响应 | ReentrantLock |
tryLock/lockInterruptibly |
| 需要公平锁 | ReentrantLock(true) |
可选公平模式 |
| 需要多条件等待/唤醒 | ReentrantLock + Condition |
多个 Condition 对象 |
| 高并发计数/累加 | LongAdder |
分段累加,性能碾压锁 |
| 单线程重复获取锁 | synchronized(偏向锁) |
JDK 8 及以前零开销 |
| 低竞争短同步块 | synchronized(轻量级锁) |
CAS 自旋,无阻塞 |
| 高竞争长同步块 | synchronized(重量级锁) |
阻塞等待,不消耗 CPU |
💡 面试官想要的满分总结:
synchronized的底层原理是 JVM 并发编程中最复杂也最精密的机制之一,理解它必须建立从字节码到操作系统内核的完整认知链路:字节码层面 :
monitorenter进入,monitorexit释放(正常+异常双保险),ACC_SYNCHRONIZED标志修饰方法。JVM 层面 :重量级锁依赖
ObjectMonitor,核心字段_owner(CAS 原子修改)、_recursions(重入计数)、_cxq(LIFO 竞争队列)、_EntryList(FIFO 入口队列)、_WaitSet(等待队列)。获取锁时先 CAS,再自旋,再 park;释放锁时_recursions归零后 CAS_owner为 NULL,然后从队列唤醒线程。操作系统层面 :
park调用pthread_cond_wait,unpark调用pthread_cond_signal,涉及用户态/内核态切换,这是重量级锁开销大的根本原因。优化层面:JDK 6 后通过偏向锁(单线程零开销)、轻量级锁(CAS 自旋)、锁消除、锁粗化、自适应自旋五大优化,使 synchronized 性能大幅提升。但 JDK 15+ 已默认禁用偏向锁,现代应用应更多关注轻量级锁和锁消除的优化效果。
最后记住:
synchronized锁的是对象(对象头 Mark Word),而非代码。锁对象必须用final修饰,避免引用变化导致锁失效。
觉得对您有帮助,麻烦 点点关注啦 ,您的关注是我创作的最大动力~ 🎯