------ 从 synchronized / ObjectMonitor 到 ReentrantLock / AQS + futex 全链路解析
第一章 概述
Java 提供了两种主流的锁机制:
- synchronized:JVM 内置的监视器锁,基于 ObjectMonitor 实现,经过偏向锁高版本jdk废弃、轻量级锁、重量级锁的多级优化。
- ReentrantLock:JDK 显式锁,基于 AQS(AbstractQueuedSynchronizer)框架实现,配合 Condition 提供多条件变量能力。
本文档围绕一个核心问题展开:线程在调用 wait() / await() 之后,究竟是如何被阻塞的?调用 notify() / signal() 之后,线程又是如何被唤醒的?锁的释放与线程唤醒之间是什么关系?
我们将沿着完整调用链,从 Java API 层 → JVM / C++ 实现层 → Linux 内核 futex 层,逐层拆解,讲透两种锁机制的阻塞与唤醒原理。
第二章 synchronized 与 ObjectMonitor 机制
2.1 对象头与 Mark Word
Java 对象在内存中的布局由三部分组成:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。对象头又分为 Mark Word 和 Klass Pointer。
Mark Word 在不同状态下存储不同信息:
- 无锁状态:对象哈希码、分代年龄
- 偏向锁:偏向线程 ID、时间戳、分代年龄
- 轻量级锁(没有发生过锁等待):指向栈中锁记录(锁对象?)的指针
- 重量级锁:指向 ObjectMonitor(监视器对象)的指针
当 synchronized 存在多线程竞争且自旋失败时,锁会膨胀为重量级锁,此时 Mark Word 中存储的是一个指向 C++ ObjectMonitor 对象的指针。本文讨论的 wait / notify 机制均基于重量级锁的 ObjectMonitor。
锁记录(Lock Record,LR)
锁记录不是锁对象,只是线程栈里一块临时记录空间;真正被加锁的还是那个 Java 对象本身。
1. 分清两个东西
锁记录 Lock Record(LR)
- 存在:当前线程的栈帧里面(栈上,不是堆)
- 本质:一块栈内存,保存对象 Mark Word 的拷贝(displaced mark word,置换标记字) + 指向当前对象的指针
- 作用:轻量级锁加锁时,线程先在自己栈上创建 LR,把对象原来的 Mark Word 复制进去;然后用 CAS 尝试把对象 Mark Word 改成指向这个 LR 的指针。
也就是你说的:轻量级锁状态下,对象 Mark Word 存的就是「指向栈中锁记录的指针」。
锁对象(你要加锁的那个 Object)
- 在堆上,包含对象头(Mark Word + Klass 指针)、实例数据、padding。
- 锁的持有者【那个线程】判断依据:对象 Mark Word 里的指针。
2. 轻量级锁加锁过程一句话
栈创建 LR → 复制对象原始 Mark Word 到 LR(displaced mark word)→ CAS 尝试修改堆里对象的 Mark Word,让它指向栈里这个 LR
- CAS 成功:对象进入轻量级锁状态。
- 此时:对象 Mark Word 的值 = 线程栈中 Lock Record 的地址。
所以:Mark Word 存的指针,指向的是线程栈帧里的 Lock Record ,Lock Record 只是副本 + 记录,它本身不是锁对象;锁对象仍然是堆上那个对象。
3. 和重量级锁对比,方便记忆
- 轻量级锁 :对象 Mark Word → 指向线程栈的 Lock Record(栈上),无内核态,自旋。
- 重量级锁 :对象 Mark Word → 指向堆里 ObjectMonitor(监视器对象,堆上),依赖操作系统 mutex,阻塞线程。
4. 容易踩坑点
Lock Record 只是临时记录 ,线程执行完同步代码块,会通过 CAS 把 LR 里保存的displaced mark word写回对象 Mark Word,然后销毁栈上 LR,解锁。
LR 生命周期很短:随栈帧创建、栈帧销毁而消失,不是堆上对象
完整步骤拆解(轻量级锁加锁)
- 线程在自己栈帧创建 Lock Record(LR)
- 把堆对象当前原始 Mark Word,拷贝到 LR 的 displaced mark word(备份!)
- CAS 尝试:修改堆上对象的 Mark Word ,把它的值改成 LR 的内存地址,同时把锁标记位改成
00(轻量级锁标记)
👉 现在出现两份 Mark Word:
- 堆对象头 Mark Word:存 LR 地址 + 锁标记 00(对外标记:对象已经被轻量级锁持有)
- LR 内部 displaced mark word:存加锁之前原始的 Mark Word (哈希码、分代年龄等全部原始信息)(释放锁是把 这个备份内容 要还原到 锁对象的头部信息MarkWord中)
Mark Word 的内容有时是一个指针 有时是具体的 内容 比如年代
Mark Word 是「复用同一块内存空间」,不同锁状态下,这块内存存的东西含义完全不一样:要么存哈希码 / 分代年龄这类对象属性;要么存指针(指向栈 LR / ObjectMonitor)。
Mark Word 固定大小(64 位 JDK 默认 8 字节 = 64bit),位分段复用 ,靠最后 2bit 的锁标记位来区分当前里面存的是什么
锁恢复异常
获取了锁的线程栈 溢出了 锁还没释放 会不会导致 锁对象的 markword没有办法恢复?
不会出现永久无法恢复 Mark Word。但是分两种场景:轻量级锁、重量级锁,行为完全不一样。
前提:线程栈溢出(StackOverflowError),线程直接异常终止,同步代码块还没执行完,没走正常解锁流程。
1、轻量级锁场景(重点,对应你刚才的 LR/displaced mark word)
回顾正常解锁逻辑: 正常解锁是当前线程主动拿 LR 里的 displaced mark word,CAS 写回堆对象 Mark Word 。 👉 但是!LR 是线程栈帧上的内存。 一旦发生栈溢出:
- 线程栈直接崩溃,栈帧销毁 → 栈上 Lock Record 直接被回收,displaced mark word(原始 MarkWord 备份)直接丢了!
- 正常解锁的 CAS 代码根本跑不到。
❌ 此时:堆上对象 Mark Word 仍然存着「LR 的地址」,但是LR 已经不存在了。
这时别的线程尝试抢锁,读取 Mark Word 发现是轻量级锁标记 (00),尝试去访问这个 LR 地址,发现栈上 LR 无效。 JVM 检测到这个情况,会直接把锁膨胀为重量级锁,创建 ObjectMonitor,把对象 MarkWord 改成指向 monitor。 线程异常退出时,JVM 底层会自动释放 monitor,后续其他线程可以竞争这个重量级锁。
简单一句话: 轻量级锁线程栈溢出,原始 displaced mark word 丢失,无法恢复成【无锁 / 偏向锁的原始 MarkWord】,锁会膨胀成重量级锁 。 不是锁永远卡死,只是回不到原来的轻量 / 无锁状态了。
面试坑:轻量级锁没有操作系统层面的互斥内核对象,线程异常退出无法自动恢复原始 MarkWord,只能膨胀。
2、重量级锁(ObjectMonitor)
重量级锁不一样: ObjectMonitor 在堆上 ,不属于线程栈。 线程持有重量级锁,线程发生栈溢出异常退出: JVM 会自动捕获,自动释放 ObjectMonitor,把 monitor 的 owner 置空,其他线程可以进入等待队列竞争锁。 Mark Word 维持指向 monitor 指针,锁不会卡死。
3、关键总结
- 轻量级锁 + 线程栈溢出,LR(栈上)销毁,原始 MarkWord 备份丢了,不能恢复成加锁前的 MarkWord;锁膨胀成重量级锁,锁不会死锁。
- 重量级锁,monitor 在堆,线程崩溃 JVM 自动释放 monitor,锁可正常被其他线程抢占。
- 所以轻量级锁的这个特性:依赖线程正常执行完代码、正常栈帧出栈来解锁。一旦线程异常终止,备份就丢,锁膨胀。
锁对象的HashCode变化
那hashcode 重新计算 话 如果变了 如果这个对象是放在一个hashset中 那么会不会有问题啊 重复了?
会出严重问题!对象留在 HashSet 里面,但 hashcode 变了,会导致:找不到对象、contains 返回 false、删不掉,甚至内存泄漏,但不会直接出现 "重复存入同一个对象"。
先把整个链路串起来
- 对象 A,无锁状态,提前算好了 hashCode,存入
HashSet。 HashSet 底层:根据hashCode放到对应哈希桶。 - 对 A 加轻量级锁 :
- 原始 MarkWord(包含 hashcode)拷贝进栈上 LR 的 displaced mark word
- 堆 MarkWord 改成 LR 指针(轻量级锁标记 00)
- 线程栈溢出,线程崩了:
- 栈销毁 → LR 连同里面保存的原始 hashcode 永久丢失
- JVM 将锁膨胀成重量级锁,MarkWord 指向 ObjectMonitor
- 之后调用
A.hashCode(): 原来的 hashcode 备份没了,JVM 重新算 hash,得到一个全新 hash 值
问题来了:对象还在 HashSet 里面
HashSet 存入的时候,是用当时旧 hash放到对应的哈希桶。 现在对象本身 hashCode () 返回新值:
- 调用
set.contains(A): 先拿新 hash 去定位新哈希桶,去新桶找 A,找不到 ,返回 false。 但对象 A实际上还躺在旧的哈希桶里面! set.remove(A):同样逻辑,按新 hash 找桶,找不到,删不掉。
对象引用还在集合里,但是你再也无法用 contains/remove 正常操作它,相当于 "幽灵对象",内存泄漏。
但是:不会出现同一个对象重复存入
HashSet 添加元素的时候,是添加那一刻 判断是否存在。 上面这个场景是对象已经放进去之后,hashCode 发生变化,不是 put 的时候重复判断。
再区分两个重要场景
-
重写过 hashCode () 方法(用户自定义) 这种情况不受 MarkWord 影响! 自定义 hashCode 是执行 Java 代码算出来的,不从 MarkWord 读取。 上面的问题只针对:没有重写 hashCode (),使用 Object 原生 hashCode ()(identity hash)。
Object 默认 hashCode:优先从 MarkWord 读取;MarkWord 丢失后,重新生成。
-
偏向锁 / 无锁状态 MarkWord 里面存着 identity hash,只要不进入轻量级锁,hash 不会变,没有这个坑。
面试一句话考点
只有原生 Object.hashCode () 才会存在这个风险;如果重写了 hashCode,返回值由你的业务字段决定,不受 MarkWord、锁状态影响,不会出现这个 bug。
2.2 ObjectMonitor 结构(HotSpot C++)
ObjectMonitor 是 HotSpot 中 synchronized 重量级锁的核心 C++ 结构体,关键成员如下:
struct ObjectMonitor {
void* _owner; // 当前持有监视器锁的线程指针
ObjectWaiter* _WaitSet; // 条件等待集合(调用wait的线程链表)
ObjectWaiter* _EntryList; // 锁竞争队列(抢锁失败的线程链表)
int _recursions; // 重入计数
// ... 其他成员
};
struct ObjectWaiter {
Thread* _thread; // 保存Java线程的指针
ObjectWaiter* next; // 下一个节点,串成单向链表
// ... 其他成员
};
两个队列的职责严格隔离:
- _WaitSet:存放拿到锁后主动调用 wait() 等待条件的线程。只有 notify() 能将节点移出此队列。移出的线程要重新回到了_EntryList,因为_WaitSet里面的线程已经主动释放了锁,所以需要重新回到_EntryList 来重新强锁。
- _EntryList:存放抢锁失败、阻塞等待获取锁的线程。monitor.exit() 释放锁时从此队列取节点唤醒。
为什么需要 _WaitSet 队列?因为同一个对象可以有多个线程同时调用 wait(),当 notify() 发生时,JVM 需要知道该唤醒谁。内核的 futex 只负责阻塞/唤醒线程,并不记录线程属于哪个对象的等待集合,这部分业务语义必须由 JVM 在用户态维护。
2.3 Object.wait() 完整流程
前提:当前线程已经进入 synchronized(obj) 块,即当前线程是 ObjectMonitor._owner。
wait() 是 native 方法,内部执行以下步骤:
步骤一:前置校验
检查当前线程是否等于 _owner。如果不是,直接抛出 IllegalMonitorStateException。这就是未在 synchronized 块内调用 wait() 会报错的根本原因------wait() 内部需要释放锁,你都没拿到锁,拿什么释放?
步骤二:加入 _WaitSet
将当前线程封装成 ObjectWaiter 节点(节点中保存线程指针),追加到 _WaitSet 链表。这是 JVM 用户态维护的链表,不是内核队列。
步骤三:释放监视器锁
将 _owner 置为 null,_recursions 清零。锁被释放,其他线程现在可以进入 synchronized(obj) 竞争锁。注意:这一步是 wait() 内部自动完成的,不需要开发者手动释放。
??这里释放锁应该会 发一个通知吧 通知EntiryList里面的线程去抢锁?
步骤四:内核阻塞
调用底层 futex(FUTEX_WAIT) 系统调用。内核将当前线程从 CPU 就绪队列移除,加入 futex 对应的内核等待链表,线程状态变为 TASK_WAITING,内核调度器触发 schedule() 进行上下文切换。
此时线程代码停在 obj.wait() 这一行,不再向下执行,也不会走到 synchronized 块末尾的隐式 unlock。
2.4 Object.notify() / notifyAll() 流程
notify() 同样是 native 方法,必须在持有监视器锁的前提下调用。
notify() 做的事情非常简单------仅做用户态链表搬家:
- 从 _WaitSet 链表头部取出一个 ObjectWaiter 节点
- 将该节点移动到 _EntryList 链表(锁竞争队列)
- 不调用 futex wake,不唤醒内核线程
这是一个关键设计:notify() 只是把等待条件的线程标记为"可以去抢锁了",将其从条件等待队列转移到锁竞争队列,但并不真正唤醒它。线程此时仍然在内核阻塞。
notifyAll() 则将 _WaitSet 中的全部节点都移动到 _EntryList。
为什么 notify 不直接唤醒?因为此时监视器锁仍被调用 notify 的线程持有,就算唤醒了等待线程,它也拿不到锁,只能立刻再次阻塞,造成一次无效的上下文切换。HotSpot 选择延迟唤醒,等锁真正释放时再唤醒 EntryList 中的线程。
2.5 monitor.exit() 锁释放与 EntryList 唤醒
当持有锁的线程执行完 synchronized 块,JVM 隐式调用 monitor.exit():
- 将 _owner 置为 null,释放监视器锁
- 检查 _EntryList 是否有等待节点
- 如果有,取出 _EntryList 头部的一个节点
- 调用 futex(FUTEX_WAKE) 唤醒对应线程
被唤醒的线程回到用户态,尝试通过 CAS 将 _owner 设置为自己:
- CAS 成功:拿到锁,wait() 方法返回,继续执行 wait() 之后的代码
- CAS 失败:说明其他线程抢先拿到锁,重新放回 _EntryList,再次 futex 阻塞
2.6 为什么 monitor.exit() 不直接唤醒 WaitSet
这是一个常见的疑问:线程释放锁的时候,为什么不去扫描 _WaitSet 唤醒等待的线程?原因有三:
第一,语义隔离。
_WaitSet 中的线程是在等待某个业务条件(例如"队列不满"),不是单纯等待锁。就算锁空闲了,如果业务条件没满足,唤醒它没有意义。必须由业务代码调用 notify() 来告诉 JVM:条件好了,可以把等待线程挪去排队抢锁了。
第二,避免无意义唤醒。
如果 monitor.exit() 自动扫描 _WaitSet 唤醒线程,锁一空就唤醒 wait 线程,但业务条件可能依然不满足,线程拿到锁后发现条件不对,只能再次 wait(),白白发生上下文切换,性能很差。这也是"虚假唤醒"的来源之一。
第三,职责单一。
_WaitSet 的移出权专属于 notify(),_EntryList 的唤醒权专属于 monitor.exit()。两套队列各司其职,不会混淆。
因此,一个线程如果调用了 wait() 进入 _WaitSet,但从未有其他线程调用 notify(),那么即使锁被反复释放,这个线程也永远不会被唤醒------它会永久处于 WAITING 状态。这就是 wait/notify 必须配对使用的原因。
第三章 ReentrantLock 与 AQS + Condition 机制
3.1 AQS 同步队列(CLH 变体)
ReentrantLock 的底层是 AQS(AbstractQueuedSynchronizer),它是 JDK 整个 java.util.concurrent 包的基础框架。AQS 核心要素:
- state:volatile int 类型的锁状态。0 表示空闲,>0 表示被占用(可重入时记录重入次数)。
- exclusiveOwnerThread:当前持有独占锁的线程。
- 同步队列:双向链表(CLH 变体),head 是虚节点,tail 指向队列尾部。抢锁失败的线程被封装成 Node 加入队列尾部。
struct Node {
Thread thread; // 被阻塞的线程
int waitStatus; // 节点状态:SIGNAL(-1)、CONDITION(-2)、0等
Node prev; // 前驱指针
Node next; // 后继指针
Node nextWaiter; // 条件队列中的下一个节点(条件队列专用)
}
当线程调用 lock() 抢锁失败时,会被封装成 Node 加入 AQS 同步队列尾部,然后调用 LockSupport.park() 阻塞。这与 ObjectMonitor 的 _EntryList 作用完全对应。
3.2 Condition 条件队列
Condition 是 ReentrantLock 体系下的条件变量,对应 ObjectMonitor 中的 _WaitSet。每个 Condition 对象(ConditionObject)内部维护一个单向链表:
- firstWaiter:条件队列头节点
- lastWaiter:条件队列尾节点
- 节点通过 nextWaiter 指针串联(不是同步队列的 next 指针)
- 节点 waitStatus = CONDITION(-2),表示当前在条件队列中
与 ObjectMonitor 最大的不同:一把 ReentrantLock 可以 new 出多个 Condition,每个 Condition 拥有独立的条件队列。例如有界阻塞队列中:
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 队列不满条件
Condition notEmpty = lock.newCondition(); // 队列不空条件
notFull 和 notEmpty 是两个完全独立的条件队列,各自管理等待的线程。这就是 Condition 比 Object.wait/notify 强大的地方------可以精准区分不同的等待条件,避免 notifyAll 带来的惊群效应。
3.3 Condition.await() 完整流程
前提:当前线程已经调用 lock.lock() 持有 ReentrantLock。
步骤一:前置校验
检查当前线程是否是 AQS 的 exclusiveOwnerThread。如果不是,抛出 IllegalMonitorStateException。
步骤二:加入条件队列
新建 Node 节点(waitStatus = CONDITION),加入当前 Condition 条件队列的尾部。
步骤三:释放 ReentrantLock
调用 AQS.release() 释放锁。release() 内部会将 state 减到 0,并唤醒 AQS 同步队列中 head 的后继节点(如果有的话)。这一步是 await() 内部自动完成的。
步骤四:内核阻塞
调用 LockSupport.park() 阻塞当前线程。park() 最终通过 futex(FUTEX_WAIT) 进入内核阻塞,线程停在 await() 这一行。
3.4 Condition.signal() / signalAll() 流程
signal() 的作用与 Object.notify() 完全对应------仅做用户态链表转移:
- 从条件队列头部取出第一个 Node(firstWaiter)
- 将节点从条件队列移除(firstWaiter 后移)
- 修改节点 waitStatus:CONDITION(-2) → 0,然后加入 AQS 同步队列尾部
- 不调用 LockSupport.unpark(),不唤醒内核线程
signalAll() 则将条件队列中的全部节点逐个转移到 AQS 同步队列。
同样的设计思想:signal 只是把等待条件的线程标记为"可以去抢锁了",真正的唤醒延迟到 unlock() 时才发生。
3.5 lock.unlock() 释放锁与同步队列唤醒
当持有锁的线程调用 lock.unlock() 时:
- AQS.release() 被调用,state 递减
- 当 state 减到 0 时,exclusiveOwnerThread 置为 null,锁完全释放
- 找到 AQS 同步队列 head 的后继节点(head 是虚节点,真正等待的是 head.next)
- 调用 LockSupport.unpark(node.thread) 唤醒该线程
被唤醒的线程重新尝试获取锁(CAS 设置 state):
- 成功:await() 方法返回,继续执行后续代码
- 失败:重新 park 阻塞,等待下一次唤醒
3.6 为什么 signal 不直接 unpark
与 ObjectMonitor 的 notify 同理:调用 signal() 时,ReentrantLock 仍被当前线程持有,就算 unpark 唤醒了等待线程,它也抢不到锁,只能再次 park。延迟到 unlock() 时唤醒,可以避免这次无效的上下文切换。这是两种机制共有的设计哲学。
第四章 底层线程阻塞与唤醒:LockSupport 与 futex
4.1 LockSupport.park() / unpark()
无论是 ObjectMonitor 的 wait() 还是 AQS Condition 的 await(),最终阻塞线程都依赖 LockSupport.park()。它是 JDK 提供的最基础的线程阻塞原语。
Java 层代码极其简单:
public static void park() {
UNSAFE.park(false, 0L); // native方法
}
public static void unpark(Thread thread) {
if (thread != null)
UNSAFE.unpark(thread); // native方法
}
参数说明:park(false, 0L) 中第一个参数 false 表示不使用绝对时间,第二个参数 0L 表示无限期阻塞。真正的逻辑不在 Java 代码中,而在 native 方法 Unsafe.park 的 C++ 实现里。
4.2 Parker 结构与许可机制
每个 Java 线程在 HotSpot 中都绑定一个 Parker 对象(C++ 结构体)。Parker 是 park/unpark 的核心实现:
class Parker : public os::PlatformParker {
private:
volatile int _counter; // 许可计数器:0=无许可,1=有许可
pthread_mutex_t _mutex;
pthread_cond_t _cond;
public:
void park(bool isAbsolute, jlong time);
void unpark();
};
许可机制(_counter)是 Parker 的核心设计:
- park():先检查 _counter,如果 >0 说明有许可,直接将 _counter 置为 0 并返回,不阻塞。如果 _counter = 0,则进入阻塞流程。
- unpark():将 _counter 置为 1,然后调用 pthread_cond_signal 唤醒等待的线程。
关键特性:许可最多只有 1 个,多次 unpark 不会叠加。而且 unpark 可以先于 park 调用------提前给一个许可,之后调用 park 时直接消费许可返回,不会阻塞。这是 Object.wait/notify 不具备的能力。
Parker::park() 的简化 C++ 逻辑:
void Parker::park(bool isAbsolute, jlong time) {
// 1. 先看许可
if (_counter > 0) {
_counter = 0;
return; // 有许可,直接返回,不阻塞
}
// 2. 拿内部mutex锁
pthread_mutex_lock(&_mutex);
// 双重检查,防止并发unpark
if (_counter > 0) {
_counter = 0;
pthread_mutex_unlock(&_mutex);
return;
}
// 3. 没有许可,进入阻塞
if (time == 0) {
// 无限等待 → pthread_cond_wait → 底层 futex(FUTEX_WAIT)
pthread_cond_wait(&_cond, &_mutex);
} else {
pthread_cond_timedwait(&_cond, &_mutex, ...);
}
// 4. 被唤醒后,重置许可
_counter = 0;
pthread_mutex_unlock(&_mutex);
}
4.3 futex 系统调用(Linux)
pthread_cond_wait 在 Linux 底层封装的是 futex(fast userspace mutex)系统调用。futex 是 Linux 提供的快速用户态互斥锁原语,也是 IPC(Inter-Process Communication,进程间通信)的底层机制之一。
futex(FUTEX_WAIT, uaddr, val) 的内核行为:
- 检查用户态地址 uaddr 处的值是否仍等于 val(防止竞态)
- 如果相等,将当前线程从 CPU 就绪运行队列中移除
- 将线程加入 futex 对应的内核等待链表(以 uaddr 为 key 的哈希表)
- 将线程状态设置为 TASK_WAITING(不可调度)
- 内核调度器调用 schedule(),触发上下文切换,CPU 去运行其他就绪线程
futex(FUTEX_WAKE, uaddr, n) 的内核行为:
- 在 futex 哈希表中查找 uaddr 对应的等待链表
- 从链表中取出最多 n 个等待线程
- 将这些线程移回 CPU 就绪队列
- 线程状态变为可调度,等待 CPU 分配时间片
注意:futex 的唤醒并不意味着线程立刻运行,只是把线程放回就绪队列,什么时候真正获得 CPU 时间片由操作系统调度器决定。
4.4 park 完整调用链
将以上各层串联,LockSupport.park() 的完整调用链如下:
LockSupport.park() Java
└→ Unsafe.park(false, 0L) native → JNI
└→ Parker::park() HotSpot C++
├→ 检查 _counter(许可)
├→ pthread_mutex_lock(&_mutex)
├→ 双重检查 _counter
└→ pthread_cond_wait(&_cond, &_mutex)
└→ futex(FUTEX_WAIT, uaddr, val) 系统调用 → 内核
├→ 线程从CPU就绪队列移除
├→ 加入futex内核等待链表
├→ 线程状态 = TASK_WAITING
└→ schedule() 上下文切换
被唤醒后
├→ 线程从futex等待链表移回就绪队列
├→ futex系统调用返回
└→ pthread_cond_wait 返回
├→ _counter = 0
└→ pthread_mutex_unlock
Unsafe.park 返回
LockSupport.park 返回
线程继续执行
4.5 中断与 park
LockSupport.park() 会响应线程中断:当其他线程调用 thread.interrupt() 时,被 park 阻塞的线程会从 park 中返回,但 park() 本身不抛出 InterruptedException。需要调用方自行检查 Thread.interrupted() 或 isInterrupted() 来判断是否因中断而返回。
Condition.await() 则在内部处理了中断:当 park 因中断返回后,await() 会检查中断状态,如果是中断导致的唤醒,则抛出 InterruptedException。这也是为什么 await() 声明了 throws InterruptedException,而 park() 没有。
第五章 两种机制对比与选型
5.1 结构对比表
|----------|----------------------------------|-------------------------------------|
| 对比维度 | synchronized / ObjectMonitor | ReentrantLock / AQS + Condition |
| 实现层 | JVM C++(ObjectMonitor) | JDK Java(AQS + LockSupport) |
| 等锁队列 | _EntryList(单向链表) | AQS 同步队列(双向链表,CLH变体) |
| 等条件队列 | _WaitSet(仅1个) | 每个Condition独立条件队列(可多个) |
| 条件变量数量 | 仅1套(wait/notify) | 多套(await/signal,如notFull/notEmpty) |
| 可重入 | 支持(_recursions计数) | 支持(state计数) |
| 公平锁 | 不支持 | 支持(构造参数fair=true) |
| 超时获取锁 | 不支持 | tryLock(timeout, unit) |
| 可中断获取锁 | 不支持 | lockInterruptibly() |
| 底层阻塞原语 | futex(pthread cond) | futex(Parker + park) |
| 锁升级优化 | 偏向锁→轻量级→重量级 | 无(直接AQS队列) |
5.2 流程对应关系
两种机制在阻塞与唤醒的流程上高度一致,可以一一对应:
|--------|--------------------------------------|----------------------------------------|
| 操作 | synchronized 体系 | ReentrantLock 体系 |
| 等待条件 | wait() → 加入_WaitSet → 释放锁 → futex阻塞 | await() → 加入Condition队列 → 释放锁 → park阻塞 |
| 通知条件 | notify() → WaitSet节点移到EntryList(不唤醒) | signal() → 条件队列节点移到AQS同步队列(不唤醒) |
| 释放锁 | monitor.exit() → 释放锁 → 唤醒EntryList头部 | unlock() → 释放锁 → 唤醒AQS同步队列后继 |
| 重新获锁 | 被唤醒线程CAS设置_owner,成功则wait返回 | 被唤醒线程CAS设置state,成功则await返回 |
核心设计思想完全一致:条件等待队列的节点先转移到等锁队列(仅用户态操作),真正的内核唤醒延迟到锁释放时才发生。这样可以避免在锁仍被持有时无效唤醒线程。
5.3 选型建议
优先使用 synchronized 的场景:
- 简单的方法级或代码块级同步
- 单一条件等待(不需要多条件变量)
- 低竞争场景:JVM 的偏向锁、轻量级锁优化在低竞争下性能优异
- 代码简洁性优先:synchronized 由 JVM 自动释放锁,不会忘记 unlock
优先使用 ReentrantLock 的场景:
- 多条件变量:如生产者消费者队列需要 notFull / notEmpty 两个独立条件
- 需要公平锁:按 FIFO 顺序分配锁
- 需要超时获取锁:tryLock(timeout) 避免无限阻塞
- 需要可中断获取锁:lockInterruptibly() 响应中断
- 高竞争、长时间持锁场景:ReentrantLock 提供更细粒度的控制
性能方面,JDK 6 之后 synchronized 经过大量优化(偏向锁、轻量级锁、自适应自旋、锁消除、锁粗化),在低竞争场景下性能不输甚至优于 ReentrantLock。ReentrantLock 的优势在于功能丰富性和可控性,而非绝对性能。
第六章 常见问题与最佳实践
6.1 为什么 wait/await 必须在循环中判断条件
标准范式是使用 while 而非 if:
// 正确:while循环
while (queue.isFull()) {
notFull.await();
}
// 错误:if判断
if (queue.isFull()) {
notFull.await();
}
原因有二:
- 虚假唤醒(spurious wakeup):线程可能在没有被 notify/signal 的情况下从 wait/await 中返回(底层 futex 和 pthread 实现都可能出现)。如果用 if,唤醒后直接往下执行,但条件可能仍不满足。
- 条件竞争:线程被唤醒后,在重新获取锁之前,其他线程可能已经改变了条件。例如 notifyAll 唤醒了多个线程,第一个抢到锁的线程消费了资源,后面的线程抢到锁时条件又不满足了。
因此,wait/await 返回后必须重新检查条件,不满足则继续等待。while 循环天然实现了这一点。
6.2 为什么 wait/await 必须先持有锁
三个原因:
- 内部需要释放锁:wait/await 的核心语义之一就是释放锁,让其他线程可以进入临界区修改条件。如果没有持有锁,就无锁可释。
- JVM 校验:ObjectMonitor 检查 _owner,AQS 检查 exclusiveOwnerThread,不匹配则抛 IllegalMonitorStateException。
- 保证检查与等待的原子性:条件检查(如 while(queue.isFull()))和等待操作必须在同一把锁的保护下,否则可能在检查条件后、调用 wait 前,条件被其他线程改变并发出 notify,导致信号丢失。
6.3 notify vs notifyAll / signal vs signalAll
|--------|-------------------------|---------------------------|
| 特性 | notify / signal | notifyAll / signalAll |
| 唤醒数量 | 条件队列中1个线程 | 条件队列中全部线程 |
| 信号丢失风险 | 多条件场景可能丢失 | 无 |
| 惊群效应 | 无 | 可能(多个线程被唤醒但只有一个能拿到资源) |
| 适用场景 | 所有等待线程等同一条件,且唤醒任意一个都能推进 | 多条件共享一个队列,或不确定唤醒哪个合适 |
在 ReentrantLock + 多 Condition 的场景下(如 notFull / notEmpty),使用 signal 是安全的,因为每个 Condition 队列中的线程都在等同一个条件,不会出现信号丢失。而在 synchronized 单 _WaitSet 的场景下,如果不同类型的等待线程共用一个 WaitSet,应该使用 notifyAll 避免信号丢失。
6.4 有界阻塞队列的标准实现
结合本文讨论的全部原理,一个最简有界阻塞队列的实现如下:
public class SimpleBlockingQueue<T> {
private final Object\[\] arr;
private int head = 0, tail = 0, size = 0;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public SimpleBlockingQueue(int capacity) {
arr = new Objectcapacity;
}
public void put(T e) throws InterruptedException {
lock.lock();
try {
while (size == arr.length) {
notFull.await(); // 满了:加入notFull条件队列,释放锁,park阻塞
}
arrtail = e;
tail = (tail + 1) % arr.length;
size++;
notEmpty.signal(); // 放入后:notEmpty队列头部节点移到AQS同步队列
} finally {
lock.unlock(); // 释放锁:唤醒AQS同步队列中的线程
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (size == 0) {
notEmpty.await(); // 空了:加入notEmpty条件队列,释放锁,park阻塞
}
T val = (T) arrhead;
arrhead = null;
head = (head + 1) % arr.length;
size--;
notFull.signal(); // 取出后:notFull队列头部节点移到AQS同步队列
} finally {
lock.unlock(); // 释放锁:唤醒AQS同步队列中的线程
}
return val;
}
}
这正是 JDK ArrayBlockingQueue 的核心实现思路。put 和 take 分别使用 notFull 和 notEmpty 两个独立的 Condition,实现精准唤醒,不会出现信号丢失或惊群。
6.5 锁升级与降级(synchronized)
synchronized 的性能优化依赖多级锁状态:
- 无锁 → 偏向锁:只有一个线程进入同步块时,将线程 ID 记录在 Mark Word,后续进入无需 CAS。
- 偏向锁 → 轻量级锁:出现多线程交替访问(非同时竞争)时,撤销偏向锁,线程在栈中创建锁记录,通过 CAS 尝试获取锁。
- 轻量级锁 → 重量级锁:多线程同时竞争,CAS 自旋失败超过阈值,锁膨胀为重量级锁,Mark Word 指向 ObjectMonitor,走本文讨论的完整 wait/notify + futex 流程。
锁升级是不可逆的(JDK 15 之前),一旦膨胀为重量级锁就不会降级。只有在全局安全点(GC 时)才可能发生批量重偏向或撤销。因此,在高竞争场景下,synchronized 最终都会走重量级锁的 ObjectMonitor 全流程。
附录:关键术语
|---------------|------------------------------|--------------------------------------------------------------------|
| 术语 | 英文全称 | 说明 |
| AQS | AbstractQueuedSynchronizer | JDK 并发包基础框架,ReentrantLock 的底层实现,维护 state 和同步队列 |
| CLH 队列 | Craig-Landin-Hagersten Queue | 一种基于链表的自旋锁队列,AQS 同步队列是其变体 |
| futex | fast userspace mutex | Linux 快速用户态互斥锁,用户态无竞争时不陷入内核,有竞争时通过系统调用阻塞 |
| IPC | Inter-Process Communication | 进程间通信,futex 是其底层原语之一,也可用于线程间同步 |
| Parker | --- | HotSpot 中每个 Java 线程绑定的 C++ 对象,维护许可计数器,实现 park/unpark |
| ObjectMonitor | --- | HotSpot 中 synchronized 重量级锁的 C++ 结构体,包含 _owner、_WaitSet、_EntryList |
| Mark Word | --- | Java 对象头的一部分,存储对象哈希码、分代年龄、锁状态标记或锁指针 |
| 虚假唤醒 | spurious wakeup | 线程在没有被显式通知的情况下从等待中返回,需用 while 循环重新检查条件 |
| 惊群效应 | thundering herd | notifyAll/signalAll 唤醒大量线程但只有一个能获得资源,其余线程再次阻塞 |
------ 全文完 ------
