前阵子有朋友问我能不能不用 JDK 自带的定时器框架纯手写一个 Java 模拟实现定时器用来给内部项目跑一些轻量延迟任务。这个问题其实问得很好因为大多数并发教程讲锁、讲队列、讲条件变量都是分开讲的而一个定时器恰好可以把这些知识点串起来延时任务怎么排队调度线程怎么阻塞时间到了怎么被唤醒任务执行异常了怎么兜底。我先说个结论自己模拟实现定时器并不需要把代码写得多复杂核心就是“优先队列 工作线程 锁与条件变量”这三件套。但真正让它稳定运行背后要考虑的边界情况很多。这篇文章我会从头到尾拆解一遍我自己的实现思路代码也给全同时聊聊哪些地方很容易被忽略。1. 一个好的定时器要解决什么问题1.1 定时任务的两个基本能力不管是用 JDK 的Timer还是用ScheduledExecutorService定时器本质都是在做两件事延迟执行、周期执行。延迟执行就是“3 秒后跑一次”周期执行就是“每 5 秒跑一次”。我自己做这套模拟实现的时候第一步就是把任务抽象成三个要素任务本身是什么也就是要执行的逻辑下一次执行的时间点这个不能直接用“我现在等多久”因为在队列里等待时时间一直在变必须存一个绝对时间如果是周期任务周期是多少如果只执行一次周期就是 0 或负数。有了这三个要素定时器就能回答两个关键问题队列里谁最紧急当前线程该睡到什么时候1.2 线程模型单线程调度还是多线程调度我们平时写 Java 并发第一反应可能是“多线程效率高”。但定时器的调度线程单线程反而是更自然的起点。调度线程只需要负责一件事不断看队首任务的时间到了没有到了就取出来执行没到就睡到对应时间。为什么单线程够了因为定时器本身不负责并行执行任务。任务如果特别耗时那是另一套线程池的事。在我的实现里调度线程执行任务时也是同步执行的这样代码最简单也最容易说清楚原理。后面我会专门讲这个设计会带来什么问题以及现成框架是怎么解决的。1.3 设计目标清单动手之前我先给自己列了一个目标清单编号目标说明1支持延迟执行提交任务时指定从当前时间起多久后执行2支持周期执行每次执行完按规则计算下一次执行时间3调度线程不空转队列空或时间未到时线程要阻塞不能忙等4任务异常不影响调度一个任务抛异常不能让整个定时器死掉5支持取消任务任务被取消后不再执行也不影响其他任务第 1、2 点决定了队列的排序策略第 3 点决定锁和条件变量的用法第 4 点是实际开发里最容易踩的坑第 5 点是工程上绕不开的需求。2. 从数据结构开始为什么延时任务的排队要用优先队列2.1 普通队列和优先队列的区别直观想法是用一个LinkedList把任务按提交顺序存起来然后每隔一段时间扫描一遍看哪个任务到时间了。这种做法不是不行但问题很大假设有十万个任务每次扫描都要遍历十万个对象判断时间是否到达O(n) 的复杂度会让系统高频空转。换成优先队列就不一样了。优先队列内部是二叉堆peek()永远拿到最小元素也就是最近要到期的那个任务。调度线程只需要看队首其他任务根本不用管。插入和删除的复杂度是 O(log n)堆顶查询是 O(1)。对定时器这种场景这是非常合适的。2.2 优先队列的排序规则按触发时间排队在 Java 里最方便的做法是让任务对象实现Comparable直接按触发时间排序。这里有个细节如果两个任务的触发时间一样它们的执行顺序其实无所谓但compareTo不能返回 0否则会把它们当成同一个对象导致插入时互相覆盖。所以比较时用Long.compare(fireTimeNanos, o.fireTimeNanos)就足够安全了就算返回 0 也不会破坏堆结构只是顺序不稳定。我写的任务实体大致长这样class MyTimerTask implements Runnable, ComparableMyTimerTask { final Runnable command; long fireTimeNanos; final long periodNanos; volatile boolean cancelled; MyTimerTask(Runnable command, long fireTimeNanos, long periodNanos) { this.command command; this.fireTimeNanos fireTimeNanos; this.periodNanos periodNanos; } Override public int compareTo(MyTimerTask o) { return Long.compare(fireTimeNanos, o.fireTimeNanos); } Override public void run() { command.run(); } }2.3 用单调时钟还是墙上时钟这是我一开始没注意后来反复踩壳的地方。获取当前时间有人用System.currentTimeMillis()有人用System.nanoTime()。定时器最好用System.nanoTime()。currentTimeMillis()返回的是墙上时间会受到系统时间调整的影响。比如用户手动改系统时间或者 NTP 同步纠正时间定时器可能立刻触发或延迟很久。nanoTime()返回的是 JVM 启动以来的某个单调递增时间只适合计算时间间隔不适合表示“2025 年某月某日”。我们的任务只需要记录“从此刻开始之后的多少纳秒触发”所以用单调时间最稳。3. 把整个机制串起来优先队列加锁加调度线程的完整骨架3.1 定时器主体锁、条件变量、工作线程模拟实现的定时器主体并不复杂我分成三个部分来讲调度线程的启动、任务的提交、线程的循环调度。先看主体代码import java.util.PriorityQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class MyTimer { private final PriorityQueueMyTimerTask queue new PriorityQueue(); private final Lock lock new ReentrantLock(); private final Condition available lock.newCondition(); private final Thread worker; public MyTimer(String name) { worker new Thread(this::scheduleLoop, name); worker.setDaemon(true); worker.start(); } public void schedule(Runnable command, long delay, TimeUnit unit) { schedule(command, delay, 0, unit); } public void scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { schedule(command, initialDelay, period, unit); } private void schedule(Runnable command, long initialDelay, long period, TimeUnit unit) { if (command null) { throw new NullPointerException(command); } long delayNanos unit.toNanos(initialDelay); long periodNanos period 0 ? 0 : unit.toNanos(period); long now System.nanoTime(); MyTimerTask task new MyTimerTask(command, now delayNanos, periodNanos); lock.lock(); try { queue.offer(task); available.signal(); } finally { lock.unlock(); } } private void scheduleLoop() { while (!Thread.currentThread().isInterrupted()) { lock.lock(); try { while (queue.isEmpty()) { available.await(); } MyTimerTask next queue.peek(); if (next.cancelled) { queue.poll(); continue; } long waitNanos next.fireTimeNanos - System.nanoTime(); if (waitNanos 0) { available.awaitNanos(waitNanos); continue; } queue.poll(); if (next.periodNanos 0) { next.fireTimeNanos next.periodNanos; queue.offer(next); } runQuietly(next); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } finally { lock.unlock(); } } } private void runQuietly(MyTimerTask task) { try { task.run(); } catch (Throwable t) { System.err.println(定时任务执行异常: t.getMessage()); } } }这段代码看起来短但信息量很大我逐段解释。3.2 提交任务时为什么需要 signalschedule方法里拿到锁后把任务放进队列调用了available.signal()。这一步很容易漏。调度线程可能正卡在queue.isEmpty()的条件等待里如果不通知它新任务来了它也醒不过来。signal()的作用就是告诉调度线程别睡了队列里有新东西了起来重新判断。另一个细节是signal()和signalAll()的区别。这个场景只有一个调度线程用signal()就够了。如果以后改成多线程调度才需要signalAll()把多个线程都唤醒让它们竞争队首任务。3.3 循环里为什么要用 while 而不是 ifavailable.await()被唤醒后队列状态可能已经变化。比如线程被虚假唤醒或者另一个线程抢先修改了堆所以必须用while (queue.isEmpty())循环重新检查条件。这也是 Java 并发编程里条件等待的标准写法先加锁再循环判断再进入等待。同样地awaitNanos(waitNanos)之后不能直接断定时间到了就执行。线程可能被中途唤醒也可能在多核环境下发生竞争所以代码里continue回去重新计算剩余等待时间。这种写法看起来多转了几圈但能避免很多偶发问题。3.4 runQuietly 存在的意义runQuietly是整个模拟实现里最容易区分“写着玩”和“用于生产”的地方。很多玩具版定时器直接把task.run()放在循环里如果任务抛出一个非受检异常调度线程当场死掉后面所有任务全部瘫痪而且没有日志没有恢复机制。我在runQuietly里捕获了Throwable比捕获Exception更保险。因为有些严重错误比如任务内部触发了某些底层问题属于Error但它们不该把整个调度线程带走。至少要让定时器继续活着同时打印出错误信息方便排查。4. 调度线程的阻塞与唤醒从忙等到条件变量的关键设计4.1 为什么不能 sleep 固定时间也不能死循环新手最容易写出的版本是调度线程每隔 100 毫秒醒一次遍历整个队列看看有没有任务到期。这就是忙等加轮询CPU 会被白白吃掉很多。假设系统里只有个把稀疏任务这种写法也要每秒空转十次非常浪费。更好的思路是“让线程睡到下一个任务到期的时间”。队首任务就是整个队列里最近要到期的任务所以调度线程只需要等待fireTimeNanos - now这么长时间醒过来后再次检查队首即可。如果中途有新任务插入且比当前队首更早schedule方法里的signal()会把线程提前唤醒重新排序这就是条件变量的用武之地。4.2 awaitNanos 不是精确闹钟awaitNanos(waitNanos)结束后并不保证任务就在指定的纳秒时刻被执行。线程唤醒需要操作系统调度JVM 可能因为垃圾回收暂停机器可能因为 CPU 资源紧张而延迟调度。所以定时器本质是尽力而为不是硬实时。这段话不是打消积极性而是提醒一个工程认知如果你需要纳秒级别或者毫秒级别严格保证的定时纯 Java 用户态模拟定时器并不合适应该考虑更底层的时钟机制或者专用中间件。但如果只是做延迟重试、定期清理、异步缓存刷新这个实现完全够用。4.3 为什么要用可重入锁而不是 synchronized用synchronized也能实现类似逻辑但ReentrantLock搭配Condition在源码可读性和功能丰富度上有优势。Condition.awaitNanos可以精确指定等待纳秒数而且还支持超时后返回剩余纳秒这些在synchronized的wait/notify体系里写起来更别扭。当然synchronized版本完全可行JDK 的Timer早期实现也大量用了同步机制。我这里只是提供一个更贴近现代并发风格的版本。如果要给小白讲原理换成synchronized wait(long)也说得通核心思想不变。5. 周期任务的两种玩法fixed-rate 和 fixed-delay5.1 我最初写的周期任务有什么问题我在第一版代码里对周期任务这么处理任务执行完把fireTimeNanos加上periodNanos重新放回队列。这其实是 fixed-rate 的思路严格按照时间轴每隔一段时间触发一次。这个思路有个隐含坑。如果任务的执行时间超过了周期或者某一次执行前系统卡顿了很久重新放回队列时它的fireTimeNanos可能已经远远落后于当前时间。调度线程一旦把任务取出来会立刻再次执行然后再次补时间继续立刻执行。这会造成一个“爆发追赶”的现象任务连续被触发好几次几乎不间隔。5.2 什么时候该用 fixed-delay很多业务场景并不需要追赶而是要“上一次执行完之后再过固定时间执行下一次”。比如定时拉取数据你更关心的是拉取动作不要集中爆发而是每一次都留出完整间隔。这时应该用 fixed-delay也就是在任务执行结束之后用“当前时间 period”来计算下一次触发时间。模拟实现里体现这种差异只需要修改一处// fixed-rate 风格 next.fireTimeNanos next.periodNanos; // fixed-delay 风格 next.fireTimeNanos System.nanoTime() next.periodNanos;JDK 的ScheduledThreadPoolExecutor也提供了两种方法区分这两种语义scheduleAtFixedRate和scheduleWithFixedDelay。自己在模拟实现时可以做成两套调度策略也可以只做一种然后写清楚限制。我当时做的是 fixed-rate 版本但在工程注释里明确标注了追赶现象。5.3 时间溢出问题nanoTime()返回的是一个比较大的 long 值如果任务周期极短或者系统运行时间极长fireTimeNanos periodNanos可能超过Long.MAX_VALUE。这个概率极低但理论上有风险。更稳妥的写法是用long溢出安全计算或者在调度层捕捉到触发时间小于当前时间时直接把任务调整为当前时间加一个很短的时间片。对于模拟实现知道有这个坑就够了不需要过度设计。6. 边界情况与真实运行里踩过的坑取消、异常、长时间任务与内存泄漏6.1 取消任务标记清除而不是实时删除第一版我以为取消就是把任务从队列里删掉后来发现太天真。迭代器扫描优先队列删除对象的代价很高而且取消了周期任务后如果删除操作不及时堆结构也会受影响。更稳妥的取消方式是标记volatile boolean cancelled让调度线程在取到队首时发现标记直接跳过。但这种做法的代价是取消的任务会一直留在队列里直到它成为队首。如果取消的是一个触发时间很远的任务它会一直在堆里占着内存。任务量很大的场景下这确实可能累积成内存压力。成熟的延迟队列会在内部做懒删除或者配合定时清理。我们模拟实现的版本知道取舍即可。6.2 调度线程执行任务期间其他任务只能等待单线程调度模型的天然限制是如果一个任务执行了 30 秒后面所有排队的任务都必须等。哪怕后面的任务早就到期了也只能排着。这是设计选择不是 bug。如果你要并行执行定时任务模拟实现的下一步应该引入线程池让调度线程只负责从堆里取任务然后把任务交给线城池执行。引入线程池之后又会出现新问题多个任务并发执行一个任务执行很久不影响其他任务但任务的执行顺序和资源占用变得更难控制。这正是从”模拟实现“走向”工程框架“时要考虑的分水岭。6.3 任务异常不能让调度线程退场很多人在网上搜定时器代码都会看到捕获异常的版本但未必理解为什么必须捕获。举个例子你有一个每天凌晨 3 点跑一次的数据清理任务某天清理逻辑里因为一个空指针挂掉了。如果调度线程不捕获异常线程直接死亡第二天凌晨的数据清理也不会再执行。这个故障极其隐蔽因为代码看起来还活着但定时器已经不调度了。我在runQuietly里打印了堆栈信息只是最低成本的兜底。工程上还可以加失败重试策略或者把失败任务发送到某个重试队列再或者只统计失败次数留给人去处理。核心原则只有一个调度线程永远不能被用户任务的异常带走。6.4 任务重入同一个任务不会被并发执行两次因为调度线程是单线程同一个周期任务绝不可能同时执行两次。这是单线程模型带来的附赠好处。如果改成多线程调度且不做限制一个慢任务还没执行完下一次触发时间已经到了就会出现同一任务并发执行的问题。很多框架里专门有DisallowConcurrentExecution之类的注解来控制这一点。模拟实现阶段没有这种烦恼但设计文档里要写清楚。6.5 队列无界带来的内存风险PriorityQueue是一个无界容器。如果业务代码疯狂提交任务而且任务触发时间都设置在很远的未来堆积在堆里的任务对象会持续占内存。JDKScheduledThreadPoolExecutor底层用的延迟队列同样是无界的这个问题是同类实现共享的。真正常见的缓解方式是限制任务总量或者在提交时做拒绝策略。模拟实现里明确知道这个隐患即可。7. 和 JDK 内置定时器对比哪些地方值得改进7.1 Timer、模拟实现、ScheduledThreadPoolExecutor 的差异自己在纸上写完一套模拟实现之后再回头去看 JDK 的Timer和ScheduledThreadPoolExecutor很多设计点就活了。我整理了一张对比表方便大家理解各自的定位对比维度JDK Timer我的模拟实现ScheduledThreadPoolExecutor任务调度线程单线程单线程线程池可多线程并发执行任务异常隔离任务异常导致整个 Timer 终止捕获 Throwable不影响调度任务异常不影响线程池其他任务周期任务语义fixed-delay 为主自己设定 fixed-rate/fixed-delay同时支持 fixed-rate 和 fixed-delay取消任务TimerTask.cancel被取消标记懒跳过Future.cancel内部排队延迟清理数据结构基于优先队列基于优先队列基于 DelayedWorkQueue相对复杂度低低较高这个对比表让我深刻认识到一件事JDK 的ScheduledThreadPoolExecutor就是“模拟实现拓宽成工程框架”的标准答案。它把调度线程扩展成线程池解决了单线程执行任务的瓶颈又把任务包装成 RunnableScheduledFuture天然支持取消和获取结果。我们没必要在生产环境重复造轮子但通过模拟实现可以完全理解它为什么这么设计。7.2 从模拟实现到工程改进的路线图如果真想让这套模拟实现变得更接近可用我建议按这个顺序演进把调度线程改成调度线程池至少支持多个工作线程消费队列把任务对象的command改成支持返回值、取消、完成通知的统一任务接口在提交端加入容量限制和拒绝策略防止无界队列堆积引入精准的时钟抽象方便测试时模拟时间跳变增加统计指标比如总提交数、成功执行数、失败执行数、平均排队时间。这些点上每一条都够单独写一篇文章但核心调度机制已经是基础。其实很多人学并发死记锁和队列的 API 总觉得不落地。亲手实现一遍定时器把这些 API 放在一个真实场景里用起来概念之间的咬合感会完全不一样。8. 写在最后一点实际体会这套模拟实现我没有用到什么复杂的框架也没有引入任何外部依赖核心代码就集中在一个类、一个任务类和一个优先队列里。但它给我最大的收获不是代码量而是在调试过程中对“阻塞唤醒”有了直观感受线程睡多久、谁叫它醒、醒来看什么条件每一环都缺一不可。如果让我给后来者一句建议我会说不要急着背await和signal的用法先把一个定时器的骨架写出来然后故意制造任务异常、周期偏差、取消任务这些场景亲眼看看程序怎么反应。踩过一遍之后你对 Java 并发调度的理解会比翻十遍教程都深。
阅读完成 · 觉得有帮助?