likes
comments
collection
share

Java线程详解一:使用线程执行并发任务

作者站长头像
站长
· 阅读数 15

1.  并发执行

并发执行是指多个程序或程序段的执行时间在客观上互相重叠,即一个程序段尚未执行结束另一个程序段就已经可以开始执行。通过将程序中一些可以并发执行的工作分解为多并发执行个的任务,即可以显著的提示程序的执行效率也可简化程序的组织结构。

并发执行的多个任务可以被调度到不同的处理器核心上执行,也可以通过将任务分散到处理器不同的时间片中执行,来到达并发执行的效果。

Java线程详解一:使用线程执行并发任务

1.1.  提升程序的执行效率

自2000年开始摩尔定律就有放缓的迹象,在2018年芯片实际性能与摩尔定律的要求间的差距就已扩大到了15倍。

Java线程详解一:使用线程执行并发任务

当前处理器性能的提高的主要以多核处理器的形式而不是更快的芯片的形式出现,使用并发执行可以更高效的利用处理器的性能。并发执行的程序运行在多处理器的机器上时,多个任务可以分布在这些处理器上执行,从而极大的提高程序的吞吐量。

即使是单核处理器上采用并发也能很好的提升程序的性能,这或许有些违背直觉,但我们的程序往往会因某些条件而被阻塞,例如同步IO,如果使用并发的方式执行任务,那么当一个任务阻塞时,程序中的其他任务还可以继续执行,因此这个程序可以保持继续向前执行。当然如果没有阻塞,那么并发执行在性能上是低于顺序执行的,因为并发执行多出了上下文切换的消耗,这时在单核处理器上使用并发执行就没有太大意义了。

Java线程详解一:使用线程执行并发任务

1.2.  简化程序组织结构

使用并发还可以简化程序的组织结构。 使用并发时我们将需要将程序中可并发执行的部分抽象出来,作为并行执行的任务,并发应用程序基本上都是围绕‘任务执行’来构建的。‘任务’提供一种自然的并行工作结构来提升并发性,也提供一种自然的事务边界来优化错误恢复过程。

当围绕“任务执行”来设计应用程序结构时,第一步就是要找出清晰的任务边界。在理想情况下,各个任务之间是相互独立的:一个任务并不依赖于其它任务的状态或结果。任务的独立性有助于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执行。大多数服务端的应用都提供了一种自然的任务边界:以独立的客户请求为边界。例如,web 服务、文件服务、数据库服务等,这些服务都能通过网络接受远程客户的连接请求。将独立的请求作为任务边界,既可以实现任务的独立性,又可以实现合理的任务规模。

使用并发还可以简化异步事件的处理。例如服务端在处理客户端的套接字请求时,如果使用单线程对套接字执行读操作而此时还没有数据到来,这个读操作将一直阻塞,直到有数据到达,这期间整个程序都将被阻塞,其它的请求将得不到处理而停顿。

如果为每个连接都分配其各自的线程并且使用同步 I/0,那么就会降低这类程序的开发难度。每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。当然线程过多时将占用大量的资源,同时操作系统对一个进程中可创建的线程数量是有线程限制的,所以Java 类库也提供了一组实现非阻塞 NIO包,在线程中使用NIO可以进一步提升系统的吞吐量。

不过在现代操作系统中,线程数量已得到极大的提升,这使得在某些平台上,即使有更多的客户端,为每个客户端分配一个线程也是可行的。

2.  使用线程执行任务

在一台机器上实现并发执行有多进程和多线程两种方式。Java 采用的是多线程的方式实现程序的并发执行。通过使用多线程可以将每一个并行执行的任务都由执行线程来驱动。一个进程可以包含多个线程。

执行时 CPU 轮流给每个线程分配占用时间,Java 的虚拟机和操作系统一同屏蔽了CPU 对线程的调度、切换和恢复执行线程的细节,使用时每个线程看起来像是一直占用 CPU。

