Wetts's blog

Stay Hungry, Stay Foolish.

0%

Java多线程编程实战指南-第9章-Thread Pool(线程池)模式

读书笔记 - Java 多线程编程实战指南


第九章 Thread Pool(线程池)模式

模式介绍

  • 一个系统中的线程相对于其所要处理的任务而言,总是一种非常有限的资源。线程不仅在其执行任务时需要消耗 CPU 时间和内存等资源,线程对象(Thread 实例)本身以及线程所需的调用栈(Call Stack)也占用内存,并且 Java 中创建一个线程往往意味着 JVM 会创建相应的依赖于宿主机操作系统的本地线程(Native Thread)。因此,为每个或者每一批任务创建一个线程以对其进行执行,通常是一种奢侈而不现实的事情。比较常见的一种做法是复用一定量的线程,由这些线程去执行不断产生的任务。绝大多数的 Web 服务器就是采用这种方法。例如,Tomcat 服务器复用一定数量的线程用于处理其接收到的请求。
  • Thread Pool 模式的核心思想是使用队列对待处理的任务进行缓存,并复用一定数量的工作者线程去取队列中的任务进行执行。

-

模式架构

-

实战案例

某系统在用户执行一些关键的操作前要求其输入一个验证码。验证码是一串随机数字,由系统的服务器端代码生成并通过短信发送给用户。
服务端代码实现短信发送功能需要调用其他系统提供的 Web 服务(Web Service)。从设计上看,我们希望一个名为 SmsVerficationCodeSender 的类负责验证码的生成和相应短信的下发。这样,系统在发送验证码短信时只需要调用 SmsVerficationCodeSender 实例的相应方法即可。考虑到 SmsVerficationCodeSender 的客户端代码(即该系统需要发送验证码短信的代码)是运行在服务器的请求处理线程中的,我们不希望发送验证码短信时所涉及的网络 I/O 这种相对慢的操作影响服务器请求处理线程执行其他操作(如继续处理其它请求)。因此,SmsVerficationCodeSender 需要采用专门的工作者线程负责验证码短信的发送。另外,考虑到系统的并发量,每次发送验证码短信都启动一个专门的工作者线程显然是过于昂贵。
这里,Thread Pool 模式就可以派上用场。我们可以创建一个包含若干个工作者线程的线程池,系统需要下发验证码短信时就创建一个相应的任务,并将其提交给这个线程池执行。由于向线程池提交任务这个操作可即刻返回,因此验证码短信发送的快慢并不会影响需要发送验证码的线程继续处理。

本案例 Demo 详见 multithreading 工程的 com.ThreadPool 包

-

Thread Pool 模式的评价与实现考量

  • Thread Pool 模式通过复用一定数量的工作者线程去执行不断被提交的任务,节约了线程这种有限而昂贵的资源。Thread Pool 模式还可以带来以下的好处:
  • 抵消线程创建的开销,提高响应性。 创建线程的消耗不仅包括线程对象本身以及其调用栈所需的内存空间,也包括创建以来雨 JVM 宿主机操作系统的本地线程。
  • 封装了工作者线程生命周期管理。 线程池本身负责了其工作者线程的生命周期的管理,包括何时创建工作者线程、创建多少工作者线程以及何时销毁工作者线程。
  • 减少销毁线程的开销。 JVM 销毁一个已停止的线程也有其时间上的开销。Thread Pool 模式使我们避免了频繁地创建工作者线程,因此避免了频繁地销毁已停止的线程,从而减少了相应开销。
  • 自行实现 Thread Pool 模式比较容易出错,并且 Java 自 JDK 1.5 开始已经提供了 ThreadPoolExecutor 这个线程池实现类。因此,下面我们讨论的有关使用 Thread Pool 模式的风险和问题都是基于 ThreadPoolExecutor 这个线程池实现类的。
  • 工作队列的选择

工作队列通常可以在有界队列(Bounded Queue)、无界队列(Unbound Queue)和直接交接队列(SynchronousQueue)之间选择。

以无界队列(如 LinkedBlockingQueue)作为工作队列,虽然工作队列本身并不限制线程池中等待运行的任务的数量,但工作队列中实际可容纳的任务数取决于任务本身对资源的使用情况。例如,线程池的客户端代码在创建向线程池提交的任务对象(Runnable)的时候同时创建了该任务对象所需引用的其它对象,而这些被引用的对象占用的内存空间比较大。这样一来,随着工作队列中这样的任务对象越来越多,这些任务对象所导致的内存占用也越来越多(这些任务还没有被执行,因此其占用的内存空间不能被垃圾回收)。极端的情况下,这种情形还能导致 JVM 内存溢出,从而影响了这个 Java 应用程序,而不仅仅是使用该线程池的对象。因此,无界工作队列可能导致系统的不稳定,适合在任务占用的内存空间以及其它稀缺资源比较少的情况下使用。

