《Java 并发编程·JUC 源码与容器》7.2 SynchronousQueue、Priority、Delay

拆解 JDK 25 中已重构的 SynchronousQueue(Transferer 继承 LinkedTransferQueue)、PriorityBlockingQueue 的堆与 allocationSpinLock 扩容、DelayQueue 的 leader/available 协议,用零容量交接、同优先级出队、延迟到期三组实跑数据说明边界与代价。

承接的问题

7.1 的两个队列解决的是“存下来再取走”。但有三类需求不适合存储:生产者必须把元素直接交到某个消费者手里,中间不能有缓冲;元素要按优先级而不是到达顺序出队;元素要等到某个时刻才能被取走。JUC 分别给了 SynchronousQueue、PriorityBlockingQueue、DelayQueue。

这三个类的源码结构差异很大,但都围绕同一个问题:当“能不能取”不由元素数量决定时,等待条件该怎么表达。7.1 的条件是“非空/非满”,这一节的条件分别是“有配对的交接者”“堆顶存在”“堆顶到期”。

先给定位:

类容量出队顺序阻塞点
SynchronousQueue0由配对线程决定没有配对的生产者/消费者
PriorityBlockingQueue无界堆序(最小/比较器)队列为空
DelayQueue无界最早到期者堆顶未到期

SynchronousQueue:零容量的直接交接

先纠正一个旧版教材里常见的说法。很多资料会写“SynchronousQueue 在非公平模式下用 TransferStack,公平模式下用 TransferQueue”。这句话描述的是 JDK 8 的实现,JDK 25 已经不是这样了;本机 JDK 25 的 SynchronousQueue.java 里 TransferStack 出现 0 次。本机 javap -p 的输出是:

final class java.util.concurrent.SynchronousQueue$Transferer<E> extends java.util.concurrent.LinkedTransferQueue<E> {
  java.util.concurrent.SynchronousQueue$Transferer();
  final java.lang.Object xferLifo(java.lang.Object, long);
  private void unspliceLifo(java.util.concurrent.LinkedTransferQueue$DualNode);
}

Transferer 不再是一个抽象基类,而是直接继承 LinkedTransferQueue,只额外加了 LIFO 模式的 xferLifo。SynchronousQueue 自己的 xfer 只做一次分派:

// JDK 25 源码摘录,已删减
private Object xfer(Object e, long nanos) {
    Transferer<E> x = transferer;
    return (fair) ? x.xfer(e, nanos) : x.xferLifo(e, nanos);
}

也就是说,公平模式走的是父类 LinkedTransferQueue.xfer 的 FIFO 队列,非公平模式走子类 xferLifo 的 LIFO 栈。两个模式共用同一套 DualNode 与 CAS 链接逻辑,不再维护两套节点类。

xferLifo 的核心循环值得读一遍:

// JDK 25 源码摘录,已删减
static final class Transferer<E> extends LinkedTransferQueue<E> {
    final Object xferLifo(Object e, long ns) {
        boolean haveData = (e != null);
        Object m;
        outer: for (DualNode s = null, p = head;;) {
            while (p != null) {
                boolean isData; DualNode n, u;
                if ((isData = p.isData) != ((m = p.item) != null))
                    p = (p == (u = cmpExHead(p, (n = p.next)))) ? n : u;
                else if (isData == haveData)   // same mode; push below
                    break;
                else if (p.cmpExItem(m, e) != m)
                    p = head;                  // missed; restart
                else {                         // matched complementary node
                    Thread w = p.waiter;
                    cmpExHead(p, p.next);
                    LockSupport.unpark(w);
                    break outer;
                }
            }
            if (ns == 0L) { m = e; break; }
            if (s == null) s = new DualNode(e, haveData);
            s.next = p;
            if (p == (p = cmpExHead(p, s))) {
                if ((m = s.await(e, ns, this, p == null || p.waiter == null)) == e)
                    unspliceLifo(s);
                else if (m != null) s.selfLinkItem();
                break;
            }
        }
        return m;
    }
}

