Java 线程中断的本质和编程原则

Java 的中断机制是一种协作机制。也就是说,调用线程对象的 interrupt 方法并不一定立即中断正在运行的线程,它只是要求线程自己在合适的时机中断自己。

一、Java 中断的现象

首先,查看 Thread 类中与中断相关的几个核心方法:

方法签名行为描述
public static boolean interrupted()测试当前线程是否已经中断。线程的中断状态由该方法清除。换句话说,如果连续两次调用该方法,第二次调用将返回 false(除非在第一次调用清除状态后、第二次调用前,线程再次被中断)。
boolean isInterrupted()测试指定线程是否已经中断。线程的中断状态不受该方法影响(不清除状态)。
public void interrupt()中断线程。实际上是将线程的中断标志位设置为 true

上面列出了与中断有关的几个方法及其行为。可以看到 interrupt 是用于中断线程的方法。如果不了解 Java 的中断机制,这种解释极容易造成误解,认为调用了线程的 interrupt 方法就一定会强制停止线程。

其实,Java 的中断是一种协作机制。调用线程对象的 interrupt 方法并不一定就中断了正在运行的线程,它只是要求线程自己在合适的时机中断自己。每个线程都有一个 boolean 类型的中断状态(该状态确实不是 Thread 的普通字段,而是 JVM 内部维护的标记),interrupt 方法仅仅只是将该状态置为 true

public class TestInterrupt {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start();
        t.interrupt();
        System.out.println("已调用线程的 interrupt 方法");
    }

    static class MyThread extends Thread {
        public void run() {
            int num = longTimeRunningNonInterruptMethod(2, 0);
            System.out.println("长时间任务运行结束,num=" + num);
            System.out.println("线程的中断状态:" + Thread.interrupted());
        }

        private static int longTimeRunningNonInterruptMethod(int count, int initNum) {
            for (int i = 0; i < count; i++) {
                for (int j = 0; j < Integer.MAX_VALUE; j++) {
                    initNum++;
                }
            }
            return initNum;
        }
    }
}

一般情况下,程序会打印如下内容:

已调用线程的 interrupt 方法
长时间任务运行结束,num=-2
线程的中断状态:true

可见,interrupt 方法并不一定能中断线程的执行流程。但是,如果改成下面的程序,情况会怎样呢?

import java.util.concurrent.TimeUnit;

public class TestInterrupt {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start();
        t.interrupt();
        System.out.println("已调用线程的 interrupt 方法");
    }

    static class MyThread extends Thread {
        public void run() {
            int num = -1;
            try {
                num = longTimeRunningInterruptMethod(2, 0);
            } catch (InterruptedException e) {
                System.out.println("线程被中断");
                throw new RuntimeException(e);
            }
            System.out.println("长时间任务运行结束,num=" + num);
            System.out.println("线程的中断状态:" + Thread.interrupted());
        }

        private static int longTimeRunningInterruptMethod(int count, int initNum) throws InterruptedException {
            for (int i = 0; i < count; i++) {
                TimeUnit.SECONDS.sleep(5);
            }
            return initNum;
        }
    }
}

经运行可以发现,程序抛出异常停止了,run 方法里的后两条打印语句没有执行。那么,区别在哪里?

一般说来,如果一个方法声明抛出 InterruptedException,表示该方法是可中断的(没有在方法中处理中断却也声明抛出 InterruptedException 的情况除外)。也就是说,可中断方法会对 interrupt 调用做出响应(例如 sleep 响应 interrupt 的操作包括清除中断状态,抛出 InterruptedException)。

如果 interrupt 调用是在可中断方法之前调用,可中断方法一定会处理中断。像上面的例子,interrupt 方法极可能在 run 未进入 sleep 的时候就调用了,但 sleep 检测到中断,就会处理该中断。如果在可中断方法正在执行中的时候调用 interrupt,会怎么样呢?这就要看可中断方法处理中断的时机了,只要可中断方法能检测到中断状态为 true,就应该处理中断。

那么自定义的可中断方法该如何处理中断呢?那就是在适合处理中断的地方检测线程中断状态并处理。

public class TestInterrupt {
    public static void main(String[] args) throws Exception {
        Thread t = new MyThread();
        t.start();
        // TimeUnit.SECONDS.sleep(1); // 如果不能看到处理过程中被中断的情形,启用这句再看看
        t.interrupt();
        System.out.println("已调用线程的 interrupt 方法");
    }

