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 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。

1.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 把无关的线程全部唤醒。

三、公平性的代价 ​

3.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 版本和运行环境变化,不要依赖。

3.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 次),但带超时的版本在有人排队时从不成功,这一点由公平策略保证。

3.3 什么时候用公平锁 ​

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

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

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

四、synchronized 还是 ReentrantLock ​

需要的能力synchronizedReentrantLock
基本互斥、自动释放支持,离开代码块自动释放必须在 finally 里手动释放
等待锁时响应中断不支持lockInterruptibly()
超时放弃不支持tryLock(timeout, unit)
多个条件队列只有一个多个 Condition
公平策略不支持构造参数 true
查询锁状态(排队数、持有次数)不支持getQueueLength() 等
虚拟线程JDK 21~23 在块内阻塞会 pin 住载体线程;JDK 24 起不会不会 pin

实测非公平 ReentrantLock 的吞吐比 synchronized 高,但在真实业务里,临界区里通常有更慢的操作,两者的差距可以忽略。默认选 synchronized,它不会忘记释放,代码也更短。需要表中右列的能力时再换 ReentrantLock。虚拟线程与 pinning 的实测见 虚拟线程迁移。

五、读多写少:读写锁与 StampedLock ​

ReentrantReadWriteLock 同样建在 AQS 上:state 的高 16 位记读锁的持有数,低 16 位记写锁的重入次数。读锁之间不互斥,写锁与任何锁互斥。StampedLock 是另一套独立的实现,多了一种不加锁的「乐观读」。下面几条边界在 JDK 25.0.4 上逐个验证过。

5.1 能降级,不能升级 ​

操作结果
持有读锁时 tryLock 写锁false
持有写锁时 tryLock 读锁true;释放写锁后仍持有读锁

写锁要等所有读锁释放,包括自己手里的那一个,所以「读到发现要改,直接拿写锁」会自己等自己,换成阻塞的 lock() 就是死锁,而且线程转储里看不到锁环。正确的顺序是先释放读锁、再拿写锁,然后重新检查条件:释放和获取之间别的线程可能已经改过了。反方向的降级是允许的,常用于「写完之后还要接着读,并且不想让别人插进来改」。

5.2 乐观读要校验,校验之前的数据不能用 ​

StampedLock.tryOptimisticRead() 不获取任何锁,只返回一个版本戳。读完之后用 validate(stamp) 检查期间有没有发生过写入:

java
long stamp = lock.tryOptimisticRead();
int x = this.x, y = this.y;              // 先复制到局部变量
if (!lock.validate(stamp)) {             // 期间有写入:退回读锁重读
    stamp = lock.readLock();
    try { x = this.x; y = this.y; } finally { lock.unlockRead(stamp); }
}
return compute(x, y);                    // 校验通过之后才使用

实测两个和恒为 100 的字段,读完第一个之后发生一次写入:

读到的值validate
乐观读期间发生写入x=60、y=70,和为 130false
退回读锁重读x=30、y=70,和为 100—
乐观读期间没有写入一致true,全程没有获取锁

乐观读拿到的可能是「一半旧、一半新」的数据,校验的作用就是发现这种情况。所以顺序很重要:先把要用的字段都复制到局部变量,再校验,校验通过后才用它们做计算。校验之前不要基于这些值执行不可撤销的操作,也不要在乐观读里遍历可变的集合或者解引用可能被替换的对象,那些代码可能在不一致的数据上抛出异常或者陷入循环。

5.3 StampedLock 不可重入,转换后要换 stamp ​

操作结果
ReentrantReadWriteLock 同一线程第二次获取写锁成功,持有 2 次
StampedLock 持有写锁时 tryWriteLock / tryReadLock都返回 0(失败)
只有一个读者时 tryConvertToWriteLock成功,返回新的 stamp
转换成功后用旧的读 stamp 解锁抛 IllegalMonitorStateException,写锁仍被持有
有两个读者时 tryConvertToWriteLock返回 0,原来的读锁仍然持有

不可重入意味着持锁的代码不能再调用另一个会获取同一把锁的方法,否则阻塞版本会自己等自己。把 synchronized 或 ReentrantLock 的代码改成 StampedLock 时,这是最容易漏的一处。锁转换成功后,旧 stamp 作废,后续的解锁必须用返回的新 stamp;失败返回 0 时旧 stamp 仍然有效。StampedLock 也没有 Condition。

5.4 用读写锁做缓存:别在全局写锁里加载 ​

「读锁查缓存,未命中就拿写锁加载」是读写锁的典型用法。它有一个容易忽略的代价:写锁是全局的。

写法键 A 加载期间读已缓存的键 B两个线程同时请求键 A
全局写锁里加载100ms 内拿不到读锁加载一次(拿到写锁后重新检查)
ConcurrentHashMap.computeIfAbsent不被阻塞加载函数执行 1 次

一次慢加载(一个回源查询)会让所有键的读取都停下来。computeIfAbsent 只锁住键所在的桶,同一个键的并发请求只加载一次,其他键基本不受影响(落在同一个桶里的键仍会等待)。它的限制是加载函数里不能再修改这个 map,耗时也不宜太长。需要过期、容量上限和异步刷新时直接用 Caffeine;缓存层面的击穿与一致性见缓存一致性的四种写法。

选择上:读写比例不悬殊、临界区很短时,读写锁未必比普通互斥锁快,它自己的记账也有成本;StampedLock 的乐观读适合「读非常多、数据是几个字段的简单状态」的场景。两者都要在自己的负载上测过再换。

锁本身没有问题、各个线程锁的却不是同一个对象的情况,见加了 synchronized,计数还是少了。


配套实验

每个实验都可以用 make verify 一条命令复现;链接固定在实验仓库的对应提交。

参考资料

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