从 AQS 看 ReentrantLock:同步队列、条件队列与公平性的代价
把
new ReentrantLock()换成new ReentrantLock(true),8 个线程抢锁的吞吐从每秒 4,696 万次掉到 46 万次,只剩百分之一。公平锁换来的是每个线程拿到锁的次数几乎完全一样。而在公平锁上调用tryLock(),200 次里有 186—194 次插到了仍在排队的线程前面。
AbstractQueuedSynchronizer(AQS)是 ReentrantLock、Semaphore、CountDownLatch、读写锁共同的骨架。本文用它的几个可观察接口(getHoldCount、getQueueLength、getWaitQueueLength、hasQueuedThread)把同步队列、条件队列与公平性直接测出来。实验环境是 JDK 21.0.5(10 核),部分对比在 Docker 中的 JDK 21.0.12 与 25.0.4 上完成。
一、先说结论
- AQS 由一个
volatile int state和一个等待队列组成。state的含义由子类决定:ReentrantLock里是重入次数,Semaphore里是剩余许可,CountDownLatch里是剩余计数。 - 同步队列等的是锁,条件队列等的是业务条件。
signal()只是把节点从条件队列移到同步队列,被唤醒的线程仍然要重新抢锁。 - 公平锁很贵:每次交接都必须唤醒排在最前面的线程,实测吞吐只有非公平锁的百分之一,线程数超过核数时差距更大。默认用非公平锁。
- 公平锁上的
tryLock()不遵守公平策略,tryLock(0, TimeUnit.SECONDS)才遵守。 - 优先用
synchronized,需要可中断、超时、多个条件队列或公平策略时再换ReentrantLock。两者的差别主要在语义,不在性能。
二、AQS 的结构
AQS 自己不定义「什么时候算拿到锁」,它把这部分留给子类(tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared),自己负责剩下的通用工作:获取失败的线程怎么排队、怎么阻塞(LockSupport.park)、怎么唤醒、中断和超时怎么取消。
| 同步器 | 模式 | state 的含义 |
|---|---|---|
ReentrantLock | 独占 | 持有者的重入次数,0 表示未被持有 |
ReentrantReadWriteLock | 写锁独占、读锁共享 | 高 16 位是读锁持有数,低 16 位是写锁重入数 |
Semaphore | 共享 | 剩余许可数 |
CountDownLatch | 共享 | 剩余计数,减到 0 时放行所有等待者 |
state 用 volatile 修饰,保证读写的可见性;状态的变更则靠 CAS 完成,比如 ReentrantLock 加锁时把 state 从 0 改成 1。CAS 本身的语义和失败重试的代价,见 volatile、CAS 与 LongAdder。可见性与 volatile 的语义见 JMM 与 happens-before。
2.1 可重入
ReentrantLock 记录了持有者线程,同一个线程再次加锁时只把 state 加一:
加锁 3 次后 getHoldCount() = 3
释放 2 次后 getHoldCount() = 1,isLocked() = true
多释放一次:IllegalMonitorStateException加锁几次就要释放几次,所以释放必须写在 finally 里:
lock.lock();
try {
updateState();
} finally {
lock.unlock();
}lock() 要放在 try 外面:如果它本身抛出异常,finally 里的 unlock() 会因为没有持有锁而再抛一个 IllegalMonitorStateException,把原始异常盖掉。
三、条件队列与同步队列
Condition.await() 的含义是:已经拿到锁,但业务条件还不满足。线程会完全释放锁(不管重入了几次),进入这个 Condition 自己的条件队列。
实测:3 个线程在同一个 Condition 上 await(),主线程再加锁观察两个队列:
| 时刻 | 条件队列 getWaitQueueLength | 同步队列 getQueueLength | 等待线程状态 |
|---|---|---|---|
3 个线程 await() 之后 | 3 | 0 | WAITING |
主线程 signalAll() 之后、unlock() 之前 | 0 | 3 | WAITING |
主线程 unlock() 之后 | 0 | 0 | 依次拿到锁并结束 |
第二行说明了 signal 的实际行为:它没有让等待线程开始运行,只是把它们从条件队列搬到了同步队列。这 3 个线程仍然处于阻塞状态,要等主线程释放锁之后,一个一个地抢到锁,才能从 await() 返回。
所以条件等待必须写在循环里:
lock.lock();
try {
while (queue.isEmpty()) { // 醒来之后重新检查条件
notEmpty.await();
}
return queue.removeFirst();
} finally {
lock.unlock();
}原因有两个。一是从被 signal 到真正拿到锁之间,别的线程可能先抢到锁,把条件又改掉了;二是规范允许虚假唤醒,线程可能在没有被 signal 的情况下醒来。
synchronized 只有一个隐式的条件队列(wait/notify)。一把锁上需要区分多个条件(比如「非空」和「非满」)时,ReentrantLock 的多个 Condition 能避免 notifyAll 把无关的线程全部唤醒。
四、公平性的代价
4.1 吞吐与均匀度
8 个线程在 2 秒内反复抢同一把锁,每次持锁做 50 次自增:
| 锁 | 吞吐 | 各线程获取次数(最多 / 最少) |
|---|---|---|
非公平 ReentrantLock | 46,956,929 次/秒 | 1.1 倍 |
synchronized | 25,154,104 次/秒 | 4.6 倍 |
公平 ReentrantLock | 464,597 次/秒 | 1.0 倍 |
非公平锁允许刚释放锁的线程(或刚到达的线程)直接 CAS 抢锁,这时锁的持有者往往还在 CPU 上,缓存是热的,也不需要唤醒别人。公平锁则要求:只要队列里有人在等,新来的线程就必须排到后面。于是每次释放都要唤醒队首线程,等它被调度、再拿锁,一次交接就是一次上下文切换。
线程数超过核数时,这个代价更明显。在 6 核的容器里同样 8 个线程,公平锁每秒只有约 7 万次,非公平锁约 5,000 万次。
另一个观察是 synchronized 的分配不太均匀:JDK 21.0.5 上最多和最少的线程相差 4.6 倍,在容器中的 JDK 21.0.12 上是 1.9 倍,JDK 25.0.4 上是 1.1 倍。它不保证任何公平性,具体的分配结果随 JDK 版本和运行环境变化,不要依赖。
4.2 tryLock() 会插队
公平锁被持有、另一个线程已经在排队时,持有者释放锁后立刻调用 tryLock(),重复 200 次。成功时再检查排队线程是否还在同步队列里:还在,才算真正插队。
| 调用 | 抢到锁的次数 | 其中插队(排队线程仍在队列中) |
|---|---|---|
tryLock() | 196 / 200 | 186 |
tryLock(0, TimeUnit.SECONDS) | 18 / 200 | 0 |
ReentrantLock 的 Javadoc 写明了这一点:即使设置了公平策略,只要锁当前可用,tryLock() 就会立即获取,不管有没有线程在等。需要遵守公平策略时,用带超时的版本,超时时间可以是 0。表中带超时版本的 18 次成功,都发生在排队线程已经拿到锁、用完、释放之后,锁上已经没有人排队了。
只看「成功次数」会误判:在 4 核的 CI 机器上,一次运行中 tryLock() 成功 132 次,带超时的版本反而成功 176 次,因为调度更慢时,排队线程常常在主线程调用之前就完成了。插队次数随核数变化(限制为 2 核的容器中是 96 次),但带超时的版本在有人排队时从不成功,这一点由公平策略保证。
4.3 什么时候用公平锁
默认用非公平锁。只有在这两种情况下才考虑公平锁:
- 观察到了真实的饥饿,某些线程长时间拿不到锁;
- 业务对等待时间的上限有要求,宁可牺牲吞吐也要让等待时间可预测。
更常见的做法是缩短临界区、减少锁的争用,而不是在锁的分配策略上想办法。
五、synchronized 还是 ReentrantLock
| 需要的能力 | synchronized | ReentrantLock |
|---|---|---|
| 基本互斥、自动释放 | 支持,离开代码块自动释放 | 必须在 finally 里手动释放 |
| 等待锁时响应中断 | 不支持 | lockInterruptibly() |
| 超时放弃 | 不支持 | tryLock(timeout, unit) |
| 多个条件队列 | 只有一个 | 多个 Condition |
| 公平策略 | 不支持 | 构造参数 true |
| 查询锁状态(排队数、持有次数) | 不支持 | getQueueLength() 等 |
| 虚拟线程 | JDK 21~23 在块内阻塞会 pin 住载体线程;JDK 24 起不会 | 不会 pin |
实测非公平 ReentrantLock 的吞吐比 synchronized 高,但在真实业务里,临界区里通常有更慢的操作,两者的差距可以忽略。默认选 synchronized,它不会忘记释放,代码也更短。需要表中右列的能力时再换 ReentrantLock。虚拟线程与 pinning 的实测见 虚拟线程迁移。
六、常见误区
- 「
signal()之后等待的线程就开始运行了」:它只是被移到同步队列,还要等锁释放、重新抢到锁。 - 「公平锁只是稍微慢一点」:实测慢了两个数量级。
- 「公平锁上所有获取方式都公平」:
tryLock()会插队。 - 「条件等待用
if判断就够了」:必须用while,醒来后要重新检查条件。 - 「
state是volatile的,所以线程安全」:volatile只保证可见性,状态的变更靠 CAS。
小结
AQS 的核心就是一个整数状态加一个等待队列,锁、信号量、闭锁都是在它上面定义「什么时候算获取成功」。理解它有两个直接的用处:一是分清同步队列和条件队列,写出正确的条件等待;二是知道公平性的代价,默认用非公平锁,并且记住 tryLock() 不受公平策略约束。
配套实验
- codesphere-labs/java/aqs-and-locks:公平锁吞吐、条件队列与
tryLock插队(验证记录)
可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。
参考资料