DualNode 的字段是 volatile Object item、DualNode next、Thread waiter、final boolean isData。isData 区分节点是“送数据”还是“要数据”,item 在匹配时被 CAS 替换。循环先跳过已匹配/取消的栈顶;遇到同模式就准备压栈;遇到互补模式就 cmpExItem(m, e) 把对方的 item 换成自己的值,CAS 成功后弹栈并 unpark 对方线程。非公平模式的“栈”体现在压栈位置:cmpExHead(p, s) 把新节点放到 head,后到的等待者先被匹配。公平模式的父类 xfer 从 head 沿 next 找,先到的先匹配。两者的差别只是链接方向和匹配顺序,底层都是“先尝试无锁匹配,匹配不到才分配节点并 park”。

旧版还有一个 qlock + WaitQueue waitingProducers/waitingConsumers 的说法。本机 javap -p 确实能看到 ReentrantLock qlock 与两个 WaitQueue 字段,但源码里写得很清楚,它们只为跨版本反序列化而保留:

// JDK 25 源码摘录,已删减
/*
 * To cope with serialization across multiple implementation
 * overhauls, we declare some unused classes and fields that exist
 * solely to enable serializability across versions.  These fields
 * are never used, so are initialized only if this object is ever
 * serialized. We use readResolve to replace a deserialized queue
 * with a fresh one.
 */
static class WaitQueue implements java.io.Serializable { }
static class LifoWaitQueue extends WaitQueue { }
static class FifoWaitQueue extends WaitQueue { }
private ReentrantLock qlock;
private WaitQueue waitingProducers;
private WaitQueue waitingConsumers;

qlock 只在 writeObject 里被创建,readResolve 用 waitingProducers instanceof FifoWaitQueue 恢复公平标志,然后返回一个全新的 SynchronousQueue。传输路径一次都没有读过 qlock,公平模式完全由 fair 布尔值选择 xfer 还是 xferLifo。所以“公平模式靠 qlock 保护两个 WaitQueue”是把 JDK 8 的字段名和 JDK 25 的类结构拼在一起的错误叙述。

边界行为与源码一致:它没有容量,size() 恒为 0,remainingCapacity() 恒为 0,isEmpty() 恒为 true,peek() 恒为 null,contains 恒为 false。offer(e) 只有在恰好有消费者等待时才返回 true。本机实测:

== SynchronousQueue 零容量 ==
size=0 remainingCapacity=0 isEmpty=true peek=null
offer(no consumer) = false
offer(with waiting consumer)=true, identitySame=true

交接的是同一个对象引用,没有复制。公平性实验连跑三轮,每轮先让两个消费者挂起,再依次 put("first")、put("second"):

== SynchronousQueue 公平/非公平的交接顺序 ==
fair=true  -> [c1:first, c2:second, c1:first, c2:second, c1:first, c2:second]
fair=false -> [c2:first, c1:second, c2:first, c1:second, c2:first, c1:second]

公平模式三轮都是先到的 c1 拿到 first;非公平模式三轮都是后到的 c2 先拿到,符合 LIFO 的预期。这个顺序差异不是实现细节,而是使用语义:线程池用非公平模式获得时间局部性(刚被唤醒的线程更可能在缓存里),消息传递用公平模式获得可预测的先后顺序。

什么时候不要用 SynchronousQueue:把它当普通队列做缓冲。它没有任何缓冲能力,生产速度略高于消费速度就会让生产者 park;本机 1 对 1 的吞吐探针里,它约 41.9 万 ops/s,ArrayBlockingQueue 约 1226 万 ops/s(负载均值 69,只能看数量级)。正确用法是“线程之间的 rendezvous”:Executors.newCachedThreadPool() 用它把任务直接交给空闲线程,交接不出去就新建线程。用它做业务队列,等于把背压直接传导给上游。

PriorityBlockingQueue:堆与扩容自旋锁

PriorityBlockingQueue 的字段很少:

// JDK 25 源码摘录,已删减
private transient Object[] queue;
private transient int size;
private transient Comparator<? super E> comparator;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private transient volatile int allocationSpinLock;
private PriorityQueue<E> q;   // 仅序列化兼容用

