《Java 并发编程·JUC 源码与容器》11.3 DelayedWorkQueue

DelayedWorkQueue 用数组堆加 heapIndex 反查实现 O(log n) 取消,用 leader 与 available 保证同一时刻只有一个线程带超时等待。本节逐分支拆 take(),用四个 worker 等一个到期任务的本机输出证明只有 leader 被唤醒,并对比 DelayQueue 的容量与删除代价。

承接的问题

卷一第 11 章讲的是 ScheduledExecutorService 的用法:scheduleAtFixedRate 与 scheduleWithFixedDelay 的差别、周期任务漂移怎么来的。本节看容器:ScheduledThreadPoolExecutor 内部那个没有公开的 DelayedWorkQueue。

要回答三个问题:到期任务按时间排序,为什么不用 PriorityQueue 而自己写一个数组堆?取消一个尚未到期的任务,凭什么能做到 O(log n)?多个 worker 同时等在同一个到期任务上,是谁去睡、睡多久?

数组堆与 heapIndex 反查

DelayedWorkQueue 是一个 AbstractQueue<Runnable>,内部是数组堆加一把 ReentrantLock:

// JDK 25 源码摘录,已删减
private static final int INITIAL_CAPACITY = 16;
private RunnableScheduledFuture<?>[] queue =
    new RunnableScheduledFuture<?>[INITIAL_CAPACITY];
private final ReentrantLock lock = new ReentrantLock();
private int size;
private Thread leader;
private final Condition available = lock.newCondition();

private static void setIndex(RunnableScheduledFuture<?> f, int idx) {
    if (f instanceof ScheduledFutureTask)
        ((ScheduledFutureTask)f).heapIndex = idx;
}

关键在 setIndex:每个 ScheduledFutureTask 自己记着"我在堆数组的哪个下标"。siftUp / siftDown 每搬动一个元素就调一次 setIndex,所以堆序变化之后反查表仍然准确。取消时不必扫描数组:

// JDK 25 源码摘录,已删减
private int indexOf(Object x) {
    if (x != null) {
        if (x instanceof ScheduledFutureTask) {
            int i = ((ScheduledFutureTask) x).heapIndex;
            // Sanity check; x could conceivably be a
            // ScheduledFutureTask from some other pool.
            if (i >= 0 && i < size && queue[i] == x)
                return i;
        } else {
            for (int i = 0; i < size; i++)
                if (x.equals(queue[i]))
                    return i;
        }
    }
    return -1;
}

两处防御值得注意。第一,queue[i] == x 用引用相等而不是 equals,因为下标可能来自另一个池的同名任务,或者已经过期失效。第二,只有 ScheduledFutureTask 有 heapIndex,别的 RunnableScheduledFuture 实现只能退回线性扫描——源码注释的原文是"we expect that most tasks will not be decorated, and that the faster cases will be much more common"。

remove 拿到下标后做的是"末尾元素补位、先 siftDown 再按需 siftUp",并把被删任务的 heapIndex 置 -1。取消的复杂度因此从 O(n) 降到 O(log n),同时避免了一个更隐蔽的问题:如果只标记不摘除,已取消的任务会一直占着堆里的位置,直到它升到堆顶才被清理。扩容则是每次加 50%:

// JDK 25 源码摘录,已删减
private void grow() {
    int oldCapacity = queue.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1); // grow 50%
    if (newCapacity < 0) // overflow
        newCapacity = Integer.MAX_VALUE;
    queue = Arrays.copyOf(queue, newCapacity);
}

下面这段程序直接拿一个真实的 ScheduledThreadPoolExecutor 观察队列:先看 leader 字段和任务实际跑在哪个线程上,再看扩容节奏、DelayQueue 的对比容量,最后看 heapIndex 在取消前后的变化。

import java.lang.reflect.Field;
import java.util.PriorityQueue;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/** 观察 ScheduledThreadPoolExecutor 内部 DelayedWorkQueue 的 leader、扩容与 heapIndex。 */
public final class QueueProbe {

    record D(long trigger) implements Delayed {
        public long getDelay(TimeUnit u) {
            return u.convert(trigger - System.nanoTime(), TimeUnit.NANOSECONDS);
        }
        public int compareTo(Delayed o) {
            return Long.compare(getDelay(TimeUnit.NANOSECONDS), o.getDelay(TimeUnit.NANOSECONDS));
        }
    }

