简介本资源是一套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。比如tickDuration100ms、wheelSize512一个 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 时间。后来把「时间源」抽成一个接口测试时注入可控的假时钟指针推进就能精确控制单元测试才写得下去。这个习惯我一直保留——凡是依赖系统时间的组件时间源一定要可注入这是给自己留的后悔药。希望帮到你。本文还有配套的精品资源点击获取