queue 是一个二叉堆数组:queue[n] 的两个孩子是 queue[2*n+1] 与 queue[2*(n+1)],父节点不大于孩子,所以 queue[0] 是堆顶。offer 在锁内 siftUp 到合适位置,take 在锁内取走 queue[0] 再把最后一个元素 siftDown。put 直接调用 offer,因为它是无界的,永远不需要等空位;remainingCapacity() 恒返回 Integer.MAX_VALUE。

真正特别的是扩容。tryGrow 不在持有主锁的情况下分配数组:

// JDK 25 源码摘录,已删减
private void tryGrow(Object[] array, int oldCap) {
    lock.unlock(); // must release and then re-acquire main lock
    Object[] newArray = null;
    if (allocationSpinLock == 0 &&
        ALLOCATIONSPINLOCK.compareAndSet(this, 0, 1)) {
        try {
            int growth = (oldCap < 64)
                ? (oldCap + 2) // grow faster if small
                : (oldCap >> 1);
            int newCap = ArraysSupport.newLength(oldCap, 1, growth);
            if (queue == array)
                newArray = new Object[newCap];
        } finally {
            allocationSpinLock = 0;
        }
    }
    if (newArray == null) // back off if another thread is allocating
        Thread.yield();
    lock.lock();
    if (newArray != null && queue == array) {
        queue = newArray;
        System.arraycopy(array, 0, newArray, 0, oldCap);
    }
}

这段代码回答了三个“为什么”:

  • 为什么先释放主锁:new Object[newCap] 与 System.arraycopy 可能是毫秒级操作,分配还可能触发 GC。握着主锁做这件事,所有 offer/take 都会被卡住;先放锁,让其他线程继续在旧数组上操作。
  • 为什么需要 allocationSpinLock:主锁已释放,必须保证同一时刻只有一个线程在扩容。它是一个 volatile int,用 VarHandle 做 CAS;抢不到的线程 Thread.yield() 后回到 offer 的 while 重试,而不是自旋等待。
  • 为什么最后要检查 queue == array:从放锁到重新拿锁之间,别的线程可能已经完成了扩容。只有当前数组仍是自己看到的那个旧数组时,才把新数组装回去;否则丢弃自己分配的结果,避免覆盖别人的扩容。

扩容策略是“小数组每次 +2,大数组每次 +50%”,oldCap < 64 时增长更快,避免小队列频繁扩容。初始容量默认 11(DEFAULT_INITIAL_CAPACITY)。

堆序不等于稳定排序。本机实验先插入优先级 [7,3,9,1,5],再依次 poll:

== PriorityBlockingQueue 堆序与稳定性 ==
inserted priorities [7,3,9,1,5], polled=[1, 3, 5, 7, 9]
remainingCapacity=2147483647
8 个同优先级元素的出队序号=[0, 7, 6, 5, 4, 3, 2, 1](不保证 FIFO)

前一行验证了最小堆;后一行说明同优先级元素的相对顺序由 siftUp/siftDown 的具体路径决定,可能完全反转。需要稳定顺序时,必须在比较器里加第二关键字(例如入队序号),不能依赖插入顺序。

还有一个容易踩的边界:iterator() 不保证按优先级遍历,它只是按堆数组的下标顺序走。要按顺序消费,只能用 poll/take,不能 for (E e : queue) 然后假设拿到了有序序列。

什么时候不要用:它无界,生产速度长期高于消费速度时没有背压,最终 OOM;比较器在锁内执行,比较器里做 I/O 或调用外部服务会把整个队列拖死;它没有公平模式,同优先级也不稳定。需要按时间出队用它的小弟 DelayQueue,需要固定容量背压用 7.1 的 ArrayBlockingQueue。

DelayQueue:leader 与 available

DelayQueue 的字段是三个角色:

// JDK 25 源码摘录,已删减
private final transient ReentrantLock lock = new ReentrantLock();
private final PriorityQueue<E> q = new PriorityQueue<E>();
private Thread leader;
private final Condition available = lock.newCondition();