即使在程序中没有显式地创建线程,JVM 启动时会创建一些内部的后台线程(例如,GC线程),同时JVM会创建一个主线程来运行 我们的main方法。我们使用的框架可能会创建线程,例如 Servlet 。当某个框架在应用程序中引入并发性时,通常不可能将并发性仅局限于框架代码,因为框架本身会回调(Callback)应用程序的代码,而这些代码将访问应用程序的状态。同样,对线程安全性的需求也不能局限于被调用的代码,而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径。因此,对线程安全性的需求将在程序中蔓延开来。这就要去我们就熟悉并发性和线程安全性。

2.1.  定义任务

Java 提供了 Runnable 接口表示一个异步任务,接口中只简单的定义了一个 run 方法,如下:

public interface Runnable {
    public abstract void run();
}

使用是我们实现 Runnable 接口,将异步任务的操作写到 run 方法中,再将其实例提交到线程去执行,线程执行时便会执行 run 方法。通常 run 方法会以某种形式一直循环下去直到任务不再需要。

例如,创建一个异步记录收到的请求的日志的任务:

class LogTask implements Runnable {
     private final Map req;

     public LogTask(Map req) {
         this.req = req;
     }

     @Override
     public void run() {
         // 简单的建请求输出到控制台
         System.out.println("received request:" + req.toString());
     }
}

Runnable 只是表示一个异步任务包含的操作,其自身并不是一个线程。若在需要执行任务则需要将 Runnable 的实例显示的给到一个线程。

2.2.  Thread

Java 提供了Thread 类代表一个线程。使用是只需要创建一个其实例,并在构造方法中指定一个需要执行的异步任务的 Runnable 的实例,随后调用其 start 方法启动线程执行任务。

例如创建一个线程并执行上面的 LogTask 任务:

Map req = new HashMap<>();
req.put("userId", "user123");
req.put("key", "abc");
Thread thread = new Thread(new LogTask(req));
thread.start();

// 输出
received request: {userId=user123, key=abc}

虽然线程启动后会一直运行直到 run 方法结束或抛出异常,但 start 方法调用之后很快就会返回并继续执行后续的指令。

Thread 还提供了可以指定‘线程名’构造方法:

public Thread(Runnable target, String name);

通过 name 参数为为线程指定一个线程名,并可以通过 Thread 的getName() 方法来获取到。线程名在需要标识线程时时非常有用的,例如 Java 中常见的日志框架输出日志信息时默认都会输出打印这条日志的线程名,以方便问题的排查。

2.3.  线程的基本机制

2.3.1.  sleep 线程

Thread 类中提供了一个静态方法 sleep。调用这这个方法可以使当前线程暂停执行一段时间。

public static native void sleep(long millis) throws InterruptedException;

sleep 参数的时间单位为毫秒。

调用 sleep 方法后当前线程会被中止执行直到给定的时间或线程被中断。sleep方法会响应中断请求,在 sleep期间若线程被中断的话 sleep 方法会抛出 InterruptedException。

例如下面的线程,一直循环从队列中获取一些需要异步处理的数据,但这些数据处理的实时性要求并不高,为了防止一直在处理这些数据而占用过多的资源,可以在这个任务中添加了一个 sleep。

class Task implements Runnable {
    public final BlockingQueue queue;
    public Task(BlockingQueue queue) {
        this.queue = queue;
    }
    @Override
    public void run() {
        Data data = null;
        while ((data = queue.poll()) != null) {
            process(data);
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                // 被中断时退出循环
                Thread.currentThread().interrupt();
                break;
            }
        }
    }

    private void process(Data data) {...}
}

Java 5 开始引入了更显示的 sleep 版本,位于 TimeUnit 类中。通过TimeUnit 类我们可以同时选择中止的时间单位。

2.3.2.  线程优先级

