代码里写了 thread.interrupt(),然后线程照常跑完了十秒的循环。开发者以为中断没生效,改成在循环里加 Thread.stop()——这次线程真的停了,但连接池里的对象留在半初始化状态,第二天出现了一批脏数据。
interrupt() 不是"停止线程",而是设置一个标志并唤醒阻塞点。
这个设计选择有历史原因:强制停止线程会导致对象处于不一致状态,而 JVM 无法知道你的临界区在哪。所以 Java 选择了协作式——把"何时安全"的判断交给写代码的人。
它的语义是"建议你停下来",而要不要停、在哪停,由被中断的代码决定。
1. interrupt 做的三件事
| 动作 | 说明 |
|---|---|
| 设置中断标志 | isInterrupted() 变 true |
| 唤醒阻塞 | sleep/wait/join 抛 InterruptedException |
| 清标志(部分情况) | 抛异常时标志被清除 |
标志被清除不是 bug 而是刻意设计:它让"处理完这次中断"与"继续下一次中断"成为两条独立的事。但对"捕获后不处理"的代码来说,这个清除等于把信号丢掉了。
第三项是最容易误解的:InterruptedException 抛出后,中断标志已被清除。所以"捕获异常后继续循环"等于把中断吞掉了——线程照常跑,而你认为已经通知过它。
// 反例:吞掉中断,线程继续跑
try { Thread.sleep(100); } catch (InterruptedException e) { /* 什么也不做 */ }
// 正例:要么向上抛,要么恢复标志后退出
try { Thread.sleep(100); }
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复标志,交给上层
return; // 并主动退出
}
2. 不阻塞的代码为什么不会响应中断
| 代码形态 | 是否响应 |
|---|---|
sleep / wait / join |
是(抛异常) |
BlockingQueue.take() |
是(抛异常) |
| 纯计算循环 | 否 |
synchronized 等锁 |
否 |
NIO 的 Selector.select |
是(返回或抛) |
普通 InputStream.read |
否 |
第四项与第六项是最容易踩的:等锁与读文件都不会响应中断。所以"点了取消但十分钟没停"这类问题,通常卡在这两处。
第六项(普通 InputStream.read)有个实用替代:换成 InterruptibleChannel 或设置读超时,把"不可中断"变成"可超时"。这比在循环里轮询标志更干净。
纯计算循环需要自己检查标志:在循环体里每若干次迭代检查一次 Thread.interrupted()。注意大小写——interrupted() 会清除标志,isInterrupted() 不清除。
// 长计算:主动检查标志(带频率控制,避免每次迭代都查)
int i = 0;
while (i < n) {
if ((i & 0x3FF) == 0 && Thread.currentThread().isInterrupted()) {
throw new CancellationException("cancelled at " + i);
}
work(i++);
}
3. 三种"取消"的语义差别
| 方式 | 语义 |
|---|---|
interrupt() |
协作式,由被中断方决定 |
标志位(自定义 volatile boolean) |
纯协作,不唤醒阻塞 |
Future.cancel(true) |
等价于 interrupt |
| 进程退出 | 强制,不可控 |
三种方式还有一个"可观测性"差别:interrupt() 会留下明确异常(容易在日志里发现),而自定义标志被忽略时是全静默的——你只能看到"线程还在跑",看不到"有人要求过它停"。
第二种与第一种的差别在阻塞点:自定义标志不会唤醒 sleep,所以最坏情况要等一次 sleep 结束。而 interrupt() 能立刻打断。
第四项是"最后手段":它不给任何清理机会,线程池、文件句柄、数据库连接都可能是半关闭状态。
4. 正确的中断处理模板
| 位置 | 做法 |
|---|---|
| 库代码(不知道上层意图) | 恢复标志后向上抛 |
| 应用代码(知道要退出) | 清理后退出 |
| 框架层(线程池 worker) | 记录并退出,不吞异常 |
| 循环中 | 检查标志 + 有限清理 |
第一项是最重要的约定:库代码不该决定"要不要停",它只负责把"有人要求停"这件事传递上去。
第四项(循环中检查 + 有限清理)需要注意"清理也要可中断":清理过程若阻塞很久,取消就变成了"十分钟后停止"。所以清理应当设上限,或只做不可省略的那几件事。
第三项常见错误是:线程池的 worker 捕获 InterruptedException 后继续取下一个任务,于是"取消"变成"跳过执行",而调用方以为任务已终止。
// 应用层:清理后退出,并把中断传给上层
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
Task t = queue.take(); // 阻塞点:可被中断
execute(t);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复标志
} finally {
cleanup(); // 释放资源
}
}
中断还有一个"层级传递"的问题:任务内部再起线程时,父线程的中断不会自动传给孩子。所以嵌套并发的组件必须自己实现"收到中断 → 通知子线程",否则外层取消了,内层还在跑。
// 中断的向下传递:父被中断时通知子
ExecutorService inner = Executors.newFixedThreadPool(4);
try {
Future<?> f = inner.submit(work);
f.get(); // 阻塞点:父被中断时抛 InterruptedException
} catch (InterruptedException e) {
inner.shutdownNow(); // 把中断往下传
Thread.currentThread().interrupt();
}
5. 与线程池的配合
| 场景 | 注意 |
|---|---|
提交任务后 cancel(true) |
只有阻塞点能被打断 |
| 任务内部用了不可中断的 IO | 取消无效 |
线程池 shutdownNow |
对每个 worker interrupt |
| 定期任务被取消 | 需检查 isShutdown |
第一项的关键:cancel(true) 只对"正在阻塞"或"检查标志"的任务有效。一个正在做纯计算的任务不会立刻停,它要等到下一次检查。
第三项的语义常被误解:shutdownNow 返回"从未开始的任务列表",而不是"已停止的任务"。已开始的任务是否停止,取决于它们是否响应中断。
6. 排错:五种"中断没生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 调了 interrupt 但线程继续跑 | 无阻塞点、无检查 | 循环里查标志 |
| 取消后仍写数据库 | 卡在不可中断 IO | 用可中断的替代 API |
| 取消后线程消失但资源泄漏 | 无 finally 清理 | 加清理块 |
| 取消一次后整个池变慢 | 标志未清除残留 | 恢复/清除要一致 |
| 同一任务被执行两次 | 队列中任务被重投 | 检查语义 |
第五项(重复执行)在"任务被中断后又被重投"的场景里出现:中断本身不保证"任务没有开始",所以任何会写外部状态的任务都需要幂等键或去重标记。
第四项有个细节:线程池 worker 复用线程时,残留的中断标志会让下一个任务刚启动就"被中断"。所以 ThreadPoolExecutor 在取任务前会清除标志——这也是为什么自定义线程池要谨慎。
7. 边界:什么情况不必用中断
| 场景 | 为什么 | 做法 |
|---|---|---|
| 短任务(毫秒级) | 来不及响应 | 不必 |
| 进程即将退出 | 无需优雅 | 不必 |
| 纯批处理(可重跑) | 中断无意义 | 用标志位即可 |
| 有外部超时机制(如 HTTP 客户端) | 已能取消 | 依赖上层 |
最后一条与"测试"有关的边界:中断行为几乎无法靠单元测试覆盖,因为它依赖线程时序。可行的验证方式是"启动后 100ms 取消,断言 1 秒内退出且资源已释放"——用集成测试替代单元测试。
// 集成测试:断言"取消后能在限定时间内退出"
@Test(timeout = 3000)
void 取消后一秒内退出() throws Exception {
var t = new Thread(task); t.start();
Thread.sleep(100);
t.interrupt();
t.join(1000);
assertFalse(t.isAlive(), "1 秒内未退出");
assertTrue(resource.isReleased());
}
判断口径一句话:这个任务被取消时,是否需要"停下来并清理"。需要,就必须有可中断的阻塞点与检查;不需要(可重跑、无资源),标志位就够。
8. 自测
- interrupt 做的三件事分别是什么?为什么"抛出异常后标志被清除"是最容易误解的一点?
- 哪两类代码不会响应中断?为什么它们会造成"点了取消但十分钟没停"?
interrupted()与isInterrupted()的差别是什么?在循环检查里该用哪一个?- 为什么库代码不该决定"要不要停"?线程池 worker 吞掉中断会造成什么后果?
shutdownNow返回的是什么?它与"任务已停止"为什么不是一回事?- 判断"是否需要中断机制"的那个问题是什么?