承接的问题
6.1 说明了 interrupt 的语义:它只是设置标志,线程必须自己检查。但真实的服务不是单个线程,而是一个「取任务—执行—再取任务」的循环,外加一个队列。要把它干净地停下来,需要一套协议,而不是一次 interrupt() 调用。
这套协议叫两阶段终止(two-phase termination):
- 阶段一:发出停止请求。设置一个所有线程都能看到的标志,拒绝新任务。
- 阶段二:等待在途工作结束。给一个明确的上限,超时之后才升级为强制手段。
Thread.stop() 曾经是「一步到位」的答案,但它在 JDK 25 上已经直接抛 UnsupportedOperationException。历史教训是:强行终止会让对象停留在半更新状态,锁没释放、缓冲区写了一半、事务没提交。协作式取消慢一点,但状态可控。
三个动作,一个都不能少
一个正确的关停流程由三个动作组成:
| 动作 | 作用 | 少了会怎样 |
|---|---|---|
volatile boolean running = false | 让工作线程看见停止请求 | 线程永远在 while (true) 里打转 |
thread.interrupt() | 把线程从 poll/take/sleep 里叫醒 | 空闲线程要等下一次轮询超时才退出 |
thread.join(timeout) | 确认线程真的结束 | 方法返回了,线程还在写数据 |
running 必须用 volatile 修饰。第 3 章的 happens-before 规则保证:写线程对 running 的写,对随后读到 false 的线程可见。如果只是普通字段,工作线程可能一直读到缓存里的 true,关停就变成了「偶尔有效」。
interrupt() 在这里的职责很窄:只负责唤醒,不负责终止。它让阻塞在 poll 上的线程立刻返回,然后由 running 决定是否退出。把两者混在一起——用中断标志代替停止标志——就会出现在途任务被误杀的问题,下面的实测会看到。
join(timeout) 是阶段二的边界。没有超时的 join() 等于把系统的可用性交给一个可能已经死循环的线程;有超时但没有后续动作,又等于假装关停成功。正确做法是:超时后记录日志、升级中断、再给一个短窗口。
完整的 Worker 类
下面这个类把三个动作封装在一起,可以直接放进 conc-core:
public final class Worker implements AutoCloseable {
private final BlockingQueue<Runnable> tasks;
private final Thread thread;
private final Object lifecycle = new Object();
private volatile boolean running = true;
public Worker(int capacity, String name) {
this.tasks = new ArrayBlockingQueue<>(capacity);
this.thread = Thread.ofPlatform().name(name).start(this::loop);
}
public void submit(Runnable task) {
synchronized (lifecycle) {
if (!running) {
throw new IllegalStateException("worker stopped");
}
tasks.add(task);
}
}
private void loop() {
while (running) {
Runnable task;
try {
task = tasks.poll(200, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (!running) {
break; // 阶段二:中断只为唤醒
}
continue; // 意外中断:继续工作
}
if (task == null) {
continue;
}
try {
task.run();
} catch (RuntimeException e) {
System.err.println("task failed: " + e);
}
}
}
public List<Runnable> shutdown(Duration timeout) throws InterruptedException {
synchronized (lifecycle) {
running = false; // 阶段一:拒绝新任务
}
thread.join(timeout.toMillis()); // 阶段二:限时等待
if (thread.isAlive()) {
thread.interrupt(); // 超时才升级为硬中断
thread.join(500);
}
List<Runnable> pending = new ArrayList<>();
tasks.drainTo(pending);
return pending;
}
public boolean isAlive() {
return thread.isAlive();
}
@Override
public void close() {
synchronized (lifecycle) {
running = false;
}
thread.interrupt();
}
}
几个设计选择值得单独说明。
为什么用 poll(200ms) 而不是 take()。 take() 会无限阻塞,常规的唤醒手段只有中断或投一个哨兵任务;poll 带超时,即使中断丢失,线程最多 200ms 后也会自己检查 running。这是「不依赖单一信号」的防御性设计,代价是空闲时每 200ms 醒一次。
为什么 shutdown 返回 List<Runnable>。 队列里还没开始的任务不会被自动执行,也不应该被静默丢弃。把它们取出来交给调用方,是「取消边界」的一部分:调用方可以选择重新入队、写补偿表或落盘,而不是让它们随线程一起消失。
为什么 InterruptedException 在循环里分两种情况。 如果 running 已经是 false,中断是关停流程的一部分,直接退出;如果不是,说明是外部误中断,直接继续工作即可。这里没有重新 interrupt(),因为线程马上就要继续跑;只有在退出循环、需要把信号交给上层时,才应该恢复标志。
为什么 submit 和 shutdown 都要拿 lifecycle 锁。 只检查 running 再 add 存在竞态:关停线程可能正好在两步之间置位并排空队列,任务就既没执行、也没被返回。把「读标志 + 入队」与「置标志」放进同一把锁,才能保证关停之后不会再有任务溜进队列。
为什么 close() 直接中断。 AutoCloseable 语义通常用于 try-with-resources 的快速清理,不等在途任务。需要优雅关停时显式调用 shutdown(Duration)。
三种关停路径的取舍
| 路径 | 在途任务 | 队列任务 | 延迟 | 适用场景 |
|---|---|---|---|---|
只发信号 + poll 超时 | 跑完 | 返回给调用方 | 最坏一个轮询周期 | 任务可重入、不能丢 |
发信号后立即 interrupt | 被打断 | 返回给调用方 | 接近 0 | 任务可丢弃、要求快停 |
信号 + join(timeout) + 超时升级 | 先等,超时才断 | 返回给调用方 | 由 timeout 决定 | 通用默认 |
实测:三种关停路径
用一个 8 容量、轮询 200ms 的 Worker,提交 3 个 50ms 的短任务和 1 个 300ms 的长任务,20ms 后关停:
完成 quick-0
[signal-only] 耗时 28ms, 完成 1 个, 遗留 3 个, 线程存活=false
quick-0 是正在执行的那个,它跑完后循环看到 running == false,线程退出;quick-1、quick-2 和 slow 还在队列里,作为 pending 返回。28ms 接近 quick-0 剩下的执行时间(关停发生在提交后 20ms,任务总长 50ms),与轮询周期无关——轮询超时只在队列空、线程空闲时才决定退出延迟。
同一份任务,改为「发信号后立即中断」:
取消 slow
任务异常: java.util.concurrent.CancellationException: slow
[interrupt-now] 耗时 0ms, 完成 0 个, 线程存活=false
slow 正在 Thread.sleep(3000),中断立刻把它从睡眠里拽出来,任务抛出 CancellationException,完成计数为 0。这就是「在途任务被误杀」:阶段一刚刚发出停止请求,阶段二还没来得及等,工作就被打断了。如果你的任务有副作用(写库、发消息),这种关停方式必须配合补偿或幂等。
第三种:任务完全不检查中断,shutdown(300ms) 会怎样?
[timeout] 300ms 阶段二超时, 耗时 515ms, 线程存活=true, 遗留 0
[timeout] 再 interrupt 后线程存活=true
聋任务自然结束
join(300ms) 等满 300ms,interrupt() 对纯 CPU 循环无效,再等 200ms 仍然活着,方法只能返回 false 语义的结果(调用方通过 isAlive() 判断)。515ms 就是 300 + 200 加少量开销。两阶段终止不是万能的:它只能请求,不能强制。 真正兜底的是进程级手段(下一节的 shutdown hook 也只负责最后一程)。
Future.cancel(true) 的局限
线程池把取消包装成 Future.cancel(boolean mayInterruptIfRunning)。很多代码把它当成「停止任务」的保证,实际上它只是「尝试中断执行该任务的线程」。先看一个完全不看中断标志的任务:
Future<?> f = pool.submit(() -> {
long end = System.nanoTime() + 1_500_000_000L;
long n = 0;
while (System.nanoTime() < end) {
n++;
}
System.out.println("[ignore] 任务仍跑完, 迭代 " + n);
});
Thread.sleep(100);
boolean cancelled = f.cancel(true);
本机输出摘录:
[ignore] cancel(true)=true, isCancelled=true, isDone=true
[ignore] get() 抛 CancellationException
[ignore] 任务仍跑完, 迭代 109861993
cancel(true) 返回 true,isCancelled() 是 true,isDone() 也是 true,get() 立刻抛 CancellationException——但任务还在跑,1.5 秒后才自然结束。Future 的状态机描述的是「调用方还能不能拿到结果」,不是「任务有没有停止」。
任务吞掉中断时也一样:
[swallow] 捕获后吞掉, 继续执行
[swallow] catch 之后继续跑到自然结束
[swallow] cancel(true)=true
[swallow] 300ms 后 isDone=true, 但任务打印还在后面
cancel(true) 在 100ms 时发出中断,任务捕获后没有恢复标志,继续执行到自然结束。isDone 早已是 true,进程却还在干活。
cancel(true) 的能力边界可以列成一张清单:
| 局限 | 说明 |
|---|---|
| 只是请求 | 任务不检查中断、或不进入可中断阻塞,就停不下来 |
| 状态提前完成 | isDone() 在 cancel 返回时即为 true,不代表线程已退出 |
| 无法确认停止时刻 | 没有「等待任务真正结束」的 API,只能配合自己的 latch |
| 可能停在半路 | 中断点由 JVM 决定,可能正好在两次写之间 |
| 不负责回滚 | 已经发生的副作用不会撤销 |
| 中断发给线程,不是任务 | 无法保证中断恰好落在目标任务上 |
对比之下,cancel(false) 对还没开始的任务是可靠的:
[queued] cancel(false)=true, isCancelled=true
任务还在队列里,取消只是把它标记为不执行,没有任何并发风险。所以一个实用规则是:能取消尚未开始的任务,就不要去中断正在执行的任务;必须中断时,任务自身要按 6.1 的规范响应中断,并在 finally 里做清理。
shutdownNow() 也是同一类语义。本机实测:
[shutdownNow] 未启动任务数=5, awaitTermination=true, isShutdown=true, isTerminated=true
shutdownNow() 返回 5 个从未启动的任务,并向 2 个在途线程发中断;awaitTermination(1s) 能等到它们结束,是因为这两个任务恰好响应了中断。如果它们像前面的「聋任务」一样,awaitTermination 就会返回 false,线程池也不会真正终止。
在 libraryd-conc 里落地
把 Worker 用到批量写借阅流水上:
public final class LoanBatchService {
private final Worker worker;
private final LoanRepository loans;
public LoanBatchService(LoanRepository loans, int capacity) {
this.loans = loans;
this.worker = new Worker(capacity, "loan-batch");
}
public void submit(Loan loan) {
worker.submit(() -> loans.save(loan));
}
public List<Runnable> shutdown(Duration timeout) throws InterruptedException {
// 返回值必须由调用方接管:重新入队、写补偿表或落盘
return worker.shutdown(timeout);
}
public boolean isAlive() {
return worker.isAlive();
}
}
这段代码的取消边界有三个明确约定:
submit在关停后抛IllegalStateException,不会静默丢弃。调用方必须处理这个异常,通常是把请求转成 503 或写入待办表。- 在途的
loans.save允许跑完。Loan有loanId,保存按 id 覆盖,重复执行是幂等的;这是「可以等」的前提。 - 队列里的任务由调用方接管。
shutdown不会执行它们,也不会假装执行过。关停流程必须把返回的List<Runnable>落盘或重放,否则这些借阅记录就丢了。
如果业务要求「一条都不能丢」,更稳妥的是把队列本身持久化(数据库 outbox),Worker 只做消费者。内存队列 + 两阶段终止能保证「不丢正在执行的」,但保证不了「不丢还没开始的」——后者需要存储,不是线程 API 能解决的。
常见错误与边界
| 错误 | 后果 | 修法 |
|---|---|---|
running 不加 volatile | 工作线程看不见停止请求 | 加 volatile,或用 AtomicBoolean |
| 用中断标志代替停止标志 | 在途任务被误杀 | 两个信号分开:running 管意图,interrupt 管唤醒 |
join() 不设超时 | 关停线程被一个死循环拖死 | 必须给上限,超时后升级并记录 |
shutdown 丢弃 pending | 队列任务静默丢失 | 返回给调用方或持久化 |
在 shutdown 里先 interrupt 再置标志 | 任务可能在标志生效前被中断 | 先置标志,再中断 |
用 cancel(true) 当「已停止」 | 资源已关闭,任务还在写 | 自己维护完成 latch,或以 isAlive/awaitTermination 为准 |
什么时候不要用两阶段终止:
- 任务的副作用不可撤销、也不幂等(扣款、发短信)。此时取消必须走补偿事务或 outbox,不能只靠中断。
- 关停窗口只有几百毫秒、任务又可能跑几分钟。与其假装优雅,不如明确「快速失败 + 启动时对账」。
- 你想用它保证「恰好一次」。两阶段终止保证的是「尽量不丢、最终能停」,不是分布式事务。
小结
- 两阶段终止的三个动作缺一不可:
volatile停止标志表达意图,interrupt唤醒阻塞,join(timeout)确认结束。 - 只发信号会让在途任务跑完、队列任务遗留;立即中断会误杀在途任务;超时升级是通用折中,但无法强制一个不检查中断的任务。
- 本机实测:只发信号 28ms 结束、遗留 3 个任务;立即中断 0ms 结束但取消了在途任务;300ms 超时升级最终耗时 515ms,线程仍然存活。
Future.cancel(true)只是中断请求,isDone提前变true不代表任务停止;能取消未开始的任务时优先cancel(false),必须中断时任务要自己响应并清理。
单机线程能这样停,整个 JVM 退出时又该在哪里做最后一轮清理?6.3 讲 shutdown hook 的能力边界:它能保证什么、绝不保证什么,以及为什么多个 hook 之间的顺序不可依赖。
阅读导航:上一节 6.1 interrupt 的真实语义 | 下一节 6.3 shutdown hook 与释放顺序
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。