Thread 类提供 setPriority 方法来设置线程的优先级。优先级高的线程被调度器选中的概率就越高,得到执行的频率也就越高。而优先级的的线程得等执行频率相对低一些,但也不是说一直得不到执行。

Thread thread = new Thread(new Task());
// 设置线程优先级
thread.setPriority(Thread.MAX_PRIORITY);

Java 的线程有十个级别的优先级,范围为1~10。创建新线程时默认优先级为1。虽然 Java 线程可以设置十个级别的优先级,但在不同的平台中支持的范围是不同的,例如 linux 使用分时调度策略时是不支持优先级的,而 windows 线程优先级访问为 0 - 31,所以为了获得更好的兼容性建议只使用 Java 预先提供好的三个优先级:Thread.MIN_PRIORITY、Thread.NORM_PRIORITY和Thread.MAX_PRIORITY。

Thread 类还提供了 getPriority 方法了来获取线程的优先级。

2.3.3.  后台线程

后台线程是指:程序中在后台运行的为非后台线程提供服务的线程。那么当非后台线程都结束后,后台线程没有了服务对象也会被结束。相对的当还有非后台线程没有结束时程序就不会结束。启动 Java 进程时执行 main 入口的线程就是一个非后台线程。

新创建的线程默认都是非后台线程。Thread 类提供了 setDaemon 方法来将线程设置为后台线程,但需在启动之前调用 setDaemon(true)。

示例:

 public static void main(String[] args) throws Exception {
    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                System.out.println(Thread.currentThread().getName() + " running");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }
    });
    thread.setDaemon(true);
    thread.start();
    Thread.sleep(10000);
}

上面代码中将线程设置为了后台线程,所以即使线程执行的任务中有一个 while (true) 的循环,但是当 main 的线程 sleep 10秒结退出后, 后台线程也会结束,最后整个进程结束。若不将线程设置为后台线程,则当 main 线程退出后该线程还好继续执行,那进程自然也就不会结束。

2.3.4.  捕获异常

线程在执行时若发生了一个未捕获的异常,异常将在调用栈中逐层传递,直到 run 方法中仍未被捕获的话,run 方法也将被终止,并在控制台中输出异常的栈追踪信息,最终线程也将被终止。

示例:

static class ExceptionTask implements Runnable {
    @Override
    public void run() {
        throw new RuntimeException("test");
    }
}

public static void main(String[] args) throws Exception {
    Thread thread = new Thread(new ExceptionTask());
    thread.start();
    Thread.sleep(10000);
    System.out.println("end");
}

输出:

Exception in thread "Thread-1" java.lang.RuntimeException: test
	at cntest.java.Test$ExceptionTask.run(Test.java:36)
	at java.lang.Thread.run(Thread.java:750)
end

示例代码中的线程执行时将抛出一个未捕获异常而终止线程的执行,并在控制台输出异常信息,但执行 main 的线程还是会继续执行下去并最终输出‘end’。

但一些长时间运行的程序中往往没人会去观察控制台的输出,线程因异常而终止时程序可能看起来仍然在工作,所以线程的异常很容易被遗漏掉。在线程的生命周期中,往往会调用到许多‘未知’的代码,我们应该对在这些线程中执行的代码能否表现出正确的行为保持怀疑。因此,这些线程应该在 try-catch 代码块中调用这些任务,这样就能捕获那些未检查的异常了,或者也可以使用 try-finally 代码块来确保框架能够知道线程非正常退出的情况,并做出正确的响应。

当然我们不能总是对所有代码都进行异常捕获。对于未捕获的异常,在Thread API中提供了 UncaughtExceptionHandler,它能检测出某个线程由于末捕获的异常而终结的情况。

public interface uncaughtExceptionHandler{
    void uncaughtException (Thread t, Throwable e)
}

当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler 异常处理器。如果没有提供任何异常处理器,那么默认的行为是将栈追踪信息输出到 System.err。

异常处理器通过Thread 的静态方法 setDefaultUncaughtExceptionHandler 来设置。

