《Java 并发编程·JUC 源码与容器》1.2 独占模式的 acquire/release

对照 JDK 8 与 JDK 25 两套实现,拆解独占模式从 tryAcquire 失败到入队、park、唤醒、取消的完整路径,用一个非重入互斥锁跑出四线程严格 FIFO 的获取与释放顺序,并给出可观测插队次数的 BargingMutex 落地实现。

承接的问题

上一节把 AQS 的字段摆开了:state 表达同步语义,head/tail 维护一条 CLH 变体队列。这一节回答三个连在一起的问题:

  1. 一个线程调用 lock(),tryAcquire 失败之后,它是怎么变成队列节点的?
  2. 它什么时候真正睡下,又是谁把它叫醒?
  3. 唤醒发生在 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 共享模式与传播机制

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收