本节目标:在「必须等待某个条件成立」的场景里,写出可超时、可中断、不会丢唤醒的等待队列。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。
承接的问题
4.1 解决了单个变量的可见性,4.2 解决了单个变量的原子性。但 libraryd 的借阅流程里有一个更复杂的判断:这本书当前有没有可借副本,没有的话这个会员要不要排队。这是一个「检查—等待—再检查」的复合动作,volatile 和 AtomicLong 都给不了。
卷二的实现是「查数据库,没有就返回 BOOK_ALREADY_BORROWED」。这对图书馆场景不够:会员点了借阅,书正在归还台上,两分钟后就能借,直接拒绝体验很差。产品希望加一条内存等待队列:请求最多等 3 秒,期间如果有人归还,就唤醒最久的等待者。
同时还有一个运维诉求:等待中的请求必须能在停机时被叫醒,而不是傻等到超时。这就要求锁支持超时和中断——synchronized 两样都不给。
synchronized 的边界
synchronized 简单、由 JVM 管理、不会忘记释放,这些都是优点。但它有两个硬边界:拿不到锁时会一直等,且不响应中断。
用一个实验把差异摆出来。monitorHolder 持有 MONITOR 500ms,lockHolder 持有 LOCK 500ms;主线程先 tryLock(200ms),再启动一个 lockInterruptibly 的线程并在 100ms 后中断它,最后让一个线程去抢 MONITOR 并记录拿到锁用了多久:
Thread blocked = new Thread(() -> {
long start = System.nanoTime();
synchronized (MONITOR) {
System.out.printf("synchronized acquired after %dms (no timeout possible)%n",
(System.nanoTime() - start) / 1_000_000);
}
});
blocked.start();
long start = System.nanoTime();
boolean got = LOCK.tryLock(200, TimeUnit.MILLISECONDS);
System.out.printf("tryLock(200ms) got=%s after=%dms%n",
got, (System.nanoTime() - start) / 1_000_000);
本机输出(三次输出顺序随调度变化,数值为同一次运行):
tryLock(200ms) got=false after=201ms
lockInterruptibly interrupted after waiting
synchronized acquired after 452ms (no timeout possible)
两点结论:
tryLock(200ms)在 200ms 后放弃,返回false;lockInterruptibly在等待中被中断,抛出InterruptedException。前者让调用方可以返回 429 或降级,后者让停机时能叫醒等待者。synchronized只能等。它最终在 452ms 拿到锁(holder 还剩约 450ms),期间没有任何办法让它提前退出。
synchronized 在 JDK 25 里仍然值得用:它没有额外对象、JIT 可以做锁消除和偏向优化、代码短。当你不需要超时、中断、多个条件队列时,它就是更好的选择。反过来,需要这些能力时才上 ReentrantLock。
ReentrantLock 与 AQS
ReentrantLock 的内部是一个 Sync,继承 AbstractQueuedSynchronizer(AQS)。AQS 用一个 volatile int state 表示同步状态,加一个双向链表(CLH 变体)存放等待线程:
state == 0表示没人持有;lock()用 CAS 把 0 改成 1。- 重入时
state递增,unlock()递减到 0 才真正释放。 - 抢不到锁的线程被包装成节点挂到队列尾部,然后
park。 - 释放时唤醒队首,队首重新竞争。
公平与非公平的差别就在「新来的线程要不要排队」:非公平锁允许刚到的线程直接 CAS 抢,抢不到再排队;公平锁检查队列里有没有前驱,有就直接排队。非公平吞吐更高(减少唤醒开销),公平锁更可预测。
ReentrantLock 暴露的几个方法值得记住:
| 方法 | 行为 | 典型用途 |
|---|---|---|
lock() | 阻塞直到拿到锁 | 临界区短、确定不会死锁 |
tryLock() | 立即返回,不等待 | 避免死锁、可跳过的工作 |
tryLock(time, unit) | 最多等指定时间 | 有 SLA 的请求 |
lockInterruptibly() | 等待时可被中断 | 停机、取消 |
newCondition() | 创建条件队列 | 生产者消费者、等待队列 |
state 只是一个 int,AQS 不关心它代表什么。子类通过 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享)定义语义。ReentrantLock 的语义是「重入计数」,Semaphore 是「许可数」,CountDownLatch 是「还剩几个」,BooleanLatch 是「0 或 1」。
Condition:等待某个条件成立
有了锁只能保证互斥,还不能表达「等可借副本出现」。Condition 提供三个动作:
await():释放锁,把当前线程挂到条件队列,被唤醒后重新拿锁再返回。signal():把条件队列里等待最久的线程转移到锁的等待队列,等当前线程释放锁后它才有机会运行。signalAll():转移全部。
关键点是 await() 会释放锁。这看起来理所当然,但它是整个机制能工作的前提:如果等待者不释放锁,归还线程永远拿不到锁,也就永远没人能改变条件。
用一个三等待者的实验验证。队列初始 0 个副本,三个会员分别等 1000ms、400ms、400ms;100ms 后有一个副本归还,且只 signal() 一个:
boolean borrow(Duration timeout, String member) throws InterruptedException {
long nanos = timeout.toNanos();
lock.lock();
try {
while (copies == 0) {
if (nanos <= 0) {
return false;
}
nanos = copyReturned.awaitNanos(nanos);
}
copies--;
return true;
} finally {
lock.unlock();
}
}
本机输出:
member-0 awaits, lock held by=waiter-0
member-1 awaits, lock held by=waiter-1
member-2 awaits, lock held by=waiter-2
return 978000000001 acquired lock in 0ms, copies=1, waiters=3
member-0 got a copy, left=0
member-2 timed out, copies=0
member-1 timed out, copies=0
每一行都对应一个设计点:
return ... acquired lock in 0ms:三个线程都在await里,锁是释放的,归还线程瞬间拿到锁。如果await不释放锁,这里会一直等到第一个等待者超时。waiters=3且member-0 got a copy:signal()在归还时调用,此时 3 个线程都还挂在条件队列上(被转移的那个要等锁释放后才摘除);只唤醒了一个,被选中的是等待最久的member-0。这里用了公平锁,条件队列按 FIFO 转移;非公平锁下不保证。- 另外两个
timed out:没有被signal的线程只能等自己的awaitNanos到期。
三个必须遵守的写法:
- 条件必须用
while重查,不能用if。await返回不代表条件成立:可能被signalAll误唤醒,可能awaitNanos超时返回,也可能在重新拿锁的间隙被别人抢先消费掉副本。 awaitNanos的返回值是剩余纳秒,要接住。 每次被唤醒后重新计算剩余时间,否则循环一次就把超时重置了,等待时间会无限延长。signal/signalAll必须在持有锁时调用,否则抛IllegalMonitorStateException。这不是形式要求:只有持锁时调用,才能保证「条件变更」和「唤醒」之间不被插入。
AQS 的最小实现:BooleanLatch
CountDownLatch 是一次性的,不能重置;有时只需要一个「准备好了」的开关。直接用 AQS 写一个 BooleanLatch,能看清共享模式下的四个钩子:
public final class IndexWarmupLatch {
private final Sync sync = new Sync();
private static final class Sync extends AbstractQueuedSynchronizer {
boolean isDone() {
return getState() != 0;
}
@Override
protected int tryAcquireShared(int ignored) {
return isDone() ? 1 : -1;
}
@Override
protected boolean tryReleaseShared(int ignored) {
setState(1);
return true;
}
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public boolean await(Duration timeout) throws InterruptedException {
return sync.tryAcquireSharedNanos(1, timeout.toNanos());
}
public void complete() {
sync.releaseShared(1);
}
public boolean isDone() {
return sync.isDone();
}
}
tryAcquireShared 返回负数表示失败,0 表示成功但后续的共享获取也会成功,正数表示成功且还有余量。这里返回 1:一旦完成,所有等待者都能通过。等待者由 AQS 自动排队、park、被唤醒后重试,不需要自己写循环。
本机运行:
index warmed, signal()
request proceeded after 302ms, state=true
timeout await=false after=204ms
第一个请求在预热完成后 302ms 通过(预热本身睡了 300ms),第二个 latch 从未完成,await(200ms) 在 204ms 返回 false。这就是 CountDownLatch 的底层形态,理解了它,Semaphore(state 是许可数)和 ReentrantReadWriteLock(state 高低位分别记读锁写锁)都是同一套骨架。
在 libraryd 里落地
第一处是借阅等待队列。它是「按 ISBN 一条」的:每本书一个队列,互不干扰。
package com.plume.library.persistence;
import java.time.Duration;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class LoanWaitQueue {
private final String isbn;
private final ReentrantLock lock = new ReentrantLock(true);
private final Condition copyReturned = lock.newCondition();
private int copies;
private int waiting;
public LoanWaitQueue(String isbn, int copies) {
if (copies < 0) {
throw new IllegalArgumentException("copies 不能为负: " + copies);
}
this.isbn = isbn;
this.copies = copies;
}
public boolean borrow(Duration timeout, String memberId) throws InterruptedException {
long nanos = timeout.toNanos();
lock.lock();
try {
while (copies == 0) {
if (nanos <= 0) {
return false;
}
waiting++;
try {
nanos = copyReturned.awaitNanos(nanos);
} finally {
waiting--;
}
}
copies--;
return true;
} finally {
lock.unlock();
}
}
public void registerReturn(String memberId) {
lock.lock();
try {
copies++;
copyReturned.signal();
} finally {
lock.unlock();
}
}
public int copies() {
lock.lock();
try {
return copies;
} finally {
lock.unlock();
}
}
public int waiting() {
lock.lock();
try {
return waiting;
} finally {
lock.unlock();
}
}
}
几个实现决定:
new ReentrantLock(true)用公平锁。等待队列是有业务含义的:先来的会员应该先拿到书。非公平锁吞吐更高,但在「有人排了 2 秒」的场景里可能被新请求插队。waiting的加减也在锁内,保证waiting()读到的是一个一致的值,可以直接进指标。registerReturn只signal()一个,因为只多了一个副本。如果一次归还多本,应该循环signal()或直接signalAll()。memberId参数目前只用于日志和后续的公平性审计;真正的「谁先来」由条件队列的 FIFO 顺序保证。
第二处是启动预热。libraryd 启动时要加载 ISBN 索引、罚金规则、馆藏分布三份数据,全部就绪才开放流量:
package com.plume.library.app;
public final class Warmup {
public static void awaitAll(StartupGate gate, ExecutorService executor, List<Runnable> tasks)
throws InterruptedException {
for (Runnable task : tasks) {
executor.submit(() -> {
try {
task.run();
} finally {
gate.taskDone();
}
});
}
if (!gate.await(Duration.ofSeconds(30))) {
throw new LibraryException(ErrorCode.DB_UNAVAILABLE,
"预热超时,剩余 " + gate.remaining() + " 个任务未完成");
}
}
}
StartupGate 内部就是一个 CountDownLatch。用 await(Duration) 而不是无参 await(),是为了让启动失败时能带着「还剩几个任务」的信息退出,而不是卡在启动脚本里。
第三处要说清边界:JVM 锁只保护单进程内的状态。LoanWaitQueue 的 copies 是内存值,多实例部署时每个实例各有一份,A 实例归还不会唤醒 B 实例的等待者。真正的并发控制仍然靠数据库的条件更新:
update loans
set returned_on = ?
where isbn = ? and returned_on is null
内存等待队列只负责「让等的人不要空转」,不负责正确性。这两个层次不能混。
常见错误与边界
错误一:用 if 代替 while。
// 反例:只检查一次
if (copies == 0) {
copyReturned.await();
}
copies--;
await 返回时条件可能仍然不成立(超时唤醒、被别的等待者抢走),此时 copies-- 会把计数减成负数。所有条件等待都必须是 while (条件不成立) await();。
错误二:unlock 没放在 finally。 临界区里抛异常(比如 memberId 为空触发 IllegalArgumentException),锁就永久泄漏。写法固定为 lock(); try { ... } finally { unlock(); },中间不要 return 在 try 外面。
错误三:以为 signal 会立刻切换线程。 signal 只是把等待者从条件队列转到锁队列,当前线程继续执行到 unlock 才可能让对方运行。所以「先 signal 再改状态」是错的:
// 反例:先唤醒,后改状态
copyReturned.signal();
copies++;
被唤醒的线程拿到锁后看到 copies 还是 0,又回去等,而这次 signal 已经用掉了。正确顺序永远是「改状态 → signal」。
边界:公平锁不等于公平调度。 new ReentrantLock(true) 只保证锁的获取按排队顺序,不保证线程被 CPU 调度的顺序。上面实验里 member-0 先拿到,是因为条件队列 FIFO 加上公平锁,不是 JVM 承诺的。
边界:synchronized 在 JDK 25 不再 pin 虚拟线程,但仍然是互斥锁。 6.1 会用实验说明这一点。不 pin 只意味着等待时不占住载体线程,不意味着两个线程可以同时进入临界区。把全局 synchronized 保留在借阅主链路上,虚拟线程带来的并发度会被这把锁吃掉。
小结
synchronized无法超时、无法中断;需要这两种能力时用ReentrantLock。- 本机实验中
tryLock(200ms)在 201ms 返回false,lockInterruptibly被中断退出,synchronized只能等满 452ms。 - AQS 用
state加等待队列实现同步器;ReentrantLock的 state 是重入计数,Semaphore是许可数。 Condition.await会释放锁,这是「等待者不阻塞条件变更者」的前提;条件必须while重查,超时用awaitNanos的返回值续算。signal只唤醒一个、按等待时间选择;signalAll唤醒全部,代价是惊群。- libraryd 的
LoanWaitQueue只做进程内排队,正确性仍由数据库条件更新保证。
单进程内的锁讲完了,但把所有读都串行化并不划算。下一节看 ConcurrentHashMap 与 CopyOnWriteArrayList 如何在不加全局锁的情况下支撑读多写少的索引。
阅读导航:上一节:4.2 原子类与 CAS:AtomicLong 与 LongAdder 的取舍 · 下一节:5.1 并发容器:ConcurrentHashMap 与 CopyOnWriteArrayList 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。