示例:

static class ExceptionTask implements Runnable {
    @Override
    public void run() {
        throw new RuntimeException("test throw error");
    }
}

public static void main(String[] args) throws Exception {
    // 设置异常处理器
    Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler() {
        @Override
        public void uncaughtException(Thread t, Throwable e) {
            System.out.println(t.getName() + " exec error," + e.getMessage());
        }
    });
    Thread thread = new Thread(new ExceptionTask());
    thread.start();
    Thread.sleep(10000);
    System.out.println("end");
}

异常处理器如何处理未捕获异常,取决于对服务质量的需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入日志中。异常处理器还可以采取更直接的响应,例如尝试重新启动线程,关闭应用程序,或者执行其他修复或诊断等操作。

在运行时间较长的应用程序中,通常会为所有线程的未捕获异常指定同一个异常处理器,并且该处理器至少会将异常信息记录到日志中。

3.  线程之间的协作

虽然我们抽象出并发任务时会尽量使得不同类型的任务之间尽量的相互独立,执行也尽量时互不干扰。但线程毕竟同属于同一个进程,某些情况下线程之间可能需要互相协作来解决某个问题。

3.1.  wait 与 notify

线程执行时可能需要等待某个条件发生变化后才能继续执行,同时这个条件的触发是由其它线程发起的。简单的方法是线程内不停循环的检查条件是否发生了变化,若发生了变化则退出循环继续执行,但若条件被触发的等待时间比较久,那么这时不停地循环等待只会白白浪费 CPU 资源。所以一种好的方式时,先放弃 CPU 资源然后但条件触发再时主动通到线程。

例如有一份数据被分别保存在了三个不同的文件中,当需要完整的数据时就是需要将三个文件都下载下来并汇总。下载文件的操作往往是比较耗时的,同时三份文件又是相互独立的,这时我们就可以创建三个线程同时发起三个文件的下载,而主线程等待三个文件下载完成后输出汇总的内容,如下图:

Java线程详解一:使用线程执行并发任务

主线程在等待三个文件都下载完成期间是无事可做的,这时就可以使用 await 放弃 CPU 资源。而下载文件的三个线程可以同时进行,当文件下载好后通过 notify通知一下主线线程,notify后主线程等待的条件发生了变化其将被唤醒,这时主线线程再判断一下是否三个文件都下载完成了,若否则继续调用 await 等待,如是则输出结果。

代码实现如下:

class DownloadTask implements Runnable {
    private final String file;
    private final List allData;

    public DownloadTask(String file, List allData) {
        this.file = file;
        this.allData = allData;
    }

    @Override
    public void run() {
        String data = download(file);
        // allData非线程安全需要加锁,同时notify也要先获得锁
        synchronized (allData) {
            allData.add(data);
            allData.notify();
        }
    }

    private String download(String file) {
        // 模拟文件下载并读取文件内容返回
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
        return file + ":" + new Random().nextInt();
    }
}

public static void main(String[] args) throws Exception {
    List files = new ArrayList<>();
    files.add("file1");
    files.add("file2");
    files.add("file3");
    List allData = new ArrayList<>(files.size());
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < files.size(); i++) {
        Thread thread = new Thread(new DownloadTask(files.get(0), allData));
        thread.start();
    }
    // wait前需要先获得锁
    synchronized (allData) {
        while (allData.size() < files.size()) {
            allData.wait();
        }
    }
    System.out.println("all data: " + allData);
    System.out.println("cost time: " + (System.currentTimeMillis() - startTime) + "ms");

}

为什么 wait 和 notify 不是在 Thread 类提供?为什么 wait 和 notify 之前需要先获得锁?

Java 中‘一切都是对象’,我们的条件最终也是保存在某个对象之中的,例如上面的例子中我们等待通过判断 allData 保存的数量是否和要下载的文件数一直,所以 wait 和 notify 在Object 中提供的,所有对象都可以保存等待条件。

