1. 项目概述与技术背景
1.1 为什么需要时间轮:从一次真实性能问题说起
先说个我自己的经历。去年在维护公司的一个消息推送网关时,发现定时任务这块总是出幺蛾子。当时用的是ScheduledThreadPoolExecutor,每个连接维护一个定时任务用于超时检测,连接量一上来,内存和CPU双双报警。排查发现,大量定时任务其实是短期任务,几秒甚至几百毫秒后就该到期,但线程池里的每个任务都对应一个DelayedWorkQueue节点,入队出队、锁竞争、唤醒开销全堆在一起,性能自然上不去。
后来我翻 Netty 源码时注意到了HashedWheelTimer,第一反应是这名字有点意思——哈希加时间轮。研究完才发现,它解决的核心问题和我的场景完全一致:大量短期、零散的定时任务,如何用最小的开销高效调度。
简单说,HashedWheelTimer是 Netty 中一个基于时间轮算法实现的定时任务调度器。它不像ScheduledThreadPoolExecutor那样用一个优先队列维护所有任务,而是把时间划分成一个个固定的 tick(滴答),用环形数组存储任务,指针每 tick 前进一格。提交任务时通过取模哈希计算任务应该放在哪个槽位,指针转到对应槽位时,直接遍历该槽位上的任务链表,判断哪些到期就执行。
这个设计思路的巧妙之处在于:新增任务和到期任务检查的时间复杂度都接近 O(1),避免了优先队列在任务量大时的排序和插入开销。
1.2 这个项目能解决哪些问题
适合用HashedWheelTimer的场景,我总结下来有这么几类:
- 连接超时管理:像 Netty 服务端对每个 Channel 维护空闲检测、读超时、写超时,这类任务数量大、单次执行时间极短、对绝对时间的精确性要求不高。
- 心跳检测与重连机制:客户端定时发送心跳,服务端定时检查上次心跳时间,超时则断开连接,典型的时间轮应用。
- 批量延迟任务:比如延迟重试、延迟消息投递。注意这里说的是“批量”,如果延迟任务很少,比如一秒钟就几个,时间轮反而没有优势。
- 低精度的定时调度:能容忍几十到几百毫秒误差的场景。时间轮的 tick 越小精度越高,但代价是更频繁的指针扫描,需要做权衡。
至于适合谁来学习,我觉得只要是写 Java 后端、做中间件、研究网络框架源码的人,都应该花一个下午把HashedWheelTimer弄明白。它背后不只是一个类的实现,更是一种“用空间换时间、用近似换效率”的工程思维,理解了它,再看很多定时调度、延迟队列的设计都会豁然开朗。
2. 时间轮算法的整体设计与思路拆解
2.1 从电梯间里的时钟说起:时间轮的基本模型
理解时间轮,最好的类比是我们办公楼下电梯间的钟表。表盘被 12 个刻度分成 12 格,指针每走一格表示一小时。假如你想设置一个“8小时后提醒我”,你不需要盯着秒针数 28800 下,你只需要记住“当前在 3 点的位置,8 格以后是 11 点”,然后该干嘛干嘛,等指针转到 11 点的时候,自然就知道该提醒了。
时间轮算法就是把这个物理模型搬到了内存里。HashedWheelTimer内部维护一个环形数组,数组的每个元素是一个链表头(在代码里叫HashedWheelBucket),数组长度叫wheelSize。另一个核心参数是tickDuration,表示指针每走一格需要的时间。两者相乘,就是这个时间轮能表达的最大延迟时间,比如默认wheelSize=512、tickDuration=100ms,那 wheel 一圈就是 512 × 100ms = 51.2 秒。
任务提交时,算出它还差多少个 tick 到期,然后对 wheelSize 取模,决定放进哪个槽位。比如 tickDuration 是 100ms,某任务需要在 1.5 秒后执行,那就是 15 个 tick 后到期,15 mod 512 = 15,放进第 15 个槽位,同时记录一个round(圈数)字段表示 15 除以 512 的商,这里是 0。
等等,这里有个问题——如果任务延迟超过一整圈,比如 60 秒呢?60 秒 = 600 个 tick,600 mod 512 = 88,放入槽位 88,round = 600 / 512 = 1。指针第一次转到槽位 88 时,发现该任务 round 为 1,说明还没到,跳过;等指针跑完一整圈再次转到槽位 88 时,把 round 减 1 变为 0,此时任务才真正到期,执行。
注意:这种设计下,数组的每个槽位里可能有多个任务,它们的到期时间并不相同,而是“放在同一个槽位但 round 不同”。所以遍历某个槽位的链表时,要逐个检查 round 是否为 0,不为 0 的只减 round,不执行。
2.2 为什么选环形数组而不是优先队列
很多人第一次接触时间轮会问:ScheduledThreadPoolExecutor用二叉堆优先队列不是挺好吗?插入和删除都是 O(log n),为什么还要做时间轮?
关键差异在于任务的分布特征。优先队列的每个节点都独立维护一块延时信息,新增任务时需要在堆里做调整,取出最近到期的任务也要调整堆结构。当任务量达到几万甚至几十万时,堆操作的日志复杂度叠加锁竞争,开销非常可观。
环形数组的方案把“比较时间”这件事变成了“看位置”。新增一个任务,只需要算一下它该进哪个槽位,然后头插法丢进链表,这个操作是 O(1) 的;指针每 tick 前进一格,只需处理当前槽位的链表,而链表上的任务数量通常远小于总任务数。最理想的情况下,任务的插入和触发都不需要全局排序。
当然,它也有明显的代价:时间粒度被离散化了。原定 1.05 秒后执行的任务,如果 tickDuration 是 100ms,实际最早可能在 1.1 秒后才执行,误差最多一个 tick。这正是那句经典的工程权衡——大多数超时检测、心跳场景根本不在乎这几十毫秒,但换来的吞吐量提升是实打实的。
2.3 哈希在时间轮里扮演什么角色
名字里的Hashed指的就是取模运算。Java 里的取模对负数有特殊处理,-1 % 512是 -1,所以 Netty 源码里会看到对tick做index = tick & (wheelSize - 1)这类位运算。为什么可以用位运算?因为代码里强制了wheelSize必须是 2 的幂,这样才能用与操作代替取模,进一步压缩计算开销。
这里有个实际的例子:默认 wheelSize = 512,二进制是 1000000000,那么mask = wheelSize - 1 = 511 = 0111111111。任意 tick 值跟 511 做与运算,结果范围必然落在 0 到 511,这就是哈希映射。因为 2 的幂这个约束,整个哈希过程只需要一次位运算,无需除法,性能极好。
我一直觉得把取模哈希和环形数组放在一起是时间轮最优雅的设计——哈希决定了“放哪个格子”,环形结构决定了“格子怎么转”。
3. 核心数据结构与关键源码实现解析
3.1 三个核心类:Timer、Bucket、Timeout
HashedWheelTimer的结构可以分为三层,记熟这三个类,整体逻辑就有了骨架:
第一层是HashedWheelTimer本身。它负责创建Worker线程、推进指针、维护整个环形数组。对外暴露三个方法:start()、stop()、newTimeout()。其中newTimeout(TimerTask task, long delay, TimeUnit unit)是提交任务的核心入口。
第二层是HashedWheelBucket。它是环形数组中的槽位,本质是一个HashedWheelTimeout的双向链表。注意用的是双向链表,因为删除中间节点时,双向链表可以在 O(1) 时间内完成,不需要从头遍历。
第三层是HashedWheelTimeout。它包装了提交进来的TimerTask,持有任务的到期时间deadline、所在槽位bucket、剩余圈数remainingRounds,以及任务自身的状态。
这三个类层层嵌套:Timer 有多个 Bucket,Bucket 有多个 Timeout,Timeout 里才是用户真正的任务逻辑。理解了这层结构,再看源码就不会迷路。
3.2 tickDuration 与 wheelSize 的数学关系
先看两个参数的关系式。假设 wheelSize = W,tickDuration = T,时间轮一整圈的时间就是 W × T。任务延迟 delay 会被折算成 tick 数:ticks = delay / T * 1000(单位换算后),或者更严谨地说,计算任务是“在第几个 tick 被触发”。
Netty 源码中newTimeout的核心计算逻辑大致如下(这是简化版本,真实实现会更细):
long deadline = System.nanoTime() + unit.toNanos(delay) - startTime; long calculated = deadline / tickDuration; long remainingRounds = (calculated - tick) / wheelSize; int stopIndex = (int) (calculated & mask);这里有个小坑:deadline减去了startTime,也就是时间轮启动的时间。因为时间轮内部所有的 tick 计算都是相对启动时刻的,不用绝对时间戳,避免手动维护一个随时变化的基准点。
remainingRounds的计算有意思。calculated是任务到期的总 tick 数,tick是当前指针所在的 tick 数,两者相减就是还需要多少个 tick 到期,再除以 wheelSize 得到剩余圈数。比如当前 tick 是 10,任务总 tick 是 520,那么还差 510 个 tick,510 / 512 = 0,说明任务在接下来不到一圈内就会到期。
3.3 任务入槽的完整流程演示
我画过很多次时间轮任务入槽的流程图,其实核心就是四步:
- 调用
newTimeout(),传入TimerTask和 delay。 - 计算 deadline,并校验 delay 不能为负。Netty 还做了一层保护:如果 delay 超过
Long.MAX_VALUE / 1000 / 1000,直接抛异常,防止溢出。 - 如果时间轮尚未启动,自动调用
start()。启动由startTime和 Worker 线程共同完成。 - 调用
transferTimeoutsToBuckets(),把任务真正放入对应的槽位。
最后一步值得展开。为什么单独有个transferTimeoutsToBuckets方法?因为任务提交时 Worker 线程可能正在推进指针,如果每提交一个任务就立刻操作环形数组,就需要加锁保护,这在高并发下是性能杀手。
Netty 的处理方式是:newTimeout先把任务丢进一个Timeouts队列,这是HashedWheelTimer内部的一个普通队列,然后在每次处理 tick 时,一次性把队列里的任务批量转移到 Bucket 中。这个设计彻底避免了写锁,代价是最多延迟一个 tick 的入槽时间,完全可接受。
我摘一段关键逻辑帮助理解:
private void transferTimeoutsToBuckets() { for (int i = 0; i < 100000; i++) { HashedWheelTimeout timeout = timeouts.poll(); if (timeout == null) { break; } if (timeout.state() != HashedWheelTimeout.ST_INIT) { continue; } timeout.deadline += startTime; long calculated = timeout.deadline / tickDuration; long remainingRounds = (calculated - tick) / wheelSize; timeout.remainingRounds = remainingRounds; final long ticks = Math.max(calculated, tick); int stopIndex = (int) (ticks & mask); HashedWheelBucket bucket = wheel[stopIndex]; bucket.addTimeout(timeout); } }注意里面有个100000的循环上限,这是防止某个 tick 时间内提交的任务太多,无限转移导致 Worker 线程饿死。如果单次转移超过 10 万个任务,剩下的只能等下一个 tick 再处理。实际场景中 10 万这个阈值已经非常大了,但知道这个保护机制的存在,有助于理解极端场景下的行为。
4. 调度执行流程与 Worker 线程运转机制
4.1 Worker 线程:时间轮的心跳
时间轮能转起来,靠的是一个后台线程Worker。它在start()时通过ThreadFactory创建,名字默认是HashedWheelTimer-1、HashedWheelTimer-2这样的格式。Worker 线程做的事可以抽象成一句话:循环执行“等待一个 tick、转移队列任务、推进指针、处理当前槽位所有到期任务、处理取消任务”这五步。
启动时的startTime是一个很重要的基准点。在start()方法中,会把当前系统时间System.nanoTime()记录下来作为基准,之后所有任务计算都用相对时间,这避免了系统时间被调整时导致任务错乱。
worker 线程的核心循环逻辑:
private void run() { do { final long deadline = waitForNextTick(); if (deadline > 0) { transferTimeoutsToBuckets(); tick++; int idx = (int) (tick & mask); HashedWheelBucket bucket = wheel[idx]; bucket.expireTimeouts(deadline); } } while (!WORKER_STATE_UPDATER.get(this) == WORKER_STATE_SHUTDOWN); }4.2 waitForNextTick:不是 busy loop 而是精准睡眠
waitForNextTick()这个方法的实现值得单独讲。它的作用是在当前 tick 的起始时刻开始,睡到下一个 tick 应该开始的时刻,然后返回那个时刻的值。
private long waitForNextTick() { long deadline = tickDuration * (tick + 1); for (;;) { long currentTime = System.nanoTime() - startTime; long sleepTimeMs = (deadline - currentTime + 999999) / 1000000; if (sleepTimeMs <= 0) { break; } try { Thread.sleep(sleepTimeMs); } catch (InterruptedException e) { // 处理中断逻辑 } } return deadline; }看到那个(deadline - currentTime + 999999) / 1000000了吗?这是把纳秒转成毫秒并向上取整。为什么向上取整?因为Thread.sleep的参数是毫秒级的,向下取整会导致提前唤醒,指针还没到时间就开始处理,多了一轮空转。向上取整可以让线程多睡那么不到 1 毫秒,总体更稳妥。
还有一个细节:Thread.sleep被中断时,代码没有直接退出,而是继续进入下一次循环,重新计算剩余睡眠时间。这保证了即使有中断信号,循环也会继续直到真正到点。这是线程协作式取消的一个标准写法——中断只作为信号,具体是否退出由业务逻辑决定。
4.3 槽位任务的执行:expireTimeouts 的完整逻辑
Worker 推进指针找到当前槽位后,调用bucket.expireTimeouts(deadline)。这个方法会遍历槽位里的双向链表:
- 任务状态不是
ST_INIT的,直接移除。比如已经被取消的任务,此时正好顺带清理掉。 remainingRounds大于 0 的,减 1,本轮不执行,继续留在链表里。这对应延迟时间超过一圈的任务。remainingRounds等于 0,且deadline小于等于当前时间,说明到期了。把任务从链表移除,并通过expireTimeout里调用的task.run(timeout)执行用户逻辑。- 执行完成后再标记状态为
ST_EXPIRED,方便后续清理。
有个细节容易忽略:执行用户任务时,Worker 线程会逐个调用任务的run方法,如果某个任务执行时间过长,会让整个 Worker 线程卡住,后续所有任务都延迟。也就是说,时间轮假设提交的任务都是短小精悍的。如果你要在任务里做耗时操作,请自己丢到业务线程池去执行,不要阻塞 Worker。
我还观察到一个细节:expireTimeouts里对每个过期任务执行时,传入了 deadline 而不是当前时刻。这是因为任务的到期判定应该基于时间轮的内部时间基准,而不是System.currentTimeMillis(),内部一致性在前,外部共识在后。
5. HashedWheelTimer 的进阶细节与生产环境注意事项
5.1 内存与性能:为什么说它省内存
时间轮的内存占用是预先分配好的:一个长度为 wheelSize 的对象数组,每个元素是一个 Bucket 链表头。不管有没有任务,这 512 个 Bucket 都在。任务提交时不额外分配堆结构,只是把HashedWheelTimeout对象挂到链表中。
对比优先队列方案,每个任务至少需要持有ScheduledFutureTask对象,里面有堆索引、触发时间、状态等字段,还涉及DelayedWorkQueue的扩容。当任务频繁提交、频繁取消时,优先队列里的标记删除会产生空洞,需要定期purge清理,否则内存占用会缓慢增长。时间轮的方案则是任务放在固定数组里,随指针推进自然移除,不需要额外的 gc 压力。
不过有个反向注意点:如果 wheelSize 设置得太大,即使没有任务,数组本身也占内存。默认 512 个 Bucket 非常小,但如果你调成1 << 20也就是一百万个槽位,光数组引用就是 8MB。盲目调大 wheelSize 并不能提高精度,精度只由 tickDuration 决定,wheelSize 只决定最大延迟范围。所以不要盲目调参数。
5.2 任务取消与状态流转
HashedWheelTimeout的内部状态有四个:ST_INIT(初始态)、ST_CANCELLED(已取消)、ST_EXPIRED(已过期执行)、ST_PROCESSED(已处理但被标记)。取消任务通过timeout.cancel()实现,内部通过 CAS 把状态从ST_INIT改成ST_CANCELLED。
取消的任务并不会立即从链表中移除,只是打上标记。真正被清理是在 Worker 线程推进到该 Bucket 时,遍历链表发现状态不对就顺手移除了。这种“惰性删除”的思路在 Java 的 LinkedList、HashMap 等底层实现里都有应用,目的都是避免在并发环境下做复杂的写操作。
所以如果你的业务代码大量依赖取消任务,请注意:主动调cancel()会返回是否成功,但内存释放是异步的,不会立刻发生。
5.3 生产环境中的几个真实教训
我把实际使用中踩过的坑整理成表格,希望你能避开:
| 问题现象 | 原因 | 解决方法 |
|---|---|---|
| 任务时不时出现几十毫秒的延迟 | tickDuration设置过大 | 按业务精度需求设置,比如 50ms~100ms;不要小于 1ms,否则空转浪费 CPU |
| 某个任务执行后,后续所有任务全部延迟 | 用户代码里做了耗时操作 | 耗时操作丢到业务线程池执行,不要让 Worker 线程阻塞 |
| 时间轮空闲时 CPU 占用不为零 | tickDuration设置小于 10ms,或线程忙轮询 | 适当调大 tickDuration,检查是否有大量短周期任务 |
| 提交延迟极大的任务报错 | delay 超出Long.MAX_VALUE / 1000 / 1000 | 拆分成多层时间轮,或用其他调度方案 |
| 任务执行顺序不可控 | 同槽位任务按链表顺序执行,不同槽位按 tick 顺序 | 业务上不要强依赖任务执行顺序,必要时加业务层排序 |
5.4 与 ScheduledThreadPoolExecutor 的选型对比
很多开发者纠结到底用哪个,我整理了一个对比:
| 维度 | HashedWheelTimer | ScheduledThreadPoolExecutor |
|---|---|---|
| 新增任务复杂度 | O(1) | O(log n) |
| 到期任务检查 | 每 tick 扫描固定槽位 | 堆顶起跳 |
| 执行线程 | 单线程 Worker | 默认多线程池 |
| 时间精度 | 误差约一个 tick | 纳秒级精确 |
| 适合任务量 | 大量短期任务 | 少量精确任务 |
| 耗时任务 | 会阻塞 Worker | 可容纳耗时任务 |
| 内存占用 | 固定数组 + 少量对象 | 堆结构随任务数增长 |
一句话总结:海量短任务、低精度、可控延迟选时间轮;少量长任务、高精度、任务本身耗时长选线程池调度。两者也可以结合:时间轮负责触发“检查时间到了”的信号,实际工作丢给线程池执行。这也是很多生产级中间件的标配方案。
6. 动手验证:写一个简单的时间轮示例
前面讲了这么多理论,不写代码总是隔着一层。我建议你本地建一个测试工程,把 Netty 依赖引进来,跑一个最小示例体会一下。
Maven 依赖:
<dependency> <groupId>io.netty</groupId> <artifactId>netty-common</artifactId> <version>4.1.100.Final</version> </dependency>示例代码:
import io.netty.util.HashedWheelTimer; import io.netty.util.Timeout; import io.netty.util.TimerTask; import java.util.concurrent.TimeUnit; public class TimeWheelDemo { public static void main(String[] args) throws InterruptedException { // 每 100ms 走一格,共 512 格,最长一圈 51.2 秒 HashedWheelTimer timer = new HashedWheelTimer( r -> new Thread(r, "my-timer"), 100, TimeUnit.MILLISECONDS, 512); TimerTask task1 = timeout -> System.out.println("task1 executed at " + System.currentTimeMillis()); Timeout timeout1 = timer.newTimeout(task1, 1, TimeUnit.SECONDS); TimerTask task2 = timeout -> System.out.println("task2 executed at " + System.currentTimeMillis()); timer.newTimeout(task2, 5, TimeUnit.SECONDS); // 取消 task1 if (timeout1.cancel()) { System.out.println("task1 cancelled"); } Thread.sleep(6000); timer.stop(); } }注意timer.stop()会默认等待所有已提交任务执行完毕,如果任务太多或单个任务执行时间长,stop()本身也会阻塞。如果希望立刻停止并返回未执行任务,可以用stop()的返回类型为Set<Timeout>,或者用isStop()配合处理。
我在实测中还注意到一个有意思的现象:同一个任务在tickDuration = 100ms的情况下,实际执行时间和预期时间误差在 0~100ms 之间随机分布。误差来源有两个,一是Thread.sleep的唤醒精度本身有波动,二是任务提交时间点若刚好错过指针当前槽位,就要等下一圈。理解了这个,就不会纠结那几十毫秒的误差了。
7. 总结的个人体会与一个小技巧
分析HashedWheelTimer的源码是一次很过瘾的体验。它没有复杂的锁策略、没有高性能队列的激进优化,用的全是最基础的数据结构和算法思想——环形数组、链表、CAS、位运算。但正是这种朴素的设计,在特定场景下实现了惊人的吞吐量。
我个人在实际项目里习惯把它用在连接管理上。每个客户端连接对应一个定时任务,用于空闲检测,毫秒级误差完全没关系,但上万个连接同时在线时,时间轮方案比优先队列顺畅得多。踩过几次坑之后,我也学到一个教训:时间轮不是一把万能钥匙。凡是需要精确到毫秒以下级别的调度,或者任务本身执行耗时存在不确定性,都应该优先考虑其他方案。
最后分享一个扩展小技巧:如果感觉单个时间轮无法满足超大范围的延迟需求(比如要支持小时级别的延迟任务),可以设计多层时间轮。像操作系统里经典的层次化时间轮,第一层管秒级、第二层管分级、第三层管小时级,高层的 tick 到来时,把任务降级投递到底层。这是时间轮最常用的进阶玩法,理解了基础版本的原理,推导出多层结构其实是水到渠成的事。
如果你正在为大量的超时任务、心跳检测发愁,不妨先试着用HashedWheelTimer感受一下从 O(log n) 到 O(1) 带来的直观差异。