AQS、Lock 与 Condition:同步器怎样排队、唤醒和取消
线程 dump 里出现 LockSupport.park 和 AbstractQueuedSynchronizer.acquire,不能直接下结论“线程死锁了”。它可能在等 ReentrantLock,可能在等 Semaphore 许可,也可能只是线程池 worker 正常等队列。AQS 的价值,是把许多同步器共同的排队、阻塞、唤醒和取消机制收拢起来;排障时要继续追到具体同步器和它的 state 含义。
ReentrantLock 与 Condition 已足以走完同步器的关键链路:快速获取失败后入队,线程 park,释放唤醒后继,中断和超时取消节点,Condition 则通过另一条等待队列保存条件等待者。
先用 API 表达失败控制
内置锁适合简单互斥;当获取锁必须能中断、能超时,或需要多个等待条件时,ReentrantLock 更直接:
private final ReentrantLock lock = new ReentrantLock();
boolean update(Duration budget) throws InterruptedException {
if (!lock.tryLock(budget.toMillis(), TimeUnit.MILLISECONDS)) {
return false;
}
try {
updateState();
return true;
} finally {
lock.unlock();
}
}unlock 必须在成功获取之后的 finally 中。把 tryLock 写进 try 的条件表达式又在 finally 无条件解锁,会在获取失败时抛 IllegalMonitorStateException。锁超时也不能只返回 false 就结束,调用方要有明确降级、重试或错误语义。
公平构造 new ReentrantLock(true) 让排队更接近先来先得,但会减少抢占捷径,吞吐通常有代价。默认非公平锁并不承诺无饥饿;是否使用公平性,要由等待上界与业务优先级决定,不由“公平听起来更正确”决定。
AQS 把同步器拆成状态与队列
AbstractQueuedSynchronizer 跟踪一个可原子更新的 int state,子类定义获取和释放时这个值代表什么:
ReentrantLock 可用它表达持有与重入次数。Semaphore 用它表达剩余许可。CountDownLatch 用它表达尚未完成的计数。
读写锁需要在一个状态中编码共享与独占部分。
获取失败后,AQS 管理 FIFO 等待队列、检查前驱、阻塞当前线程,并在释放时推进后继。队列近似 FIFO 不等于每次调度严格公平;节点被取消、线程竞争和非公平快速路径都会影响实际顺序。
tryAcquire、tryRelease 是同步器定义语义的地方,AQS 负责通用机械部分。业务排障看到 AQS 栈时,必须先识别外层组件,否则无法解释 state=0、state=5 分别意味着什么。
park 是暂停,不是丢掉唤醒
LockSupport 为每个线程维护至多一个许可。unpark(thread) 使许可可用;线程随后调用 park() 时会消费许可并立即返回。如果线程已经 park,unpark 让它有机会继续。这个模型避免了必须“先 wait 后 notify”的严格时序,但许可不会累积成多个。
park() 可能因为许可、中断或无理由返回,所以 AQS 被唤醒后仍会循环检查能否获取状态。业务直接使用 LockSupport 时也必须把条件检查放在循环中,不能把一次返回当成条件成立。
线程处于 WAITING (parking) 只说明它让出执行机会。下面几种栈含义完全不同:
| 栈上层 | 常见含义 | 下一步 |
|---|---|---|
ReentrantLock.lock | 等独占锁 | 找持有者和临界区 |
Semaphore.acquire | 等许可 | 看许可容量和归还路径 |
ConditionObject.await | 等业务条件 | 找 signal 方和条件状态 |
ThreadPoolExecutor.getTask | worker 等任务 | 通常是正常空闲 |
CompletableFuture.waitingGet | 等异步结果 | 查完成链与超时 |
中断和超时必须把取消节点清理出去
lockInterruptibly()、带时限 tryLock 的难点不只是让调用返回。线程已经进入同步队列后被中断或超时,节点需要标记取消,队列还要绕过它继续推进,不能让一个失效前驱堵住后续线程。
应用代码要尊重这种失败语义:
try {
lock.lockInterruptibly();
try {
updateState();
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.cancelled();
}如果捕获后无限重试 lock(),就把可取消 API 重新变成不可取消。超时后也不要在紧循环中立刻重试,否则大量线程会形成周期性竞争尖峰;应把剩余截止时间、退避和业务优先级一起考虑。
从入队到取消:为什么一个超时节点不能堵死后继
获取失败并不是立刻 park。线程先把自己的节点接到同步队列尾部,再检查前驱:只有排到队首附近且再次获取仍失败时,才适合暂停。这个“先入队、再复查”处理了释放与入队并发发生的窗口;即使许可在准备休眠前到达,循环也会重新尝试获取,LockSupport 的单许可语义还能避免一次先到的 unpark 丢失。
超时或中断会让节点失效,但节点不能只把线程字段清空后留在链路中央。后继寻找有效前驱时需要跨过取消节点,释放路径也要能唤醒真正可能推进的节点。不同 OpenJDK 版本的节点状态位和清理算法会演进,稳定不变的判断是:取消必须同时完成调用方返回与等待队列可继续推进;只做到前者,同步器会随着超时积累越来越长的失效链。
AqsCancellationDemo 固定了“中间等待者超时、后继仍能获得锁”的顺序:
cd examples/backend-development/concurrency/aqs-lock-condition
javac --release 17 -Xlint:all -Werror AqsCancellationDemo.java
java AqsCancellationDemo
# timed-acquired=false
# successor-acquired=true持有者占锁后,限时线程先排队,普通后继再排队。限时线程退出时锁仍未释放;随后持有者释放,后继能够越过已经取消的等待继续推进。若自定义同步器的 tryRelease 没有正确表达“状态已经完全释放”,或者取消清理破坏前后链接,这类实验会表现为后继永久停在 park。
公平性也要放到这个队列里理解。公平获取通常检查前面是否已有等待者,减少新线程插队;但队列首节点获得的只是参与竞争的优先权,不是调度器保证的立即执行权。节点可能取消,首线程可能尚未被调度,非公平快速路径又允许新调用者直接尝试状态。所谓 FIFO 是同步队列的推进策略,不是端到端完成顺序保证。
独占与共享的区别是后继能否成批推进
独占模式下一次通常只有一个线程成功持有;共享模式下,一个成功获取可能允许多个等待者继续。例如 Semaphore 有多个许可,CountDownLatch 归零后所有等待者都能通过。
Semaphore permits = new Semaphore(32);
Result callDownstream() throws InterruptedException {
if (!permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return Result.overloaded();
}
try {
return client.call();
} finally {
permits.release();
}
}这段代码限制同时进入下游的数量,却没有限制等待者总数;tryAcquire 的 100ms 才让等待有界。许可必须在成功 acquire 后归还,异常路径漏 release 会让容量逐渐缩水。监控应该暴露可用许可、等待线程、获取超时和下游耗时。
CountDownLatch 的 countDown 同样要放到工作任务 finally,否则一个异常任务会让等待者永久卡住。它是一次性同步器,计数归零后不能重置;需要多轮会合时选择 CyclicBarrier、Phaser 或重新设计阶段所有权。
Condition 有自己的条件队列
一个有界缓冲区需要两个不同条件:不为空时消费者才能取,不为满时生产者才能放。Condition 允许同一把锁创建多个等待集合:
final ReentrantLock lock = new ReentrantLock();
final Condition notEmpty = lock.newCondition();
final Condition notFull = lock.newCondition();
void put(Task task) throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.addLast(task);
notEmpty.signal();
} finally {
lock.unlock();
}
}await() 会释放关联的 ReentrantLock,把当前线程放入 Condition 条件队列。signal() 并不让线程直接执行,而是把合适节点转移到 AQS 同步队列;它仍需重新获取锁,才从 await 返回。
因此条件仍要用 while 检查。signal 后继续在锁内做长工作,会让被通知者迟迟拿不到锁。等待和通知还必须使用同一个 Condition;如果把 notEmpty 等待者用 notFull.signal() 唤醒,系统可能永久停滞。
await 必须保存并恢复完整重入深度
一个线程可能重入同一把 ReentrantLock 多次后再调用 await。条件等待不能只释放一次,否则它仍是锁的 owner,其他线程永远无法取得锁去改变条件并 signal。AQS 条件实现会保存当前独占状态,完全释放后进入条件队列;收到信号并转移到同步队列后,再以保存的状态重新获取,从 await 返回时恢复原来的重入深度。
这条路径也解释了为什么 signal 只能在持锁状态调用:发信号方必须在同一互斥边界内先修改条件,再挑选等待节点转移。若允许无锁 signal,就无法保证等待者重新获得锁时看见条件更新,也无法可靠协调“检查条件—入条件队列”与“修改条件—发信号”的竞态。
中断发生在条件队列与同步队列的不同阶段,结果也不同:尚未收到 signal 时可以取消条件等待;已经转移后则必须先完成同步队列上的重新获取,再按协议报告中断,才能保证 await 抛出前线程重新持有锁。业务代码可以依赖的稳定契约是:await 返回或抛出前会重新获取关联锁,finally unlock 因此仍成立;不要依赖某版节点状态位判断中断先后。
同步队列与条件队列要分开取证
看到 ConditionObject.await,线程正在等待业务条件;看到 AbstractQueuedSynchronizer.acquire,线程在同步队列里争锁或许可。Condition 被 signal 后,栈和节点会从前者迁到后者,所以一次快照可能只看到迁移后的锁竞争。
jcmd "$PID" Thread.print -l > aqs.txt
grep -n -E 'AbstractQueuedSynchronizer|ConditionObject|LockSupport|ReentrantLock|Semaphore' aqs.txt接着检查:
外层同步器是什么,state 的业务含义是什么。谁持有独占锁,或谁消耗了许可却未归还。等待者是在同步队列还是条件队列,谁负责推进它。
中断与超时路径有没有退出,取消是否被上层重新吞掉。多次快照中队列是否推进,还是同一批线程永远停住。
JFR 的锁等待和线程 park 事件可以补充等待时长。AQS 提供队列长度、是否有等待者等监控方法,但这些是瞬时估计,不应作为严格业务正确性条件。
什么时候不该自己继承 AQS
AQS 允许构建自定义同步器,但正确处理重入、所有权、独占/共享传播、中断、超时、取消、序列化和监控并不轻松。业务开发应先组合现有工具:锁、信号量、latch、阻塞队列、原子状态或消息队列。
只有通用基础设施确实存在独特状态机,并且能做到模型审查、压力测试、jcstress、超时取消测试和长期维护时,才考虑扩展 AQS。自定义同步器还要暴露诊断信息,否则线上只剩一排 park,没人知道在等什么。
把同步器当容量边界治理
每次 acquire 是否有中断或超时,等待预算来自哪里。state 代表所有权、许可还是阶段,谁负责释放。是否存在同池依赖或持锁等待另一个需要本锁的任务。
公平性与吞吐怎样取舍,是否真有饥饿证据。Condition 的谓词是什么,谁改变谓词,谁发送 signal。队列长度、等待时长、超时、取消和许可泄漏如何监控。
进程关闭时等待者怎样被唤醒并结束。
用超时取消和条件转移验证两条队列
完整源码位于 examples/backend-development/concurrency/aqs-lock-condition/。AqsCancellationDemo.java 让中间等待者超时后验证后继仍能推进;AqsConditionDemo.java 验证条件等待者经 signal 转入同步竞争并恢复执行。进入目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out AqsCancellationDemo.java AqsConditionDemo.java
java -cp out AqsCancellationDemo
java -cp out AqsConditionDemo稳定输出为:
timed-acquired=false
successor-acquired=true
take=ready第一行证明超时调用方已经返回,第二行才证明取消节点没有堵住同步队列,第三行证明条件谓词改变后等待者重新取得锁。同步器验收应同时观察等待时长分位数、超时/中断率、队列长度趋势、许可或锁持有量以及后继推进率。getQueueLength() 等值只能作趋势估计,正确性必须由“超时者退出、后继最终推进、许可总量守恒、关闭后等待者归零”这些不变量证明。
理解 AQS 的最终目的不是背节点字段,而是把一条 WAITING (parking) 栈还原成“谁在等什么状态、谁能推进、何时放弃”。
源码与契约入口可查看 AbstractQueuedSynchronizer、ReentrantLock 与 Condition。