    public static void main(String[] args) throws Exception {
        AtomicInteger seq = new AtomicInteger();
        ScheduledThreadPoolExecutor stpe = new ScheduledThreadPoolExecutor(4,
                r -> Thread.ofPlatform().name("sched-" + seq.incrementAndGet()).unstarted(r));
        stpe.prestartAllCoreThreads();
        stpe.setRemoveOnCancelPolicy(false);

        Object q = stpe.getQueue();
        Field leaderF = q.getClass().getDeclaredField("leader");
        leaderF.setAccessible(true);
        Field arrayF = q.getClass().getDeclaredField("queue");
        arrayF.setAccessible(true);

        Thread.sleep(200);
        System.out.println("4 个 worker 空闲时 leader=" + leaderF.get(q));

        stpe.schedule(() -> System.out.println("T1 跑在 " + Thread.currentThread().getName()),
                300, TimeUnit.MILLISECONDS);
        Thread.sleep(20);
        Object leader = leaderF.get(q);
        System.out.println("schedule 之后 leader="
                + (leader == null ? "null(尚未交接)" : ((Thread) leader).getName()));

        Thread.sleep(1000);
        System.out.println("1 秒后 size=" + stpe.getQueue().size() + " poolSize=" + stpe.getPoolSize());

        for (int i = 2; i <= 4; i++) {
            int id = i;
            stpe.schedule(() -> System.out.println("T" + id + " 跑在 "
                    + Thread.currentThread().getName()), 0, TimeUnit.MILLISECONDS);
        }
        Thread.sleep(500);

        int last = 0;
        for (int i = 0; i < 40; i++) {
            stpe.schedule(() -> { }, 60, TimeUnit.SECONDS);
            int cap = ((Object[]) arrayF.get(q)).length;
            if (cap != last) {
                System.out.println("DelayedWorkQueue 元素数=" + stpe.getQueue().size() + " 数组长度=" + cap);
                last = cap;
            }
        }

        DelayQueue<D> dq = new DelayQueue<>();
        for (int i = 0; i < 40; i++) dq.add(new D(System.nanoTime() + TimeUnit.SECONDS.toNanos(60)));
        Field pqField = DelayQueue.class.getDeclaredField("q");
        pqField.setAccessible(true);
        Field pqArray = PriorityQueue.class.getDeclaredField("queue");
        pqArray.setAccessible(true);
        System.out.println("DelayQueue 元素数=" + dq.size()
                + " 内部数组长度=" + ((Object[]) pqArray.get(pqField.get(dq))).length);

        Field heapIndexF = Class.forName(
                "java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask")
                .getDeclaredField("heapIndex");
        heapIndexF.setAccessible(true);
        ScheduledFuture<?> f1 = stpe.schedule(() -> { }, 60, TimeUnit.SECONDS);
        ScheduledFuture<?> f2 = stpe.schedule(() -> { }, 60, TimeUnit.SECONDS);
        ScheduledFuture<?> f3 = stpe.schedule(() -> { }, 60, TimeUnit.SECONDS);
        System.out.println("三个任务 heapIndex=" + heapIndexF.get(f1) + ","
                + heapIndexF.get(f2) + "," + heapIndexF.get(f3));
        System.out.println("removeOnCancel=false: cancel 中间一个 -> " + f2.cancel(false)
                + " heapIndex=" + heapIndexF.get(f2) + " 队列 size=" + stpe.getQueue().size());

        stpe.setRemoveOnCancelPolicy(true);
        ScheduledFuture<?> g1 = stpe.schedule(() -> { }, 60, TimeUnit.SECONDS);
        ScheduledFuture<?> g2 = stpe.schedule(() -> { }, 60, TimeUnit.SECONDS);
        ScheduledFuture<?> g3 = stpe.schedule(() -> { }, 60, TimeUnit.SECONDS);
        System.out.println("removeOnCancel=true: 三个任务 heapIndex=" + heapIndexF.get(g1) + ","
                + heapIndexF.get(g2) + "," + heapIndexF.get(g3));
        System.out.println("cancel 中间一个 -> " + g2.cancel(false)
                + " heapIndex=" + heapIndexF.get(g2) + " 队列 size=" + stpe.getQueue().size());
        stpe.shutdownNow();
    }
}

用 javac --release 25 编译,再以 --add-opens java.base/java.util.concurrent=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED 运行,本机输出:

4 个 worker 空闲时 leader=null
schedule 之后 leader=sched-1
T1 跑在 sched-1
1 秒后 size=0 poolSize=4
T2 跑在 sched-2
T3 跑在 sched-4
T4 跑在 sched-3
DelayedWorkQueue 元素数=1 数组长度=16
DelayedWorkQueue 元素数=17 数组长度=24
DelayedWorkQueue 元素数=25 数组长度=36
DelayedWorkQueue 元素数=37 数组长度=54
DelayQueue 元素数=40 内部数组长度=50
三个任务 heapIndex=40,41,42
removeOnCancel=false: cancel 中间一个 -> true heapIndex=41 队列 size=43
removeOnCancel=true: 三个任务 heapIndex=43,44,45
cancel 中间一个 -> true heapIndex=-1 队列 size=45

