承接的问题
5.3 关闭有界缓冲区时,我们主动调用 close() 唤醒所有等待者。但线上停服务不会给你这个体面的机会:运维发 SIGTERM,线程池调用 shutdownNow(),上游 RPC 超时后取消请求。这些动作最终都会落到同一个方法上——Thread.interrupt()。
围绕它有三个很常见的误解:
- 以为
interrupt()会立刻终止线程。它不会,它只是设置一个标志位,线程照常运行。 - 以为线程会自动退出。不会,只有线程自己(或它调用的阻塞方法)检查这个标志,退出才可能发生。
- 以为
catch (InterruptedException e)之后标志还在。不在,JVM 在抛出异常前已经把标志清掉了。
这一节把这三件事拆开,用本机真实输出说明「谁检查、谁清除、谁不响应」,最后给出业务代码里推荐的收尾写法。
interrupt 只做一件事:设置标志位
Thread.interrupt() 的实现可以概括成一句:如果目标线程正阻塞在可中断的等待上,就唤醒它并抛 InterruptedException;否则把它的中断标志置为 true。它不抢占 CPU,不注入异常,也不改变线程的调度状态。
下面这段代码让一个线程纯做加法,主线程 100ms 后中断它:
public class FlagDemo {
public static void main(String[] args) throws Exception {
Thread t = Thread.ofPlatform().name("counter").start(() -> {
long n = 0;
while (!Thread.currentThread().isInterrupted()) {
n++;
}
System.out.println("[flag] 循环退出, 迭代 " + n
+ ", 标志仍为 " + Thread.currentThread().isInterrupted()
+ ", interrupted() 返回 " + Thread.interrupted()
+ ", 之后标志 " + Thread.currentThread().isInterrupted());
});
Thread.sleep(100);
long start = System.nanoTime();
t.interrupt();
System.out.println("[flag] 调用 interrupt 耗时 "
+ (System.nanoTime() - start) / 1000 + "us, 状态=" + t.getState());
t.join();
}
}
本机 JDK 25 的输出:
[flag] 调用 interrupt 耗时 5us, 状态=RUNNABLE
[flag] 循环退出, 迭代 169251656, 标志仍为 true, interrupted() 返回 true, 之后标志 false
两行输出各说明一件事:
interrupt()本身只花了 5 微秒,调用返回时目标线程仍然是RUNNABLE。- 输出里的 1.69 亿次是整个循环的总迭代次数。关键事实是:线程没有在收到中断的那一刻停下,而是在之后某次执行
isInterrupted()时才发现。
这就是「协作式取消」的全部含义:中断是请求,不是命令。请求发出后,被中断线程想什么时候看、看完退不退出,由它自己决定。
三个与标志相关的方法必须分清:
| 方法 | 作用 | 是否清除标志 |
|---|---|---|
void interrupt() | 设置目标线程的中断标志,或在可中断等待上唤醒它 | 不清除(标志由被唤醒的阻塞方法清除) |
boolean isInterrupted() | 读取当前线程(或指定线程)的标志 | 不清除 |
static boolean interrupted() | 读取当前线程的标志 | 清除 |
Thread.interrupted() 是唯一会静默清除标志的读取方法,所以它通常只出现在「明确要消费掉这次中断」的地方。业务代码里判断状态请一律用 isInterrupted()。
中断标志在生命周期两端还有两个容易踩的点。本机实测:
[before-start] 未启动时标志=true
[before-start] 启动后标志=true
[after-death] 终止后 isInterrupted=false
[after-death] interrupt 之后再查=true
线程还没 start() 时调用 interrupt(),标志会被记住,启动后立刻就是 true;线程已经终止后再调用 interrupt(),标志居然也能被置为 true。Javadoc 对后者的措辞是「不必产生任何效果」(need not have any effect),所以不要把终止后的标志当成可靠状态。要判断线程是否结束,只能用 isAlive() 或 join()。
InterruptedException 会清除标志
真正让新手困惑的是:进入 catch (InterruptedException e) 之后,isInterrupted() 是 false。
Thread sleeper = Thread.ofPlatform().start(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
System.out.println("标志=" + Thread.currentThread().isInterrupted());
}
});
Thread.sleep(100);
sleeper.interrupt();
sleeper.join();
本机输出摘录(sleep、wait、join 三种阻塞各跑一次,去掉重复的收尾行):
[sleep] 捕获 InterruptedException, 标志=false
[wait] 捕获 InterruptedException, 标志=false
[join] join 抛 InterruptedException, 标志=false
[join] worker 仍存活=true
Thread.sleep、Object.wait、Thread.join 在抛出 InterruptedException 之前都会清除中断标志。这是规范行为,不是实现细节:异常本身携带了「你被中断了」这个信息,标志就没有必要重复保留。
这带来两个直接后果。
第一,catch 块里再查 isInterrupted() 永远是 false。 用这个值判断「是不是被中断」,逻辑必然走错分支。
第二,如果你把异常吞掉,中断信号就彻底丢了。 下面这种写法在日志里看起来很完整,实际上让上层永远等不到取消:
try {
task.run();
} catch (InterruptedException e) {
log.warn("task interrupted", e); // 信号到此为止
}
线程继续跑,shutdown() 里的 join() 继续等,最后只能靠超时兜底。很多「停服务停了 30 秒」的事故,根子就是某一行这样的 catch。
还有一种情况要注意:join 被中断的是调用 join 的线程,不是被 join 的线程。上面输出里 [join] worker 仍存活=true 就是这个意思——joiner 抛异常退出了,worker 还在睡。中断是发给某个具体线程的,不会沿着等待关系传播。
哪些阻塞响应中断,哪些不响应
interrupt() 的效果完全取决于目标线程此刻在做什么。把常见阻塞分成两列:
| 场景 | 响应中断 | 行为 |
|---|---|---|
Thread.sleep / join / Object.wait | 是 | 清除标志,抛 InterruptedException |
BlockingQueue.put / take / poll(timeout) | 是 | 同上 |
Lock.lockInterruptibly / Condition.await | 是 | 同上 |
Semaphore.acquire / CountDownLatch.await | 是 | 同上 |
InterruptibleChannel 上的 I/O | 是 | 通道关闭,抛 ClosedByInterruptException |
进入 synchronized 块等监视器 | 否 | 继续 BLOCKED,标志保留 |
ReentrantLock.lock() | 否 | 继续等待,拿到锁后才恢复标志 |
传统 Socket 的 InputStream.read() | 否 | 连接不断,读继续阻塞 |
| 纯 CPU 循环 | 否 | 只有标志变化,循环照跑 |
前两行和第 6、7 行值得各做一次实测。
等监视器:中断不会把你从 BLOCKED 里捞出来
Object lock = new Object();
Thread holder = Thread.ofPlatform().start(() -> {
synchronized (lock) {
try { Thread.sleep(600); } catch (InterruptedException ignored) { }
}
});
Thread.sleep(100);
Thread blocker = Thread.ofPlatform().start(() -> {
long start = System.nanoTime();
synchronized (lock) {
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("[monitor] 进入临界区, 等锁耗时 " + ms + "ms");
}
});
Thread.sleep(100);
System.out.println("[monitor] 中断前状态=" + blocker.getState());
blocker.interrupt();
Thread.sleep(150);
System.out.println("[monitor] 中断 150ms 后状态=" + blocker.getState()
+ ", 标志=" + blocker.isInterrupted());
blocker.join();
holder.join();
本机输出:
[monitor] 中断前状态=BLOCKED
[monitor] 中断 150ms 后状态=BLOCKED, 标志=true
[monitor] holder 释放锁
[monitor] 进入临界区, 等锁耗时 612ms, 标志=true
中断发生在 100ms 左右,但 blocker 一直等到 holder 睡满 600ms 才进入临界区。中断只把标志置为 true,没有改变它在监视器入口队列里的位置。等它终于拿到锁时,标志仍然是 true,所以临界区里的代码可以用 isInterrupted() 决定要不要继续做。
这条边界在实际代码里很危险:如果你用 synchronized 保护一个可能长时间持有的临界区,那么「取消」最多只能让线程在拿到锁之后尽快退出,无法缩短等待锁的时间。
ReentrantLock.lock():不可中断,但会记住
[lock] 中断 200ms 后仍 WAITING, 标志=false
[lock] 拿到锁, 标志=true
[lockInterruptibly] 抛 InterruptedException, 标志=false
[take] 抛 InterruptedException, 标志=false
lock() 不会因为中断而放弃等待,这点和监视器一致。但注意中间那行:等待期间标志一度是 false,拿到锁之后又变回 true。原因是 AQS 在排队过程中读取并暂存了中断状态,成功获取锁后再用 selfInterrupt() 补回来;具体代码在卷二第 9 章展开。这里只需记住结论:lock() 不响应中断,但不会弄丢中断。
如果希望等待锁的过程可以被取消,必须显式使用 lockInterruptibly(),它会在等锁时抛 InterruptedException 并清除标志。这是锁选型时一个必须写进设计文档的差异。
队列与通道
ArrayBlockingQueue.take() 的表现和 sleep 一致:抛异常、清标志。NIO 的 InterruptibleChannel 则是关闭通道并抛 ClosedByInterruptException,同时保留中断标志——不同 API 的清理策略并不统一,读文档比背结论可靠。
捕获后恢复标志的两种正确写法
InterruptedException 是受检异常,编译器逼你处理。合法的处理只有两条路:
路线一:继续向外抛。 方法签名声明 throws InterruptedException,把异常原样交给调用方。此时不需要也不应该再 interrupt(),异常本身就是信号。
public void awaitSlot() throws InterruptedException {
slots.acquire(); // 直接让 InterruptedException 冒泡
}
路线二:不能抛时,恢复标志再转换。 例如实现 Runnable.run() 或 LoanService 这种不允许受检异常的接口:
try {
audit.put(loan);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 把信号还给线程
throw new LoanException(ErrorCode.INTERRUPTED, "audit enqueue interrupted");
}
恢复标志的意义在于:调用栈上更外层的代码(线程池、框架、关闭钩子)仍然能看到「这个线程被要求停止」。如果只抛一个业务异常而不恢复,外层可能把它当成普通失败重试,取消信号再次丢失。
路线三(仅限线程入口):恢复后直接返回。 如果你就是 run() 的最外层,恢复标志后 return 也可以,因为线程马上结束,没有更外层的代码需要看到这个信号。但不要恢复后继续跑长循环,那等于自己骗自己。
反过来,三种写法都是错的:
| 错误写法 | 后果 |
|---|---|
catch (InterruptedException e) { } | 信号丢失,关停永远等不到 |
catch (InterruptedException e) { log.warn(...); } | 同上,只是多了日志 |
catch (InterruptedException e) { throw new RuntimeException(e); } | 标志已清除,外层若按「可重试」处理会重复执行 |
最后一行尤其隐蔽:转换成 RuntimeException 看起来把异常传出去了,但中断语义已经降级成普通故障。
在 libraryd-conc 里落地
借阅服务的 reserve 不允许抛受检异常,又要经过一个可能阻塞的审计队列,正好用路线二:
public final class InterruptAwareLoanService implements LoanService {
private final BookRepository books;
private final LoanRepository loans;
private final BlockingQueue<Loan> audit = new ArrayBlockingQueue<>(1024);
public InterruptAwareLoanService(BookRepository books, LoanRepository loans) {
this.books = books;
this.loans = loans;
}
@Override
public Reservation reserve(String isbn, String memberId) {
if (Thread.currentThread().isInterrupted()) {
throw new LoanException(ErrorCode.INTERRUPTED,
"caller already interrupted: " + isbn);
}
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
Reservation reservation = new Reservation(
UUID.randomUUID().toString(), isbn, memberId,
Instant.now().plusSeconds(86_400));
Loan loan = new Loan(reservation.loanId(), isbn, memberId, reservation.dueAt());
loans.save(loan);
try {
audit.put(loan); // 满队列阻塞,可被中断
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 先恢复标志
throw new LoanException(ErrorCode.INTERRUPTED,
"audit enqueue interrupted: " + isbn);
}
return reservation;
}
@Override
public boolean returnBook(String loanId) {
return loans.findById(loanId).map(loan -> {
books.findByIsbn(loan.isbn()).ifPresent(book ->
books.save(new Book(book.isbn(), book.title(), book.stock() + 1)));
return true;
}).orElse(false);
}
}
三个设计点:
- 入口先检查标志。如果调用方在提交任务前就已经被取消,没必要再扣库存、写借阅,直接失败更安全。
- 阻塞点只放在
audit.put。真正可能无限等的地方只有队列满,其余步骤都是内存操作,不需要中断处理。 - 恢复标志再包装。
LoanException携带ErrorCode.INTERRUPTED,让上层能区分「业务失败」和「我被要求停下」。
调用方要配合这个契约:拿到 code() == INTERRUPTED 时不要重试,也不要把异常吞掉转成 200。正确的动作是停止当前批次,把未完成的任务交回队列或记录待补偿,然后让线程退出。
常见错误与边界
| 错误 | 现象 | 修法 |
|---|---|---|
在 catch 里查 isInterrupted() 判断是否中断 | 永远为 false | 用 catch 本身作为信号 |
吞掉 InterruptedException | 关停卡在 join,只能等超时 | 恢复标志或继续抛 |
| 中断线程池里的线程当作取消任务 | 可能中断到下一个任务 | 用 Future.cancel(true) 并让任务自查 |
用 interrupt() 等 synchronized 退出 | 线程继续 BLOCKED | 改用 lockInterruptibly 或缩短临界区 |
| 中断后立即认为线程已停 | 线程还在跑,状态可能仍是 RUNNABLE | 用 join(timeout) 确认 |
在 finally 里无条件 interrupt() | 覆盖真实异常,或误伤复用的线程 | 只在明确的取消路径上调用 |
什么时候不要用 interrupt:
- 不要把它当强制终止。JDK 25 里
Thread.stop()已经直接抛UnsupportedOperationException(本机实测,且编译器给出 removal 警告),历史已经证明强行终止会破坏对象一致性;interrupt是官方剩下的协作式替代。 - 不要中断你不拥有的线程。线程池里的线程属于池,任务结束时留下的中断标志可能被下一个任务看到;规范做法是让任务自己响应
Future.cancel。 - 不要用
interrupt代替超时。中断只表达「请停」,不表达「等太久了」,两者在监控和重试策略上不是一回事。
小结
interrupt()只设置标志位,不终止线程;线程必须在安全点自行检查,实测调用本身只花 5 微秒、返回时目标线程仍在RUNNABLE。sleep、wait、join以及大多数 JUC 阻塞方法在抛InterruptedException前清除标志;synchronized等监视器、ReentrantLock.lock()、传统 socket 读都不响应中断。lock()虽然不可中断,但会在拿到锁后恢复标志;需要可取消就用lockInterruptibly(),实现细节见卷二第 9 章。- 捕获后只有两种正确收尾:继续向上抛,或恢复标志再转换成业务异常;吞掉异常是关停超时最常见的根因。
6.1 讲的是单个线程怎么被「请求」停下。6.2 把它组合成一套可用的关停协议:先拒绝新任务,再等在途任务,最后才升级为硬中断。
阅读导航:上一节 5.3 手写有界缓冲区 | 下一节 6.2 两阶段终止与协作式取消
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。