简介:本资源是一套Java时间轮定时器的完整实现代码,面向具备一定Java基础、希望深入理解高效定时任务调度机制的开发者。内容围绕单轮与多轮两种时间轮结构展开,涵盖槽位划分、指针轮询、任务插入与移除等核心环节,可用于心跳检测、超时处理、过期数据清理等场景的学习与二次开发。压缩包共33个文件,约41KB,以11个java源码和13个class文件为主体,另有8个xml配置及1个iml工程文件,构成可直接导入IDE运行的Maven项目结构。目前已有1976人学习下载,适合对照源码理解时间轮从任务分配到触发执行的完整流程,掌握单轮与多轮在长间隔任务调度上的差异与取舍,为分布式系统与消息队列中的定时需求提供可复用的实现思路。
1. 时间轮定时器到底解决了什么问题
做过长连接网关或订单超时关闭的兄弟大概率踩过这个坑:用ScheduledExecutorService管理几十万个定时任务,堆里全是按到期时间排序的节点,每次插入和取消都是 O(log n),任务量一上来,调度线程光维护堆结构就吃满了 CPU。更难受的是取消任务——ScheduledFuture.cancel()只是打个标记,节点还赖在队列里,内存迟迟不释放,这就是典型的「定时器把业务拖垮」。
时间轮(Timing Wheel)换了个思路:不再按到期时间排序,而是把时间切成固定大小的槽位(slot),任务按「还有几轮 + 落在哪个槽」直接挂到对应格子的链表上。插入和删除都是 O(1),取消任务就是把它从链表里摘掉,内存立刻回收。它特别适合两类场景:一是海量短周期任务(心跳检测、会话超时),二是任务取消频繁(连接断开要撤销超时任务)。Java 里 Netty 的HashedWheelTimer就是单轮时间轮的经典实现,而 Kafka 的SystemTimer走的是分层多轮思路。这篇就把单轮和多轮两种实现从原理到代码讲透,参数怎么调、坑在哪,一次说清。
2. 单轮时间轮:一个环形数组怎么转起来
单轮时间轮的结构非常朴素:一个固定长度的数组当环,一个指针指向当前槽,指针每隔tickDuration往前走一格,走到哪格就把那格链表上的任务全拿出来执行。核心就三个参数:tickDuration(每格代表多长时间)、wheelSize(一圈多少格)、maxDelay = tickDuration * wheelSize(能表达的最大延迟)。
2.1 环形数组 + 双向链表的数据结构
先定义任务节点和槽位。任务节点要能挂到链表上,所以带前后指针;槽位就是一个链表头。这里用双向链表是为了 O(1) 删除——取消任务时能直接摘节点,不用遍历。
// 时间轮任务节点,双向链表结构,支持 O(1) 摘除 public class TimerTaskEntry { long expireMs; // 绝对到期时间戳 int rounds; // 还需要转几圈才执行(单轮里恒为 0) Runnable task; // 真正的业务逻辑 TimerTaskEntry prev, next; // 双向链表指针 TimerTaskList list; // 所属槽位,取消时用来定位 public TimerTaskEntry(long expireMs, Runnable task) { this.expireMs = expireMs; this.task = task; } } // 槽位:一个带哨兵的双向链表,避免边界判空 public class TimerTaskList { TimerTaskEntry head = new TimerTaskEntry(-1, null); // 哨兵 public TimerTaskList() { head.prev = head; head.next = head; } // 头插,O(1) public void add(TimerTaskEntry entry) { entry.next = head.next; entry.prev = head; head.next.prev = entry; head.next = entry; entry.list = this; } // 摘除任意节点,O(1),这就是时间轮取消任务快的根本原因 public void remove(TimerTaskEntry entry) { if (entry.list != this) return; entry.prev.next = entry.next; entry.next.prev = entry.prev; entry.list = null; } }expireMs存绝对时间戳而不是相对延迟,是为了让任务在轮转过程中不用反复换算。rounds字段在单轮实现里用不上,但多轮实现会复用这个结构,所以先留着。哨兵节点head让链表操作不用判断空指针,这是写链表的老套路了。
2.2 指针推进与任务到期判定
时间轮靠一个后台线程驱动指针。每推进一格,就把该槽位链表上的任务全部取出,逐个判断是否真的到期——因为同一个槽里的任务到期时间可能不完全一样(都落在这一格的时间窗口内)。
public class SingleWheelTimer { private final TimerTaskList[] wheel; private final long tickDurationMs; private final int wheelSize; private long currentTime; // 当前指针指向的时间 private int currentIndex; // 当前槽下标 public SingleWheelTimer(long tickDurationMs, int wheelSize) { this.tickDurationMs = tickDurationMs; this.wheelSize = wheelSize; this.wheel = new TimerTaskList[wheelSize]; for (int i = 0; i < wheelSize; i++) wheel[i] = new TimerTaskList(); this.currentTime = System.currentTimeMillis(); } // 添加任务:算出落在哪个槽 public void addTask(long delayMs, Runnable task) { long expireMs = System.currentTimeMillis() + delayMs; // 超过一圈能表达的最大延迟,单轮直接拒绝(多轮实现会处理) if (delayMs >= tickDurationMs * wheelSize) { throw new IllegalArgumentException("delay exceeds maxDelay"); } // 关键:用 (expireMs / tickDuration) % wheelSize 定位槽位 int index = (int) ((expireMs / tickDurationMs) % wheelSize); wheel[index].add(new TimerTaskEntry(expireMs, task)); } // 指针推进一格,处理该槽所有任务 private void advance() { currentIndex = (currentIndex + 1) % wheelSize; currentTime += tickDurationMs; TimerTaskList bucket = wheel[currentIndex]; TimerTaskEntry entry = bucket.head.next; while (entry != bucket.head) { TimerTaskEntry next = entry.next; // 先存 next,任务里可能改链表 if (entry.expireMs <= currentTime) { bucket.remove(entry); entry.task.run(); // 实际项目里应丢到业务线程池,别阻塞指针 } entry = next; } } }槽位定位公式(expireMs / tickDurationMs) % wheelSize是单轮时间轮的核心。它把绝对时间戳映射到环上的一个格子,同一格里的任务到期时间差不超过一个tickDuration。advance()里先保存next再执行任务,是因为任务执行过程中可能触发取消操作改动链表,不先存指针会踩空。
2.3 参数怎么定:tickDuration 与 wheelSize 的取舍
这两个参数直接决定精度和内存,调错了要么精度不够要么空转浪费。
| 参数 | 含义 | 调大 | 调小 |
|---|---|---|---|
| tickDuration | 每格时间跨度 | 精度变粗,CPU 空转少 | 精度高,指针推进频繁 |
| wheelSize | 一圈格子数 | 能表达的最大延迟变大,内存占用上升 | 内存小,最大延迟受限 |
经验值:如果业务超时精度要求是秒级,tickDuration设 100ms 到 500ms 都行;wheelSize一般取 512 或 1024,这样单轮能覆盖512 * 100ms ≈ 51 秒到1024 * 500ms ≈ 8.5 分钟。超过这个范围的延迟,单轮就表达不了,必须上多轮。我一般会先算业务里 95% 的延迟落在什么区间,再倒推这两个值,而不是拍脑袋。
注意:
tickDuration不是越小越好。设成 1ms,指针每秒推进 1000 次,即使没任务也在空转,CPU 白白消耗。精度够用就行。
3. 多轮时间轮:延迟超过一圈怎么办
单轮的死穴是maxDelay有限。订单 30 分钟未支付关闭,如果maxDelay只有 1 分钟,单轮根本放不下。多轮时间轮(分层时间轮)就是来解决长延迟的:用多个轮子,每个轮子负责一个时间量级,像时钟的秒针、分针、时针一样逐级进位。
3.1 分层结构:秒轮、分轮、时轮怎么协作
分层时间轮的思想是:延迟 3 秒的任务放秒轮,延迟 3 分钟的任务放分轮,延迟 3 小时的任务放时轮。低层轮子转满一圈,就往高层轮子「进位」一个任务。这样每一层的maxDelay都不用很大,但组合起来能表达任意长的延迟。
// 分层时间轮:每层是一个单轮,层与层之间按溢出规则降级 public class HierarchicalWheelTimer { private final SingleWheelTimer[] levels; private final long[] levelTickMs; // 每层每格的时间跨度 private final int[] levelSize; // 每层格子数 public HierarchicalWheelTimer(long[] tickMs, int[] sizes) { this.levelTickMs = tickMs; this.levelSize = sizes; this.levels = new SingleWheelTimer[tickMs.length]; for (int i = 0; i < tickMs.length; i++) { levels[i] = new SingleWheelTimer(tickMs[i], sizes[i]); } } // 根据延迟选择放哪一层 public void addTask(long delayMs, Runnable task) { for (int i = 0; i < levels.length; i++) { long maxDelay = levelTickMs[i] * levelSize[i]; if (delayMs < maxDelay) { levels[i].addTask(delayMs, task); return; } } // 超出所有层,放最高层并记录剩余轮数(这里简化处理) levels[levels.length - 1].addTask(delayMs, task); } }选层逻辑是:从最低层开始,谁的maxDelay能装下这个延迟就放谁。比如秒轮maxDelay是 60 秒,分轮是 60 分钟,一个 3 分钟的任务秒轮装不下,就落到分轮。这种「就近放低层」的策略能让大部分短任务集中在低层,减少高层轮子的负担。
3.2 任务降级:从高层轮子搬到低层轮子
分层时间轮真正的难点在降级。当分轮指针走到某个槽,里面的任务可能还没到期(它只是「轮到分轮处理了」),这时要把任务重新计算,搬到秒轮去,等秒轮再精确触发。
// 分轮指针推进时,把槽里任务降级到秒轮 private void cascade(int levelIndex, TimerTaskList bucket) { TimerTaskEntry entry = bucket.head.next; while (entry != bucket.head) { TimerTaskEntry next = entry.next; bucket.remove(entry); long remaining = entry.expireMs - System.currentTimeMillis(); if (remaining <= 0) { entry.task.run(); // 已到期,直接执行 } else { // 还没到期,重新计算放到低一层轮子 addTask(remaining, entry.task); } entry = next; } }降级的本质是「重新入轮」:把任务从当前层摘下来,算出剩余延迟,再按addTask的规则放回合适的层。这个过程可能跨多层,一个 1 小时的任务会从时轮降到分轮再降到秒轮。每次降级都要重新定位槽位,所以分层时间轮的插入成本比单轮高,但换来的是能表达任意长延迟。
3.3 多轮实现里 rounds 字段的正确用法
还有一种更常见的多轮实现,不是分层,而是「单环 + 轮数」。它用一个环,但每个任务记录还要转几圈(rounds)才执行。指针每走到一个槽,把该槽任务的rounds减一,减到 0 才真正执行。
// 单环多轮:用 rounds 字段表达超过一圈的延迟 public void addTaskWithRounds(long delayMs, Runnable task) { long expireMs = System.currentTimeMillis() + delayMs; long ticks = delayMs / tickDurationMs; // 总共要经过多少格 int index = (int) ((expireMs / tickDurationMs) % wheelSize); int rounds = (int) (ticks / wheelSize); // 需要转几圈 TimerTaskEntry entry = new TimerTaskEntry(expireMs, task); entry.rounds = rounds; wheel[index].add(entry); } // 指针推进时对 rounds 做减法 private void advanceWithRounds() { currentIndex = (currentIndex + 1) % wheelSize; TimerTaskList bucket = wheel[currentIndex]; TimerTaskEntry entry = bucket.head.next; while (entry != bucket.head) { TimerTaskEntry next = entry.next; if (entry.rounds > 0) { entry.rounds--; // 还没到,减一圈继续等 } else if (entry.expireMs <= System.currentTimeMillis()) { bucket.remove(entry); entry.task.run(); } entry = next; } }rounds的算法是总格数 / wheelSize。比如tickDuration=100ms、wheelSize=512,一个 60 秒的任务需要经过 600 格,rounds = 600 / 512 = 1,也就是转一圈多。这种实现比分层简单,但有个隐患:如果rounds很大,任务会在环上「空转」很多圈才执行,期间指针每次经过都要做一次减法判断,任务量大的时候这部分开销不能忽略。
提示:单环多轮适合延迟分布相对均匀的场景;如果延迟跨度极大(几毫秒到几小时混在一起),分层时间轮的效率更稳。
4. 避坑与排查:时间轮上线后最容易翻车的几处
时间轮代码看着简单,真上生产环境,坑一个比一个隐蔽。下面这几条都是血泪经验,按「现象 → 原因 → 解决」列出来。
4.1 任务执行阻塞指针导致整体延迟
现象:明明设了 100ms 精度,实际任务延迟到几秒甚至几十秒,越积越多。
原因:advance()里直接entry.task.run(),如果任务里有耗时操作(查库、发 HTTP),指针线程被卡住,后面的槽位全部推迟处理。
解决:指针线程只负责「取出到期任务」,执行丢给独立业务线程池。时间轮和业务执行彻底解耦。
// 指针线程只投递,不执行 private final ExecutorService workerPool = Executors.newFixedThreadPool(8); // advance 里改成投递 workerPool.execute(entry.task);4.2 取消任务后内存不释放
现象:连接断开后调了取消,但堆内存不降,GC 后老年代还是涨。
原因:取消时只把任务标记为「已取消」,没从槽位链表里摘除,节点还挂在环上,要等指针转到才清理。
解决:取消时立刻从链表摘除。这就是前面TimerTaskList.remove()存在的意义——O(1) 摘除,内存立即回收。
public void cancel(TimerTaskEntry entry) { if (entry.list != null) { entry.list.remove(entry); // 立即摘除,不等指针 } }4.3 tickDuration 设太小导致 CPU 空转
现象:任务量不大,但调度线程 CPU 占用稳定在 10% 以上。
原因:tickDuration设成 1ms,指针每秒推进 1000 次,大部分槽是空的,纯空转。
解决:按业务精度需求反推。秒级超时用 100ms 到 500ms 足够,别为了「看起来精确」设成 1ms。
4.4 多轮 rounds 计算溢出
现象:设置超长延迟(比如 24 小时)后任务永远不触发,或者触发时间完全错乱。
原因:rounds用int存,ticks / wheelSize在某些参数组合下超出int范围,或者expireMs / tickDurationMs本身溢出。
解决:rounds和中间计算全用long,并在addTask入口校验delayMs上限,超限直接拒绝或走降级逻辑。
long ticks = delayMs / tickDurationMs; // 用 long,别用 int long rounds = ticks / wheelSize; if (rounds > Integer.MAX_VALUE) { throw new IllegalArgumentException("delay too large"); }4.5 系统时间跳变导致任务错乱
现象:服务器 NTP 校时后,一批任务提前触发或迟迟不触发。
原因:时间轮用System.currentTimeMillis()做基准,墙钟时间被回拨或前跳,指针和任务到期判断全乱。
解决:改用System.nanoTime()做单调递增的基准,它不受系统时间调整影响。所有时间计算基于相对时间,只在需要展示时转成墙钟。
5. 把时间轮接进业务:一个可复用的封装技巧
前面讲的都是骨架,真接进业务还得解决几个工程问题:怎么统一管理任务句柄、怎么优雅停机、怎么监控积压。我一般会封装一个Timeout句柄,业务侧只拿到句柄,取消和状态查询都通过它,不直接碰内部链表。
// 对外的任务句柄,屏蔽内部实现 public class Timeout { private final TimerTaskEntry entry; private volatile boolean cancelled = false; public Timeout(TimerTaskEntry entry) { this.entry = entry; } public boolean cancel() { if (cancelled) return false; cancelled = true; if (entry.list != null) entry.list.remove(entry); return true; } public boolean isCancelled() { return cancelled; } } // 业务侧用法:拿到句柄,随时可取消 Timeout timeout = timer.newTimeout(30_000, () -> closeOrder(orderId)); // 支付成功回调里 timeout.cancel();这个封装的价值在于:业务代码不关心任务挂在哪个槽、转了几圈,只关心「30 秒后关单」和「支付成功就取消」。取消的幂等性由cancelled标志保证,重复取消不会出错。
验证时间轮是否正确,我习惯用两个手段。一是压测插入和取消:造 100 万个任务,测插入耗时和取消后内存回收情况,正常应该是插入接近 O(1)、取消后堆内存明显下降。二是精度测试:设一批不同延迟的任务,记录实际触发时间和预期时间的偏差,偏差应该稳定在一个tickDuration以内,如果偏差越来越大,说明指针被阻塞了。
监控上,至少暴露三个指标:当前环上任务总数、指针单次推进耗时、业务线程池队列深度。指针推进耗时一旦超过tickDuration,说明处理速度跟不上,得加线程或调大tickDuration。这几个指标接进现有监控体系,时间轮就不再是黑匣子。
最后说个我踩过的坑:早期图省事,把时间轮和业务逻辑写在同一个类里,结果测试时想单独验证指针推进逻辑,发现根本没法 mock 时间。后来把「时间源」抽成一个接口,测试时注入可控的假时钟,指针推进就能精确控制,单元测试才写得下去。这个习惯我一直保留——凡是依赖系统时间的组件,时间源一定要可注入,这是给自己留的后悔药。
希望帮到你。
本文还有配套的精品资源,点击获取