先看后半段的容量与取消:

  • 16 → 24 → 36 → 54,每次加 50%,与 grow() 的 oldCapacity + (oldCapacity >> 1) 一致。DelayQueue 的 PriorityQueue 起始 11,小数组时按 old + old + 2 翻倍,40 个元素后是 50。
  • heapIndex 是从 40 开始的,因为堆里前面已经排了 40 个 60 秒后到期的任务;三个新任务接在末尾,正是"新元素先放到数组尾部、再 siftUp“的表现。
  • 默认 removeOnCancel=false 时 cancel 返回 true,但 heapIndex 还是 41、size 还是 43。 任务只是被标记为已取消,仍占着堆里的位置,要等到它的到期时间点才被 run() 发现并丢弃。长延迟任务被大量取消时,队列会持续膨胀。
  • 开启 setRemoveOnCancelPolicy(true) 后 heapIndex 变成 -1、size 从 46 变 45。 这才是真正走 remove → indexOf → siftDown 的反查路径,heapIndex 被 setIndex(f, -1) 清掉,正是"移除"的标志。

leader 与 available:只让一个人等

take() 是整个类里最值得逐分支读的方法:

// JDK 25 源码摘录,已删减
public RunnableScheduledFuture<?> take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
            RunnableScheduledFuture<?> first = queue[0];
            if (first == null)
                available.await();
            else {
                long delay = first.getDelay(NANOSECONDS);
                if (delay <= 0L)
                    return finishPoll(first);
                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 && queue[0] != null)
            available.signal();
        lock.unlock();
    }
}

四个分支:

  1. 队列空 → available.await() 无限期等,直到有人 offer。
  2. 堆顶已到期(delay <= 0)→ finishPoll 摘掉堆顶,返回。
  3. 有 leader 了 → 自己 available.await() 无限期等,不设超时。
  4. 没有 leader → 自己当 leader,awaitNanos(delay) 只睡到堆顶的到期时刻。

这就是 Leader-Follower 的变体:同一时刻只有一个线程带超时睡,其余线程无限期睡,等 leader 醒来处理完再交接。 如果让所有人都 awaitNanos(delay),每次定时等待都要挂一个定时器,到期时全部唤醒、全部抢锁、全部发现"任务已经被别人拿走了”,白白制造一次惊群。只让一个人带超时,其余人靠 signal 唤醒,唤醒次数就从"等待者数量"降到"任务数量"。first = null 那行也不是随手写的:等待期间不再持有堆顶任务的引用,避免一个大任务对象被等待者拖住无法回收。

交接发生在两处。take 的 finally 里:

// JDK 25 源码摘录,已删减
if (leader == null && queue[0] != null)
    available.signal();

离开时如果自己已经不是 leader(或当 leader 期间被 finally 清掉了),而队列里还有下一个任务,就叫醒一个等待者去当新的 leader。offer 里则是另一头:

// JDK 25 源码摘录,已删减
if (queue[0] == e) {
    leader = null;
    available.signal();
}

新任务插到了堆顶(比原堆顶更早到期),原来的 leader 睡过头了:把 leader 置空作废,signal 叫醒一个线程重新评估。被叫醒的线程可能发现自己抢不到 leadership(别人先到),于是回到 await()——源码注释专门提醒"waiting threads must be prepared to acquire and lose leadership while waiting"。

上面的输出正好是这套机制的现场:四个 worker 空闲时 leader=null;排入一个 300 ms 后到期的任务后,sched-1 成为 leader,也只有它被定时唤醒,T1 跑在 sched-1;另外三个仍在 available.await() 上无限期等,直到补进三个零延迟任务,才各自被 signal 唤醒,T2/T3/T4 分别跑在 sched-2/sched-4/sched-3 上(谁拿到哪个由唤醒顺序决定,重复运行会变,但每个任务只被取走一次这一点不变)。1 秒后 size=0 说明到期任务已被摘除,没有重复投递。

与 DelayQueue 的差异

两者都是"堆 + leader + available",差异在于 DelayQueue 把堆交给 PriorityQueue,DelayedWorkQueue 自己管数组:

维度DelayedWorkQueueDelayQueue
底层容器自管 RunnableScheduledFuture[] 数组PriorityQueue<E>
初始容量INITIAL_CAPACITY = 1611(PriorityQueue 默认)
扩容每次 +50%:16→24→36→54小于 64 时 old+old+2,之后 +50%;实测 40 个元素为 50
删除定位ScheduledFutureTask.heapIndex 反查,O(log n)线性扫描 + equals,O(n)
元素类型必须是 RunnableScheduledFuture(offer 强转)任意 Delayed
取消即移除需 setRemoveOnCancelPolicy(true)remove 立即摘除

