qinghuan
← 回到知识探索

后端现场 / 动手实验

定时任务只跑一次就没了,线程池真的停了吗?

用 JDK 17 的真实调度器观察一次异常如何结束周期序列,再把任务状态、执行结果与继续策略分别记录。

展开本文目录
  1. 下载完整练习包
  2. 先区分一次执行、周期序列和线程池
  3. 先复现失败,再提交另一项任务作对照
  4. 检查任务结果,别把日志缺失当成成功
  5. 已知失败要不要继续,由任务规则决定
  6. 固定频率与执行后等待,解决的是不同时间问题
  7. 运行完整程序,等待明确事件而不是猜时间
  8. 最后分清异常结束和主动关闭
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 会运行 Java 单文件程序,能读懂 try/catch 与 lambda。
  • 了解线程池用于执行提交的任务。
  • 准备 JDK 17;本例不依赖 Spring、数据库或外部服务。

一个任务配置为每隔一段时间执行,启动时确实打印过一次日志,后来却再也没有输出。服务进程还在,其他接口也正常。此时先别把现象概括为“定时器失效”:有可能是某次执行抛出异常,这一个周期序列已经结束,而调度器仍然在处理其他任务。

本篇直接使用 JDK 17 的 ScheduledThreadPoolExecutor。实验真的创建工作线程,保留任务的 Future,通过带超时的等待取得结果,再验证另一项任务还能执行。它不模拟框架定时注解,也不测试分布式调度或精确计时。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17;制作时实测 Microsoft OpenJDK 17.0.20.1。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • PeriodicFailureDemo.java:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的观察记录
  • verification.json:对应源码的验证范围
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
java --source 17 PeriodicFailureDemo.java

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 真实 JDK 17 调度器与有限等待,不代表 Spring 调度、持久任务或线上定时精度。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先区分一次执行、周期序列和线程池

一次执行是这回调用任务函数;周期序列是调度器安排它重复调用的整体;线程池则可以容纳多项任务。提交 scheduleAtFixedRate 会返回代表整个重复序列的 ScheduledFuture。保留这个对象,才能查询或取消该序列,而不仅是在日志里寻找某次执行。

JDK 文档明确规定,周期任务某次执行抛出异常后,后续执行会被抑制。返回的 Future 变为已完成,调用 get 会得到带原因的 ExecutionException。这里的完成表示序列已结束,并不等于业务成功;正常运行中的无限周期序列也不会因为刚成功执行一次,就让 get 正常返回。

先复现失败,再提交另一项任务作对照

代码创建只有一个工作线程的调度器,提交一个第一次执行便抛 IllegalStateException 的周期任务。主线程对它的 Future 调用带五秒上限的 get,取得异常原因,断言执行次数是一、isDone 为真、isCancelled 为假。这把异常结束与显式取消分开观察。

随后,代码向同一个调度器提交独立的一次性任务,等待它返回 still alive,并检查调度器尚未关闭。因此这里支持的结论是“这个失败序列停止了,另一个任务仍能执行”。本例没有统计底层线程身份,也无需靠进程是否活着来推断所有任务都健康。

检查任务结果,别把日志缺失当成成功

只记录“已提交定时任务”,只能证明调用过提交接口。后面可以分别观察最近开始时间、最近成功时间、失败原因和序列状态:有开始没有成功,和根本没有开始,是不同问题。本文的结果列表只记录两次教学执行;在长期开启的服务里,应把这些信息接到实际可查询的记录方式。

如果扩展 ScheduledThreadPoolExecutor 并使用 afterExecute,也要读它的文档边界:这个类用 Future 包装任务,传给 afterExecute 的第二个 Throwable 参数可以仍是 null。

不能仅凭它为空宣布任务没报错;应在合适的完成状态下检查 Future。也不要对仍在正常循环的周期 Future 无超时地调用 get,等待它像单次任务一样返回。

已知失败要不要继续,由任务规则决定

第二条周期序列模拟一个已知、没有外部写入效果的临时失败:第一次抛专用异常,catch 记录“本次失败,下个周期继续”;第二次记录成功。被捕获的异常没有逃出任务,因此调度器可以安排下一次。本例同时保留失败和成功记录,不会为了继续运行而把第一回伪装成成功。

未知 RuntimeException 走另一分支:记录后继续抛出,使该序列结束。示例没有提供“捕获一切就一直重试”的通用政策。若任务会扣款、发消息或修改持久数据,失败时可能已有部分效果;继续到下一周期之前,还需明确如何识别已完成部分。本篇没有实现这些业务恢复能力,也不据此承诺重复执行安全。

固定频率与执行后等待,解决的是不同时间问题