    static class MyThread extends Thread {
        public void run() {
            int num;
            try {
                num = longTimeRunningNonInterruptMethod(2, 0);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("长时间任务运行结束,num=" + num);
            System.out.println("线程的中断状态:" + Thread.interrupted());
        }

        private static int longTimeRunningNonInterruptMethod(int count, int initNum) throws InterruptedException {
            if (interrupted()) {
                throw new InterruptedException("正式处理前线程已经被请求中断");
            }
            for (int i = 0; i < count; i++) {
                for (int j = 0; j < Integer.MAX_VALUE; j++) {
                    initNum++;
                }
                // 假如这就是一个合适的地方
                if (interrupted()) {
                    // 回滚数据,清理操作等
                    throw new InterruptedException("线程正在处理过程中被中断");
                }
            }
            return initNum;
        }
    }
}

如上面的代码,方法 longTimeRunningNonInterruptMethod 此时已是一个可中断的方法了。在进入方法的时候判断是否被请求中断,如果是,就不进行相应的处理了;处理过程中,可能也有合适的地方处理中断,例如上面最内层循环结束后。

这段代码中检测中断用了 Thread 的静态方法 interrupted,它将中断状态置为 false,并将之前的状态返回;而 isInterrupted 只是检测中断,并不改变中断状态。一般来说,处理过了中断请求,应该将其状态置为 false,但具体还要看实际情形。

二、Java 中断的本质

在历史上,Java 试图提供过抢占式限制中断,但问题多多,例如已被废弃的 Thread.stopThread.suspendThread.resume 等。另一方面,出于 Java 应用代码健壮性的考虑,降低了编程门槛,减少不清楚底层机制的程序员无意破坏系统的概率。

如今,Java 的线程调度不提供抢占式中断,而采用协作式的中断。其实,协作式的中断原理很简单,就是轮询某个表示中断的标记,我们在任何普通代码中都可以实现:

volatile boolean isInterrupted;
// ...
while (!isInterrupted) {
    compute();
}

但是,上述的代码问题也很明显。当 compute 执行时间比较长时,中断无法及时被响应。另一方面,利用轮询检查标志变量的方式,想要中断 waitsleep 等线程阻塞操作也束手无策。

如果仍然利用上面的思路,要想让中断及时被响应,必须在虚拟机底层进行线程调度时对标记变量进行检查。是的,JVM 中确实是这样做的。下面摘自 java.lang.Thread 的源代码:

public static boolean interrupted() {
    return currentThread().isInterrupted(true);
}
// ...
private native boolean isInterrupted(boolean ClearInterrupted);

可以发现,isInterrupted 被声明为 native 方法,取决于 JVM 底层的实现。

实际上,JVM 内部确实为每个线程维护了一个中断标记。但应用程序不能直接访问这个中断变量,必须通过下面几个方法进行操作:

public class Thread {
    // 设置中断标记
    public void interrupt() { ... }
    // 获取中断标记的值
    public boolean isInterrupted() { ... }
    // 清除中断标记,并返回上一次中断标记的值
    public static boolean interrupted() { ... }
    ...
}

通常情况下,调用线程的 interrupt 方法,并不能立即引发中断,只是设置了 JVM 内部的中断标记。因此,通过检查中断标记,应用程序可以做一些特殊操作,也可以完全忽略中断。

你可能想,如果 JVM 只提供了这种简陋的中断机制,那和应用程序自己定义中断变量并轮询的方法相比,基本也没有什么优势。

JVM 内部中断变量的主要优势,就是对于某些情况,提供了模拟自动“中断陷入”的机制。

在执行涉及线程调度的阻塞调用时(例如 waitsleepjoin),如果发生中断,被阻塞线程会“尽可能快的”抛出 InterruptedException。因此,我们就可以用下面的代码框架来处理线程阻塞中断:

try {
    // wait、sleep 或 join
} catch (InterruptedException e) {
    // 某些中断处理工作
}

所谓“尽可能快”,推测 JVM 就是在线程调度的间隙检查中断变量,速度取决于 JVM 的实现和硬件的性能。

三、一些不会抛出 InterruptedException 的线程阻塞操作

然而,对于某些线程阻塞操作,JVM 并不会自动抛出 InterruptedException 异常。例如,某些 I/O 操作和内部锁操作。对于这类操作,可以用其他方式模拟中断:

  1. java.io 中的阻塞式 Socket I/O
    读写 socket 的时候,InputStreamOutputStreamreadwrite 方法会阻塞等待,但不会响应 Java 中断。不过,调用 Socketclose 方法后,被阻塞线程会抛出 SocketException 异常。
  2. 利用 Selector 实现的异步 I/O
    如果线程被阻塞于 Selector.select(在 java.nio.channels 中),调用 wakeup 方法会引起 ClosedSelectorException 异常。
  3. 锁获取
    如果线程在等待获取一个内部锁(synchronized),我们将无法中断它。但是,利用 Lock 类的 lockInterruptibly 方法,我们可以在等待锁的同时,提供中断能力。

四、两条编程原则

另外,在任务与线程分离的框架中,任务通常并不知道自身会被哪个线程调用,也就不知道调用线程处理中断的策略。所以,在任务设置了线程中断标记后,并不能确保任务会被取消。因此,有以下两条编程原则:

  1. 除非你知道线程的中断策略,否则不应该中断它。
    这条原则告诉我们,不应该直接调用 Executor 之类框架中线程的 interrupt 方法,应该利用诸如 Future.cancel 的方法来取消任务。
  2. 任务代码不该猜测中断对执行线程的含义。
    这条原则告诉我们,一般代码在遇到 InterruptedException 异常时,不应该将其捕获后“吞掉”,而应该继续向上层代码抛出。

总之,Java 中的非抢占式中断机制,要求我们必须改变传统的抢占式中断思路,在理解其本质的基础上,采用相应的原则和模式来编程。

说明:本文基于 Java 标准平台线程(Platform Threads)的中断机制进行阐述,适用于 Java 8 至 Java 17 等主流版本。Java 21 引入的虚拟线程(Virtual Threads)在中断行为上大体兼容,但在底层调度实现上有所不同。