《Java 并发编程·线程与内存模型》6.1 interrupt 的真实语义

interrupt 不是杀死线程,只是把一个标志位置为 true,真正的退出仍要靠被中断线程自己检查。本文用 sleep、wait、join、synchronized 竞争与 ReentrantLock 在本机实测中断行为,说明 InterruptedException 为什么会清除标志、哪些阻塞根本不响应中断,并给出捕获后恢复标志再包装成 LoanException 的写法与边界。

承接的问题

5.3 关闭有界缓冲区时,我们主动调用 close() 唤醒所有等待者。但线上停服务不会给你这个体面的机会:运维发 SIGTERM,线程池调用 shutdownNow(),上游 RPC 超时后取消请求。这些动作最终都会落到同一个方法上——Thread.interrupt()。

围绕它有三个很常见的误解:

  1. 以为 interrupt() 会立刻终止线程。它不会,它只是设置一个标志位,线程照常运行。
  2. 以为线程会自动退出。不会,只有线程自己(或它调用的阻塞方法)检查这个标志,退出才可能发生。
  3. 以为 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 两阶段终止与协作式取消

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收