scheduleAtFixedRate 按初始延迟加周期安排后续时刻;scheduleWithFixedDelay 则在上一次结束后,再等待给定延迟。前一项失败实验采用固定频率,后一项继续实验采用固定延迟。选择哪种方式不会取消“未捕获异常结束该序列”的规则,两种方法都有这条约定。

如果同一周期任务执行时间超过周期,固定频率的后续执行可能晚到,但不会因此把同一序列的两次执行并发重叠。调度器也不保证到点立即获得 CPU。本例十毫秒只是让实验迅速结束,没有测量实际间隔,更没有证明准点性;不要把设置值抄成实测性能数据。

运行完整程序,等待明确事件而不是猜时间

将下方完整代码保存为 PeriodicFailureDemo.java,使用 JDK 17 运行。程序先输出失败序列状态与独立任务结果,再输出已知失败、第二次成功和最终取消状态。全部断言通过才打印 PASS。五秒限制用于让异常环境尽快报失败,不是说任务正常执行应花五秒。

主线程用 Future 等待第一项结果,用 CountDownLatch 等待第二条序列完成两次尝试,再等待线程池退出。代码没有睡一小会儿便猜“现在大概跑完了”。若超时,保留报错去检查机器负载或程序变化,不能直接增加睡眠并删掉断言来让演示看起来成功。

展开收起代码 · 小实验java · 94 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/** JDK 17; actual scheduler, bounded waits, no timing benchmark or persistent jobs. */
public class PeriodicFailureDemo {
    static final long LIMIT_SECONDS = 5;

    static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }

    static void await(CountDownLatch latch, String label) throws InterruptedException {
        check(latch.await(LIMIT_SECONDS, TimeUnit.SECONDS), "timed out: " + label);
    }

    static final class KnownTemporaryFailure extends RuntimeException {
        KnownTemporaryFailure(String message) { super(message); }
    }

    public static void main(String[] args) throws Exception {
        ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(1);
        scheduler.setRemoveOnCancelPolicy(true);
        AtomicInteger brokenRuns = new AtomicInteger();
        AtomicInteger controlledRuns = new AtomicInteger();
        List<String> outcomes = new CopyOnWriteArrayList<>();
        AtomicReference<Throwable> unexpected = new AtomicReference<>();
        CountDownLatch completedTwo = new CountDownLatch(1);
        try {
            ScheduledFuture<?> broken = scheduler.scheduleAtFixedRate(() -> {
                brokenRuns.incrementAndGet();
                throw new IllegalStateException("deliberate periodic failure");
            }, 0, 10, TimeUnit.MILLISECONDS);

            Throwable cause;
            try {
                broken.get(LIMIT_SECONDS, TimeUnit.SECONDS);
                throw new AssertionError("periodic future returned normally");
            } catch (ExecutionException failure) {
                cause = failure.getCause();
            }
            check(cause instanceof IllegalStateException, "wrong failure cause");
            check(broken.isDone() && !broken.isCancelled(), "failure is not explicit cancellation");
            check(brokenRuns.get() == 1, "failed series should stop after first execution");

            String independent = scheduler.schedule(() -> "still alive", 0, TimeUnit.MILLISECONDS)
                    .get(LIMIT_SECONDS, TimeUnit.SECONDS);
            check(independent.equals("still alive"), "other task did not run");
            check(!scheduler.isShutdown(), "scheduler was unexpectedly shut down");
            System.out.println("failed series: runs=" + brokenRuns.get()
                    + " done=" + broken.isDone() + " cancelled=" + broken.isCancelled());
            System.out.println("failure cause: " + cause.getClass().getSimpleName());
            System.out.println("independent task: " + independent);

            // A policy for this known, side-effect-free demo failure only.
            ScheduledFuture<?> controlled = scheduler.scheduleWithFixedDelay(() -> {
                int attempt = controlledRuns.incrementAndGet();
                try {
                    if (attempt == 1) throw new KnownTemporaryFailure("fixture temporarily unavailable");
                    outcomes.add("attempt " + attempt + ": success");
                } catch (KnownTemporaryFailure known) {
                    outcomes.add("attempt " + attempt + ": failed; continue next tick");
                } catch (RuntimeException failure) {
                    unexpected.set(failure);
                    throw failure; // Unknown failure ends this periodic series.
                } finally {
                    if (attempt == 2) {
                        // Stop this demo's periodic series from inside its own task.
                        scheduler.shutdown();
                        completedTwo.countDown();
                    }
                }
            }, 0, 10, TimeUnit.MILLISECONDS);

            await(completedTwo, "two controlled attempts");
            check(scheduler.awaitTermination(LIMIT_SECONDS, TimeUnit.SECONDS), "scheduler did not stop");
            check(unexpected.get() == null, "unexpected task error");
            check(controlledRuns.get() == 2, "expected exactly two controlled attempts");
            check(outcomes.equals(List.of("attempt 1: failed; continue next tick", "attempt 2: success")),
                    "outcome log mismatch");
            check(controlled.isDone() && controlled.isCancelled(), "shutdown should cancel this series");
            for (String outcome : outcomes) System.out.println(outcome);
            System.out.println("controlled series: cancelled after two attempts=" + controlled.isCancelled());
            System.out.println("PASS: per-task failure, independent task, explicit continuation, clean shutdown");
        } finally {
            scheduler.shutdownNow();
            if (!scheduler.awaitTermination(LIMIT_SECONDS, TimeUnit.SECONDS)) {
                throw new AssertionError("worker did not terminate");
            }
        }
    }
}

