承接的问题
独占模式一次只放行一个线程,共享模式一次可以放行一批。Semaphore 的许可、CountDownLatch 的归零、ReentrantReadWriteLock 的读锁,都是共享模式的应用。
共享模式比独占模式多了一个问题:唤醒要传多远。释放一个许可,是只叫醒队首,还是让整条链依次醒来?如果只叫醒一个,这个线程醒来后发现条件仍然不满足又睡回去,后面本来能通过的线程就被卡住了。这就是“传播”(propagation)。
这一节回答三件事:tryAcquireShared 的三种返回值到底承诺了什么;旧版为什么要引入 PROPAGATE 状态;当前实现为什么能把它删掉。
tryAcquireShared 的三种返回值
方法签名只有一个 int,但模板方法的契约有三种语义:
| 返回值 | 模板方法契约 | JDK 25 AQS 的处理 |
|---|---|---|
| 负数 | 获取失败,共享状态不允许 | 入队、阻塞、等待唤醒 |
0 | 获取成功,但后续共享获取不太可能成功 | 视为成功;节点成为 head 时仍会叫下一个共享节点 |
| 正数 | 获取成功,后续共享获取可能也成功 | 同上 |
最容易混淆的是 0:它不是失败,是“我过了,但后面的人大概率过不了”。Semaphore 的 tryAcquireShared 返回剩余许可数,减到 0 时就返回 0;CountDownLatch 归零时直接返回 1,因为它知道所有等待者都能过。
JDK 25 的 acquire 只判断符号:
// JDK 25 源码摘录,已删减
if (shared)
acquired = (tryAcquireShared(arg) >= 0);
else
acquired = tryAcquire(arg);
>= 0 把 0 和正数一视同仁。也就是说,JDK 25 的 AQS 内部不用返回值的大小决定传播距离,只把它当成“成功/失败”二值信号。三值语义仍然是模板方法的契约:子类应当如实返回,其他调用方或后续版本可能依赖它,只是 AQS 自己不再消费这个大小。
旧版的传播:setHeadAndPropagate 与 PROPAGATE
旧实现里(本机用 Zulu 8u482 对照),共享获取成功后会调用 setHeadAndPropagate(node, propagate)。它的判断大致是:
如果 propagate > 0,或者旧 head / 新 head 的 waitStatus < 0,
并且后继为 null 或处于共享模式,就调用 doReleaseShared()。
doReleaseShared() 则循环处理 head:
- head 是
SIGNAL:CAS 清成 0,唤醒后继。 - head 是 0:CAS 改成
PROPAGATE。 - head 变了就重来。
PROPAGATE = -3 是一个“便条”:释放线程发现当前没有明确的唤醒对象,就在 head 上留一个负值,告诉后来的获取者“这次释放还没有传播完,你成为 head 时记得继续叫醒后面”。它解决的是释放与获取并发时的丢失唤醒:如果释放只叫醒一个,而被叫醒的线程恰好又失败睡回去,后面本来能过的线程就永远等不到第二次唤醒。
这个设计的代价是状态机复杂:waitStatus 在 0、SIGNAL、PROPAGATE 之间来回 CAS,setHeadAndPropagate 要同时检查新旧两个 head 的状态,注释里也承认“可能产生不必要的唤醒”。
JDK 25 的传播:每个新 head 主动叫下一个
当前实现把传播责任交给获取方。共享节点成功获取并成为 head 时,直接调用 signalNextIfShared:
// JDK 25 源码摘录,已删减
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;
}
signalNextIfShared 只看下一个节点是不是共享节点:
// JDK 25 源码摘录,已删减
private static void signalNextIfShared(Node h) {
Node s;
if (h != null && (s = h.next) != null &&
(s instanceof SharedNode) && s.status != 0) {
s.getAndUnsetStatus(WAITING);
LockSupport.unpark(s.waiter);
}
}
释放路径也只做一件事:
// JDK 25 源码摘录,已删减
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
signalNext(head);
return true;
}
return false;
}
于是传播变成一条链:释放者叫醒 head 的后继 → 后继获取成功、成为新 head → 它再叫醒自己的后继 → 直到某个节点获取失败、重新睡下,或者后继不是共享节点。每个节点只负责“叫下一个”,不需要一个全局的“还要传播几次”标记。
为什么 PROPAGATE 可以删掉?旧版的风险是“释放线程叫了一次,但被叫醒的线程发现自己还不能获取,睡回去,链条断了”。新版把传播义务绑定在“成为 head”这个事件上:每个成功成为 head 的共享节点都会调用 signalNextIfShared;失败的后继不会成为 head,它重新睡下,下一次释放仍会 signalNext(head) 再次叫到它。链条不会因为一次失败而永久断开,也不需要一个可能被覆盖的状态位来记录“还没传播完”。
这也有代价:signalNextIfShared 不检查后继是否真的能获取成功,可能产生多余唤醒;被叫醒的线程失败后会重新置 WAITING 再睡。这是用“可能多叫一次”换“状态机更简单、释放路径更短”。
tryAcquireShared 返回值的“大小”在新版里确实没有用武之地:传播距离由链式 head 决定,不由返回值决定。类注释也直说,多数场景下忽略这个返回值更高效。
验证一:自写共享门,比较返回值 0 与 1
下面这个一次性共享门在打开前返回 -1,打开后返回构造时指定的值(0 或 1):
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public final class SharedGate {
private final Sync sync;
public SharedGate(int successValue) {
this.sync = new Sync(successValue);
}
private static final class Sync extends AbstractQueuedSynchronizer {
private final int successValue;
Sync(int successValue) {
this.successValue = successValue;
}
@Override
protected int tryAcquireShared(int arg) {
return getState() == 0 ? -1 : successValue;
}
@Override
protected boolean tryReleaseShared(int arg) {
setState(1);
return true;
}
}
public void await() { sync.acquireShared(1); }
public void open() { sync.releaseShared(1); }
public int queueLength() { return sync.getQueueLength(); }
}
驱动代码让六个线程先全部入队,再打开门,统计有多少线程通过:
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public final class PropagationTrace {
public static void main(String[] args) throws Exception {
run("return=1", 1);
run("return=0", 0);
}
private static void run(String label, int successValue) throws Exception {
int waiters = 6;
SharedGate gate = new SharedGate(successValue);
CountDownLatch entered = new CountDownLatch(waiters);
List<Thread> threads = new ArrayList<>();
for (int i = 1; i <= waiters; i++) {
Thread t = Thread.ofPlatform().name("s" + i).start(() -> {
entered.countDown();
gate.await();
System.out.printf(" %s passed (%s)%n",
Thread.currentThread().getName(), label);
});
threads.add(t);
}
entered.await();
while (gate.queueLength() < waiters) Thread.onSpinWait();
System.out.printf("%s: queued=%d, opening gate%n",
label, gate.queueLength());
long start = System.nanoTime();
gate.open();
for (Thread t : threads) t.join();
System.out.printf("%s: all %d returned in %d ms%n%n", label, waiters,
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start));
}
}
本机真实输出(线程通过顺序受调度影响,计数稳定):
return=1: queued=6, opening gate
s3 passed (return=1)
s2 passed (return=1)
s4 passed (return=1)
s1 passed (return=1)
s6 passed (return=1)
s5 passed (return=1)
return=1: all 6 returned in 1 ms
return=0: queued=6, opening gate
s1 passed (return=0)
s3 passed (return=0)
s2 passed (return=0)
s4 passed (return=0)
s6 passed (return=0)
s5 passed (return=0)
return=0: all 6 returned in 1 ms
两种返回值都让六个线程全部通过,耗时都在 1 ms 量级。这不是说返回值没有语义,而是说明 JDK 25 的传播不依赖它的大小:只要 >= 0 且该节点成为 head,它就会继续叫下一个。在 AQS 内部,0 与正数没有行为差异;返回值的大小只对子类自己的逻辑或外部调用者有意义。不要因为 JDK 25 忽略它,就在自己的同步器里随便返回。
验证二:CountDownLatch 的共享语义
CountDownLatch 是共享模式最干净的用法。它的 Sync 只做两件事:
// JDK 25 源码摘录,已删减
private static final class Sync extends AbstractQueuedSynchronizer {
Sync(int count) {
setState(count);
}
int getCount() {
return getState();
}
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
protected boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c - 1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
}
tryAcquireShared 在计数归零时返回 1,否则返回 -1,没有中间态。tryReleaseShared 用 CAS 递减,只有把计数减到 0 的那一次返回 true,也只有那一次会触发 releaseShared 的 signalNext(head)。五个等待者、计数 3 的本机输出:
countDown #1, remaining=2
countDown #2, remaining=1
countDown #3, remaining=0
a1 passed at 305
a4 passed at 305
a2 passed at 305
a3 passed at 305
a5 passed at 305
done, remaining=0
前两次 countDown 没有唤醒任何人,因为 tryReleaseShared 返回 false;第三次归零后,signalNext 叫醒队首,队首成为 head 后 signalNextIfShared 继续叫下一个,五个线程在 1 ms 内全部通过。五个时间戳都落在 305 ms,说明它们在归零后的同一毫秒内被连锁唤醒;驱动代码在启动后先睡 100 ms,再每隔 50 ms 递减一次。
在 libraryd-conc 里落地
conc-core 的补货通知适合用共享模式:库存到位后,所有等待同一 ISBN 的借阅线程都应该被放行,而不是只放一个。下面是最小实现:
package com.plume.conc.core;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
/** 一次性补货门:restocked() 之后,所有等待者一起通过。 */
public final class StockReadyGate {
private final Sync sync = new Sync();
private static final class Sync extends AbstractQueuedSynchronizer {
@Override
protected int tryAcquireShared(int arg) {
return getState() == 0 ? -1 : 1;
}
@Override
protected boolean tryReleaseShared(int arg) {
setState(1);
return true;
}
}
public void awaitStock() {
sync.acquireShared(1);
}
public void restocked() {
sync.releaseShared(1);
}
public int waitingThreads() {
return sync.getQueueLength();
}
}
这个门只能开一次。库存会反复补货,所以真实实现要么每次补货创建一个新门,要么换成 Phaser 这类可重用同步器(4.2 展开)。把它和 AqsStockGuard 组合时要注意顺序:先拿守卫再查库存,库存不足才 awaitStock,并且必须在等待前释放守卫,否则补货线程拿不到锁,永远走不到 restocked()。
什么时候不要用
共享模式没有“持有者”概念。releaseShared 可以被任何线程调用,AQS 不校验是谁释放的;如果业务语义要求“只有补货线程能开门”,这个校验必须自己写在 Sync 里,或者改用独占锁加条件队列。
tryAcquireShared 的返回值要诚实。StockReadyGate 永远返回 1,是因为门开后所有等待者确实都能过;如果实际没有许可却返回正数,等待者会误以为获取成功,直接破坏互斥语义;如果返回值偏乐观、后继又诚实地返回 -1,则会产生一次多余唤醒。Semaphore 返回剩余许可数,就是为了让这个信号与实际可用量一致。
一次性门不要复用。setState(1) 之后没有复位路径,重复调用 restocked() 不会报错,但语义已经含糊;需要循环使用时用 CyclicBarrier 或 Phaser。
最后,acquireShared 和 acquire 一样不响应中断。等待补货的线程如果被中断,只会在拿到许可后看到中断标志;要支持取消,用 acquireSharedInterruptibly。
常见错误与边界
第一,把 tryAcquireShared 的 0 当成失败。返回 0 表示获取成功,只是不承诺继续传播;如果当成失败返回 -1,等待者会永远等下去。
第二,在 tryAcquireShared 里修改状态。获取失败时不能留下副作用,否则重试会重复扣减;所有状态变更必须在 CAS 成功后发生。
第三,忘记 releaseShared 的返回值。releaseShared 返回的是 tryReleaseShared 的结果,不是“有没有唤醒别人”;用返回值判断“是否最后一个”时,要确认子类的语义。
第四,在共享节点后面排独占节点却指望传播链继续。releaseShared 的第一次 signalNext(head) 会唤醒任意类型的后继,但共享节点成为 head 后只用 signalNextIfShared 叫共享后继;如果后面是独占节点,链就在这里停下。混合模式要靠 signalNext / signalNextIfShared 的分工,ReentrantReadWriteLock 在这里最复杂(3.1)。
第五,拿旧版 PROPAGATE 解释 JDK 25。当前实现没有这个状态,传播由 head 链驱动;用旧模型调试会找不到对应的字段。
小结
共享模式的关键不是“多个线程同时拿到锁”,而是“唤醒要传到哪里”。tryAcquireShared 的契约用负数、0、正数表达失败、成功但不建议传播、成功且可能继续传播;当前实现只消费正负,传播由 head 链驱动。旧实现用 setHeadAndPropagate、doReleaseShared 和 PROPAGATE 状态实现这条链,代价是状态机复杂;当前实现把传播绑定到“成为 head”这个事件上,由 signalNextIfShared 逐个叫醒,删掉了 PROPAGATE,释放路径只剩一次 signalNext。CountDownLatch 的归零唤醒和自写共享门都验证了这条链。
阅读导航:上一节 1.2 独占模式的 acquire/release | 下一节 2.1 ReentrantLock 的公平与非公平
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。