Abstract

所有的异常都是“负面”异常么?它们只是被用来通知我们程序出现非预期的状况的么?
InterruptedException是个什么样的异常?为什么它不从RuntimeException继承,每次sleep、wait都得进行显式处理?在什么情况下它会被抛出?我可以忽略它么?
如何使用InterruptedException正确地暂停、恢复、中止一个线程?

什么是线程?

有关线程的概念,线程与进程之间的区别,网上已经有相当丰富的阐述和讨论了,这里不再赘述。只是强调一点,线程编程的出现,是为了解决进程编程模型中不同进程间难于共享数据(或者说内存)的问题。因为同一个进程的不同线程隶属于同一片内存地址空间,因此可以很方便地在线程间共享数据。换句话说,在一个线程中可以很自然直观地对同一进程的另一个线程中的非私有变量进行存取操作。

Java中与线程相关的一些概念

Thread/Runnable

对于Java程序员,线程开发相对容易,只需:

  • 继承Thread类,覆盖run方法,或
  • 由于Java不支持多继承,可以选择实现Runnable接口,及相应的run方法。另外,Runnable接口可以被更灵活地使用于需要闭包的语境中(如参数传递,临时、局部变量声明)。

线程的状态及改变状态的方法: wait、notify、notfyAll、yield、sleep

Java中的线程对象遵循经典的线程模型定义,具有以下状态:

  • 初始状态(New)——Thread对象初始化完成后进入就绪状态,此时调用start方法将使Thread对象进入就绪状态。
  • 就绪(Ready)——调用Thread对象的start方法后使该对象进入就绪状态,等待JVM调度。
  • 运行(Running)——当Thread对象分得CPU时间片时,该线程进入运行状态。当时间片耗尽或调用线程的yield方法时,线程返回就绪状态。
  • 阻塞(Blocked)——当线程处于就绪状态并出现以下情况时,该线程进入阻塞状态。1)执行至synchronized代码块或者方法,经竞争没有获得锁;2)调用线程的sleep方法;3)在线程中调用wait方法。对应上述三种情况,以下情况发生时线程返回就绪状态:1)通过竞争线程获得锁时;2)sleep方法指定的时间结束时;3)调用同一对象上的notify或notifyAll方法时;若调用带参数的wait方法,则参数指定的时间结束时。这里需要注意一种特殊情况:当sleep、wait方法抛出InterruptedException时,线程立即进入运行状态,处理异常。
  • 终止(Dead):当Thread对象的run方法执行完毕(或抛出异常至外层调用方法)后,该线程结束。

上面提到了一些与线程操作相关的方法,这里对容易误用的地方做一些简要的说明,详细说明可参阅JDK的Javadoc。

yield、sleep

这两个方法定义在Thread类上,且是静态方法。对于程序员而言,yield可能是个相对陌生的方法,Javadoc中的描述是“临时暂停当前线程以允许其它线程执行”。换一个更容易理解的说法,yield方法的主要做作用就是告诉CPU:“我想休息一会儿,让我进入就绪状态,把CPU让给其它线程”。另外,Javadoc中明确指出,yield和sleep方法都是作用于当前线程,对于这一点很容易被忽略,这里以sleep为例举一个例子。

清单 1. 哪个线程被sleep?
public class SleepThread extends Thread {
    private Thread thread = new Thread();
    public void run () {
        thread.start();

        // 省略异常处理

        thread.sleep(10000); // 哪个线程被sleep?thread还是this?
    }
}

wait、notify、notifyAll

