一句话结论(30s)
ScheduledThreadPoolExecutor 的本质是”多线程线程池 + 基于最小堆的 DelayedWorkQueue”,因为 Timer 的单线程会把慢任务阻塞、异常会静默杀死整条调度线。关键设计是 DelayedWorkQueue 用数组实现二叉堆,堆顶永远是最近要执行的任务,取堆顶 O(1)、插入删除 O(log n)。权衡是牺牲 Timer 的简单,换来多线程隔离、抗 NTP 漂移(nanoTime)和异常后继续服务的可靠性。
核心原理(2min)
任务先进入 DelayedWorkQueue,worker 线程 take 时看堆顶:若 delay<=0 取走并下沉堆化,否则 awaitNanos(delay) 等待;到点后交给线程池并行执行。调度分两种模式——fixed-rate 用”上一次计划开始时间 + period”追节奏(适合监控采样),fixed-delay 用”上一次实际完成时间 + delay”(适合批处理避免堆积)。
底层深入(5-10min)
Java的定时任务调度经历了从Timer到ScheduledThreadPoolExecutor的演进。如果你还在用Timer,可能需要重新审视——它有一些你未必意识到的致命缺陷。
Timer的问题:单线程的定时炸弹
Timer的实现非常直接:一个后台线程 + 一个优先级队列(TaskQueue,内部是二叉堆的实现)。后台线程循环从队列中取任务,计算等待时间,sleep到点执行。
问题就出在”一个后台线程”上:
问题一:任务互相阻塞。 如果前一个任务执行时间超过了它的周期,后一个任务就得排队等。所有TimerTask共享一个线程,排期再精确也没用。
想一想:为什么单线程的 Timer 排期再精确也没用?因为所有 TimerTask 共用一个后台线程,前一个慢任务占着线程,后一个到点也只能排队等——单线程决定了”排期精确”无法兑现为”按时执行”。
问题二:异常静默失败。 Timer线程如果抛出未捕获的异常,这个线程就挂了——而且不会重启。之后所有排好的任务都不会再执行,而且没有任何日志和警告。
想一想:为什么一个异常会”杀死”整条调度线?因为 Timer 的后台线程抛出未捕获异常就终止、且不会重启,后续所有已排任务都跟着失联,还不会有任何日志——这是单线程模型最隐蔽的坑。
Timer timer = new Timer();
timer.schedule(new TimerTask() {
public void run() { throw new RuntimeException("炸了"); }
}, 1000);
timer.schedule(new TimerTask() {
public void run() { System.out.println("永远不会打印"); }
}, 2000);
// 第一个任务异常后,第二个任务永远不会执行
问题三:时间漂移。 fixed-delay模式下(schedule方法),Timer用System.currentTimeMillis()做时间基准,系统时间被NTP调整会导致任务提前或延迟执行。
ScheduledThreadPoolExecutor的架构
ScheduledThreadPoolExecutor extends ThreadPoolExecutor,本质是多线程线程池 + 专用延迟队列。
核心数据结构是DelayedWorkQueue——一个基于**最小堆(二叉堆)**的无界阻塞队列。堆顶始终是最近需要执行的任务。
DelayedWorkQueue的内部实现
下面是 JDK 中 DelayedWorkQueue 的真实源码(核心字段 + offer/take + 堆化):
// 核心字段
private static final int INITIAL_CAPACITY = 16;
private RunnableScheduledFuture<?>[] queue =
new RunnableScheduledFuture<?>[INITIAL_CAPACITY];
private final ReentrantLock lock = new ReentrantLock();
private int size;
private Thread leader;
private final Condition available = lock.newCondition();
// 插入任务(offer):尾部插入 + 上浮
public boolean offer(Runnable x) {
if (x == null)
throw new NullPointerException();
RunnableScheduledFuture<?> e = (RunnableScheduledFuture<?>)x;
final ReentrantLock lock = this.lock;
lock.lock();
try {
int i = size;
if (i >= queue.length)
grow();
size = i + 1;
if (i == 0) {
queue[0] = e;
setIndex(e, 0);
} else {
siftUp(i, e);
}
if (queue[0] == e) {
leader = null;
available.signal();
}
} finally {
lock.unlock();
}
return true;
}
// 取任务(take):看堆顶,没到点就等
public RunnableScheduledFuture<?> take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
for (;;) {
RunnableScheduledFuture<?> first = queue[0];
if (first == null)
available.await();
else {
long delay = first.getDelay(NANOSECONDS);
if (delay <= 0L)
return finishPoll(first);
first = null; // don't retain ref while waiting
if (leader != null)
available.await();
else {
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
available.awaitNanos(delay);
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
if (leader == null && queue[0] != null)
available.signal();
lock.unlock();
}
}
// 上浮:与父节点比较,时间早的上浮
private void siftUp(int k, RunnableScheduledFuture<?> key) {
while (k > 0) {
int parent = (k - 1) >>> 1;
RunnableScheduledFuture<?> e = queue[parent];
if (key.compareTo(e) >= 0)
break;
queue[k] = e;
setIndex(e, k);
k = parent;
}
queue[k] = key;
setIndex(key, k);
}
// 下沉:与较小的子节点比较,时间晚的下沉
private void siftDown(int k, RunnableScheduledFuture<?> key) {
int half = size >>> 1;
while (k < half) {
int child = (k << 1) + 1;
RunnableScheduledFuture<?> c = queue[child];
int right = child + 1;
if (right < size && c.compareTo(queue[right]) > 0)
c = queue[child = right];
if (key.compareTo(c) <= 0)
break;
queue[k] = c;
setIndex(c, k);
k = child;
}
queue[k] = key;
setIndex(key, k);
}
// 取堆顶:末尾元素补到堆顶,再下沉堆化
private RunnableScheduledFuture<?> finishPoll(RunnableScheduledFuture<?> f) {
int s = --size;
RunnableScheduledFuture<?> x = queue[s];
queue[s] = null;
if (s != 0)
siftDown(0, x);
setIndex(f, -1);
return f;
}
插入/删除的时间复杂度是O(log n),取堆顶是O(1)。用数组存堆而非链表的对象节点,既有缓存友好性(数据连续)又避免了指针开销。take() 里藏着一个 Leader-Follower 模式:只有一个”leader”线程用 awaitNanos(delay) 精确等到队头任务到点,其余线程 available.await() 无限等,leader 醒来后 signal() 唤醒下一个——避免所有线程一起醒、一起抢、又一起睡回去的”惊群”。
想一想:为什么只让一个 leader 线程
awaitNanos精确等待?因为多个线程一起精确等待会”惊群”——一起醒、一起抢、又一起睡回去,白白浪费 CPU;用 leader 独占等待、醒来signal下一个,就能有序传递。
拿到任务后,ScheduledThreadPoolExecutor 通过 delayedExecute() 把它塞进队列并确保有线程消费:
private void delayedExecute(RunnableScheduledFuture<?> task) {
if (isShutdown())
reject(task);
else {
super.getQueue().add(task);
if (!canRunInCurrentRunState(task) && remove(task))
task.cancel(false);
else
ensurePrestart();
}
}
ensurePrestart() 会在 workerCount 为 0 时补一个 addWorker(null, false) 线程去消费队列——这正是上一篇文章里 execute() 二次检查的同一套兜底逻辑。入队后若池子刚好 shutdown,则把任务 remove 掉并 cancel,避免定时任务悬空。不同的定时任务可以被不同的线程并行执行,互不阻塞。
fixed-rate vs fixed-delay:两种调度模式的本质区别
JDK 里用一个 period 字段的正负号区分两种模式——正数 fixed-rate,负数 fixed-delay,0 表示一次性任务:
/**
* Period for repeating tasks, in nanoseconds.
* A positive value indicates fixed-rate execution.
* A negative value indicates fixed-delay execution.
* A value of 0 indicates a non-repeating (one-shot) task.
*/
private final long period;
// 计算下一次执行时间
private void setNextRunTime() {
long p = period;
if (p > 0)
time += p; // fixed-rate:上次计划时间 + period
else
time = triggerTime(-p); // fixed-delay:now + delay
}
// ScheduledFutureTask.run():周期任务执行后重新入队
public void run() {
if (!canRunInCurrentRunState(this))
cancel(false);
else if (!isPeriodic())
super.run();
else if (super.runAndReset()) {
setNextRunTime();
reExecutePeriodic(outerTask);
}
}
而提交入口里,scheduleAtFixedRate 传正 period、scheduleWithFixedDelay 传负 delay:
// scheduleAtFixedRate:period 为正
ScheduledFutureTask<Void> sft =
new ScheduledFutureTask<Void>(command,
null,
triggerTime(initialDelay, unit),
unit.toNanos(period),
sequencer.getAndIncrement());
// scheduleWithFixedDelay:delay 取负
ScheduledFutureTask<Void> sft =
new ScheduledFutureTask<Void>(command,
null,
triggerTime(initialDelay, unit),
-unit.toNanos(delay),
sequencer.getAndIncrement());
scheduleAtFixedRate(固定速率):
|--任务1--| |--任务2--| |--任务3--|
0s 10s 20s 30s
下一次任务的开始时间 = 上一次任务的计划开始时间 + period。即使上一次任务执行了很长时间,下一次也会尽量追回节奏——如果上一次延迟了太久导致多次执行”积压”,会合并执行。
想一想:为什么 fixed-rate 要”追节奏”而不是”顺延”?因为它以”计划开始时间 + period”为基准,即使上次执行慢了,下一次也按固定频率补,必要时把积压的多次执行合并——保证采集/心跳有稳定的时间节拍。
scheduleWithFixedDelay(固定延迟):
|--任务1--| | |--任务2--| | |--任务3--|
0s 10s 15s 25s 28s 38s
下一次任务的开始时间 = 上一次任务的实际完成时间 + delay。前一个任务结束了才开始计时,不受执行时长影响。
这两种模式的选择取决于业务语义:
- fixed-delay适合”批处理”类任务——前一批处理完了再等一段时间开始下一批,避免堆积。
- fixed-rate适合”监控采样”类任务——需要固定频率采集数据,即使任务执行慢也要保持采集节奏。
// fixed-rate:每10秒发一次心跳
scheduler.scheduleAtFixedRate(() -> sendHeartbeat(), 0, 10, TimeUnit.SECONDS);
// fixed-delay:上一批处理完后等30秒再跑下一批
scheduler.scheduleWithFixedDelay(() -> processBatch(), 0, 30, TimeUnit.SECONDS);
ScheduledThreadPoolExecutor vs Timer 对比总结
| 特性 | Timer | ScheduledThreadPoolExecutor |
|---|---|---|
| 线程模型 | 单线程 | 多线程池 |
| 异常处理 | 线程死亡,任务丢失 | 任务异常后线程继续服务 |
| 任务隔离 | 无,互相阻塞 | 不同任务不同线程,隔离 |
| 调度精度 | 依赖System.currentTimeMillis | 依赖System.nanoTime,抗NTP |
| 追赶策略 | 无 | fixed-rate可追赶 |
使用建议
- 生产环境定时任务优先用
ScheduledThreadPoolExecutor,不要用Timer。 - 如果任务量很大或者是分布式环境,更推荐用专门的任务调度框架(如Quartz、XXL-JOB),它们提供持久化、分布式协调、失败重试等能力。
- 对于Spring Boot项目,
@Scheduled注解底层默认用的就是单线程的TaskScheduler,高并发时记得配置线程池大小。 corePoolSize决定了能同时执行的定时任务数,如果所有核心线程都在忙,新到时间的任务会排队。根据业务并发度适当设置。
章末提问
追问 1:为什么生产环境不该用 Timer?
回答思路:结论先行——Timer 是单线程,有三个致命缺陷:任务互相阻塞、异常静默杀死整条调度线、用 currentTimeMillis 受 NTP 漂移影响。因为所有 TimerTask 共享一个后台线程,前一个慢任务阻塞后一个;线程抛未捕获异常就终止且不重启、后续任务全部丢失;时间基准用墙钟时间,NTP 调时会导致任务提前或延迟。
追问 2:DelayedWorkQueue 凭什么能做到取堆顶 O(1)、插入 O(log n)?
回答思路:结论先行——因为它用数组实现最小二叉堆,堆顶永远是最近要执行的任务。因为堆顶取走时把末尾元素补到根再下沉堆化(siftDown),插入时尾插上浮(siftUp),都是 O(log n);数组存堆比链表节点更缓存友好、省指针开销。
追问 3:fixed-rate 和 fixed-delay 的本质区别?怎么选?
回答思路:结论先行——fixed-rate 用”上一次计划开始时间 + period”追节奏,fixed-delay 用”上一次实际完成时间 + delay”。因为前者即使上次执行慢也按固定频率补、必要时合并积压任务,适合监控采样;后者等上次完成后才计时、避免堆积,适合批处理。