承接的问题
上一节把 AQS 的字段摆开了:state 表达同步语义,head/tail 维护一条 CLH 变体队列。这一节回答三个连在一起的问题:
- 一个线程调用
lock(),tryAcquire失败之后,它是怎么变成队列节点的? - 它什么时候真正睡下,又是谁把它叫醒?
- 唤醒发生在
park之前或之后,为什么都不会丢?
仍然要先说版本。JDK 25 的独占路径已经没有 addWaiter、acquireQueued、shouldParkAfterFailedAcquire、unparkSuccessor 这四个方法(本机 grep 结果均为 0 处),它们被合并进一个 acquire 大循环。网上讲“五段链路”的材料针对的是 JDK 8 时代的实现;JDK 25 已经换成另一套。本节先把旧链路作为历史模型讲清楚,再逐段映射到 JDK 25 的真实实现。
旧版五段链路(JDK 8 时代)
旧实现的分工非常整齐:
| 方法 | 职责 |
|---|---|
acquire(arg) | 先 tryAcquire,失败则 addWaiter + acquireQueued |
addWaiter(mode) | 新建节点,CAS 挂到 tail,返回该节点 |
acquireQueued(node, arg) | 循环:前驱是 head 就再试;否则判断能否 park |
shouldParkAfterFailedAcquire(p, node) | 检查/设置前驱的 SIGNAL,决定是否 park |
unparkSuccessor(h) | 清 SIGNAL,找到 head 之后第一个未取消节点并唤醒 |
对本机 Zulu 8u482 执行 javap -p,可以看到这些方法的真实签名(历史对照,不是 JDK 25):
private java.util.concurrent.locks.AbstractQueuedSynchronizer$Node addWaiter(java.util.concurrent.locks.AbstractQueuedSynchronizer$Node);
private void unparkSuccessor(java.util.concurrent.locks.AbstractQueuedSynchronizer$Node);
private static boolean shouldParkAfterFailedAcquire(java.util.concurrent.locks.AbstractQueuedSynchronizer$Node, java.util.concurrent.locks.AbstractQueuedSynchronizer$Node);
final boolean acquireQueued(java.util.concurrent.locks.AbstractQueuedSynchronizer$Node, int);
private void setHeadAndPropagate(java.util.concurrent.locks.AbstractQueuedSynchronizer$Node, int);
private void doReleaseShared();
旧链路的关键是 shouldParkAfterFailedAcquire 里那段 CAS:
- 前驱
waitStatus == SIGNAL:返回true,可以安全 park。 - 前驱
waitStatus > 0(CANCELLED):跳过前驱,往前找。 - 否则:CAS 把前驱改成
SIGNAL,返回false,让外层再试一次tryAcquire。
为什么要“改完再试一次”?因为改成 SIGNAL 和真正 park 之间有一个窗口:如果前驱在这期间释放并唤醒了这个还不存在的等待,唤醒会丢。先改状态、再重试获取、确认仍然失败后才 park,就把窗口关上了。
JDK 25 的实际路径:一个 acquire 循环
JDK 25 源码里的 acquire 把上面五步折叠成一个包级方法。源码里那段 Repeatedly: 注释就是分支提纲:
// JDK 25 源码摘录,已删减
final int acquire(Node node, int arg, boolean shared,
boolean interruptible, boolean timed, long time) {
Thread current = Thread.currentThread();
byte spins = 0, postSpins = 0; // retries upon unpark of first thread
boolean interrupted = false, first = false;
Node pred = null; // predecessor of node when enqueued
/*
* Repeatedly:
* Check if node now first
* if so, ensure head stable, else ensure valid predecessor
* if node is first or not yet enqueued, try acquiring
* else if queue is not initialized, do so by attaching new header node
* else if node not yet created, create it
* else if not yet enqueued, try once to enqueue
* else if woken from park, retry (up to postSpins times)
* else if WAITING status not set, set and retry
* else park and clear WAITING status, and check cancellation
*/
循环的前半段负责“判断自己是不是第一个”和“再试一次”:
// JDK 25 源码摘录,已删减
for (;;) {
if (!first && (pred = (node == null) ? null : node.prev) != null &&
!(first = (head == pred))) {
if (pred.status < 0) {
cleanQueue(); // predecessor cancelled
continue;
} else if (pred.prev == null) {
Thread.onSpinWait(); // ensure serialization
continue;
}
}
if (first || pred == null) {
boolean acquired;
try {
if (shared)
acquired = (tryAcquireShared(arg) >= 0);
else
acquired = tryAcquire(arg);
} catch (Throwable ex) {
cancelAcquire(node, interrupted, false);
throw ex;
}
if (acquired) {
if (first) {
node.prev = null;
head = node;
pred.next = null;
node.waiter = null;
if (shared)
signalNextIfShared(node);
if (interrupted)
current.interrupt();
}
return 1;
}
}
三个细节值得停一下。pred.status < 0 是取消快判:前驱已经取消,直接 cleanQueue() 把它摘掉,不需要遍历整个队列。pred.prev == null 表示前驱正在成为新的 head,prev 已被清空,此时代码自旋等它完成,保证摘链和设 head 的顺序。成功获取时,只有 first 为真才更新 head:一个尚未入队、在 pred == null 时抢先拿到锁的线程,不会把自己设成 head,也不会碰队列。
循环的后半段负责建队、入队、睡眠:
// JDK 25 源码摘录,已删减
Node t;
if ((t = tail) == null) { // initialize queue
if (tryInitializeHead() == null)
return acquireOnOOME(shared, arg);
} else if (node == null) { // allocate; retry before enqueue
try {
node = (shared) ? new SharedNode() : new ExclusiveNode();
} catch (OutOfMemoryError oome) {
return acquireOnOOME(shared, arg);
}
} else if (pred == null) { // try to enqueue
node.waiter = current;
node.setPrevRelaxed(t); // avoid unnecessary fence
if (!casTail(t, node))
node.setPrevRelaxed(null); // back out
else
t.next = node;
} else if (first && spins != 0) {
--spins; // reduce unfairness on rewaits
Thread.onSpinWait();
} else if (node.status == 0) {
node.status = WAITING; // enable signal and recheck
} else {
spins = postSpins = (byte)((postSpins << 1) | 1);
try {
long nanos;
if (!timed)
LockSupport.park(this);
else if ((nanos = time - System.nanoTime()) > 0L)
LockSupport.parkNanos(this, nanos);
} catch (Error | RuntimeException ex) {
cancelAcquire(node, interrupted, interruptible);
throw ex;
}
node.clearStatus();
if ((interrupted |= Thread.interrupted()) && interruptible)
break;
}
}
摘录省略了超时立即到期时的 break 分支。这段代码就是旧版 addWaiter + acquireQueued + shouldParkAfterFailedAcquire 的合并版。入队仍然只竞争一次 tail:CAS 成功才写 t.next,失败就把 prev 清空、下一轮再试。setPrevRelaxed 用普通写而非 volatile 写,因为节点此时还没发布。
真正的睡眠条件是 node.status != 0。线程先把自己标成 WAITING,下一轮循环再走到 park。如果释放者在这两步之间清掉了 WAITING 并调用了 unpark,park 会立即返回;如果清位发生在 park 之后,unpark 会把它唤醒。两种顺序都不会丢,这就是 1.1 说的 Dekker 式握手。
postSpins 控制被唤醒后的重试次数:第一次唤醒后自旋 1 次,之后按 (postSpins << 1) | 1 增长。这是为了抵消“刚被唤醒就被新来的线程插队”的不公平,类注释说最多重试 256 次。
释放路径则短得多:
// JDK 25 源码摘录,已删减
public final boolean release(int arg) {
if (tryRelease(arg)) {
signalNext(head);
return true;
}
return false;
}
private static void signalNext(Node h) {
Node s;
if (h != null && (s = h.next) != null && s.status != 0) {
s.getAndUnsetStatus(WAITING);
LockSupport.unpark(s.waiter);
}
}
signalNext 只叫醒 head 的直接后继,不清 SIGNAL(已经没有这个状态),也不再从 tail 反向遍历找“第一个未取消节点”。取消节点由 cancelAcquire 标成 CANCELLED,再由 cleanQueue() 摘掉;后续 acquire 一旦发现前驱 status < 0 也会主动调用 cleanQueue()。摘链后如果前驱正好是 head,会补一次 signalNext(p),避免后继无人唤醒。
取消路径同样收敛到两个方法:cancelAcquire 先置 waiter = null、status = CANCELLED,再调用 cleanQueue();后者从 tail 向前用 CAS 把取消节点从 prev/next 链上摘掉,摘完如果前驱正好是 head,会补一次 signalNext(p)。旧版的 unparkSuccessor 需要在 next 不可信时从 tail 反向找活节点;JDK 25 把“找后继”变成“只信 head.next”,找不到就让 cleanQueue 修复链接。这是把复杂度从唤醒路径挪到了取消路径,热路径更短。
手写最小 AQS 子类
模板方法没变,所以常见的最小锁示例在 JDK 25 上仍然成立。下面是一把非重入互斥锁,state 只有 0 和 1:
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public final class MiniMutex {
private final Sync sync = new Sync();
private static final class Sync extends AbstractQueuedSynchronizer {
@Override protected boolean tryAcquire(int arg) {
return compareAndSetState(0, 1);
}
@Override protected boolean tryRelease(int arg) {
if (!compareAndSetState(1, 0))
throw new IllegalMonitorStateException("not owner");
return true;
}
@Override protected boolean isHeldExclusively() {
return getState() == 1;
}
boolean tryAcquireDirect() { return tryAcquire(1); }
int stateValue() { return getState(); }
}
public void lock() { sync.acquire(1); }
public void unlock() { sync.release(1); }
public boolean tryLock() { return sync.tryAcquireDirect(); }
public int state() { return sync.stateValue(); }
public int queueLength() { return sync.getQueueLength(); }
}
驱动代码让主线程先持有锁,再依次启动四个工作线程,并且每启动一个就等它真正入队,保证打印顺序反映队列顺序而不是调度运气:
public final class MutexTrace {
public static void main(String[] args) throws Exception {
MiniMutex mutex = new MiniMutex();
mutex.lock();
System.out.printf("main acquired, state=%d, queue=%d%n",
mutex.state(), mutex.queueLength());
Thread[] workers = new Thread[4];
for (int i = 1; i <= 4; i++) {
workers[i - 1] = Thread.ofPlatform().name("w" + i).start(() -> {
mutex.lock();
try {
System.out.printf("%s acquired, state=%d, queue=%d%n",
Thread.currentThread().getName(),
mutex.state(), mutex.queueLength());
Thread.sleep(60);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.printf("%s release%n",
Thread.currentThread().getName());
mutex.unlock();
}
});
while (mutex.queueLength() < i) Thread.onSpinWait();
}
System.out.printf("main release, queue=%d%n", mutex.queueLength());
mutex.unlock();
for (Thread t : workers) t.join();
System.out.printf("done, state=%d, queue=%d%n",
mutex.state(), mutex.queueLength());
}
}
本机真实输出(连续跑三次一致):
main acquired, state=1, queue=0
main release, queue=4
w1 acquired, state=1, queue=3
w1 release
w2 acquired, state=1, queue=2
w2 release
w3 acquired, state=1, queue=1
w3 release
w4 acquired, state=1, queue=0
w4 release
done, state=0, queue=0
四行 acquired 的 queue 依次是 3、2、1、0,说明每个线程醒来时前一个已经把自己从等待计数里摘掉了;w1 到 w4 严格 FIFO,是因为我们在主线程持锁期间把四个线程逐个送进了队列,期间没有新线程插队。换成自然并发的启动方式,顺序就不保证,这也是 AQS 默认非公平的直观体现。
在 libraryd-conc 里落地
conc-core 需要一个能观察“插队”行为的锁,用来给 2.1 的公平性选型提供数据。下面这个版本在 tryAcquire 里记录尝试时队列是否为空:
package com.plume.conc.core;
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
/** 统计“空队列直接抢”和“有人排队仍尝试抢”的次数。 */
public final class BargingMutex {
private final Sync sync = new Sync();
private static final class Sync extends AbstractQueuedSynchronizer {
private final LongAdder emptyAttempts = new LongAdder();
private final LongAdder bargedAttempts = new LongAdder();
@Override
protected boolean tryAcquire(int arg) {
if (hasQueuedThreads()) bargedAttempts.increment();
else emptyAttempts.increment();
return compareAndSetState(0, 1);
}
@Override
protected boolean tryRelease(int arg) {
if (!compareAndSetState(1, 0)) {
throw new IllegalMonitorStateException("not owner");
}
return true;
}
String stats() {
return "empty=" + emptyAttempts.sum() + ", barged=" + bargedAttempts.sum();
}
}
public void lock() { sync.acquire(1); }
public void unlock() { sync.release(1); }
public String stats() { return sync.stats(); }
}
bargedAttempts 增长得快,说明新线程经常在有人排队时仍然先试一把;这正是非公平锁吞吐高、但可能饿死老等待者的来源。计数只是诊断,不能反过来影响 tryAcquire 的返回值。LongAdder 的实现放在 8.2,这里只需要知道它比 AtomicLong 更适合高频计数。
什么时候不要用
MiniMutex 只适合讲原理,不适合进生产。它没有重入、没有可中断获取、没有超时、没有条件队列;任何一个缺失在业务里都会变成故障。生产代码用 ReentrantLock,需要读写分离用 ReentrantReadWriteLock,需要乐观读用 StampedLock。
BargingMutex 也不要用作业务锁。它在 tryAcquire 里做了计数副作用,虽然不影响正确性,但违背了“模板方法应当只做状态判断”的习惯;一旦有人在里面加日志、加 I/O,整个队列都会被拖慢。
还有一个常见误解:AQS 的 acquire 不响应中断。线程在 park 中被中断会醒来,但 acquire 只记录中断标志并继续排队,直到拿到锁才把中断状态还给你。要能被中断打断,必须用 acquireInterruptibly / lockInterruptibly(2.3 展开)。
常见错误与边界
第一,在 tryAcquire 里直接 setState(1) 而不是 CAS。两个线程会同时成功,队列永远不会被用上。
第二,tryRelease 不校验持有者。MiniMutex 用 compareAndSetState(1, 0) 顺带做了校验:状态不是 1 就抛异常。但如果状态可能被别的路径改成 1,这个校验就不够了,需要额外的 owner 字段。
第三,把 getQueueLength() 当成精确值。它遍历时队列在变,只能用于监控。
第四,在持锁线程里递归调用 lock()。非重入锁会把自己挂死,state 一直是 1,队列里躺着的是自己。
第五,以为唤醒一定按 FIFO。signalNext 唤醒 head 后继,但被唤醒的线程还要和新来的线程竞争 tryAcquire,输了就回去继续睡。严格 FIFO 要自己实现公平策略。
第六,读到旧版源码就照抄。JDK 25 没有 unparkSuccessor,也没有 SIGNAL;照抄会编译失败,或者把已经删除的协议当成现实现。
小结
独占模式的本质是一条状态机:tryAcquire 失败 → 入队 → 置 WAITING → park → 被 signalNext 清位唤醒 → 重试 → 成功则更新 head。JDK 25 用一个 acquire 循环、一个 signalNext 和一个 cleanQueue 实现了旧版五个方法的功能,并用“等待者自置位、释放者清位”的握手替代了 SIGNAL 状态。模板方法 tryAcquire/tryRelease 没有变,所以手写最小锁的方式也没有变。
阅读导航:上一节 1.1 AQS 的 state 与 CLH 变体队列 | 下一节 1.3 共享模式与传播机制
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。