这三个方法定义在Object上,作用是实现线程间的通知机制,但是在使用时以下三点需要注意:

  • wait和notify/notifyAll的调用必须在同一个“上下文(context)”里,否则会抛出IllegalMonitorStateException。而在Java的线程模型中,“上下文”是依赖synchronized划分的,清单二展示了wait/notify的正确使用方法。
  • notify和notifyAll的区别在于:当调用notify后,若有多个线程调用wait方法阻塞在同一个上下文中,这些线程中将有一个线程被唤醒,其余仍在wait状态(只有再次调用notify或notifyall方法时其它线程才有可能被唤醒);而当调用notifyAll后,所有的wait线程被唤醒,但由于它们处于同一个上下文中,因此它们需要竞争synchronized锁。获得锁的线程继续执行,而其它线程只需等待synchronized锁被释放即可继续执行,无需等待另一个wait调用。
  • wait方法调用后线程先释放锁然后进入阻塞状态,而调用sleep方法进入阻塞状态时不会释放锁
清单 2. wait/notify/notifyAll
public class SleepThread extends Thread {
    private Object lock = new Object();

    Thread thread1 = new Thread() {
        public void run () {
            synchronized(lock) { // 进入“lock”对象的上下文
                // 省略异常处理
                lock.wait();
            }
        }
    }

    Thread thread2 = new Thread() {
        public void run () {
            synchronized(lock) { // 进入“lock”对象的上下文(与wait方法相同的上下文)
                lock.notify();
            }
        }
    }
}

结合线程的状态以及上面提到的5个方法,我们可以做出一张Java线程的状态图:

图 1. Java线程状态图

InterruptedException

在使用Java进行线程编程时,你会发现很多和线程相关的方法都会显式声明抛出InterrupedException,比如wait、sleep、join。而InterruptedException又是一个“checked exception”,必须处理。因此,大多数Java程序员对这个异常不很待见,当需要处理该异常时直接控制台输出,不做处理。那InterruptedException到底是一个什么样的异常,为什么与线程相关的方法需要声明抛出这个异常呢?

什么是InterruptedException?

InterruptedException直译为“中断异常”,此处的“中断”与汇编语言中的“中断”很相似,是一种需要及时响应的非预期回调异常。按照字面意思,它有以下几个特点:

  • 需要及时处理(或优先级较高)——即使线程进入阻塞状态(wait或sleep),一旦抛出InterruptedException,线程会立即进入运行状态,以响应异常。
  • 非预期(发生时机不可预测)——对于线程来说,方法何时会抛出InterruptedException不可预知,只能进行被动的异常捕捉(catch)

什么情况下会抛出InterruptedException?

那究竟在什么情况下会抛出InterruptedException呢?根据Javadoc的描述,当线程由于调用了wait、sleep或join方法而进入了阻塞状态时,若该线程的interrupt()方法被调用,则上述方法立即抛出InterruptedException。可见,使用interrupt()和InterruptedException,我们可以使一个线程立即跳出阻塞状态,以响应我们的请求。通过一些编程技巧,可以进而终止一个线程。就这个意义来讲,InterruptedException和其它用来表征“异常情况”的异常不同,它是一种正面的提供编程便利的异常。

常见的线程中止代码(有什么问题?)

在平时的编码中,一种比较常见的线程应用场景是实现一个后台线程,周期性地执行一些操作(比如状态检查)。比较常见的实现方法如下:

清单 3. 周期状态检查线程
public class StatusCheckingThread extends Thread {
    private volatile boolean running = true;

    public void run () {
        while (running) {
            // 具体实现

            try {
                Thread.sleep(60 * 1000 * 1000); // 周期一个小时
            } catch (InterruptedException ex) {
                log.info(ex);
            }
        }
    }

    public void makeStop (){
        this.running = false;
    }
}

这段代码使用了running作为中止标志位,当需要中止该线程时,只需要调用makeStop(),线程便会在下次循环开始时退出,从而中止。而这段代码的问题就在于“下次循环”,一旦线程执行sleep,就会休眠一个小时。而在这一个小时中,即使我们调用了makeStop(),线程也无法响应,只是简单地将running置为false,只有等到下一次循环开始对running进行检查时,线程才会如我们所愿地退出。很显然,wait、join也会遇到相同的问题,而且,周期越长,线程响应得越“慢”。

