线程状态、中断、取消与超时:让任务真正可控地结束
服务发布时,容器已经发出停止信号,进程却迟迟不退出。线程栈里有一个 report-worker,它仍在循环取任务;代码里明明调用了 interrupt(),线程却像没收到一样继续运行。这样的事故很适合用来理解 Java 线程:中断不是强制终止指令,而是一份需要任务代码配合处理的取消请求。
如果只会 new Thread() 和 start(),任务可以开始,却不一定能停下来。真正可上线的任务还要回答:谁拥有它,谁能取消它,阻塞点能否响应中断,超时从哪一刻开始计算,退出前怎样释放连接和文件,服务关闭最多等多久。先把这套终止协议立住,后面的线程池、异步编排和虚拟线程才有可靠地基。
先复现一个“收到中断却不退出”的任务
下面这段代码看上去捕获了 InterruptedException,实际把取消信号吞掉了:
Thread worker = new Thread(() -> {
while (true) {
try {
Thread.sleep(1_000);
doOneBatch();
} catch (InterruptedException ignored) {
// 中断状态已被 sleep 清除,这里继续循环,任务就停不下来
}
}
}, "report-worker");
worker.start();
worker.interrupt();
worker.join(2_000);
System.out.println("alive=" + worker.isAlive());sleep、wait、join 以及许多 JUC 阻塞方法检测到中断后会抛出 InterruptedException,并清除当前线程的中断状态。捕获后什么也不做,等价于替上层撤销了取消请求。修复并不复杂:如果当前方法能够结束,就清理资源后返回;如果它没有权力决定结束,就恢复中断状态并把决定交给调用者。
try {
while (!Thread.currentThread().isInterrupted()) {
doOneBatch();
Thread.sleep(1_000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
closeResources();
}这里有三个动作,顺序不能乱:发现取消、停止继续接活、进入 finally 清理。中断只负责第一步,业务代码必须补齐后两步。
start 之后,线程状态只是线索
Thread.start() 会安排新线程执行 run();直接调用 run() 只是当前线程的一次普通方法调用。线程正常返回或异常退出后进入 TERMINATED,同一个 Thread 实例不能再次启动。
Java 暴露六种状态:
| 状态 | 常见现场 | 下一步判断 |
|---|---|---|
NEW | 对象已创建但未 start | 是否漏启动或尚未提交 |
RUNNABLE | 正在执行,也可能等待 OS 资源 | 结合 CPU、native 栈和多次快照 |
BLOCKED | 等待进入某个 monitor | 找 monitor 持有者与锁顺序 |
WAITING | wait、join、park 无期限等待 | 找应该完成、通知或唤醒的一方 |
TIMED_WAITING | sleep、带时限的 wait/join/park | 检查剩余预算是否合理 |
TERMINATED | run 已结束 | 检查结果、异常和清理是否完成 |
WAITING 不自动等于故障。线程池 worker 空闲时会等队列,CountDownLatch 使用者会等计数归零,这些都是正常等待。反过来,RUNNABLE 也不等于正在消耗 CPU,它可能卡在 socket 或文件系统调用。一次线程栈只能告诉你某一刻在哪里,要连续取样才能判断它是否一直困在同一位置。
jcmd "$PID" Thread.print -l > threads-1.txt
sleep 10
jcmd "$PID" Thread.print -l > threads-2.txt
sleep 10
jcmd "$PID" Thread.print -l > threads-3.txt在本地最小实验里,可以用 Thread.getState() 看状态迁移;在线上不要写轮询业务去依赖这个瞬时值,诊断工具拿到的状态同样会在下一刻改变。
这张图只画 Java 观察到的状态,不承诺操作系统调度器内部也按同样状态划分。
中断是一条协作协议,不是异步杀线程
调用 target.interrupt() 时,Java 会设置目标线程的中断状态;如果目标正在某些可中断阻塞点等待,阻塞方法会提前返回或抛 InterruptedException。如果目标正在执行普通计算,中断不会突然把栈帧销毁,代码必须主动检查状态。
while (!Thread.currentThread().isInterrupted()) {
calculateOneChunk();
}长计算不要每条指令都检查,可以按数据块或迭代次数设置检查点。检查太稀,取消延迟不可控;检查太密,会增加热点路径成本。更重要的是,检查点必须落在可回滚边界:半写文件、半更新外部状态时直接返回,可能比不取消更糟。
isInterrupted() 只读取状态,Thread.interrupted() 读取并清除当前线程的状态。业务代码很少需要后者;无意清除会让更外层再也看不到取消请求。
catch (InterruptedException e) {
rollbackCurrentChunk();
Thread.currentThread().interrupt();
return;
}中断也不是万能的。传统阻塞 IO、native 调用、第三方驱动或不可中断的锁竞争可能不会按预期立即响应。设计取消协议时,必须给真正的资源操作配置连接、读取、SQL 或 RPC 超时;不能只靠 Java 中断包住一个无限等待的下游调用。
Future.cancel(true) 到底保证了什么
把任务提交给 ExecutorService 后,调用者通常拿到 Future:
Future<Result> future = executor.submit(() -> queryReport());
try {
return future.get(800, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new ReportTimeoutException(e);
}get 超时只说明等待者不再等,不代表底层任务已经停止。cancel(true) 会在可能的情况下请求中断正在执行任务;任务是否及时结束,仍取决于它是否响应中断、下游调用是否有超时、异常路径是否退出。cancel(false) 则不请求中断已经开始的任务,更适合不能安全中止但结果已无人需要的场景。
因此取消要分四层观察:
接口返回超时但后台 SQL 又执行了 30 秒,是很典型的“等待已取消、工作未取消”。监控不能只看入口超时次数,还要看取消后的在途任务、连接池占用和下游执行时间。
沿 FutureTask 的状态机追一次取消
只看 Future.isCancelled() 很容易误判:它描述的是“结果对象已经接受取消”,不是“执行线程已经退出”。要把两件事拆开,最直接的办法是沿 FutureTask 的状态推进。任务最初处于 NEW;正常计算结束后完成为 NORMAL 或 EXCEPTIONAL;取消则从 NEW 竞争到 CANCELLED,或者在请求中断时经过 INTERRUPTING 再到 INTERRUPTED。这些名称是 OpenJDK FutureTask 的实现状态,不是 Future 接口要求所有实现照抄的枚举,但它们揭示了真正的取消窗口。
完成线程与取消线程都必须先赢得从 NEW 出发的状态更新。若计算已经提交结果,后来的 cancel 返回 false;若取消先赢,随后调用 run 会发现任务不再是 NEW,任务体根本不会执行。运行中取消更复杂:取消方标记正在中断,读取并中断 runner,最后发布中断完成;执行方退出前清空 runner,还要避开那个短暂的中间状态,防止中断误落到线程接下来承接的另一个任务上。
仓库中的 FutureTaskStateDemo 固定了开始前与运行中两个窗口:
cd examples/backend-development/concurrency/threads-interrupt-cancel
javac --release 17 -Xlint:all -Werror FutureTaskStateDemo.java
java FutureTaskStateDemo第一行会同时出现 cancelled=true、done=true,但任务体没有运行;第二组用门闩确认任务已经进入 sleep 后才取消,并用 finally 的完成信号证明清理走完。真正的退出证据是 cleaned=true 与 alive=false,不是 Future 的两个查询方法。若任务吞掉中断继续循环,cancel(true) 仍可能返回 true,而 alive 会保持为真,这就是“结果已取消、工作未停止”的分裂现场。
所以取消指标至少分三层:请求是否被接受、执行线程从请求到退出用了多久、资源清理是否完成。只统计第一层,会把后台继续占用连接、CPU 或文件句柄的任务误记成已结束。
超时时长和截止时间不是一回事
一个请求依次经过网关、应用、线程池、数据库。如果每层都重新设置“最多 1 秒”,实际总耗时可能远超入口承诺。更稳的做法是从入口生成绝对截止时间,向下传递剩余预算:
final class Deadline {
private final long deadlineNanos;
Deadline(Duration budget) {
this.deadlineNanos = System.nanoTime() + budget.toNanos();
}
long remainingMillis() throws TimeoutException {
long nanos = deadlineNanos - System.nanoTime();
if (nanos <= 0) {
throw new TimeoutException("deadline exceeded");
}
return Math.max(1, TimeUnit.NANOSECONDS.toMillis(nanos));
}
}计算持续时间要用单调时钟 System.nanoTime(),不要用可能因校时跳变的墙上时间。每经过一次排队或调用,就重新计算剩余预算。队列里已经等掉 700ms 的任务,不能再获得完整 800ms 的 SQL 超时。
业务还要区分三种结果:正常完成、业务失败、取消/超时。把超时统一包装成“系统异常”会诱发无脑重试;把取消记成成功又会掩盖容量问题。
服务关闭时,先停止接活,再等待在途任务
后台 worker、线程池和长任务都需要明确所有者。关闭顺序通常是:入口停止接收新任务,执行器 shutdown(),等待有界时间,超时后 shutdownNow() 请求中断,最后核对仍未结束的任务。
executor.shutdown();
try {
if (!executor.awaitTermination(20, TimeUnit.SECONDS)) {
List<Runnable> neverStarted = executor.shutdownNow();
recordUnstarted(neverStarted);
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
alertExecutorStillAlive();
}
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}shutdownNow() 也不是强杀,它会尝试中断活跃任务并返回尚未开始的队列任务。支付、状态流转或文件写入任务若不能安全重做,必须先有幂等键、进度记录或补偿日志,不能等发布时才讨论未完成任务怎么办。
线程是否 daemon 也不能替代关闭协议。daemon 线程不会阻止 JVM 退出,进程结束时它可能来不及 flush 日志或完成清理;核心业务任务不应靠 daemon 属性“自动收尾”。
一次停不下来的任务怎样取证
看到进程无法退出,先拿证据,不要连续重复发送中断:
jcmd "$PID" Thread.print -l > shutdown-stuck.txt
grep -n -E 'report-worker|java.lang.Thread.State|InterruptedException' shutdown-stuck.txt判断顺序如下:
线程是否还在接新任务,入口关闭是否真正生效。栈顶在可中断等待、不可中断锁、native IO,还是业务计算循环。捕获 InterruptedException 的代码是否恢复状态或退出。
下游连接、SQL、文件操作是否有自己的超时。finally 是否又执行了无期限清理,例如无限等待另一个线程。
如果三次线程栈都停在同一个业务循环,而且中断状态长期为真,说明任务没有检查点;如果停在驱动调用,先查驱动超时和连接状态;如果多个任务互相 join,要转到死锁与依赖环分析。
把终止协议变成代码评审问题
任何会启动线程或提交长任务的代码,评审时至少回答:
任务所有者是谁,生命周期跟进程、请求还是业务实体绑定?取消入口是什么,是否会向真正执行工作的线程和下游传播?每个阻塞点是否有超时,计算循环是否有安全检查点?
中断异常由当前层消费、恢复还是继续抛出,理由是什么?半完成状态如何回滚、补偿或幂等重做?关闭时先停入口还是先停 worker,最大等待多久?
如何观察取消延迟、仍在运行任务和未开始任务?
把取消结果、执行退出与资源清理分开验收
两份完整源码位于 examples/backend-development/concurrency/threads-interrupt-cancel/:InterruptCancellationDemo.java 证明普通任务响应中断并完成清理,FutureTaskStateDemo.java 对比开始前取消与运行中取消。进入该目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out InterruptCancellationDemo.java FutureTaskStateDemo.java
java -cp out InterruptCancellationDemo
java -cp out FutureTaskStateDemo第一份输出稳定包含 cancelled=true cleanup=true,latencyMs 随调度变化;第二份稳定包含:
before-run: accepted=true, cancelled=true, done=true
running: accepted=true, cancelled=true, done=true, cleaned=true, alive=falseaccepted=true 只证明取消赢得结果状态,alive=false 才证明执行线程退出,cleaned=true 才证明 finally 完成。生产验收也必须保留三层口径:取消请求接受率、从请求到执行退出的 p95/p99、退出后资源归还完成率。关闭演练还要同时断言入口不再接活、等待预算有上界、未开始任务有明确去向;只统计 Future.isCancelled() 会把后台仍占连接的任务记成成功结束。
这些问题一旦有明确答案,中断就不再是散落的 try/catch,而是一条从入口截止时间延伸到资源清理的工程协议。
继续核对规范时,可查看 Java Thread API 对中断状态、阻塞方法和异常处理的说明,以及 Java Thread.State 对六种状态的定义。
