从「任务怎么跑」到「任务什么时候跑」
第 10 章把线程池的参数、队列、拒绝策略和关闭流程讲完了,解决的是「任务提交后由谁执行」。定时任务多了一个维度:不是提交即执行,而是到点才执行。
JDK 里有两个答案。老的是 java.util.Timer(JDK 1.3),新的是 ScheduledThreadPoolExecutor(JDK 5,以下简称 STPE)。很多遗留代码还在用前者,很多事故也就出在从前者迁移到后者的那一步没做干净。
先把结论摆出来,后面用实验逐条验证:
| 维度 | Timer | ScheduledThreadPoolExecutor |
|---|---|---|
| 执行线程 | 固定 1 条 | corePoolSize 条 |
| 任务间影响 | 一个任务慢,全部延后 | 有闲线程就不受影响 |
| 任务抛异常 | Timer 线程死亡,全部任务停止 | 只取消该周期任务,池继续工作 |
| 时间基准 | System.currentTimeMillis() | System.nanoTime() |
| 周期语义 | schedule 固定延迟,scheduleAtFixedRate 固定频率 | 两种都显式提供 |
| 关闭 | cancel(),无等待 | shutdown() + awaitTermination |
Timer 的三个设计选择
Timer 的实现只有两个内部类:TaskQueue(一个小顶堆)和 TimerThread(唯一的执行线程)。三个缺陷不是 bug,是它的设计选择带来的必然后果。
一条线程,串行执行
public class Timer {
private final TaskQueue queue = new TaskQueue();
private final TimerThread thread = new TimerThread(queue);
// 所有 schedule 最终都落到 sched(task, time, period)
}
Timer 没有提供设置线程数的构造器,只有 Timer()、Timer(boolean isDaemon)、Timer(String name)、Timer(String name, boolean isDaemon)。名字和守护属性可以配,线程数不能配。
TimerThread.mainLoop 的循环很简单:取出堆顶任务,算出还要等多久,wait(delay),醒来执行。执行是在这条线程上同步做的,所以任何一个任务的耗时都会直接加到后面所有任务的触发时刻上。
本机实测(Apple M1 Pro,load averages 159,JDK 25.0.5+8):
== 实验一:单线程串行 ==
A(睡 300ms) 触发于 +0ms(期望 0ms)
B 触发于 +317ms(期望 50ms)
C 触发于 +318ms(期望 100ms)
B 约定 50ms 触发,实际 317ms;C 约定 100ms,实际 318ms。两者都排在 A 的 300ms 睡眠之后。注意 B 和 C 只差 1ms——它们是在 A 结束后被连续取出来执行的,说明调度精度在排队面前没有意义。
生产上的典型场景:每小时执行的数据同步任务里有一次全表扫描跑了 40 分钟,那么这一小时内所有分钟级任务全部推迟,且推迟是累积的——因为 Timer 只维护「下一次触发时刻」,不维护「应该触发几次」。
一个异常,整个 Timer 死亡
TimerThread.mainLoop 没有对 task.run() 做 catch。TimerTask.run 抛出的任何 RuntimeException 会一路穿出 mainLoop,穿出 TimerThread.run,线程终止。
timer.scheduleAtFixedRate(new TimerTask() {
@Override public void run() {
throw new IllegalStateException("boom"); // 没有 try/catch
}
}, 0, 100);
timer.scheduleAtFixedRate(healthyTask, 0, 100); // 永远等不到执行
本机实测:
== 实验二:Timer 异常终止 ==
timer 第 1 次执行,抛异常
Exception in thread "t-boom" java.lang.IllegalStateException: boom
at TimerVsStpe$2.run(TimerVsStpe.java:61)
at java.base/java.util.TimerThread.mainLoop(Timer.java:572)
at java.base/java.util.TimerThread.run(Timer.java:522)
700ms 后 timer 执行次数 = 1,Timer 线程存活 = false
四个后果值得逐条说:
第一个后果:同一个 Timer 上的健康任务一次都没跑。它 0ms 就该首次执行,但异常任务先抢到了线程,抛异常后 Timer 线程直接没了,健康任务连第一次机会都没有。
第二个后果:队列里没执行的任务被静默清空。TimerThread.run 的 finally 会把 newTasksMayBeScheduled 置为 false 并 queue.clear(),所有已排定但未执行的任务连同引用一起丢弃,没有任何日志。
第三个后果:之后提交会抛一个归因错误的异常。本机实测:
alive=false
schedule 抛出: java.lang.IllegalStateException: Timer already cancelled.
消息说「already cancelled」,但这个 Timer 从没被 cancel() 过——它是线程死了。运维看到这条日志会去查「谁取消了定时器」,而真正的原因在更早的堆栈里。
第四个后果:异常只打印到 stderr。没有线程池的 UncaughtExceptionHandler 钩子可用,日志框架也接不住,只有默认的未捕获异常输出。
相对延迟被换算成墙上时钟
Timer.schedule(task, delay) 看着是相对时间,源码里第一步就把它变成了绝对时间:
public void schedule(TimerTask task, long delay) {
if (delay < 0)
throw new IllegalArgumentException("Negative delay.");
sched(task, System.currentTimeMillis()+delay, 0);
}
sched 保存的是 System.currentTimeMillis() + delay 这个毫秒时间戳,TimerThread.mainLoop 每次循环都会重新读一次 currentTimeMillis() 来比较。
字节码可以直接看到两个类的取时函数不同:
$ javap -p -c java.util.TimerThread | grep -A2 currentTimeMillis
88: invokestatic #51 // Method java/lang/System.currentTimeMillis:()J
$ javap -p -c java.util.concurrent.ScheduledThreadPoolExecutor | grep -B3 nanoTime
long triggerTime(long);
Code:
0: invokestatic #116 // Method java/lang/System.nanoTime:()J
nanoTime 是单调的,currentTimeMillis 不是。NTP 校时、手工改时间、虚拟机暂停恢复,都会让后者跳变:
- 时钟向后跳 1 小时:所有已排定的任务多等 1 小时,期间不会有任何提示;
- 时钟向前跳:任务提前触发,固定频率任务可能在短时间内连续补跑多次。
需要诚实说明:我没有实测时钟跳变。改系统时钟需要 root,而且会影响整台机器上其它正在跑的实验,代价大于收益。这一条的证据是源码(sched 里那次 +delay)和字节码(两个类的取时函数),不是运行输出。
STPE 从设计上避开了这个问题:它的 schedule 系列方法只接受相对延迟,内部 triggerTime(delay) 用 nanoTime 计算,Javadoc 明确写着「Delayed tasks execute no sooner than they are enabled, but without any real-time guarantees」。反过来说,STPE 也没法表达「明天上午 9 点」这种绝对时刻——那需要在业务层算出距离目标时刻的毫秒数,并且自己处理时钟跳变。
ScheduledThreadPoolExecutor 的三处不同
多线程与任务隔离
STPE 继承 ThreadPoolExecutor,执行线程数由 corePoolSize 决定。Javadoc 里有一句容易被忽略的话:
it acts as a fixed-sized pool using
corePoolSizethreads and an unbounded queue, adjustments tomaximumPoolSizehave no useful effect.
也就是说 maximumPoolSize 对 STPE 没有意义,真正的并发度就是 corePoolSize。它也建议不要把 corePoolSize 设为 0,否则任务到期时可能没有线程来接。
异常只杀死那一个周期任务
同样的代码换成 STPE:
ScheduledThreadPoolExecutor stpe = new ScheduledThreadPoolExecutor(2, factory);
stpe.scheduleAtFixedRate(() -> { throw new IllegalStateException("boom"); },
0, 100, TimeUnit.MILLISECONDS);
stpe.scheduleAtFixedRate(healthy::increment, 0, 100, TimeUnit.MILLISECONDS);
本机实测:
== 实验三:STPE 异常隔离 ==
stpe 出错任务第 1 次执行,抛异常
700ms 后:出错任务执行 1 次(首次后即被取消),健康任务执行 8 次
行为分解:
- 抛异常的那次执行,异常被包进该任务对应的
ScheduledFuture,周期任务被取消,不会再执行第二次; - 工作线程不死,池继续从队列里取任务;
- 健康任务在 700ms 内跑了 8 次(0、100、…、700ms 共 8 个点),完全不受影响。
这里有个陷阱:异常被吞进了 Future,不会打印到任何地方。如果调用方扔掉了 scheduleAtFixedRate 返回的 ScheduledFuture,这个周期任务就静默消失了,日志里一个字都没有。Timer 至少还会打一段堆栈,STPE 连堆栈都没有。所以周期任务必须自己兜住异常:
private Runnable guarded(Runnable body) {
return () -> {
try {
body.run();
} catch (Throwable t) {
log.error("scheduled task failed, will retry next round", t);
}
};
}
注意这里是 Throwable 而不是 Exception:Error 同样会取消周期任务,虽然不该吞掉 OutOfMemoryError,但至少要让它在日志里出现一次,而不是无声消失。
固定频率与固定延迟是两个方法
Timer.schedule(task, delay, period) 的 period 是固定延迟语义,Timer.scheduleAtFixedRate 才是固定频率。STPE 把这两件事拆成两个名字明确的方法:
Runnable task = this::sweep;
scheduler.scheduleAtFixedRate(task, 0, 200, TimeUnit.MILLISECONDS); // 下次 = 上次计划时刻 + period
scheduler.scheduleWithFixedDelay(task, 0, 200, TimeUnit.MILLISECONDS); // 下次 = 本次结束 + delay
差别的实测与漂移的累积方式,放到 11.3 节用时间戳逐次展开。这里只记一条 Javadoc 的硬约束:
Successive executions of a periodic task scheduled via
scheduleAtFixedRateorscheduleWithFixedDelaydo not overlap.
同一周期任务不会并发执行,即使 corePoolSize 大于 1。想并行处理,必须把任务拆成多个独立的调度项,或者用「调度器只负责投递、执行交给另一个池」的两段式结构。
在 libraryd-conc 里落地
借阅系统里有一个典型定时任务:每小时扫描逾期借阅并发送催还通知。用 STPE 的正确形态:
public final class OverdueNoticeScheduler implements AutoCloseable {
private final ScheduledThreadPoolExecutor scheduler;
private final LoanRepository loans;
private final Supplier<List<String>> overdueIds; // 逾期查询端口,不属于领域契约
private final Consumer<Loan> notifier;
private final ScheduledFuture<?> handle;
public OverdueNoticeScheduler(LoanRepository loans, Supplier<List<String>> overdueIds,
Consumer<Loan> notifier) {
this.loans = loans;
this.overdueIds = overdueIds;
this.notifier = notifier;
this.scheduler = new ScheduledThreadPoolExecutor(2, r -> {
Thread t = new Thread(r, "overdue-notice");
t.setDaemon(false); // 业务任务,不能随 JVM 退出被砍
return t;
});
this.scheduler.setRemoveOnCancelPolicy(true);
this.scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
this.handle = scheduler.scheduleWithFixedDelay(
guarded(this::sweep), 0, 1, TimeUnit.HOURS);
}
private Runnable guarded(Runnable body) {
return () -> {
try {
body.run();
} catch (Throwable t) {
// 周期任务不能被一次异常取消,这里必须兜住
System.err.println("overdue sweep failed: " + t);
}
};
}
void sweep() {
Instant now = Instant.now();
for (String loanId : overdueIds.get()) {
loans.findById(loanId).ifPresent(loan -> {
if (loan.dueAt().isBefore(now)) {
notifier.accept(loan);
}
});
}
}
@Override
public void close() {
handle.cancel(false);
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(30, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
四个选择各有理由:
scheduleWithFixedDelay而不是scheduleAtFixedRate:扫描耗时随逾期数量变化,用固定延迟保证两轮之间有 1 小时间隔,不会因为某一轮跑了 50 分钟就立刻开始下一轮。setRemoveOnCancelPolicy(true):默认情况下已取消的任务要等到原定触发时刻才从队列移除,长期运行会积累大量取消项(Javadoc 明确写了可能导致「unbounded retention」)。guarded包一层:没有它,一次数据库超时就让催还任务永久消失。shutdown+awaitTermination+ 兜底shutdownNow:第 10.3 节的两段式关闭,这里直接用上。
close() 里先 cancel(false) 再 shutdown() 是有意的:shutdown() 默认会继续执行已到期的延迟任务(executeExistingDelayedTasksAfterShutdownPolicy 默认 true),cancel 保证已经排定的下一次不会再进队列。同时把该策略也设为 false,是因为不是所有关闭路径都会经过 close(),直接调 shutdown() 的代码也得有同样的语义。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
在 Timer 任务里做长耗时操作 | 后续任务全部推迟,且延迟累积 | 换 STPE,或定时器只负责投递 |
Timer 任务不 catch 异常 | Timer 线程死亡,所有任务静默停止 | 每个任务自带 try/catch;生产代码不用 Timer |
| 往已死亡的 Timer 提交任务 | 抛 Timer already cancelled,消息误导;未执行的任务已被清空 | 同上,用 STPE 并保留 ScheduledFuture |
| STPE 周期任务不 catch | 该任务被静默取消,日志无输出 | 包一层 guarded,或注册 afterExecute |
用 scheduleAtFixedRate 跑长任务 | 任务背靠背执行,越拖越远 | 改用 scheduleWithFixedDelay 或缩短单次耗时 |
忘记 setRemoveOnCancelPolicy | 取消的任务长期占队列 | 构造后立即设置 |
close 只 cancel 不 shutdown | 线程存活,JVM 不退出(非 daemon) | 两段式关闭 |
什么时候不要用 Timer。 任何生产代码。它唯一还合理的场景是单文件小工具、一次性脚本,且任务体不会抛异常、不会超过几毫秒。只要代码会进服务端,就用 STPE。
什么时候 STPE 也不合适。 三种情况:
- 需要绝对时刻语义(每天 9:00、每月 1 号):STPE 只接受相对延迟,自己算间隔要处理时钟跳变和夏令时,最好用带 cron 表达式的外部调度;
- 需要跨实例只执行一次:单机 STPE 没有选主能力,多副本会重复执行,要靠分布式锁或外部调度中心;
- 任务量在十万级以上且精度要求不高:每个任务一个
ScheduledFuture对象 + 堆节点,内存开销明显,用时间轮更合适(11.2 节展开)。
小结
Timer的三个缺陷来自三个设计选择:单线程串行、mainLoop不捕获异常、相对延迟换算成currentTimeMillis的绝对时刻。实测中 300ms 的任务把 50ms 与 100ms 的任务分别推到 317ms 和 318ms。- Timer 任务抛异常后 Timer 线程死亡,同 Timer 上的健康任务一次都没执行(700ms 内总执行次数 = 1);队列被清空,之后提交抛
IllegalStateException: Timer already cancelled.,消息把「线程死亡」误报成「已取消」。 - STPE 用
corePoolSize条线程,异常只取消当前周期任务:同样的场景 700ms 内出错任务执行 1 次、健康任务执行 8 次。代价是异常被吞进ScheduledFuture,必须自己兜住。 - STPE 的周期任务不会重叠执行,
maximumPoolSize对它无效;setRemoveOnCancelPolicy(true)应当作为标准配置。 - 生产代码不用
Timer;需要绝对时刻、跨实例唯一执行或海量定时任务时,STPE 也不是终点。
下一节换一个角度:不讨论「用哪个调度器」,而是讨论延迟任务背后的数据结构——DelayQueue 的堆与 leader 线程,以及时间轮的槽位与复杂度。
阅读导航:上一节 10.3 监控、动态调参与关闭 | 下一节 11.2 延迟队列与时间轮
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。