ScheduledThreadPoolExecutor 不用 DelayQueue 的核心原因就是删除定位:定时任务的生命周期往往由业务侧控制(请求超时、会话过期、订单关闭),取消是高频操作;用 DelayQueue 每次取消都要 O(n) 扫描,还要依赖元素的 equals 实现正确。代价是耦合——offer 会把参数强转成 RunnableScheduledFuture,传别的 Delayed 直接 ClassCastException;heapIndex 只对 ScheduledFutureTask 生效,别的实现照样退回线性扫描。

在 libraryd-conc 里落地

借阅到期提醒天然适合定时任务:借出时排一个到期任务,归还时取消。落地时最容易踩的坑就是"取消了但没摘除":

package com.plume.conc.core;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/** 借阅到期提醒。取消时必须真的把任务从堆里摘掉。 */
public final class DueReminder implements AutoCloseable {

    private final ScheduledThreadPoolExecutor scheduler;
    private final Map<String, ScheduledFuture<?>> pending = new ConcurrentHashMap<>();

    public DueReminder(int threads) {
        this.scheduler = new ScheduledThreadPoolExecutor(threads,
                r -> Thread.ofPlatform().name("due-reminder-", 0).unstarted(r));
        this.scheduler.setRemoveOnCancelPolicy(true);   // 关键:取消即摘除
        this.scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
    }

    public void schedule(String loanId, Instant dueAt, Runnable onDue) {
        long delay = Math.max(0, Duration.between(Instant.now(), dueAt).toMillis());
        ScheduledFuture<?> old = pending.put(loanId,
                scheduler.schedule(onDue, delay, TimeUnit.MILLISECONDS));
        if (old != null) old.cancel(false);             // 续借:先排新的再取消旧的
    }

    public boolean cancel(String loanId) {
        ScheduledFuture<?> f = pending.remove(loanId);
        return f != null && f.cancel(false);
    }

    public int pendingTasks() {
        return scheduler.getQueue().size();
    }

    @Override
    public void close() {
        scheduler.shutdownNow();
    }
}

三个要点。第一,setRemoveOnCancelPolicy(true) 必须显式打开——默认 false,取消只打标记,堆里仍占位,pendingTasks() 会长期高于真实待办数(上面的实测里,cancel 之后 size 一点没变)。第二,续借要"先排新的、再取消旧的",反过来会有一个短暂窗口没有待办任务。第三,setExecuteExistingDelayedTasksAfterShutdownPolicy(false) 让 shutdown 之后的到期任务不再执行,配合 close() 里的 shutdownNow(),停机时不会突然弹出一批提醒。

这个设计的代价

第一,一把锁管所有操作。 take、offer、poll、size、contains 全部抢同一把 ReentrantLock,size() 也不例外(isEmpty() 直接调 size())。定时任务量大、周期短时,这把锁就是吞吐上限。卷一第 11 章讲过"大量短周期任务用时间轮",从实现角度看就是躲开这把锁和每次堆调整。

第二,数组只扩不缩。 grow 没有对应的 shrink,一次高峰把数组撑大之后,即使队列长期为空也不会归还。长生命周期的调度器要定期 purge() 清理已取消任务,或者按业务拆成多个调度器。

第三,leader 是单点状态。 它保证只有一个线程带超时等待,代价是堆顶变化必须作废它(offer 里 leader = null),被 signal 唤醒的线程还要重新检查自己是否还能当 leader;高竞争下表现为几次多余的唤醒和抢锁。

第四,不要拿它当通用延迟队列。 它只接受 RunnableScheduledFuture,offer 的强转不做检查;iterator 只是创建时的数组快照,遍历期间的增删不可见。要通用延迟队列用 DelayQueue,要定时执行用 ScheduledThreadPoolExecutor,两者不要互相替代。

小结

DelayedWorkQueue 在"堆 + leader"的骨架上做了两处改造:把 PriorityQueue 换成自管数组,让每个 ScheduledFutureTask 记住自己的 heapIndex,使取消从 O(n) 扫描变成 O(log n) 反查;用 leader 保证同一时刻只有一个线程带超时等待,其余线程无限期 await,靠 signal 逐个交接。本机实测确认了 16→24→36→54 的扩容节奏、默认不摘除取消任务的行为,以及四个 worker 等一个到期任务时只有 leader 被定时唤醒。

下一章进入 ForkJoinPool:ctl 换成 long,WorkQueue 用 base/top 两端推进加 phase seqlock,旧教材里的 scan / steal 在 JDK 25 里已经没有同名方法。

阅读导航:上一节 11.2 addWorker、runWorker 与退出 | 下一节 12.1 工作窃取队列与任务扫描

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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