如果应用程序确实需要比较大的工作队列容量,而又想避免无界工作队列可能导致的问题,不妨考虑 SynchronousQueue。SynchronousQueue 实现上并不使用缓存空间。由于 ThreadPoolExecutor 内部实现任务提交的时候调用的是工作队列(BlockingQueue 接口的实现类)的非阻塞式入队列方法(offer 方法),因此,在使用 SynchronousQueue 作为工作队列的前提下,客户端代码向线程池提交任务时,而线程池中又没有空闲的线程能够从 SynchronousQueue 队列实例中取一个任务,那么相应的 offer 方法调用就会失败(即任务没有被存入工作队列)。此时,ThreadPoolExecutor 会创建一个新的工作者线程用于对这个入队列失败的任务进行处理(假设此时线程池的大小还未达到其最大线程池大小)。所以,使用 SynchronousQueue 作为工作队列,工作队列本身并不限制待执行的任务的数量。但此事需要限定线程池的最大大小为一个合理的有限值,而不是 Integer.MAX_VALUE,否则可能导致线程池中的工作者线程的数量一直增加到系统资源所无法承受为止。

以有界队列(如 ArrayBlockingQueue、有界的 LinkedBlockingQueue)作为工作队列则可以限定线程池中待执行任务的数量,这在一定程度上可以限制资源的消耗。通常,使用有界队列作为工作队列需要执行线程池的最大大小为一个合理的有限值,而不是 Integer.MAX_VALUE。其理由类似上面使用 SynchronousQueue 作为工作队列的情形。而有界工作队列加上有限数量的工作者线程则可能导致死锁。有界队列适合提交给线程池执行的各个任务之间时相互独立(而非有依赖关系)的情况下使用。

  • 线程池大小调校

线程池大小指线程池中的工作者线程的数量。线程池大小太大会消耗过多的资源,并增加上下文切换。线程池大小太小,又可能导致无法充分利用 CPU 资源,使任务处理的吞吐率过低。

  • 线程池监控

ThreadPoolExecutor 提供了线程池监控的相关方法。

  • 线程泄漏

线程泄漏(Thread Leak)指线程池中的工作者线程意外中止,使得线程池中实际可用的工作者线程变少。如果线程泄漏持续存在,那么线程池中的工作者线程会越来越少,最终使得线程池无法处理提交给其的任务。

如果线程池中的某个工作者线程执行的任务涉及外部资源等待,如等待网络 I/O,而该任务又没有对这种等待执行时间限制。那么,外部资源如果一直没有返回该任务所等待的结果,就会导致执行该任务的工作者线程一直处于等待状态而无法执行其他任务,这就形成了事实上的线程泄漏。

  • 可靠性与线程池饱和处理策略

ThreadPoolExecutor 提供了线程池饱和处理策略的接口和一些预定义的实现类。接口 java.util.concurrent.RejectedExecutionHandler 对线程池饱和处理策略进行了抽象。当一个提交给 ThreadPoolExecutor 实例的任务被拒绝时,相应的 ThreadPoolExecutor 实例会调用其 RejectedExecutionHandler 实例的 rejectedExecution 方法以执行线程池饱和处理策略。

  • 死锁

如果线程池中执行的任务在其执行过程中又会向同一个线程池提供另外一个任务,而前一个任务的执行结果又依赖后一个任务的执行结果,那么当线程池中所有的线程都处于这种等待其他任务的处理结果,而这些线程所等待的任务仍然还在工作队列中的时候,由于线程池已经没有可以对工作队列中的任务进行处理的工作者线程,这种等待就会一直持续下去而形成死锁(Deadlock)。

要执行彼此有依赖关系的任务可以考虑将不同类型的任务交给不同的线程池实例执行,或者对负责任务执行的线程池实例进行如下配置:

  1. 设置线程池的最大大小为一个有限值,而不是默认值 Integer.MAX_VALUE。
  2. 使用 SynchronousQueue 作为工作队列。
  3. 使用 ThreadPoolExecutor.CallerRunsPolicy 作为线程池饱和处理策略。
  • 线程池空闲线程清理

线程池中长期处于空闲状态(即没有在执行任务)的工作者线程会浪费宝贵的线程资源。因此,清理一部分这样的线程可以节约有限的资源。ThreadPoolExecutor 支持将其核心工作者线程以外的空闲线程进行清理。

-

Java 标准库实例

Java Swing 中类 javax.swing.SwingWorker 可用于执行耗时较长的任务。该类使用了 Thread Pool 模式。SwingWorker 内部维护了一个线程池(ThreadPoolExecutor 实例),该线程池包含了若干个工作者线程用于执行提交给 SwingWorker 的任务。