2345 字
约 7 分钟
16
S04-A14 · interrupt 到底做了什么:一个"建议"而不是命令

代码里写了 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. 自测

  1. interrupt 做的三件事分别是什么?为什么"抛出异常后标志被清除"是最容易误解的一点?
  2. 哪两类代码不会响应中断?为什么它们会造成"点了取消但十分钟没停"?
  3. interrupted() 与 isInterrupted() 的差别是什么?在循环检查里该用哪一个?
  4. 为什么库代码不该决定"要不要停"?线程池 worker 吞掉中断会造成什么后果?
  5. shutdownNow 返回的是什么?它与"任务已停止"为什么不是一回事?
  6. 判断"是否需要中断机制"的那个问题是什么?
S04-A14 · interrupt 到底做了什么:一个"建议"而不是命令
https://www.clxhxhhr.top/posts/6477/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。