使用对象来保存等待条件并调用对象上的 wait 和 notify 方法,这些都是在多线程的情况下使用的,所以自然存在线程安全问题,为保证一定的线程安全性,在调用 wait 和 notify 之前需要先获得对象的锁。在上面的实例中下载线程更新数据前先获得 allData 也避免了多个线程同时更新导致的不一致问题。

wait 还有一个很重要的特性,wait 调用线程将被挂起而对象上的锁也将被释放,锁被释放后其它 synchronized 等待锁的任务就可以在 wait 期间获得锁从而被执行。当 wait 的线程被唤醒之前线程需要重新再获得锁,否则线程不会被唤醒。

wait 也可以指定超时时间,单位为毫秒,当在指定的超时时间内未收到 notify 或 notifyAll 是 wait 的线程将被超时唤醒。若未指定超时时间则线程将一直等待下去。

调用 notify 方法后即使有多个线程在 wait 也只会唤醒一个,除非我们能保证总是只有一个线程在 wait 或者所有 wait 的线程的等待条件相同只一个被唤醒即可,但这个两个条件都只会使我们的代码更加复杂。所有更推荐使用notifyAIl,其会唤醒所有在同一个对象上 wait 的线程。

3.2.  加入一个线程

通过调用 Thread 类的join方法可以在当前的线程中等待另外一个线程执行结束,等待期间当前线程将被挂起。

例如上面主线等待三个文件下载线程时可以简单的使用 join 达到一样的效果。

public static void main(String[] args) throws Exception {
    List files = new ArrayList<>();
    files.add("file1");
    files.add("file2");
    files.add("file3");
    List allData = new ArrayList<>(files.size());
    List threads = new ArrayList<>(files.size());
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < files.size(); i++) {
        Thread thread = new Thread(new DownloadTask(files.get(0), allData));
        thread.start();
        threads.add(thread);
    }
    for (int i = 0; i < threads.size(); i++) {
        threads.get(i).join();
    }
    System.out.println("all data: " + allData);
    System.out.println("cost time: " + (System.currentTimeMillis() - startTime) + "ms");

}

还可以为 join 指定一个超时时间,单位为毫秒,若在超时时间内线程执行完成的话 join 也将返回。

3.3.  让步

当线程中目前的工作已经做完,暂时没有其它工作时或许可以先让出 CPU 给其它线程。这种情况下可以通过调用 Thread 类的静态方法 yield 给线程调度器一个暗示:当前线程暂时没有工作需要执行,可以让出 CPU 资源。

例如下面的线程每执行 100 次计算后就尝试让出 CPU 资源。

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            if (i % 100 == 0) {
                System.out.println("i = " + i);
                Thread.yield();
            }
        }
        System.out.println("end");
    }
});

调用yield 后只是暗示调度器当前线程可以暂时放弃 CPU 资源,后续还是会再次被调度中心的。同时这仅仅只是一个暗示,没有任务机制保证它会被采纳,所以我们的程序找那个不应该对这个调用的效果有强依赖。 

4.  关闭线程

生命周期结束(End-of-Lifecycle)的问题会使任务、服务以及程序的设计和实现变得复杂,而这个在程序设计中非常重要的要素却经常被忽略。一个在行为良好的软件需要完善地处理失败、关闭和取消等过程。

线程的启动是很容易的。在大多数时候,线程会运行到任务结束或者遇到错误而提前退出。然而,有时候我们希望提前结束线程。如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为可取消的(Cancellable)。取消某个操作的原因很多,例如请求取消、超时取消、异常事件取消、服务关闭等。

要安全、快速、可靠地关闭线程,并不是一件容易的事。Java并没有提供任何机制来安全地终止线程,只提供了中断(Interruption),中断一种协作机制,能够使一个线程通知到另一个线程应该终止当前的工作。