如何使用InterruptedException正确地中止线程?

那如何让线程即使在休眠时也能响应我们的中止请求呢?这个时候“中断”的概念就能发挥作用了。看下面这段修改后的代码:

清单 4. 周期状态检查线程(可及时中止)
public class StatusCheckingThread extends Thread {
    public void run (){
        while (!this.isInterrupted()) {
            // 具体实现

            if (this.isInterrupted()) { // 问题:这里为什么要做判断?
                break;
            }

            try {
                Thread.sleep(60 * 1000 * 1000); // 周期一个小时
            } catch (InterruptedException ex) {
                log.info(ex);

                this.interrupt(); // 关键步骤
            }
        }
    }

    public void makeStop (){
        this.interrupt();
    }
}

这段代码有几处改动:

  • 摒弃了状态位running,改为使用this.isInterrupted()判断线程状态。
  • 修改makeStop()方法的实现,直接调用this.interrupt()。
  • 在interruptedException的catch块中进行了处理,再次调用了this.interrupt()。

对于第一、二处改动还比较好理解,isInterrupted方法应该是用来判断线程是不是出于中断状态,而interrupt方法则是用来抛出InterruptedException。但是对于第三处,为什么在catch块中又调用了一次interrupt方法呢?这是因为interrupt方法的实现比较特殊,当线程阻塞在wait、sleep、join方法上时,调用interrupt方法只会抛出异常,但是isInterrupted位不会被置,依然保持false。而在其它时候调用interrupt方法时(比如在catch块里),则会将isInterrupted位置为true,却不会抛出异常。

代码经过上述修改后,线程即使在sleep过程中,只要调用了interrupt方法,线程也会立即被唤醒,继而中止。

这里留一个问题,上面代码中加注释的问题处,为何要做这个判断?

为什么stop、suspend、resume方法被废止?

如果你是一位有多年Java开发经验的程序员,你会发现从JDK 1.4开始,原先Thread类上定义的“很有用的”方法stop、suspend、resume被集体标记为废止。有关stop的废止原因,上面已经提到了一部分,即即使调用了这三个方法,线程也不保证马上响应。这篇官方文档也对废止的理由进行了阐述。

我的补充观点是,线程何时应该被中止(或者挂起、恢复),是应该由线程的开发人员决定的,而不是向外提供一个公共方法这么简单。只有线程的开发人员,才能够了解这个线程的内部逻辑,是简单的分支循环,还是有很深的调用栈(如果简单地提供stop方法,线程应该在哪一层调用栈弹出时被中止?),或者混杂着一些不可控的方法调用(比如阻塞式IO操作),甚至于启动了其它线程。就像前面的那个问题,在sleep前加了if判断,是因为“我”(线程开发人员)认为线程在那个点上是可以也是应该能够被中止的。

如何正确地暂停、恢复线程?

上面的代码片段展示了如何中止一个线程,其实稍作修改,就可以实现暂停/恢复操作。

清单 5. 周期状态检查线程(可及时中止,可暂停/恢复)
public class StatusCheckingThread extends Thread {
    private volatile boolean running = true;

    public void run (){
        while (!this.isInterrupted()) {
            if (running) {

                // 具体实现
            }

            if (this.isInterrupted()) { // 问题:这里为什么要做判断?
                break;
            }

            try {
                Thread.sleep(60 * 1000 * 1000); // 周期一个小时
            } catch (InterruptedException ex) {
                log.info(ex);

                this.interrupt(); // 关键步骤
            }
        }
    }

    public void makeStop (){
        this.interrupt();
    }

    public void makeSuspend(){
        running = false;
    }

    public void makeResume (){
        running = true;
    }
}

参考资源 (resources)

  • 参考 Java SE 6 JDK Javadoc
  • java.util.concurrent是Java SE 5引入的功能更强大的线程/同步库,有兴趣的读者可以查看相关文档