线程池的使用(第八章)

线程池的使用

Executor框架可以将任务的提交与任务的执行策略解耦开来。
并非所有的任务都使用所有的执行策略,有些任务需要明确的指定执行策略,包括:

  1. 依赖性任务:提交给线程池的任务需要依赖其他的任务,那么就隐含地给执行策略带来了约束,此时必须小心地维持这些执行策略以避免产生活跃性问题
  2. 使用线程封闭机制的任务:单线程的Executor能够对并发性做出更强的承诺,对象可以封闭在任务线程中,使得在该线程中执行的任务在访问该对象时不需要同步,即使这些资源不是线程安全的也没有问题。但这种情形将在任务与执行策略之间形成隐式的耦合—-任务要求其执行所在的Executor是单线程的。
  3. 对响应时间敏感的任务
  4. 使用ThreadLocal的任务:只有当线程本地值的生命周期受限于任务的生命周期时,在线程池的线程中使用ThreadLocal才有意义,而在线程池的线程中不应该使用ThreadLocal在任务之间传递值。

        只有当任务都是同类型的并且相互独立时,线程池的性能才能达到最佳。
        

1. 设置线程池的大小

线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。在代码中通常不会固定线程池的大小,而应该通过某种配置机制来提供,或者根据Runtime.availableProcessors来动态计算。

2. 配置ThreadPoolExecutor

ThreadPoolExecutor是一个灵活的、稳定的线程池,允许进行各种定制。
如果默认的执行策略不能满足需求,那么可以通过ThreadPoolExecutor的构造函数来实例化一个对象,并根据自己的需求来定制。

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
                              ...
                          }

ThreadPoolExecutor是可扩展的,它提供了几个可以在子类化中改写的方法:beforeExecute、afterExecute和terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。
在执行任务的线程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以添加日志、计时、监视或统计信息收集的功能。

    无论任务是从run中正常返回,还是抛出一个异常而返回,afterExecute都会被调用,如果任务在完成后带有一个Error,那么就不会调用afterExecute。如果beforeExecute抛出一个RuntimeException,那么任务将不被执行,并且afterExecute也不会被调用。在线程池完成关闭操作时调用terminated,也就是在所有任务都已经完成并且所有工作者线程已经关闭后。

3. 线程的创建与销毁

线程池的基本大小(Core Pool Size)、最大大小(Maximum Pool Size)以及存活时间等因素共同负责线程的创建与销毁。

  1. 基本大小:线程池的目标大小,即在没有任务执行时线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
  2. 最大大小:可同时活动的线程数量的上限。
  3. 存活施加:如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收的,并且当线程池的当前大小超过了基本大小时,这个线程被终止。

        通过调节线程池的基本大小和存活时间,可以帮助线程池祸首空闲线程占有的资源,从而使得这些资源可以用于执行其他工作。
        

    newFixedThreadPool工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。

newCachedThreadPool工厂方法将线程池的最大大小设置为Integer.MAX_VALUE,而将基本大小设置为0,并将超时设置为1分钟。这种方法创建出来的线程池可以被无限扩展,并且当需求降低时会自动收缩。

当新的任务请求的到达速率超过了线程池的处理速率,那么新到来的请求将积累起来,在线程池中,这些请求会在一个由Executor管理的Runnable队列中等待,而不会像线程那样去竞争CPU资源。
ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务,基本的任务排队方法有3种:无界队列、有界队列和同步移交
newFixedThreadPool和newSingleThreadExecutor在默认情况下将使用一个无界的LinkedBlockingQueue。如果所有的工作者线程都处于忙碌状态,那么任务将在队列中等候。如果任务持续快速地到达,并且超过了线程池处理它们的速度,那么队列将无限制地增加。
一种更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQueue、有界的LinkedBlockingQueue、PriorityBlockingQueue。

    在使用有界的工作队列时,队列的大小与线程池的大小必须一起调节。如果线程池较小而队列较大,那么有助于减少内存使用量,降低CPU的使用率,同时还可以减少上下文切换,但可能会限制吞吐量。对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。SynchronousQueue不是一个真正的队列,而是一种在线程之间进行移交的机制,要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接受这个元素,如果没有线程正在等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor将创建一个新的线程,否则根据饱和策略,这个任务将被拒绝。
    

使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是被首先放在队列中,然后由工作者线程从队列中提取该任务。只有当线程池是无界的或者可以拒绝任务时,SynchronousQueue才有实际价值。
只有当任务相互独立时,为线程池或工作队列设置界限才是合理的,如果任务之间存在依赖性,那么有界的线程池或队列就可能导致线程“饥饿”死锁问题,此时应该使用无界线程池,如newCachedThreadPool。

4. 饱和策略

当有界队列被填满后,饱和策略开始发挥作用。ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。JDK提供了几种不同的饱和策略:

  1. 终止(Abort)策略:默认的饱和策略,该策略将抛出未检查的RejectedExecutionException,调用者可以捕获这个异常,然后根据需求编写自己的处理代码。
  2. 抛弃(Discard)策略:当新提交的任务无法保存到队列中等待执行时,抛弃策略会悄悄抛弃该任务。
  3. 抛弃最旧的(Discard-Oldest)策略:抛弃下一个将被执行的任务,然后尝试重新提交新的任务。如果工作队列是一个有限队列,那么该策略将导致抛弃优先级最高的任务,因此最好不要将“抛弃最旧的”策略和优先级队列放在一起使用。
  4. 调用者运行(Caller-Runs)策略:该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。

5. 饱和策略

每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的。默认的线程工厂方法将创建一个新的、非守护的线程,并且不包含特殊的配置信息,通过制定一个线程工厂方法,可以定制线程池的配置信息。

//线程工厂原型
public interface ThreadFactory {
    Thread newThread(Runnable r);
}
//自定义的线程工厂
public class MyThreadFactory implements ThreadFactory {
    public Thread newThread(Runnable runnable) {
        ...
    }
}
点赞