这种协作式的方法是必要的,我们往往不希望某个任务、线程或服务立即停止,因为这种立即停止会使共享的数据结构处于不一致的状态。相反,在编写任务和服务时可以使用一种协作的方式:当需要停止时,它们首先会清除当前正在执行的工作,然后再结束。这提供了更好的灵活性,因为任务本身的代码比发出取消请求的代码更清楚如何执行清除工作。

4.1.  使用中断来关闭线程

在Java 中没有直接关闭线程的方法。只有一些协作式的机制,可取消的代码都遵循一种协商好的协议。其中一种协作机制能设置某个“已请求取消”标志,而任务将定期地查看该标志。如果设置了这个标志,那么任务将提前结束。如下程序:

public class PrimeGenerator implements Runnable {
        private final List primes = new ArrayList<>();

        private volatile  boolean cancelled;

        @Override
        public void run() {
            BigInteger p = BigInteger.ONE;
            while (!cancelled) {
                synchronized (this) {
                    primes.add(p);
                }
            }
        }

        public void cancel() {
            cancelled = true;
        }

        public synchronized List get() {
            return new ArrayList(primes);
        }
    }

一个可取消的任务必须拥有取消策略(Cancellation Policy),在这个策略中将详细地定义取消操作的“How”、“When”以及“what",即其他代码如何(How)请求取消该任务,任务在何时(When)检查是否已经请求了取消,以及在响应取消请求时应该执行哪些(What)操作。

PrimeGenerator使用了一种简单的取消策略:客户代码通过调用 cancel来请求取消,PrimeGenerator在每次搜索素数前首先检查是否存在取消请求,如果存在则退出。

4.1.1.  中断

PrimeGenerator中的取消机制最终会使得搜索素数的任务退出。然而,如果使用这种方法来退出的任务中调用了一个阻塞方法,例如BlockingQueue.put,那么可能会产生一个更严重的问题,任务可能永远不会检查取消标志,因此永远不会结束。所以Java提供了中断机制来取消任务,中断是一种协作方式,仍然需要被中断的代码去主动检查和响应中断标志。不过中断是一种‘原生’的取消机制,大多数Java和第三方类库中的阻塞方法都会检查和响应中断请求,从而避免了一直阻塞而无法取消的问题。

每个线程都有一个 boolean 类型的中断状态。当中断线程时,这个线程的中断状态将被设置为 true。

在Thread中提供了中断线程的 interrupt 方法:

public class Thread implements Runnable {
     // 中断线程
     public void interrupt();
    // 返回线程是否已经被中断
    public boolean isInterrupted();
    // 清楚当前线程的中断状态并返回清除之前的中断状态。
    public static boolean isInterrupted();
    // 获取当前线程释放被中断了,若是则会清楚中断状态并返回true。
    public static boolean interrupted()
}

当调用  interrupt 方法后线程的中断状态建被设置,但调用interrupt 并不意味着立即停止目标线程正在进行的工作,而只是向线程传递了中断请求的消息。Java 提供的大部分阻塞的方法都会在响应中断请求,它们在响应中断时执行的操作包括:清除中断状态,抛出 InterruptedException,表示阻塞操作由于中断而提前结束。JVM 并不能保证阻塞方法检测到中断的速度,但在实际情况中响应速度还是非常快的。

当线程在非阻塞状态下中断时,它的中断状态将被设置,然后需要被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,中断操作将变得“有黏性”,如果不触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。

对中断操作的正确理解是:它并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己,这些时刻也被称为取消点。有些方法,例如wait、 sleep 和 join 等,将严格地处理这种请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。设计良好的方法可以完全忽略这种请求,只要它们能使调用代码对中断请求进行某种处理。设计糟糕的方法可能会屏蔽中断请求,从而导致调用栈中的其他代码无法对中断请求作出响应。

