《Java 并发编程·线程与内存模型》11.2 延迟队列与时间轮

延迟任务有两种组织方式:堆,或者时间轮。本文拆开 DelayQueue 的优先级队列与 leader 线程,实测乱序入队仍按剩余延迟出队、指数退避重试在 +0/+60/+173ms 完成;再手写一个 8 槽 50ms 的时间轮,对比同组任务的量化误差:时间轮平均 30ms,DelayQueue 平均 2.2ms。

调度器之下,还有一个数据结构问题

11.1 节比较的是「用哪个调度器」。但无论 Timer 还是 STPE,任务最终都要回答同一个问题:一堆带触发时刻的任务,怎么排队才能高效地取出最早到期的那个。

JDK 给出的答案是堆:Timer 用 TaskQueue,STPE 用 DelayedWorkQueue,DelayQueue 用 PriorityQueue。消息中间件与 RPC 框架给出的答案常常是时间轮:它们要容纳的延时任务数量远大于 JDK 队列的设计目标。

这一节讲清两件事:

  1. DelayQueue 的接口约定与内部结构,特别是那个容易被忽略的 leader 线程;
  2. 时间轮的槽位、圈数与分层,以及它用精度换来的 O(1)。

最后用两者分别实现借阅系统的通知重试队列,并给出本机实测的误差对比。

Delayed 与 DelayQueue

接口只有两个方法

Delayed 接口只有两个抽象方法:getDelay(TimeUnit) 与继承来的 compareTo。

getDelay 返回「还有多久到期」,可以是负数(已经到期);compareTo 决定堆序。两个方法必须自洽:堆顶元素应当是 getDelay 最小的那个,否则 take() 会取到还没到期的任务。

DelayQueue<E extends Delayed> 的用法约束:

约束说明
元素必须实现 Delayed泛型上界,编译期强制
不接受 nulloffer(null) 抛 NullPointerException
无界没有容量参数,堆积只受内存限制
take() 阻塞到有元素到期poll() 非阻塞,可能返回 null
元素到期前 size() 已计入size() 包含未到期元素,不能当「可消费数量」用

堆 + 一把锁 + 一个 leader

本机 javap 看到的字段(真实输出):

private final transient java.util.concurrent.locks.ReentrantLock lock;
private final java.util.PriorityQueue<E> q;
private java.lang.Thread leader;
private final java.util.concurrent.locks.Condition available;

四个字段可以分成三部分,正好解释整个设计:

  • PriorityQueue:按 compareTo 排序的小顶堆,插入和取出都是 O(log n),peek 是 O(1)。
  • lock + available:所有操作串行化,等待走条件队列。
  • leader:这是关键。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;                      // 等的时候不持有元素引用
                if (leader != null)
                    available.await();             // 已有人定时等,我无限等
                else {
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        available.awaitNanos(delay);   // 只有 leader 定时等
                    } finally {
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null)
            available.signal();
        lock.unlock();
    }
}

为什么需要 leader? 假设 100 个消费者线程同时 take(),队列里最早的任务还有 5 秒到期。

  • 没有 leader:100 个线程都调用 awaitNanos(5s),5 秒后 100 个线程同时被唤醒,抢锁、检查、99 个发现拿不到东西又睡回去——一次典型的惊群。
  • 有 leader:第一个线程当 leader,awaitNanos(5s);其余 99 个直接 await() 无限等。5 秒后 leader 醒来取走任务,在 finally 里 signal() 一个等待者接任。

代价是leader 必须正确移交。finally 里那句 if (leader == null && q.peek() != null) available.signal() 就是移交点:只有 leader 已经清空、且队列里还有元素时才叫醒下一个。如果 leader 在 awaitNanos 期间被中断,finally 同样会清空并移交,不会让整个队列卡死。

顺带一提,STPE 的 DelayedWorkQueue 是同一思路的变体(堆 + leader + 条件队列),区别在于它服务于池内工作线程而不是外部消费者,细节留给卷二第 11 章。

实测:顺序由剩余延迟决定

DelayQueue 的排序依据是绝对到期时刻,不是入队顺序。按 job-300、job-100、job-200(延迟 300/100/200ms)的顺序入队,再依次 take(),本机取出顺序是 job-100(+106ms)、job-200(+200ms)、job-300(+310ms)。误差 0–10ms 属于本机高负载下的抖动(load averages 150 以上),不是 DelayQueue 的精度上限。