q 按 Delayed.getDelay() 排序,堆顶是最早到期的元素。leader 是 Leader-Follower 模式的“当前负责等下一个到期时刻的线程”,available 是唯一的等待条件。take 的循环是理解这个类的关键:

// JDK 25 源码摘录,已删减
public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
            E first = q.peek();
            if (first == null)
                available.await();
            else {
                long delay = first.getDelay(NANOSECONDS);
                if (delay <= 0L)
                    return q.poll();
                first = null; // don't retain ref while waiting
                if (leader != null)
                    available.await();
                else {
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        available.awaitNanos(delay);
                    } finally {
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null)
            available.signal();
        lock.unlock();
    }
}

协议可以拆成四条:

  1. 堆空:所有消费者在 available 上无限期等待,直到 offer 唤醒。
  2. 堆顶已到期:直接 poll 返回,不等待。
  3. 堆顶未到期且已有 leader:自己 await() 无限期等待。它不设定时器,因为 leader 会在到期时负责唤醒或重新竞争。
  4. 堆顶未到期且没有 leader:自己成为 leader,用 awaitNanos(delay) 只等到堆顶的到期时刻;醒来后在 finally 里清掉自己的 leader 身份。

这样做的收益是:无论多少消费者,同一时刻只有一个线程在做定时等待。如果每个消费者都 awaitNanos(delay),N 个线程会有 N 个定时器,到期时一起醒来抢锁,形成惊群;Leader-Follower 把定时等待压缩到一条线程。

offer 也要维护这个协议:在锁内 q.offer(e) 之后,如果 q.peek() == e(新元素成了更早的堆顶),就把 leader 置空并 available.signal()。原因是当前 leader 等的是旧堆顶的较晚时刻,已经不正确了;旧 leader 醒来后发现 leader != thisThread,不会清空别人的身份,而是回到循环重新判断。

本机实验先放三个元素:B 延迟 300 ms、A 延迟 100 ms、C 延迟 200 ms,然后连续 take 三次:

== DelayQueue 到期顺序与 leader ==
take 顺序与相对时刻: A-100ms@103ms C-200ms@205ms B-300ms@303ms
等待中 leader=Thread-13, c1=Thread-13, c2=Thread-14, c1IsLeader=true
两个消费者最终拿到: [c1:only, c2:later]

前一行验证了“按到期时间而不是插入顺序”出队,延迟误差 3–5 ms 来自线程调度与本机负载。后两行是两个消费者同时 take 的场景:等待 150 ms 时读取私有字段 leader,只有 c1 是 leader,c2 在无限期等待;第一个元素到期后 c1 拿走,第二个元素到期后 c2 拿走。读取私有字段需要 --add-opens java.base/java.util.concurrent=ALL-UNNAMED,这里只为观察,不是生产用法。

什么时候不要用:需要周期性任务、固定速率、取消语义时,用 ScheduledThreadPoolExecutor(11.3 会拆它的 DelayedWorkQueue,同样是堆 + leader);需要持久化、重启后恢复的延迟任务,DelayQueue 只在内存里;需要毫秒级以下精度时,awaitNanos 的唤醒精度受操作系统的定时器与调度影响,本机高负载下 3–5 ms 的偏差是正常量级。还有一个隐蔽的坑:元素入队后如果 getDelay 的返回值发生变化,堆序就失效了,take 可能一直等到错误的时刻。Delayed 实现应当让到期时间在入队后不可变。

三种队列的边界对照

边界SynchronousQueuePriorityBlockingQueueDelayQueue
put 会阻塞吗会,直到有配对者不会,无界不会,无界
offer 何时失败无等待消费者时立即 false永不失败(除非 OOM)永不失败(除非 OOM)
size() 有意义吗恒 0是元素数是元素数
顺序由什么决定公平标志 + 到达顺序比较器/自然序getDelay
典型误用当缓冲队列当有界队列当高吞吐定时器

在 libraryd-conc 里落地

预约到书后要给会员保留 48 小时,超时自动释放库存。这是 DelayQueue 的典型场景:每个预约有自己的到期时刻,消费者只关心“最早到期的那一个”,不需要周期任务。

