news 2026/10/20 9:54:11

Java模拟实现定时器:从优先队列到条件变量

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java模拟实现定时器:从优先队列到条件变量

前阵子有朋友问我,能不能不用 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, Comparable<MyTimerTask> { 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 PriorityQueue<MyTimerTask> 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 提交任务时为什么需要 signal

schedule方法里,拿到锁后把任务放进队列,调用了available.signal()。这一步很容易漏。调度线程可能正卡在queue.isEmpty()的条件等待里,如果不通知它,新任务来了它也醒不过来。signal()的作用就是告诉调度线程:别睡了,队列里有新东西了,起来重新判断。

另一个细节是signal()和signalAll()的区别。这个场景只有一个调度线程,用signal()就够了。如果以后改成多线程调度,才需要signalAll()把多个线程都唤醒,让它们竞争队首任务。

3.3 循环里为什么要用 while 而不是 if

available.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-delay

5.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 从模拟实现到工程改进的路线图

如果真想让这套模拟实现变得更接近可用,我建议按这个顺序演进:

  1. 把调度线程改成调度线程池,至少支持多个工作线程消费队列;
  2. 把任务对象的command改成支持返回值、取消、完成通知的统一任务接口;
  3. 在提交端加入容量限制和拒绝策略,防止无界队列堆积;
  4. 引入精准的时钟抽象,方便测试时模拟时间跳变;
  5. 增加统计指标,比如总提交数、成功执行数、失败执行数、平均排队时间。

这些点上每一条都够单独写一篇文章,但核心调度机制已经是基础。其实很多人学并发,死记锁和队列的 API 总觉得不落地。亲手实现一遍定时器,把这些 API 放在一个真实场景里用起来,概念之间的咬合感会完全不一样。

8. 写在最后:一点实际体会

这套模拟实现我没有用到什么复杂的框架,也没有引入任何外部依赖,核心代码就集中在一个类、一个任务类和一个优先队列里。但它给我最大的收获不是代码量,而是在调试过程中对“阻塞唤醒”有了直观感受:线程睡多久、谁叫它醒、醒来看什么条件,每一环都缺一不可。

如果让我给后来者一句建议,我会说:不要急着背await和signal的用法,先把一个定时器的骨架写出来,然后故意制造任务异常、周期偏差、取消任务这些场景,亲眼看看程序怎么反应。踩过一遍之后,你对 Java 并发调度的理解会比翻十遍教程都深。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 9:52:47

PX4位置环与速度环控制原理及多旋翼PID调参实战指南

1. 先看全局&#xff1a;位置环、速度环在PX4控制链里的确切位置1.1 一条命令从“航点”到“电机转速”要过几道关卡先说结论&#xff1a;PX4的多旋翼控制是一个标准级联结构&#xff0c;从上到下大致是“期望位置 → 位置环 → 期望速度 → 速度环 → 期望加速度 → 姿态环 →…

作者头像 李华
网站建设 2026/10/20 9:52:33

Claude + Playwright MCP 实战:让 UI 自动化测试脚本编写效率翻倍

自动化测试脚本写起来不累&#xff0c;累的是手工重复那些“找按钮、等加载、对数据、改选择器”。尤其项目进入稳定迭代期之后&#xff0c;真正的成本不是功能测试本身&#xff0c;而是每次页面一改&#xff0c;测试脚本就跟着碎一地。我身边很多团队早就把主流程测试切到了 P…

作者头像 李华
网站建设 2026/10/20 9:52:30

Agent基础设施:可组合性、可观测性与弹性执行的工程实践

1. “Agent Infra”不是新概念&#xff0c;而是旧问题在新尺度上的总爆发“Agent Infra”这个词最近频繁出现在技术会议PPT首页、早期投资人尽调清单和某大厂内部架构升级Roadmap里&#xff0c;但翻遍主流开源文档、RFC草案甚至近三年顶会论文&#xff0c;你几乎找不到一个被明…

作者头像 李华
网站建设 2026/10/20 9:51:50

STGCN时空图卷积网络PyTorch实现与交通流量预测指南

简介&#xff1a;这是一份基于 PyTorch 的 STGCN 时空图卷积网络实现代码&#xff0c;源自 IJCAI 2018 会议论文的官方工程&#xff0c;面向希望复现时空序列建模算法的开发者与研究者。其核心价值在于将图卷积与时间卷积相结合&#xff0c;分别建模节点间的空间依赖与序列随时…

作者头像 李华
网站建设 2026/10/20 9:50:25

Xilinx XADC 12位ADC Verilog源码深度解析与DRP配置实战

简介&#xff1a;本资源是一套基于Xilinx Zynq-7000系列&#xff08;XC7Z020&#xff09;平台的XADC 12位高速ADC采样Verilog实现方案&#xff0c;面向FPGA初学者与数字系统开发工程师&#xff0c;解决嵌入式系统中模拟信号实时采集与数字处理的典型需求。压缩包共62个文件&…

作者头像 李华