JavaSE阶段学多线程,绕不开定时器。定时器表面上是“按时间触发任务”,本质上却是多线程编程的浓缩战场:任务调度、线程生命周期、并发状态共享、异常隔离,全都能在这么一个小点上体现出来。这篇分享不打算讲分布式调度框架,只讲JavaSE里最常用也最容易翻车的两个方案:Timer/ScheduledExecutorService,顺便把底层原理、实操细节、常见坑一次性说透。适合刚学到多线程、想动手写定时任务的初中级Java开发,也适合正在准备面试的朋友用来串知识点。
1. 先从需求说起:定时器到底解决什么问题
1.1 一个最朴素的定时方案为什么不行
刚开始学Java的人,十有八九会这么写定时任务:
while (true) { Thread.sleep(1000); System.out.println("每秒执行一次"); }这段代码确实能“定时”,但稍微往深处想一下就发现问题很多:主线程被sleep死死占住,其他逻辑没法并行;如果任务本身执行超过1秒,下一次任务会立即补偿执行还是跳过,完全不可控;最终程序只能靠强制kill停掉,没有任何优雅取消的手段。说白了,这不是定时器,是一个阻塞循环。
真实的定时任务需求其实可以拆成四个维度:
- 延迟执行:任务不是立即跑,而是等到指定时间再触发。
- 周期执行:任务跑完一次后,隔一段固定时间再跑下一次,一直持续。
- 并发隔离:多个定时任务之间不能互相影响,某个任务卡住,其他任务不能跟着遭殃。
- 生命周期管理:能取消任务、能优雅关闭调度器,进程退出时不会留下悬空的线程。
Timer和ScheduledExecutorService之所以能成为JavaSE里的经典方案,就是因为它们在语言层面把上面这些需求做成了现成的API。新手往往只记得“new Timer().schedule(task, 1000)”这行代码,却没意识到背后有一个完整的调度线程、任务队列和时间计算逻辑。
1.2 不要一上来就选型,先理解单线程与多线程的差异
选型之前,要先明白一个关键差异:Timer是单线程调度器,ScheduledExecutorService是多线程调度器。这句话在面试里能展开一大篇,在实际项目里更是决定生死。
Timer内部只有一个TimerThread,所有TimerTask都由这个线程串行执行。好处是简单、不会出现并发竞争;坏处也很致命:如果某个任务执行时间过长,后面排队的任务全都会被延迟;更糟的是,任务一旦抛出未检查异常,这个唯一的工作线程会直接退出,整个定时器从此“哑火”,后续任务永远不再执行。
ScheduledExecutorService底层基于线程池,核心线程数可以配置为多个。多个定时任务可以并行执行,一个任务抛出异常只会影响当前这个线程的这次执行,调度线程本身不会死掉。从Java 5开始,这个类就是官方推荐替换Timer的替代方案,Timer到现在仍然是教学和面试题常客,但工程上已经很少直接用了。
选型逻辑其实可以归纳成一句话:任务少、执行快、互相独立,用Timer图省事;任务多、执行慢、要求稳定,必须用ScheduledExecutorService。我在实际项目中见过用Timer做心跳上报导致整个服务假死的情况,问题不在Timer这个类本身,而在于使用的人没意识到“所有任务共享一个线程”这个前提。
2. Timer/TimerTask源码级拆解:它到底怎么工作
2.1 三个核心成员:TimerTask、TaskQueue、TimerThread
很多人用Timer多年,只知道schedule(task, delay),却不知道它背后是三个角色在协作。看一遍源码能记住一辈子。
TimerTask是一个抽象类,实现了Runnable接口。它内部维护了一个状态字段,用来表示任务当前处于什么阶段:
- VIRGIN:任务刚创建,还没被调度。
- SCHEDULED:已经被安排进队列等待执行。
- EXECUTED:已经执行完成。
- CANCELLED:被取消了。
每次调用schedule,都会检查任务状态是不是VIRGIN,如果不是就抛出IllegalStateException。所以同一个TimerTask只能被调度一次,想重复执行必须再new一个任务对象。
TaskQueue是一个用数组实现的小顶堆(binary heap),按照任务的nextExecutionTime从小到大排序,队首永远是下一次执行时间最早的任务。这里的思想和后续要讲的延迟队列是一脉相承的,都是为了在新增任务、取出任务时保持O(log n)的复杂度。
TimerThread是Timer内部创建的工作线程,它的run方法里有一个死循环:
- 从TaskQueue取出队首任务。
- 如果任务还没到执行时间,就调用wait等待。
- 如果时间到了,把任务从队列移除并执行task.run()。
- 如果队列变空了,继续wait,等待新任务被添加。
值得注意的一个细节是:TimerThread在创建时可以被指定为守护线程。new Timer()默认是非守护线程,这就意味着如果某个类创建了Timer却没有显式cancel,即使所有业务线程都结束了,JVM进程也无法退出。以前我在一个桌面程序里踩过这个坑,关掉主窗口进程还在后台挂着,查了半天才发现是Timer没cancel。
看一个最小Demo:
public class TimerDemo { public static void main(String[] args) throws InterruptedException { Timer timer = new Timer("MyTimer", true); // 第二个参数指定为守护线程 TimerTask task = new TimerTask() { @Override public void run() { System.out.println("任务执行时间:" + System.currentTimeMillis()); } }; timer.schedule(task, 1000, 2000); // 1秒后首次执行,之后每2秒执行一次 Thread.sleep(5000); timer.cancel(); System.out.println("定时器已取消"); } }这里把TimerThread设为守护线程是刻意为之,只是为了demo演示时进程能正常退出。真实项目中我反而建议认真思考生命周期,不要随手设daemon。
2.2 schedule、scheduleAtFixedRate的区别到底在哪
Timer类里有几个schedule重载方法,面试问“Timer和ScheduledExecutorService的区别”之前,通常还会先问“schedule和scheduleAtFixedRate有什么区别”。这两个方法的差异非常subtle,但理解透了才能选对。
假设任务第一次执行时间是01:00,每次耗时为30秒。schedule方法按“固定延迟”执行,它会在上一次任务执行完成后,再等待period时间,所以时间轴是这样的:
任务开始:01:00 任务结束:01:00:30 下一次开始:01:02:00(结束时间 + 2分钟period) 如果任务延误,后续全部顺延scheduleAtFixedRate方法按“固定频率”执行,它以上一次任务开始时间为基准加上period来安排下一次,时间轴是这样的:
任务开始:01:00 任务结束:01:00:30 下一次开始:01:02:00(开始时间 + 2分钟period) 但如果任务在01:03才结束,原定01:02的那次已经错过,会紧接着立刻补偿执行一次用大白话说:schedule是“我干完活歇够两分钟再干下一次”,scheduleAtFixedRate是“我按表打卡,这次晚了但下次依然按原计划来”。补偿机制看似贴心,实际使用中很容易引发“雪崩式补任务”。
我举个例子,你在做数据同步,每天凌晨1点跑一次全量任务,单次耗时可能超过20分钟。如果使用scheduleAtFixedRate,任务只要跑慢了,即使你设定的周期是一天,它也可能在一天内连续补跑好几次,最糟糕的情况下服务重启后直接把数据库压垮。反之,如果只是想做一个固定间隔的轮询,比如每5秒检查一次队列,用schedule就够了,下一次执行严格以上一次结束时间往后推。
2.3 Timer的经典陷阱:一个任务拖垮全部
Timer最大的设计缺陷,概括起来就是两个词:串行和无异常隔离。
串行意味着所有TimerTask都跑在一个线程里。假设你往同一个Timer里塞了两个任务,任务A执行需要10秒,任务B设定的是每2秒执行一次,那么在这10秒内任务B一次都跑不了。更难受的是,如果任务A是个死循环,任务B就永远没机会跑了。
无异常隔离意味着如果某个任务在run方法里抛出了RuntimeException,TimerThread会被杀掉,Timer变成僵尸。代码示例如下:
Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { throw new RuntimeException("boom"); } }, 1000); timer.schedule(new TimerTask() { @Override public void run() { System.out.println("我还能执行吗?"); } }, 3000);运行这段代码,第二个任务的输出永远不会出现,因为TimerThread在第一次执行时已经退出。关键是:JVM进程不会崩溃,只是什么日志都没有,静态代码扫描也发现不了,非常隐蔽。这也是我在文章开头说“Timer是教学和面试题常客,工程上慎用”的原因。
3. 多线程定时任务的正确姿势:ScheduledExecutorService
3.1 为什么线程池能让定时任务“活”起来
Java 5之后,JUC包提供ScheduledExecutorService,它的核心实现类是ScheduledThreadPoolExecutor。初次接触时可以把它理解为“线程池 + 延迟队列”的组合。
线程池提供多个工作线程,让不同任务可以并发执行。延迟队列负责按时间优先级调度,工作线程空闲时会从队列头拿到期任务,没到期就阻塞等待。内核线程大小的配置直接影响定时任务并行度:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);我建议每个不同类型的任务使用独立的ScheduledExecutorService,而不是所有任务共用一个线程池。原因很简单:如果核心线程数是2,一个运行10分钟的重任务会把其中一个线程占满,另一个线程还要处理其他任务,一旦任务总数大于线程数,轻量任务依然会被重量任务拖累。把不同业务属性的任务拆到不同调度器里,可以做到真正的故障隔离。
3.2 scheduleAtFixedRate与scheduleWithFixedDelay的代码对比
ScheduledExecutorService提供了两个最常用的周期调度方法:
- scheduleAtFixedRate(command, initialDelay, period, unit):固定频率调度,和Timer的scheduleAtFixedRate思想一致,基于任务开始时间计算下一次执行。
- scheduleWithFixedDelay(command, initialDelay, delay, unit):固定延迟调度,基于上一次任务结束时间计算下一次执行。
看两段代码,感受一下差别:
public class ScheduleRateVsDelay { public static void main(String[] args) throws InterruptedException { ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); // 固定频率:每2秒触发一次,如果任务耗时超过2秒,可能马上补偿执行 scheduler.scheduleAtFixedRate(() -> { try { System.out.printf("FixedRate开始 %s%n", System.currentTimeMillis()); Thread.sleep(3000); // 模拟耗时3秒 System.out.printf("FixedRate结束 %s%n", System.currentTimeMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, 0, 2, TimeUnit.SECONDS); Thread.sleep(10000); scheduler.shutdownNow(); } }这段代码里,任务耗时3秒,周期却是2秒。实际输出会看到任务连续执行,中间几乎没有间隔,因为上一次任务还没结束,下一次触发时间就已经到了,线程池会在上一次任务结束后立即补偿跑一次。
换成scheduleWithFixedDelay:
scheduler.scheduleWithFixedDelay(() -> { try { System.out.printf("FixedDelay开始 %s%n", System.currentTimeMillis()); Thread.sleep(3000); System.out.printf("FixedDelay结束 %s%n", System.currentTimeMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, 0, 2, TimeUnit.SECONDS);这次输出会是:任务结束时间 + 2秒后,才开始下一次任务。整个执行时间轴是“3秒干活 + 2秒休息”,节奏非常固定。我个人的经验是:凡是涉及外部IO轮询场景,优先用scheduleWithFixedDelay,因为它天然避免了任务还没结束就重复触发的问题;只有当你非常确定每次执行耗时都远小于周期时,才考虑scheduleAtFixedRate。
3.3 提交任务时几个关键参数的思考
初次使用ScheduledExecutorService的人,常把initialDelay、period、delay这三个时间参数搞混。
- initialDelay:首次延迟多久执行,给系统一个预热时间。比如监听外部配置变更,可以设置初始延迟5秒,等Spring容器完全初始化完成再开始轮询。
- period:scheduleAtFixedRate里的周期,从上一次任务“开始时间”算起。
- delay:scheduleWithFixedDelay里的间隔,从上一次任务“结束时间”算起。
有个很常见的场景需要自己去算一遍:业务要求“每秒拉取一次上游状态”,但拉取操作本身可能要花500到800毫秒。如果选择scheduleAtFixedRate,period设为1秒,那么任务执行时间会被强行压缩到1秒内,一旦上游慢到1.2秒,就会出现“上一次还没结束,下一次紧接着补跑”的连环触发。如果选择scheduleWithFixedDelay,delay设为200毫秒,就能确保两次拉取之间至少有200毫秒的喘息,整体压力更平滑。
参数计算没有绝对标准,但有一个建议:周期任务的参数不要只看业务字面需求,一定要把任务最大执行时间、抖动余量一起估算进去。给delay或period留出至少20%的冗余,在负载升高时不会立刻变成灾难。
4. 实操落地:从Demo到项目里的定时任务
4.1 一个干净的定时任务管理工具类怎么写
即使不做分布式定时调度,本地定时任务也该有个统一的管理入口。我习惯写一个小工具类,把提交、取消、优雅关闭都封装在一起:
public class TaskScheduler { private final ScheduledExecutorService executor; public TaskScheduler(int corePoolSize, String threadNamePrefix) { ThreadFactory factory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, threadNamePrefix + "-" + seq.incrementAndGet()); t.setDaemon(true); // 视业务情况决定 return t; } }; this.executor = Executors.newScheduledThreadPool(corePoolSize, factory); } public ScheduledFuture<?> scheduleAtFixedRate(Runnable task, long initialDelay, long period, TimeUnit unit) { return executor.scheduleAtFixedRate(wrap(task), initialDelay, period, unit); } public ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, long initialDelay, long delay, TimeUnit unit) { return executor.scheduleWithFixedDelay(wrap(task), initialDelay, delay, unit); } public void shutdownGracefully(long timeout, TimeUnit unit) throws InterruptedException { executor.shutdown(); if (!executor.awaitTermination(timeout, unit)) { executor.shutdownNow(); } } private Runnable wrap(Runnable task) { return () -> { try { task.run(); } catch (Throwable t) { // 统一记录异常,不让线程池静默吞掉错误 System.err.println("定时任务异常:" + t.getMessage()); } }; } }这个工具类干了三件重要的事:一是给线程起了有业务含义的名字,排查问题时jstack里一眼就能看出来;二是把任务包了一层try-catch,防止单个任务的异常直接终结线程;三是提供了graceful shutdown,关停时预留时间处理正在执行的任务。
4.2 优雅关闭和守护线程的取舍
关于关闭定时线程池,很多初学者只记得一个shutdown(),但shutdown只是拒绝新任务,已提交任务还会继续执行。如果希望等待正在执行的任务跑完再接退出,必须配合awaitTermination使用:
scheduler.shutdown(); // 不再接受新任务 scheduler.awaitTermination(30, TimeUnit.SECONDS); // 等待已有任务完成 scheduler.shutdownNow(); // 30秒还没完成,强制打断在Spring等框架环境里,应用关闭时会调用DisposableBean或@PreDestroy方法,这一步必须做好。否则定时线程池可能让JVM进程迟迟无法退出,严重时还会在开发环境把端口一直占着。
守护线程怎么取舍?我的建议是:
- 如果定时任务是应用内的一次性辅助任务(比如临时统计、短时监控),设daemon=true,避免拖住进程退出。
- 如果定时任务是应用核心功能的一部分(比如消息队列消费者、心跳上报),默认非守护线程反而更安全,因为它能确保任务在进程退出前有机会执行完成,不会被JVM强制中断。
不要一概而论地“全部设daemon”,要根据任务性质来。
4.3 任务耗时监控与线程隔离
定时任务跑得久了,“慢”和“挂”是两回事。我建议在包装层里加入耗时统计,用最朴素的System.currentTimeMillis()就可以,不要引入重量级监控:
long start = System.currentTimeMillis(); try { task.run(); } finally { long cost = System.currentTimeMillis() - start; if (cost > 5000) { System.err.printf("任务执行超过5秒,耗时:%d ms%n", cost); } }这里的阈值根据业务调整,但作用是实实在在的:你可以在问题发生之前就发现某个任务在恶化。
线程隔离的原则也值得单独说。定时任务里如果还要并行处理一批子任务,不要复用调度线程池,而是再创建一个普通线程池来跑子任务,避免调度线程被长期占用。比如定时器每5分钟触发一次批处理,批处理内部需要并发请求多个接口,这时候可以让调度线程只负责任务启动,真正的活交给另一个Executors.newFixedThreadPool去干。
5. 常见问题排查与面试高频考点
5.1 定时任务“没跑”或者“跑了一次就不跑了”
这是项目里最常遇到的诡异问题。按照经验,我建议按以下顺序排查:
- 确认定时器是否还活着:线程池是否被shutdown了,排查有没有代码调用了shutdown或shutdownNow,很多服务在重启或热加载时误关了调度器。
- 确认任务是否抛异常:ScheduledThreadPoolExecutor在任务抛出异常后,会影响这个任务的下一次调度,但如果单独用execute提交,异常会被线程池的UncaughtExceptionHandler处理。使用schedule方法时,需要检查ScheduledFuture的get方法,get会抛出ExecutionException拿到具体异常。
- 确认任务是否被阻塞:长时间不执行不一定调度器死了,可能是线程池所有线程都被别的长任务占满。用jstack查看线程栈,重点看“pool-*-thread”在跑什么。
- 确认时间单位是否弄错:period是数字和TimeUnit不匹配,比如把1秒写成了1分钟,这类肉眼排查确实很费劲。
5.2 理解ScheduledThreadPoolExecutor的调度流程
ScheduledThreadPoolExecutor的核心是一个延迟队列DelayedWorkQueue,队列的元素是ScheduledFutureTask,它实现了Delayed接口。工作机制大体是:
- 提交任务时,按照任务下一次执行时间排序放进队列,队首是最近的待执行任务。
- 工作线程循环从队列头部take元素,take会阻塞,直到队首任务的delay时间到了。
- 任务执行完后,如果是周期任务,会重新计算下次执行时间,再次放回队列。
这里就牵扯出一个高频面试题:tick算法和任务的“漂移”。ScheduledThreadPoolExecutor在计算固定频率任务的nextExecutionTime时,用的是“上次执行开始时间 + period”。如果任务执行时间超过period,看似会马上补偿执行,但补偿机制并不会为了追上原计划而批量积压多个任务,它只会把下一次执行时间设为“当前时间”,保证执行顺序正确但节奏被打乱。这也是我前面建议优先用scheduleWithFixedDelay的原因之一,因为它在任务耗时波动时能天然维持稳定节奏。
5.3 题库里常出现的几个追问
追问一:Timer和ScheduledExecutorService的区别?核心差异在单线程/多线程、异常处理、关闭方式。Timer一个工作任务异常会让唯一调度线程退出;ScheduledExecutorService理论上线程池线程挂了可以自动补新线程,任务异常不会终止整个调度器。
追问二:scheduleAtFixedRate和scheduleWithFixedDelay的区别?一句话:前者以上次开始时间为基准,后者以上次结束时间为基准。前者会补偿执行,后者不会。
追问三:如果要实现高精度的定时调度,有什么改进思路?可以从时间轮(HashedWheelTimer)、延迟队列、堆结构这几个方向去答。最低限度要能说出TimerTaskQueue是二叉堆,ScheduledThreadPoolExecutor是延迟队列。
追问四:定时任务如何做分布式?这是JavaSE阶段之外的问题,思路方向包括DB锁、ZK/etcd选主、分布式调度框架。JavaSE里的本地定时器只能保证单机内调度,多实例部署时必须考虑重复执行问题,本地定时器只适合做辅助任务。
5.4 快速参数参考表
| 参数/概念 | 含义 | 常见注意点 |
|---|---|---|
| initialDelay | 首次延迟时间 | 应用启动后需要预热,建议不要设为0 |
| period | 固定频率周期 | 基于上次开始时间,任务超时会补偿 |
| delay | 固定延迟间隔 | 基于上次结束时间,节奏稳定 |
| corePoolSize | 调度线程核心数 | 不同业务任务用不同线程池隔离 |
| daemon | 是否为守护线程 | 涉及JVM退出和任务可靠性 |
我在实际项目中的体会是:定时器学起来十分钟,踩坑踩一年。很多人觉得“定时任务嘛,到点执行就行”,结果线上出现“任务偶尔不跑”“任务重复跑”的时候才意识到,真正复杂的不是触发,而是任务状态、执行耗时、异常处理和生命周期管理。最后分享一个小技巧:给每个定时任务都加一个任务ID,日志里统一带上taskId,排查问题时能把“哪个任务没跑”和“为什么没跑”在几分钟内定位清楚,这比事后翻代码省力太多。