在使用静态的 interrupted 时应该小心,因为它会清除当前线程的中断状态。如果在调用interrupted 时返回了 true,那么除非你想屏蔽这个中断,否则必须对它进行处理,可以抛出InterruptedException,或者通过再次调用 interrupt 来恢复中断状态。

如果任务代码能够响应中断,那么可以使用中断作为取消机制,并且利用许多库类中提供的中断支持。

4.1.2.  中断策略

正如任务中应该包含取消策略一样,线程同样应该包含中断策略。中断策略规定线程如何解释某个中断请求,当发现中断请求时,应该做哪些工作(如果需要的话),哪些工作单元对于中断来说是原子操作,以及以多快的速度来响应中断。

最合理的中断策略是某种形式的线程级(Thread-Level) 取消操作或服务级取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。此外还可以建立其他的中断策略,例如暂停服务或重新开始服务。

当检查到中断请求时,任务并不需要放弃所有的操作,它可以推迟处理中断请求,并直到某个更合适的时刻。因此需要记住中断请求,并在完成当前任务后抛出 InterruptedException 或者表示已收到中断请求。这样能够确保在更新过程中发生中断时,数据结构不会被破坏。

任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并且在这些服务中包含特定的中断策略。无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断状态。如果除了将 InterruptedException 传递给调用者外还需要执行其他操作,那么应该在捕获 InterruptedException 之后恢复中断状态:

Thread.currentThread ().interrupt();

由于每个线程拥有各自的中断策略,因此除非你知道中断对该线程的含义,否则就不应该中断这个线程。

当调用可中断的阻塞函数时,例如Thread.sleep或 BlockingQueue.put等,有两种实用策略可用于处理 InterruptedException:

  •  传递异常(可能在执行某个特定于任务的清除操作之后),从而使你的方法也成为可中断的阻塞方法。
  • 恢复中断状态,从而使调用栈中的上层代码能够对其进行处理。

传递 InterruptedException 与将 InterruptedException 添加到 throws 子句中一样容易,如下程所示:

BlockingQueue queue;

public Task getNextTask () throws InterruptedException {
    return queue.take();
}

如果不想或无法传递 InterruptedException,那么就应该再次调用interrupt来恢复中断状态。简单的屏蔽 InterruptedException,例如在 cateh 块中捕获到异常却不做任何处理,除非在代码中实现了线程的中断策路,但由于大多数代码并不知道它们将在哪个线程中运行,因此应该保存中断状态。

只有实现了线程中断策略的代码才能屏蔽中断请求,在常规的任务和代码库中都不应该屏蔽中断请求。

如果代码不会调用可中断的阻塞方法,那么仍然可以通过在任务代码中轮询当前线程的中断状态来晌应中断。要选择合适的轮询频率,就需要在效率和响应性之间进行权衡。如果响应性要求较高,那么不应该调用那些执行时间较长并且不响应中断的方法,从而对可调用的库代码进行一些限制。

在取消过程中可能涉及除了中断状态之外的其他状态。中断可以用来获得线程的注意,并且由中断线程保存的信息,可以为中断的线程提供进一步的指示(当访问这些信息时,要确保使用同步)。例如,当一个由 ThreadPoolExecutor 拥有的工作者线程检测到中断时,它会检查线程池是否正在关闭。如果是,它会在结束之前执行一些线程池清理工作,否则它可能创建一个新线程将线程池恢复到合理的规模。

4.1.3.  处理不可中断的阻塞

在Java 库中,许多阻塞的方法都会通过提前返回或者抛出 InterruptedException 来晌应中断请求,从而使开发人员更容易构建出能响应取消请求的任务。然而,并非所有的可阻塞方法或者阻塞机制都能响应中断;如果一个线程由于执行同步的Socket I/O 或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。

