调度器之下,还有一个数据结构问题
11.1 节比较的是「用哪个调度器」。但无论 Timer 还是 STPE,任务最终都要回答同一个问题:一堆带触发时刻的任务,怎么排队才能高效地取出最早到期的那个。
JDK 给出的答案是堆:Timer 用 TaskQueue,STPE 用 DelayedWorkQueue,DelayQueue 用 PriorityQueue。消息中间件与 RPC 框架给出的答案常常是时间轮:它们要容纳的延时任务数量远大于 JDK 队列的设计目标。
这一节讲清两件事:
DelayQueue的接口约定与内部结构,特别是那个容易被忽略的leader线程;- 时间轮的槽位、圈数与分层,以及它用精度换来的 O(1)。
最后用两者分别实现借阅系统的通知重试队列,并给出本机实测的误差对比。
Delayed 与 DelayQueue
接口只有两个方法
Delayed 接口只有两个抽象方法:getDelay(TimeUnit) 与继承来的 compareTo。
getDelay 返回「还有多久到期」,可以是负数(已经到期);compareTo 决定堆序。两个方法必须自洽:堆顶元素应当是 getDelay 最小的那个,否则 take() 会取到还没到期的任务。
DelayQueue<E extends Delayed> 的用法约束:
| 约束 | 说明 |
|---|---|
元素必须实现 Delayed | 泛型上界,编译期强制 |
不接受 null | offer(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.2ms | tick 量级,实测平均 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 调度漂移、幂等与补偿
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。