实测:失败重试与指数退避

延迟队列最典型的用途是重试:任务失败后不立刻重试,而是等一段时间再放回队列。RetryJob 的完整实现:

static final class RetryJob implements Delayed {
    final String id;
    final long readyAtNanos;
    final int attempt;

    RetryJob(String id, long delayMs, int attempt) {
        this.id = id;
        this.readyAtNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delayMs);
        this.attempt = attempt;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        long remain = readyAtNanos - System.nanoTime();
        return unit.convert(remain, TimeUnit.NANOSECONDS);
    }

    @Override
    public int compareTo(Delayed other) {
        return Long.compare(this.readyAtNanos, ((RetryJob) other).readyAtNanos);
    }
}

两个细节是正确性的关键:

  • 存绝对到期时刻(nanoTime 基准),不存剩余延迟。剩余延迟是随时间变化的量,用它做 compareTo 会让堆内顺序在执行过程中失效——元素在堆里不动,但「谁更早」已经变了。
  • compareTo 用 Long.compare,不要写 (int)(a - b)。纳秒差值轻易超过 int 范围,强转会溢出,排序结果随机。

消费者侧的重试循环:前两次故意失败,退避 50ms、100ms:

while (true) {
    RetryJob job = q.take();
    boolean ok = job.attempt == maxAttempt - 1;
    if (ok) break;
    long backoff = 50L << job.attempt;        // 50, 100, 200...
    q.put(new RetryJob(job.id, backoff, job.attempt + 1));
}

本机输出:

== 实验二:失败重试 + 指数退避 ==
  +   0ms  尝试 loan-9001#0 -> 失败
         退避 50ms 后重试
  +  60ms  尝试 loan-9001#1 -> 失败
         退避 100ms 后重试
  + 173ms  尝试 loan-9001#2 -> 成功
  重试期间队列长度最终 = 0

预期时刻是 0ms、50ms、150ms,实际 0ms、60ms、173ms。每次都有 10–23ms 的额外延迟,来源是高负载下的线程调度,不是退避算错。结构性结论成立:三次尝试的时刻严格递增,退避间隔按 2 倍增长,队列最终清空。

时间轮:用精度换 O(1)

DelayQueue 的每次插入和取出都是 O(log n)。任务量到十万级时,堆操作的常数开始显眼;更重要的是,堆里的每个任务都是一个对象 + 一个堆节点,内存开销随任务数线性增长且常数不小。

时间轮换了个思路:不开堆,开数组。数组的每个格子叫一个槽,指针按固定节拍扫过槽位,扫到哪格就执行那格里到期的任务。

槽位、tick 与圈数

一个单层时间轮由三部分组成:

组成含义例子
tickDuration指针多久走一格50ms
wheelSize槽位数(取 2 的幂可用位与代替取模)8
跨度tickDuration × wheelSize(指针位置 currentTick 从 0 递增)400ms

插入一个延迟 d 的任务:

ticks   = ceil(d / tickDuration)
dueTick = currentTick + ticks
slot    = dueTick % wheelSize
rounds  = dueTick / wheelSize - currentTick / wheelSize

如果 rounds > 0,任务要等指针转够整圈才触发,这就是「圈数」。指针每走一格,处理该槽位里 rounds == 0 的任务,其余任务 rounds--。

插入是 O(1):算三个整数,挂到槽位链表尾部。触发也是 O(1) 摊销:每格只处理该槽的任务。代价是精度被量化到 tick:延迟 30ms 的任务在 50ms 的轮子里最多可能晚 50ms 触发。

单层与分层

单层轮的跨度是 tick × slots。想覆盖 1 小时又要 50ms 精度,需要 72000 个槽;槽多了内存浪费,槽少了跨度不够。分层轮解决这个矛盾:

维度单层时间轮分层时间轮
结构一个槽位数组多级数组(秒/分/时,像钟表)
插入O(1)O(1),但可能需要跨级降级
覆盖范围tick × slots各级跨度相乘,可覆盖数月
精度tick最细一级的 tick
实现复杂度低高:降级、级联、空转判断
典型实现简单重试队列延时消息中间件的调度层

分层轮像钟表:秒针转一圈分针走一格,任务先挂粗粒度轮,随时间被「降级」到细粒度轮再触发。