package com.plume.conc.core;

import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/** 预约到期队列:到期后由工作线程释放库存。 */
public final class ReservationExpiryQueue {

    public record Reservation(String loanId, String isbn, String memberId, Instant dueAt) {}

    public record ExpiringReservation(Reservation reservation) implements Delayed {
        public long getDelay(TimeUnit unit) {
            long nanos = Duration.between(Instant.now(), reservation.dueAt()).toNanos();
            return unit.convert(nanos, TimeUnit.NANOSECONDS);
        }
        public int compareTo(Delayed other) {
            return reservation.dueAt().compareTo(
                ((ExpiringReservation) other).reservation.dueAt());
        }
    }

    private final DelayQueue<ExpiringReservation> queue = new DelayQueue<>();

    public void schedule(Reservation reservation) {
        queue.put(new ExpiringReservation(reservation));
    }

    /** 阻塞到最早一个预约到期;调用方负责释放库存。 */
    public Reservation awaitExpired() throws InterruptedException {
        return queue.take().reservation();
    }

    public int pending() {
        return queue.size();
    }
}

ExpiringReservation 的 compareTo 必须与 getDelay 的排序一致,都按 dueAt;如果 compareTo 用了别的字段,堆顶就不一定是最早到期者,take 会等到错误的时间。schedule 里的 put 不会阻塞,但队列无界,长期堆积说明释放库存的消费者停了,需要靠 pending() 做监控。

第二个落点是直接交接。馆内自助机的借书请求可以交给固定的几个虚拟线程处理,交接不出去就立即拒绝,而不是排队:

package com.plume.conc.core;

import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;

/** 零容量交接:没有空闲处理者时立刻返回 false。 */
public final class HandoffDispatcher {

    private final SynchronousQueue<Runnable> handoff = new SynchronousQueue<>(true);

    public boolean dispatch(Runnable task, long timeoutMillis) throws InterruptedException {
        return handoff.offer(task, timeoutMillis, TimeUnit.MILLISECONDS);
    }
}

这里用 offer(timeout) 而不是 put:自助机不能因为后端处理者全忙就无限挂起,超时后返回 false,前端提示“请稍后再试”。如果改成 put,高峰时所有请求线程都会 park 在 SynchronousQueue 上,等于把线程池的排队问题转移到了调用线程。

常见错误与边界

  • 用 size() 监控 SynchronousQueue 的积压:它恒为 0,要看交接延迟或 offer 失败率;调用 put 而没有配对者会永久阻塞,优先用带超时的 offer/poll。
  • 把 PriorityBlockingQueue 当有界队列:remainingCapacity() 恒为 Integer.MAX_VALUE,没有背压。
  • 用 for (E e : priorityQueue) 期待有序输出:迭代器按堆数组下标走,不按优先级;在 DelayQueue 元素入队后修改到期时间会让堆序失效。
  • 把 DelayQueue 当高频定时器:所有操作共用一把锁,每次循环还要调用 getDelay;高吞吐场景应换时间轮或专用调度器。

小结

SynchronousQueue 在 JDK 25 已经统一到 LinkedTransferQueue:Transferer 继承它,非公平模式加一个 xferLifo,公平模式复用父类的 FIFO xfer;qlock/WaitQueue 只是反序列化兼容字段,不参与交接。PriorityBlockingQueue 用二叉堆提供无界的优先级出队,扩容时释放主锁、用 allocationSpinLock 做 CAS 分配,避免在分配和复制期间卡住所有操作。DelayQueue 用 PriorityQueue 加 Leader-Follower,把定时等待压缩到一个 leader 线程,其他消费者无限期等待。三者都不是“更好的队列”,而是三种不同的等待条件:等配对、等堆顶、等到期。

7.3 把这三个类和 7.1 的两个类放在一起,比较锁粒度、节点开销、伪共享与容量语义,并解释为什么本机测出的吞吐数字只能看数量级。

阅读导航:上一节 7.1 ArrayBlockingQueue 与 LinkedBlockingQueue | 下一节 7.3 队列的锁粒度、吞吐与内存

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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