Skip to content

从 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 的结构 ​

volatile int stateReentrantLock:重入次数0 表示未被持有持有者线程 T0同步队列:等锁T1T2T3条件队列 notEmpty:等业务条件T4T5T6signal()移到同步队列队尾await() 释放锁实测:3 个线程 await 后,条件队列 3、同步队列 0;持锁线程 signalAll 后、unlock 前,条件队列 0、同步队列 3被 signal 的线程状态仍是 WAITING:它们要等持锁线程 unlock 之后,重新竞争到锁才能从 await() 返回
图 1 · 同步队列里的线程在等锁,条件队列里的线程在等业务条件;signal() 只是把节点从条件队列移到同步队列,被唤醒的线程仍要重新抢锁

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 加一:

text
加锁 3 次后 getHoldCount() = 3
释放 2 次后 getHoldCount() = 1,isLocked() = true
多释放一次:IllegalMonitorStateException

加锁几次就要释放几次,所以释放必须写在 finally 里:

java
lock.lock();
try {
    updateState();
} finally {
    lock.unlock();
}

lock() 要放在 try 外面:如果它本身抛出异常,finally 里的 unlock() 会因为没有持有锁而再抛一个 IllegalMonitorStateException,把原始异常盖掉。

三、条件队列与同步队列 ​

Condition.await() 的含义是:已经拿到锁,但业务条件还不满足。线程会完全释放锁(不管重入了几次),进入这个 Condition 自己的条件队列。

实测:3 个线程在同一个 Condition 上 await(),主线程再加锁观察两个队列:

时刻条件队列 getWaitQueueLength同步队列 getQueueLength等待线程状态
3 个线程 await() 之后30WAITING
主线程 signalAll() 之后、unlock() 之前03WAITING
主线程 unlock() 之后00依次拿到锁并结束

第二行说明了 signal 的实际行为:它没有让等待线程开始运行,只是把它们从条件队列搬到了同步队列。这 3 个线程仍然处于阻塞状态,要等主线程释放锁之后,一个一个地抢到锁,才能从 await() 返回。

所以条件等待必须写在循环里:

java
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 倍synchronized25,154,104 次/秒4.6 倍公平 ReentrantLocknew ReentrantLock(true)464,597 次/秒1.0 倍6 核容器、8 个线程超额竞争时差距更大:公平锁每秒约 7 万次,非公平锁约 5,000 万次
图 2 · 8 个线程抢同一把锁:公平锁每次交接都要唤醒排队线程,吞吐只有非公平锁的百分之一;换来的是各线程获取次数几乎完全均匀(JDK 21.0.5,10 核,横轴为对数刻度)
锁吞吐各线程获取次数(最多 / 最少)
非公平 ReentrantLock46,956,929 次/秒1.1 倍
synchronized25,154,104 次/秒4.6 倍
公平 ReentrantLock464,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 / 200186
tryLock(0, TimeUnit.SECONDS)18 / 2000

ReentrantLock 的 Javadoc 写明了这一点:即使设置了公平策略,只要锁当前可用,tryLock() 就会立即获取,不管有没有线程在等。需要遵守公平策略时,用带超时的版本,超时时间可以是 0。表中带超时版本的 18 次成功,都发生在排队线程已经拿到锁、用完、释放之后,锁上已经没有人排队了。

只看「成功次数」会误判:在 4 核的 CI 机器上,一次运行中 tryLock() 成功 132 次,带超时的版本反而成功 176 次,因为调度更慢时,排队线程常常在主线程调用之前就完成了。插队次数随核数变化(限制为 2 核的容器中是 96 次),但带超时的版本在有人排队时从不成功,这一点由公平策略保证。

4.3 什么时候用公平锁 ​

默认用非公平锁。只有在这两种情况下才考虑公平锁:

  • 观察到了真实的饥饿,某些线程长时间拿不到锁;
  • 业务对等待时间的上限有要求,宁可牺牲吞吐也要让等待时间可预测。

更常见的做法是缩短临界区、减少锁的争用,而不是在锁的分配策略上想办法。

五、synchronized 还是 ReentrantLock ​

需要的能力synchronizedReentrantLock
基本互斥、自动释放支持,离开代码块自动释放必须在 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() 不受公平策略约束。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权