实测:tick 的量化误差

手写一个 8 槽 × 50ms 的单层轮,跨度 400ms,插入 30/70/120/260/380/430ms 六个任务:

void add(String name, long delayMs, long t0) {
    long ticks = (delayMs + TICK_MS - 1) / TICK_MS;   // 向上取整到 tick
    long dueTick = currentTick + ticks;
    int slot = (int) (dueTick % SLOTS);
    wheel.get(slot).add(new Job(name, dueTick, t0 + TimeUnit.MILLISECONDS.toNanos(delayMs)));
}

推进循环有个容易写错的地方:

void awaitNextTick(long t0) {
    long nextAt = t0 + TimeUnit.MILLISECONDS.toNanos(TICK_MS * (currentTick + 1));
    long remain;
    while ((remain = nextAt - System.nanoTime()) > 0) {
        LockSupport.parkNanos(remain);   // 睡到「下一个绝对刻度」,不是固定睡 50ms
    }
    currentTick++;
}

第一版我写的是 Thread.sleep(TICK_MS),六个任务的误差分别是 25/50/53/67/54/55ms,平均 50.7ms,全部落在理论区间 [0, 50) 之外。原因很直接:sleep(50) 只保证「至少睡 50ms」,每次多出来的几毫秒会累积到指针上,10 格之后就偏了 60ms 以上,而且永远追不回来。改成按绝对刻度 parkNanos 后:

== 第二版:按绝对刻度推进(两次运行) ==
  w0   槽 1 约定 + 30ms,实际 + 62ms / +54ms,误差 32ms / 24ms
  w1   槽 2 约定 + 70ms,实际 +100ms / +100ms,误差 30ms / 30ms
  w2   槽 3 约定 +120ms,实际 +153ms / +151ms,误差 33ms / 31ms
  w3   槽 6 约定 +260ms,实际 +310ms / +305ms,误差 50ms / 45ms
  w4   槽 0 约定 +380ms,实际 +408ms / +402ms,误差 28ms / 22ms
  w5   槽 1 约定 +430ms,实际 +457ms / +456ms,误差 27ms / 26ms
  平均量化误差 = 33.3ms / 29.7ms

平均约 30ms,落在 [0, 50) 的量化区间内(个别 50ms 是负载抖动)。w3 的 260ms 稳定偏大,是因为它落在第 6 槽,前面积累了五次 tick 的调度延迟。

把同样的延迟交给 DelayQueue:

== 同一组延迟交给 DelayQueue ==
  d0   约定  30ms,实际 + 30ms,误差 0ms
  d1   约定  70ms,实际 + 74ms,误差 4ms
  d2   约定 120ms,实际 +122ms,误差 2ms
  d3   约定 260ms,实际 +261ms,误差 1ms
  d4   约定 380ms,实际 +382ms,误差 2ms
  d5   约定 430ms,实际 +434ms,误差 4ms
  平均误差 = 2.2ms

这组对比要小心解读:两者不是替代关系,是不同精度档位的选择。DelayQueue 精度取决于线程唤醒,通常在毫秒级;时间轮的精度是 tick 的一半左右,tick 50ms 就是几十毫秒。要毫秒级用堆,要海量任务且秒级够用就用轮。

维度DelayQueue单层时间轮
插入 / 取出O(log n)O(1)
精度受线程唤醒影响,实测平均 2.2mstick 量级,实测平均 30ms
内存每任务一个对象 + 堆节点每任务一个链表节点,槽数组固定
十万级任务堆操作与内存都有压力优势明显
取消任务堆中删除 O(n),通常靠标志位惰性删除链表删除 O(1)(需持有节点引用)
实现成本JDK 自带需要自己写或引第三方

在 libraryd-conc 里落地

借阅系统的通知扇出要发给 mail、sms、push 三个下游,某个下游抖动时不能立刻重试(会放大故障),也不能丢。用 DelayQueue 做一个带退避的重试队列:

public final class NoticeRetryQueue implements AutoCloseable {
    private static final int MAX_ATTEMPT = 3;

    private final DelayQueue<Retry> queue = new DelayQueue<>();
    private final Consumer<Loan> deliver;
    private final Thread worker;

    public NoticeRetryQueue(Consumer<Loan> deliver) {
        this.deliver = deliver;
        this.worker = Thread.ofPlatform().name("notice-retry").daemon(true).start(this::loop);
    }

