1. 为什么需要 ScheduledThreadPoolExecutor
1.1 从一次线上定时任务雪崩说起
前两年我负责过一个数据同步服务,核心逻辑是每隔 5 分钟从上游拉一批增量数据做清洗入库。当时图省事,直接用了一个Timer加TimerTask来实现。上线头两周一切正常,第三周开始偶发数据延迟,排查了半天才发现问题出在Timer本身——只要有一个任务抛出未捕获的异常,整个Timer线程就直接挂掉,后面所有任务全部停摆。更坑的是,Timer内部只有一个线程,多个任务串行执行,前一个任务耗时过长会直接推迟后续任务的执行时间点。
那次事故之后我把方案换成了ScheduledThreadPoolExecutor,问题迎刃而解。这个类位于java.util.concurrent包下,是ThreadPoolExecutor的子类,同时实现了ScheduledExecutorService接口。它解决的核心问题就三个:多线程并行执行定时任务、单个任务异常不影响其他任务、支持固定频率和固定延迟两种调度模式。
如果你正在做以下任何一件事,这个类都值得你花时间吃透:
- 需要周期性执行的后台任务(数据同步、缓存刷新、心跳检测、日志清理)
- 需要延迟执行的一次性任务(订单超时取消、延迟消息处理)
- 对任务执行的可靠性有要求,不能因为一个任务出错就全线崩溃
- 需要精细控制线程池大小、拒绝策略、任务队列
这篇文章我会从设计思路、核心参数、源码级原理、实操代码、踩坑经验几个维度把它讲透。不管你是刚接触并发编程的新手,还是已经用过但没深究的老手,应该都能从中找到有用的东西。
1.2 它和 Timer、普通线程池的本质区别
很多人会问:我直接用ThreadPoolExecutor加一个while(true) + sleep不也能实现定时吗?或者用Timer不也行吗?这里必须把三者的定位说清楚。
Timer是 JDK 1.3 就有的老古董,底层是单线程 + 一个 TaskQueue。它的致命缺陷有两个:一是单线程,所有任务串行;二是没有异常隔离,一个任务抛异常整个 Timer 就废了。所以现在基本不推荐在生产环境使用。
ThreadPoolExecutor是通用线程池,它本身不具备定时调度能力。你当然可以自己写循环加 sleep 来模拟,但这样做的精度很差,而且 sleep 期间线程被白白占用,浪费资源。
ScheduledThreadPoolExecutor则是在ThreadPoolExecutor基础上,把任务队列换成了DelayedWorkQueue(一个基于堆的延迟队列),并且重写了execute和submit方法,让提交进去的任务能够按照设定的延迟时间被取出执行。它既有多线程并行能力,又有精确的定时调度能力,还有完善的异常隔离机制。
用一个生活化的类比:Timer就像一个只有一个服务员的餐厅,客人排队点餐,服务员要是突然晕倒了整个餐厅就瘫痪了;ThreadPoolExecutor像有多个服务员的餐厅,但没人负责按时间叫号;ScheduledThreadPoolExecutor则是多个服务员加上一套精确的叫号系统,每个服务员独立工作,谁出问题都不影响其他人。
2. 核心参数与调度模式深度拆解
2.1 构造函数里的三个关键参数
ScheduledThreadPoolExecutor的构造函数有好几个重载,但核心参数就三个:
public ScheduledThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory, RejectedExecutionHandler handler)corePoolSize是核心线程数,这个参数决定了同时能并行执行多少个任务。注意,ScheduledThreadPoolExecutor的maximumPoolSize在默认情况下是Integer.MAX_VALUE,也就是说它几乎不会因为线程不够而拒绝任务,而是会不断创建新线程。这一点和普通ThreadPoolExecutor的行为不同,需要特别留意。
threadFactory用来定制线程的创建方式。强烈建议自定义,给线程起一个有意义的名称,比如"data-sync-scheduler-%d"。这样出问题的时候,通过jstack打印线程栈能一眼定位到是哪个业务线的任务出了问题。默认的线程工厂创建的线程名字是pool-N-thread-M,在生产环境排查问题时非常不友好。
handler是拒绝策略。虽然ScheduledThreadPoolExecutor很少触发拒绝,但当线程池被shutdown之后再提交任务,就会走拒绝策略。默认是AbortPolicy,直接抛RejectedExecutionException。如果你的场景需要优雅降级,可以换成DiscardPolicy或者自定义策略。
注意:
ScheduledThreadPoolExecutor的maximumPoolSize虽然默认是Integer.MAX_VALUE,但并不意味着你可以无限制地提交任务。每个任务都会占用内存,任务队列无限增长最终会导致 OOM。所以核心线程数的合理设置非常重要。
2.2 scheduleAtFixedRate 和 scheduleWithFixedDelay 的区别
这是最容易混淆的一对方法,我用一张表把它们的差异说清楚:
| 对比维度 | scheduleAtFixedRate | scheduleWithFixedDelay |
|---|---|---|
| 计时起点 | 从任务开始执行时计时 | 从任务执行结束时计时 |
| 执行间隔 | 固定,不受任务耗时影响 | 固定,但实际间隔 = 任务耗时 + delay |
| 任务耗时超过间隔 | 下一次立即执行,不会并发 | 下一次在任务结束后再等 delay |
| 适用场景 | 对执行频率有严格要求的场景 | 对执行间隔有要求、任务耗时不确定的场景 |
举个例子:假设 delay 设为 5 秒,任务执行耗时 8 秒。
scheduleAtFixedRate:第 0 秒开始执行,第 8 秒结束。因为已经超过了 5 秒的间隔,下一次会立即开始执行(第 8 秒),而不是等到第 10 秒。scheduleWithFixedDelay:第 0 秒开始执行,第 8 秒结束。下一次会在第 8 + 5 = 13 秒开始执行。
我个人的经验是:数据同步类任务用scheduleWithFixedDelay更安全,因为同步耗时受网络和数据量影响波动很大,用固定频率容易导致任务堆积。而心跳检测类任务用scheduleAtFixedRate更合适,因为心跳必须严格按照时间间隔发送,不能因为某次检测耗时长了就推迟下一次。
2.3 任务队列 DelayedWorkQueue 的底层原理
ScheduledThreadPoolExecutor内部使用的是一个叫DelayedWorkQueue的队列,它本质上是一个基于数组实现的最小堆。每次取任务时,堆顶就是延迟时间最早的那个任务。
这个队列有几个设计上的巧妙之处值得一说:
第一,它存储的元素是RunnableScheduledFuture,每个任务都带有一个time字段记录下次执行的时间戳。堆的比较规则就是按这个time排序。
第二,当堆顶任务的延迟时间还没到时,工作线程不会傻等,而是通过Condition.awaitNanos(delay)精确等待剩余时间。这比Thread.sleep的精度高得多,也不会浪费 CPU。
第三,任务执行完之后,如果是周期性任务,会重新计算下次执行时间,然后重新入堆。这个过程在reExecutePeriodic方法里完成。
理解了这个队列,你就能明白为什么ScheduledThreadPoolExecutor的定时精度比你自己写while + sleep高——因为它用的是堆排序加条件等待,而不是轮询。
3. 完整实操:从零搭建一个可靠的定时任务框架
3.1 基础用法与代码模板
先看一个最基础的用法,把核心 API 过一遍:
import java.util.concurrent.*; public class SchedulerDemo { public static void main(String[] args) { // 创建线程池,核心线程数设为 3 ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor( 3, r -> { Thread t = new Thread(r); t.setName("biz-scheduler-" + t.getId()); t.setDaemon(false); return t; }, new ThreadPoolExecutor.AbortPolicy() ); // 延迟 2 秒执行一次 scheduler.schedule(() -> { System.out.println("延迟任务执行: " + System.currentTimeMillis()); }, 2, TimeUnit.SECONDS); // 固定频率:首次延迟 1 秒,之后每 3 秒执行一次 scheduler.scheduleAtFixedRate(() -> { System.out.println("固定频率任务: " + System.currentTimeMillis()); }, 1, 3, TimeUnit.SECONDS); // 固定延迟:首次延迟 1 秒,每次执行完后再等 3 秒 scheduler.scheduleWithFixedDelay(() -> { System.out.println("固定延迟任务: " + System.currentTimeMillis()); }, 1, 3, TimeUnit.SECONDS); } }这段代码可以直接跑,你会看到三个任务各自按照设定的节奏输出。注意线程工厂里我把线程设为非守护线程,这样 JVM 不会因为主线程结束就退出。实际项目中通常由容器管理生命周期,这里只是演示。
3.2 优雅关闭与任务取消的正确姿势
定时任务框架最容易出问题的地方就是关闭。很多人直接调shutdown()就完事了,结果发现有些任务执行到一半被中断,数据处于不一致状态。
正确的关闭流程应该是这样的:
public void gracefulShutdown(ScheduledThreadPoolExecutor scheduler) { // 1. 停止接收新任务 scheduler.shutdown(); try { // 2. 等待已有任务执行完毕,最多等 60 秒 if (!scheduler.awaitTermination(60, TimeUnit.SECONDS)) { // 3. 超时后强制关闭 scheduler.shutdownNow(); // 4. 再给一次机会等待响应中断 if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) { System.err.println("线程池未能正常关闭"); } } } catch (InterruptedException e) { // 当前线程被中断,立即强制关闭 scheduler.shutdownNow(); Thread.currentThread().interrupt(); } }这里有个关键点:shutdown()之后,已经在队列里的周期性任务默认会继续执行。如果你希望关闭时连周期性任务也一起停掉,需要额外设置:
scheduler.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);这两个策略默认值都是true,也就是说默认情况下shutdown()不会阻止已提交的周期任务继续跑。这个设计其实有点反直觉,我第一次用的时候就被坑过——以为调了shutdown()任务就停了,结果日志还在一直打。
关于任务取消,schedule系列方法都会返回一个ScheduledFuture,你可以通过它来取消任务:
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(task, 0, 5, TimeUnit.SECONDS); // 取消任务,参数表示是否中断正在执行的任务 future.cancel(false);cancel(false)表示如果任务正在执行,等它执行完再取消;cancel(true)表示立即中断。对于周期性任务,一旦 cancel 成功,后续就不会再执行了。
3.3 异常处理:让任务不再静默死亡
这是ScheduledThreadPoolExecutor最容易被忽视的坑。看下面这段代码:
scheduler.scheduleAtFixedRate(() -> { int result = 1 / 0; // 抛出 ArithmeticException System.out.println("这行永远不会执行"); }, 0, 1, TimeUnit.SECONDS);你猜会发生什么?任务第一次执行时抛出异常,然后这个周期性任务就永久停止了,后续不会再执行。而且异常被吞掉了,控制台可能什么都看不到(取决于你的日志配置)。
这是因为ScheduledThreadPoolExecutor在run方法里捕获了异常,如果任务抛出异常,它会取消后续的执行。源码里是这样的:
public void run() { boolean periodic = isPeriodic(); if (!canRunInCurrentRunState(periodic)) cancel(false); else if (!periodic) ScheduledFutureTask.super.run(); else if (ScheduledFutureTask.super.runAndReset()) { setNextRunTime(); reExecutePeriodic(outerTask); } }注意runAndReset()这一行,如果任务抛异常,它返回false,那么reExecutePeriodic就不会被调用,任务自然就停了。
解决办法很简单:在任务内部用 try-catch 包住所有逻辑。
scheduler.scheduleAtFixedRate(() -> { try { // 你的业务逻辑 doSomething(); } catch (Throwable t) { // 记录日志,但不要让异常抛出去 log.error("定时任务执行失败", t); } }, 0, 1, TimeUnit.SECONDS);实操心得:我习惯封装一个
SafeRunnable包装类,把所有定时任务都包一层,统一做异常捕获和日志记录。这样业务代码里就不用每次都写 try-catch 了,也避免了遗漏。
4. 生产环境常见问题与排查实录
4.1 任务堆积导致内存溢出
这是生产环境最常见的问题。现象是服务运行一段时间后 GC 频繁,最终 OOM,堆 dump 出来发现DelayedWorkQueue里堆了几十万个任务。
根本原因通常是:任务执行速度跟不上提交速度。比如你设了 1 秒执行一次,但每次任务要跑 3 秒,那队列就会不断堆积。
排查思路:
- 先看线程池的
getQueue().size(),如果持续增长就是堆积了 - 再看任务的平均执行耗时,和设定的间隔对比
- 检查是否有任务被阻塞(比如等待数据库连接、等待锁)
解决方案有几个方向:
- 改用
scheduleWithFixedDelay,让间隔从任务结束开始算 - 增大核心线程数,提高并行处理能力
- 在任务内部加超时控制,避免单个任务无限期阻塞
- 加监控告警,队列长度超过阈值就报警
我一般会在任务里加一段这样的监控代码:
scheduler.scheduleAtFixedRate(() -> { long start = System.currentTimeMillis(); try { doSomething(); } catch (Throwable t) { log.error("任务异常", t); } finally { long cost = System.currentTimeMillis() - start; if (cost > 1000) { log.warn("任务耗时过长: {}ms, 队列大小: {}", cost, scheduler.getQueue().size()); } } }, 0, 1, TimeUnit.SECONDS);4.2 线程池被 shutdown 后任务丢失
前面提到过,shutdown()之后提交任务会触发拒绝策略。但还有一种更隐蔽的情况:shutdown()之后,已经在队列里的延迟任务(非周期性)默认会继续执行,但如果你设置了setExecuteExistingDelayedTasksAfterShutdownPolicy(false),它们就会被丢弃。
这个策略的选择取决于业务需求。对于订单超时取消这类任务,如果服务正在关闭,你可能希望这些任务继续执行完;但对于一些非关键的统计任务,丢弃也无所谓。
我的建议是:关键业务任务用默认策略(继续执行),非关键任务显式设置为 false,并且在关闭时记录日志,方便排查。
4.3 定时精度不准的问题
有人反馈说设了 1 秒执行一次,但实际间隔是 1.2 秒甚至更长。这通常有几个原因:
第一,线程池核心线程数不够。如果所有核心线程都在忙,新任务即使到时间了也得排队等。解决办法是适当增大核心线程数,或者用setCorePoolSize动态调整。
第二,系统负载过高。CPU 被其他进程占满,线程调度延迟增大。这种情况只能从系统层面优化。
第三,任务本身耗时波动大。用scheduleAtFixedRate时,如果某次任务耗时超过了间隔,下一次会立即执行,看起来就像"跳过了"一次。
第四,GC 停顿。Full GC 会导致所有线程暂停,定时任务自然也会延迟。这种情况需要优化内存使用,减少 Full GC 频率。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 任务不再执行 | 任务抛异常被吞 | 检查日志有无异常 | 任务内 try-catch |
| 队列持续增长 | 执行速度跟不上 | 监控 queue.size() | 改 FixedDelay 或加线程 |
| 关闭后任务还在跑 | 默认策略允许 | 检查 shutdown 策略 | 设置策略为 false |
| 定时不准 | 线程不够/GC/负载 | 看线程栈和 GC 日志 | 调大核心线程数 |
| OOM | 任务堆积 | dump 堆看队列 | 加拒绝策略和监控 |
避坑技巧:我习惯在创建
ScheduledThreadPoolExecutor时就把setRemoveOnCancelPolicy(true)设上。默认情况下,取消的任务不会立即从队列中移除,要等到下次执行时才发现被取消了。对于频繁取消任务的场景,这会导致队列里堆积大量已取消的任务,浪费内存。
5. 进阶技巧与性能优化
5.1 动态调整核心线程数
ScheduledThreadPoolExecutor继承了ThreadPoolExecutor的setCorePoolSize方法,可以在运行时动态调整线程数。这在应对突发流量时很有用。
// 业务高峰期调大 scheduler.setCorePoolSize(10); // 低峰期调小 scheduler.setCorePoolSize(2);注意,调小核心线程数时,多余的线程不会立即销毁,而是等当前任务执行完后再退出。调大则会立即创建新线程(如果需要的话)。
5.2 用 ScheduledFuture 做任务编排
多个定时任务之间如果有依赖关系,可以用ScheduledFuture做简单的编排。比如任务 B 必须在任务 A 成功执行后才能执行:
ScheduledFuture<?> futureA = scheduler.schedule(() -> { // 任务 A 的逻辑 }, 0, TimeUnit.SECONDS); scheduler.schedule(() -> { try { futureA.get(); // 等待 A 完成 // 任务 B 的逻辑 } catch (Exception e) { log.error("任务 B 执行失败", e); } }, 1, TimeUnit.SECONDS);不过这种编排方式比较粗糙,复杂的依赖关系建议用专门的流程引擎或者CompletableFuture来做。
5.3 监控指标的采集
生产环境一定要给定时任务加监控。我通常采集这几个指标:
- 活跃线程数:
scheduler.getActiveCount() - 队列大小:
scheduler.getQueue().size() - 已完成任务数:
scheduler.getCompletedTaskCount() - 任务执行耗时:在任务内部埋点统计
这些指标可以暴露给监控系统,设置合理的告警阈值。比如队列大小超过 1000 就告警,任务耗时超过间隔的 80% 就预警。
// 定期上报监控指标 scheduler.scheduleAtFixedRate(() -> { Metrics.gauge("scheduler.active", scheduler.getActiveCount()); Metrics.gauge("scheduler.queue", scheduler.getQueue().size()); Metrics.gauge("scheduler.completed", scheduler.getCompletedTaskCount()); }, 0, 10, TimeUnit.SECONDS);5.4 与 Spring 的 @Scheduled 对比
很多项目用 Spring 的@Scheduled注解来做定时任务,底层其实也是ScheduledThreadPoolExecutor(Spring 5.0 之后默认用这个)。但@Scheduled有几个限制:
- 默认只有一个线程,所有
@Scheduled任务串行执行 - 不支持动态调整执行频率
- 异常处理不够灵活
如果你的项目定时任务不多,用@Scheduled足够了。但如果任务数量多、对可靠性要求高,建议直接手动管理ScheduledThreadPoolExecutor,控制力更强。
我个人的做法是:简单的单机定时任务用@Scheduled,复杂的、需要精细控制的场景直接上ScheduledThreadPoolExecutor。两者并不冲突,可以在同一个项目里混用。
5.5 分布式环境下的注意事项
ScheduledThreadPoolExecutor是单机方案,在分布式环境下每个节点都会执行自己的定时任务。如果你的任务不允许重复执行(比如每天结算一次),就需要引入分布式锁或者选主机制。
常见的做法是用 Redis 或 ZooKeeper 实现分布式锁,任务执行前先抢锁,抢到才执行。或者用专门的分布式调度框架,把调度和执行分离。
这个展开讲就超出本文范围了,但你在做技术选型时一定要意识到这个限制。单机定时任务在集群环境下会变成 N 倍执行,这个坑我见过不止一个团队踩过。
6. 写在最后的一些个人体会
ScheduledThreadPoolExecutor这个类我用了很多年,从最初的只会调scheduleAtFixedRate,到后来踩遍各种坑,再到现在能根据业务场景灵活选型。最大的体会是:定时任务看似简单,实则暗坑无数。
几个我认为最重要的经验:永远在任务内部捕获异常,否则任务会静默死亡;永远给线程起有意义的名字,否则排查问题时你会想砸键盘;永远加监控,否则你根本不知道任务有没有在正常跑;永远想清楚关闭策略,否则服务重启时可能丢任务或者任务停不下来。
还有一个容易被忽视的点:不要在定时任务里做耗时太长的操作。如果确实需要,考虑把任务拆成"调度"和"执行"两部分,调度线程只负责触发,具体执行交给另一个业务线程池。这样调度线程不会被阻塞,定时精度更有保障。
这个类本身不复杂,源码也就几百行,但要用好它需要对并发编程有基本的理解。希望这篇内容能帮你少走一些弯路。如果你在实际项目中遇到了其他问题,欢迎一起交流。