AQS、Lock 与 Condition
ReentrantLock 表达互斥,Condition 表达“持锁检查后发现条件尚未满足”的等待。二者共用一把锁,却使用不同的等待位置:获取锁失败的线程进入同步队列,调用 await 的线程进入相应条件队列。
AQS(AbstractQueuedSynchronizer)为 ReentrantLock、Semaphore、CountDownLatch 等同步器提供状态竞争与排队设施。理解一个等待线程,需要先确定外层组件:同样显示 park,它可能等待锁所有权、剩余许可,也可能等待任务完成。
显式获取与释放锁
限时进入一段内存更新
LockedBalance 使用 tryLock 限制获取锁的等待。成功后检查余额,最后在 finally 中释放。完整代码保存为 LockedBalance.java:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class LockedBalance {
private final ReentrantLock lock = new ReentrantLock();
private long balance = 100;
public boolean debit(long amount, long waitMillis) throws InterruptedException {
if (amount <= 0 || waitMillis < 0) throw new IllegalArgumentException("input");
if (!lock.tryLock(waitMillis, TimeUnit.MILLISECONDS)) return false;
try {
if (balance < amount) throw new IllegalStateException("insufficient");
balance -= amount;
return true;
} finally { lock.unlock(); }
}
public static void main(String[] args) throws InterruptedException {
LockedBalance account = new LockedBalance();
if (!account.debit(30, 100)) throw new AssertionError("unexpected timeout");
System.out.println("accepted=true balance=" + account.balance);
}
}Linux 普通用户在可写目录操作,需完整 JDK,安装见Java 版本基线。设置实际 JAVA_HOME;以下示例兼容 Java 17,运行基线为 Temurin 25.0.4+7:
export JAVA_HOME=/opt/jdk-25
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
LAB_OUT=$(mktemp -d /tmp/aqs-learning.XXXXXX)
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror -d "$LAB_OUT" LockedBalance.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" LockedBalance输出 accepted=true balance=70。这个单线程入口验证方法与释放结构;后续实验才制造竞争。余额打印发生在同一线程,实际增加查询方法时应在相同锁下读取。
tryLock 返回 false 时尚未修改余额,调用方可以把它转换成“未接受”的结果,按请求剩余预算选择放弃或有限重试。透支属于业务拒绝,中断属于任务取消,三者不要混成同一个成功返回值。锁获取预算也不约束取得锁后的工作时间;不要在临界区执行没有超时的远程调用。
获取方式与公平性的差异
| 方法 | 无法立即取得时 | 中断行为 |
|---|---|---|
| lock() | 等待获取 | 不以 InterruptedException 取消获取 |
| lockInterruptibly() | 可中断等待 | 抛中断异常并清除中断状态 |
| tryLock() | 立即返回 false | 不作为中断检测入口 |
| tryLock(time, unit) | 最多按给定时间等待获取 | 可中断,超时返回 false |
上述规则与重入语义见 ReentrantLock API。每次成功重入都需要一次 unlock,只有释放全部层数后其他线程才能取得。解锁必须由拥有者执行,失败获取后无条件 unlock 会抛 IllegalMonitorStateException。
new ReentrantLock(true) 在竞争中优先等待较久的线程。无参数 tryLock() 仍可插队;需要遵守公平策略又不等待,可以使用带零时限的 tryLock,它同时检测中断。公平锁降低饥饿风险,通常牺牲部分吞吐,也无法保证操作系统立即调度队首线程。不能把获取顺序推导成所有业务任务的完成顺序。
Lock 的解锁与后续成功加锁具有与内置锁相应的内存同步效果。isLocked、getQueueLength 等观察方法不代替获取锁,不能用“检查空闲后直接改字段”的方式绕过保护。
AQS 状态与同步队列
state 的含义由同步器决定
AQS 保存 volatile int state,提供读、写与 CAS,子类通过 tryAcquire、tryRelease 或共享变体定义操作含义。AQS API把这种分工与扩展契约列为基础要求。
| 同步器 | state 的主要含义 | 允许后继继续的条件 |
|---|---|---|
| ReentrantLock | 持有与重入次数 | 所有重入层数释放 |
| Semaphore | 可用许可数量 | 申请所需许可后仍满足约束 |
| CountDownLatch | 剩余完成计数 | 计数到 0 |
| ReentrantReadWriteLock | 同一整数中的读/写计数编码 | 读写互斥及重入条件满足 |
AQS 的 head/tail 连接同步等待节点,独占拥有者信息通过父类的所有者字段维护。固定 OpenJDK jdk-25+36 AQS 实现中,节点包含 prev、next、waiter 与 status。WAITING 表示等待通知,CANCELLED 表示取消,COND 标记条件等待;ConditionNode 另有 nextWaiter。它们与旧 JDK 资料中的 waitStatus/SIGNAL 字段模型不同。
同步器:state + 所有者信息
同步队列:head ↔ 等待节点 ↔ 等待节点 ↔ tail
│
waiter / status节点状态只是排队机制,业务含义仍由 state 与组件决定。Semaphore 没有像独占锁一样绑定“许可拥有线程”;用它控制连接数量时,拥有者需要由应用自己追踪。
获取失败、入队与再检查
ReentrantLock 先尝试按自身规则更新 state。未能取得时,AQS 将节点接到队尾,检查前驱、取消状态与是否具备尝试获取资格。队首之后的有效节点可尝试获取;其他排队节点也可以设置等待标记并 park,并非只有靠近队首的线程才休眠。
入队与休眠之间要再次检查。若锁恰好在这个窗口释放,等待者不能在已经有机会推进时无限睡下。释放侧完成状态更新后通知合适后继,后继醒来再竞争;通知不直接把 state 改成后继拥有。
LockSupport 为每个线程提供至多一个许可,unpark 可以先于 park 到达,后一次 park 消费许可后返回。两次 unpark 不会累加出两个许可;park 还可能因中断或伪返回结束,且不清除中断状态。条件检查因此必须在循环中。规则见 LockSupport API。
取消后让后继继续
中断或超时使等待节点失效。AQS 清理或绕过取消节点,维护有效前后关系,让后继仍能取得状态。清理可能与其他入队、释放操作共同推进,不能假设取消调用返回时所有节点立即从可观察链表中消失。
下载 AQS 实验包,解压进入 aqs-lock-condition 目录,沿用 LAB_OUT:
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror -d "$LAB_OUT" *.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" AqsLab取消部分输出:
timed-acquired=false cancelled=true successor-acquired=true主线程一直持有锁,所以限时线程必然不能取得。随后确认可中断线程已入队,再启动并确认后继入队,才中断前一个线程;释放锁后验证后继完成。这里用队列查询固定受控实验状态,避免靠 sleep 猜测入队顺序。
超时调用返回只说明调用方放弃,后继成功才验证剩余队列能推进。上层捕获中断后若再次无限调用 lock,就会丢掉原先的取消能力。处理方式应与任务中断与取消中的所有者协议一致。
Condition 的等待、转移与重取
同一把锁下的多个谓词
有界缓冲区通常有 notEmpty 和 notFull 两个 Condition。生产者持锁检查是否有空位,消费者持锁检查是否有元素;各自等待不同谓词,改变状态后通知可能受益的一组线程。
lock.lockInterruptibly();
try {
while (queue.size() == capacity && !closed) {
notFull.await();
}
if (closed) throw new IllegalStateException("closed");
queue.addLast(task);
notEmpty.signal();
} finally {
lock.unlock();
}片段依赖组件已有的 lock、queue、capacity、closed 和两个 Condition。task 应先拒绝 null,所有队列和关闭字段访问遵守同一锁。完整可运行的条件转移实验位于 AqsLab,缓冲容器选型见并发容器。
await 建立条件节点、保存独占状态并完全释放关联锁;signal 把符合条件的等待节点转到同步队列,等待者重新取得锁后才返回。signalAll 转移全部适用等待者。通知方仍持锁时,被通知线程无法执行受保护代码。
条件不满足
↓ await:保存重入深度并完全释放
Condition 条件队列
↓ signal / 超时 / 中断等退出条件等待的路径
AQS 同步队列:重新取得锁
↓ 恢复原重入深度
正常返回再查谓词,或按契约报告中断对 ReentrantLock 的 Condition,等待和 signal 都要求持有关联锁。不要把 Lock 接口与 synchronized(lock) 混用:后者取得的是 lock 对象的内置 monitor,和 ReentrantLock 的 state 不是同一把锁。
重入、异常与等待预算
AqsLab 让等待者重入两次后 await,通知方修改谓词并 signal,在仍持锁时断言等待者已转入同步队列但尚未返回;释放后,等待者检查 getHoldCount 为 2。输出为:
transferred=true returned-after-unlock=true hold-count=2 invalid-signal-rejected=true最后还验证无锁调用 signal 会抛异常。这组中间状态说明完全释放和恢复深度缺一不可:只释放一层会阻止通知者取得锁,只恢复一层又会破坏调用者退出结构。
await 的中断、超时与信号存在竞争。重新取得锁后才能完成等待返回或中断报告,具体是抛异常还是保留中断标志取决于接口及发生阶段。详细契约见 Condition API。调用者正常持锁进入 await 后,可以在 finally 解锁;初始未持锁导致的 IllegalMonitorStateException 则属于另一种错误,不能声称异常时总能获得锁。
awaitNanos 返回估算剩余等待时间,循环可以继续传入剩余值,避免每次通知后重置完整预算。若预算涵盖入口取锁,先记录 nanoTime,再在 tryLock 与后续条件等待前扣除已经消耗的时间。线程重新竞争锁和调度仍可能超过预期时刻,超时不能用作硬实时保证。
停止缓冲组件时,在锁内设置 closed 并 signalAll 两个条件,使空等消费者和满等生产者都检查关闭状态;解锁后再 join 工作线程。持锁等待工作线程退出,而工作线程需要重新取同一把锁完成 await,是常见关闭死锁。
共享同步器与其他锁形态
许可和完成计数
Semaphore 允许多个任务在许可足够时推进。正确的容量调用结构是“取得成功→执行→finally 归还”;取得失败的路径不得 release,否则可用许可反而增加。它不检查归还线程是否曾获得许可,因此需要应用维持初始容量、已取得量与归还量的关系。契约见 Semaphore API。
AqsLab 先取得唯一许可,第二次限时获取失败;模拟下游异常后在 finally 归还,检查许可恢复为 1:
timeout-without-extra-release=true permits-restored=1限时获取限制每次等待时间,却没有限制瞬时等待者数量。大量虚拟线程同样可能堆积在许可外侧,需要在请求入口限制排队数量并传递剩余预算。
CountDownLatch 在计数归零后让等待者通过,无法重置。任务应在 finally countDown,并另外传递成功或失败,计数归零只表示阶段结束。CyclicBarrier 用于固定参与方反复会合,超时或中断会破坏该轮;Phaser 支持动态注册与阶段推进,参与方退出时要注销。常用协调器入口见 java.util.concurrent 包说明。
读写锁与乐观读取
ReentrantReadWriteLock 让多个读者共存,写者独占。适合读取工作有一定成本且写入相对少的共享结构;读锁自身的协调成本也需要测量。持读锁再等待写锁会卡住升级,通常应释放读锁、获取写锁后重新检查状态。持写锁时取得读锁,再释放写锁,可完成降级。只有写锁支持 Condition,规则见 ReentrantReadWriteLock API。
StampedLock 的乐观读取得 stamp 后,把字段读入局部变量,再 validate;失败则获取读锁重新读取。验证成功前,读取过程中可能出现暂时不一致,不能据此执行外部副作用、解引用可能暂时无效的组合状态或破坏数据结构。它不可重入,也不能直接继承 ReentrantLock 的调用习惯。转换写锁的方法可能失败,必须处理返回 0。适用与限制见 StampedLock API。
这些工具改变的是共享状态的访问方式。读多写少本身不足以决定采用乐观读;如果读取只是一个已经不可变的快照,原子引用发布通常更简单。
根据等待位置处理故障
在活跃目标中采集锁关系
同一 Linux 用户、PID 命名空间内,使用前面编译的实验启动 30 秒目标:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" AqsLab probe 30 > "$LAB_OUT/probe.log" 2>&1 &
PID=$!
for attempt in $(seq 1 50); do
grep -q 'queued=true' "$LAB_OUT/probe.log" && break
sleep 0.1
done
cat "$LAB_OUT/probe.log"
kill -0 "$PID"
"$JAVA_HOME/bin/jcmd" "$PID" Thread.print -l > "$LAB_OUT/aqs.txt"
grep -n -E 'aqs-holder|aqs-waiter|parking to wait|Locked ownable synchronizers' "$LAB_OUT/aqs.txt"
wait "$PID"
unset PID日志出现 queued=true 后,查看完整 aqs.txt:aqs-waiter 等待 ReentrantLock 的同步器对象,aqs-holder 的 Locked ownable synchronizers 列出持有对象。holder 自身 sleep,表示持锁线程处于等待状态也可以阻挡其他线程。对象标识只在同一快照内匹配,不固定抄写 PID。
目标已退出、不同容器或 Attach 被禁用时,先修正采集条件,不能拿旧文件作为当前状态。工具权限与输出见 jcmd 文档。
把 park 还原成具体等待
| 栈与现象 | 负责推进的对象 | 修复后核对 |
|---|---|---|
| ReentrantLock 获取长期停滞 | 拥有者、锁内 I/O、嵌套获取顺序 | 同负载等待时间下降,临界区仍保持不变量 |
| Condition.await 不返回 | 谓词修改者、对应 Condition、关闭通知 | 正常通知和关闭都使等待者退出 |
| Semaphore 等待不断增长 | 许可归还与下游处理时长 | 异常后许可恢复,入口队列有上限 |
| 超时很多但调用仍不退出 | 上层吞掉中断、重新等待 | 截止后不再提交,任务实际结束 |
| ThreadPoolExecutor.getTask 中 park | 空闲 worker 与任务队列 | 先核对是否真有积压,不把空闲当死锁 |
getQueueLength、getWaitQueueLength 是监控估计,不能作为严格入场资格判断。用等待耗时、获取超时、取消数和业务积压一起看趋势;线程转储与 JFR 的组合见并发诊断。
业务通常组合成熟锁、许可和队列即可。自定义 AQS 需要额外维护独占所有者、重入、共享传播、取消清理和 Condition 释放恢复协议;仅通过一个顺序测试不足以支撑线上组件。若必须实现,先把 state 的合法转换写清,再验证取消节点位于队首、中间和队尾,以及释放与入队并发的情况。
完整实验与目录回收
解压目录中执行 bash run.sh,脚本严格编译并运行 LockedBalance、AqsLab 全部断言和短诊断目标,最后打印 PASS aqs-lab。Docker 操作由已获权限的 Linux 主机用户执行,容器显式使用 UID 10001:
LAB_SOURCE="$(pwd -P)"
JDK_IMAGE=eclipse-temurin:25.0.4_7-jdk
docker pull "$JDK_IMAGE"
docker run --pull never --rm --network none --read-only \
--user 10001:10001 --cap-drop ALL --security-opt no-new-privileges \
--mount "type=bind,src=$LAB_SOURCE,dst=/lab,readonly" \
--tmpfs /tmp:rw,nosuid,nodev,size=128m,mode=1777 \
"$JDK_IMAGE" bash /lab/run.sh源码需让该 UID 可读;输出写到临时目录,运行不需要网络。内网提前校验并导入可信镜像归档。Java 17 对照使用 eclipse-temurin:17.0.20_8-jdk。若子进程超过脚本的 20 秒限制,按超时失败保留输出,检查资源和异常,不将其归为成功。
目标结束后回收手动实验目录:
case "$LAB_OUT" in
/tmp/aqs-learning.*) rm -r -- "$LAB_OUT" ;;
*) printf '%s\n' '保留未知目录' ;;
esac
unset LAB_OUT权威资料与规范地址
按方法语义查 API,按实现字段查固定版本源码。
| 资料 | 完整地址 |
|---|---|
| ReentrantLock API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html |
| AQS API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/AbstractQueuedSynchronizer.html |
| OpenJDK jdk-25+36 AQS 实现 | https://github.com/openjdk/jdk/blob/jdk-25%2B36/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java |
| LockSupport API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/LockSupport.html |
| Condition API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/Condition.html |
| Semaphore API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/Semaphore.html |
| java.util.concurrent 包说明 | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/package-summary.html |
| ReentrantReadWriteLock API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/ReentrantReadWriteLock.html |
| StampedLock API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/StampedLock.html |
| jcmd 文档 | https://docs.oracle.com/en/java/javase/25/docs/specs/man/jcmd.html |