    public void retryLater(Loan loan, int attempt) {
        queue.put(Retry.after(loan, attempt, 200L << attempt));   // 200, 400, 800ms
    }

    private void loop() {
        try {
            while (!Thread.currentThread().isInterrupted()) {
                Retry r = queue.poll(500, TimeUnit.MILLISECONDS);
                if (r == null) continue;                 // 空转时定期醒来,便于关闭
                try {
                    deliver.accept(r.loan());
                } catch (RuntimeException e) {
                    if (r.attempt() + 1 < MAX_ATTEMPT) {
                        retryLater(r.loan(), r.attempt() + 1);
                    } else {
                        System.err.println("notice dropped: " + r.loan().loanId());
                    }
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    @Override
    public void close() {
        worker.interrupt();
    }

    private record Retry(Loan loan, int attempt, long readyAtNanos) implements Delayed {
        static Retry after(Loan loan, int attempt, long backoffMs) {
            return new Retry(loan, attempt, System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(backoffMs));
        }

        @Override public long getDelay(TimeUnit unit) {
            return unit.convert(readyAtNanos - System.nanoTime(), TimeUnit.NANOSECONDS);
        }

        @Override public int compareTo(Delayed o) {
            return Long.compare(readyAtNanos, ((Retry) o).readyAtNanos);
        }
    }
}

三个设计点:

  • poll(500ms) 而不是 take():take() 在队列空时无限阻塞,关闭只能靠 interrupt 硬打断;带超时的 poll 让关闭更平滑,空转时每 500ms 醒一次。
  • 退避上限 + daemon(true):没有上限的重试在持续故障时会变成无限增长的内存队列;重试是辅助链路,不该阻止 JVM 退出。

这个实现仍是单机的:多副本部署时每个实例都会重试同一批通知,正确做法是把重试任务放进带唯一键的外部存储,或者只让选主实例消费。

常见错误与边界

错误后果正确做法
compareTo 用剩余延迟堆序随执行时间失效,取出未到期任务比较绝对到期时刻
compareTo 写 (int)(a - b)溢出,排序错乱Long.compare
getDelay 换算单位写反等待时间差 1000 倍unit.convert(remain, NANOSECONDS)
时间轮用 sleep(tick) 推进误差累积且不可逆(实测 67ms > tick)按绝对刻度 parkNanos
重试无上限、无退避故障时内存暴涨、下游被压垮指数退避 + 最大次数 + 死信

什么时候不要用延迟队列。 任务需要严格顺序、且「到点必须立刻执行」的场景(如交易撮合的超时撤单),延迟队列的线程唤醒精度不够;应该用专门的有序调度或数据库定时扫表 + 行锁。

什么时候不要用时间轮。 任务量小(几千级)、精度要求毫秒级、或者需要频繁按 ID 取消时,堆更简单也更准。时间轮的优势只在「任务多 + 精度容忍 tick」这两个条件同时成立时才会体现。

小结

  • DelayQueue 是「PriorityQueue + 一把锁 + 一个 leader」:只有 leader 做定时等待,其余消费者无限等待,避免到期瞬间的惊群;leader 的移交在 take 的 finally 里完成。
  • Delayed 的两个方法必须自洽:存绝对到期时刻、用 Long.compare,否则堆序会失效或溢出。
  • 实测乱序入队仍按延迟出队;三次重试在 +0/+60/+173ms 完成,退避间隔按 2 倍增长,结构正确,绝对时刻有 10–23ms 抖动(本机负载 150+)。
  • 时间轮的插入与触发是 O(1),代价是精度量化到 tick;单层轮跨度受限,分层轮用多级数组换取大跨度。tick 必须按绝对刻度推进,固定 sleep 会累积误差(实测 50.7ms 平均、67ms 峰值,超过 tick 本身)。
  • 同组延迟下 DelayQueue 平均误差 2.2ms、50ms 时间轮平均约 30ms:精度与吞吐是两条不同的曲线,按业务需求选。

下一节讨论调度里最容易被忽略的一件事:触发时刻为什么会漂移,漂移怎么累积,错过的执行怎么办,以及为什么定时任务必须带幂等键。

阅读导航:上一节 11.1 Timer 的缺陷与 ScheduledThreadPoolExecutor | 下一节 11.3 调度漂移、幂等与补偿

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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