对于那些由于执行不可中断操作而被阻塞的线程,可以使用其它类似于中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因。

  • 同步Socket I/O的中断: 在服务器应用程序中,最常见的阻塞I/O形式就是对同步的套接字进行读取和写入。虽然InputStream 和 OutputStream 中的read 和 write 等方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行 read 或 write 等方法而被阻塞的线程抛出一个SocketException。
  • InterruptibleChannel: 当中断一个正在InterruptibleChannel上等待的线程时,将抛出ClosedByInterruptException异常并关闭链路(这还会使得其他在这条链路上阻塞的线程同样地出ClosedByinteruptException)。当关闭一个 InterruptibleChannel 时,将导致所有在链路操作上阻塞的线程都抛出 AsynchronousCloseException。大多数标准的 Channel都实现了InterruptibleChannel。
  • selector的NIO: 如果一个线程在调用Selector.select 方法时阻塞了,那么调用 close 或 wakeup 方法会使线程抛出ClosedSelectorException 并提前返回。
  • lockInterruptibly: 如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程认为它肯定会获得锁,所以将不会理会中断请求。但是,在Lock 类中提供了 lockInterruptibly方法,该方法允许在等待一个锁的同时仍能响应中断。

4.2.  停止基于线程的服务

应用程序通常会创建拥有多个线程的服务,例如线程池,并且这些服务的生命周期通常比创建它们的方法的生命周期更长。如果应用程序准备退出,那么这些服务所拥有的线程也需要结束。由于无法强制的方法来停止线程,因此它们需要自行结束。

与其他封装对象一样,线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应该提供生命周期方法(Lifecyele Method) 来关闭它自己以及它所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。在线程池的 Executorervice接口中就提供了 shutdown 和 shutdownNow 等方法。同样,在其他拥有线程的服务中也应该提供类的关闭机制。

对于特有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供生命周期方法。

4.3.  JVM的关闭

在正常关闭时JVM 首先调用所有已注册的关闭钩子(Shutdown Hook)。关闭钩子是指通过 Runtime.addShutdownHook 注册的但尚未开始的线程。但JVM 并不能保证关闭钩子的调用顺序。

在关闭应用程序线程时,如果有(守护或非守护)线程仍然在运行,那么这些线程接下来将与关闭进程并发执行。当所有的关闭钓子都执行结束时,如果 runFinalizersOnExit 为 true,那么JVM 将运行终结器,然后再停止。

JVM 并不会停止或中断任何在关闭时仍然运行的应用程序线程。当JVM最终结束时,这些线程将被强行结束。如果关闭钩子或终结器没有执行完成,那么正常关闭进程“挂起”并且JVM必须被强行关闭。当被强行关闭时,只是关闭JVM,而不会运行关闭钩子。

关闭钧子应该是线程安全的:它们在访问共享数据时必须使用同步机制,并且小心地避免发生死锁,这与其他并发代码的要求相同。而且,关闭钩子不应该对应用程序的状态(例如,其他服务是否已经关闭,或者所有的正常线程是否已经执行完成)或者JVM的关闭原因做出任何假设,因此在编写关闭钩子的代码时必须考虑周全。最后,关闭钩子必须尽快退出,因为它们会延迟 JVM的结束时间,而用户可能希望JVM 能尽快终止。

关闭钩子可以用于实现服务或应用程序的清理工作,例如删除临时文件,或者清除无法由操作系统自动清除的资源。

由于关闭钓子将并发执行,因此在关闭日志文件时可能导致其他需要日志服务的关闭钩子产生问题。为了避免这种情况,关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。实现这种功能的一种方式是对所有服务使用同一个关闭钩子(而不是每个服务使用一个不同的关闭钩子),并且在该关闭钩子中执行一系列的关闭操作。这确保了关闭操作在单个线程中串行执行,从而避免了在关闭操作之间出现竞态条件或死锁等问题。无论是否使用关闭钩子,都可以使用这项技术,通过将各个关闭操作串行执行而不是并行执行,可以消除许多潜在的故障。当应用程序需要维护多个服务之间的显式依赖信息时,这项技术可以确保关闭操作按照正确的顺序执行。