怎样运行:JDK 17;保存为 PeriodicFailureDemo.java,执行 java --source 17 PeriodicFailureDemo.java。真实工作线程,带上限等待;没有外部业务调用。 2026-10-13 已用 Microsoft OpenJDK 17.0.20.1 执行这份示例并通过自带断言;下方为当次输出。真实 JDK 17 调度器与有限等待,不代表 Spring 调度、持久任务或线上定时精度。

运行后对照结果
failed series: runs=1 done=true cancelled=false
failure cause: IllegalStateException
independent task: still alive
attempt 1: failed; continue next tick
attempt 2: success
controlled series: cancelled after two attempts=true
PASS: per-task failure, independent task, explicit continuation, clean shutdown

最后分清异常结束和主动关闭

继续实验在第二次尝试的 finally 中主动调用 shutdown,并发出完成信号。这个调度器的默认策略不在关闭后继续周期任务,因此该序列最终显示已取消。这是为让教学程序有限结束而明确安排的动作,和前一条任务异常结束、线程池仍运行的路径不同。

主线程在 finally 里还执行清理并检查退出,避免遗留工作线程。shutdown 只发起关闭,不能单独证明已经退出;awaitTermination 提供等待结果。shutdownNow 也只是尝试中断,任务不响应中断时未必停止。本例任务短且没有阻塞外部调用,验证范围不能扩大成任何业务任务都能立即停下。

记住这三点

  • 一个周期序列异常结束,不等于整个调度器关闭。
  • Future 已完成可能是失败或取消,不能当作业务成功。
  • 继续执行要有明确异常范围、失败记录与业务恢复规则。

别把例子用过头

  • 丢掉 ScheduledFuture,只看到进程存在就认为任务仍在循环。
  • 吞掉所有异常让任务继续,却没有任何可查看的失败记录。
  • 用短暂 sleep 判断异步完成,或把配置周期写成实测执行间隔。

自己试一小步

如果同一调度器中有两条周期序列,其中一条第三次才抛未知异常,怎样证明另一条还能继续?

  1. 自选两个不同任务标识,先写下各自应记录哪些状态和次数,再实现第三次失败的序列。
  2. 用 Future 与明确事件同步观察两条序列,不用固定睡眠猜测执行已经完成。
  3. 加入自己的取消与退出步骤,并另外记录一次已知失败继续的情况,避免把所有结束原因混在一起。

做到这些,就算完成

  • 提交两条独立的执行记录与异常原因,证据发生在其中一条失败之后。
  • 明确区分失败、取消和成功执行次数,清理后没有遗留线程。
  • 只对实际运行的 JDK 调度器下结论,没有声称已覆盖 Spring 注解、重启恢复或分布式调度。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Java 17:ScheduledExecutorService (新标签页)↗Oracle · 英文

    阅读 scheduleAtFixedRate 与 scheduleWithFixedDelay:时间定义、异常抑制后续执行、周期 Future 不正常返回,以及同序列执行不重叠的规则。

    公开的作者或官方原始资料;本文只采用列出的阅读范围。

  • Java 17:ScheduledThreadPoolExecutor (新标签页)↗Oracle · 英文

    阅读类说明的非实时保证、execute 中 afterExecute 的 Throwable 提示、setContinueExistingPeriodicTasksAfterShutdownPolicy 默认值,以及 shutdown、shutdownNow 和 awaitTermination 相关说明。

    公开的作者或官方原始资料;本文只采用列出的阅读范围。

  • Java 17:Future (新标签页)↗Oracle · 英文

    阅读 isDone、isCancelled、get 与带超时 get:完成包含正常、异常和取消,get 提供异常原因或等待超时;支持任务状态的区分。

    公开的作者或官方原始资料;本文只采用列出的阅读范围。

  • Java 17:CountDownLatch (新标签页)↗Oracle · 英文

    阅读 countDown、await 与带超时 await 的返回约定及 Memory consistency effects;支持等待明确事件及按超时结果验收。

    公开的作者或官方原始资料;本文只采用列出的阅读范围。