引:之前介绍了Executor任务执行框架的使用,它不仅简化了任务与线程的生命周期管理,而且还提供了一种简单灵活的方式将任务的提交和任务的执行策略解耦开来。但是深入下去,我们需要对线程池进行配置和调优,并且分析再使用任务执行框架时需要注意的各种危险。
在任务与执行策略之间的隐性耦合
不是所有任务都能适用所有的执行策略的,有些类型的任务需要明确地指定执行策略,包括:
- 依赖性任务:提交给线程池的任务需要依赖其他的任务,我们需要避免线程饥饿死锁。
- 使用线程封闭机制的任务:任务要求其执行策略所在的Executor是单线程的。
- 对响应时间敏感的任务:GUI应用程序对于响应时间是敏感的。
- 使用ThreadLocal的任务:Executor会重用线程,所偶一使用ThreadLocal会没有意义。
只有当任务都是同类型的并且是相互独立时,线程池的性能才能达到最佳。
在一些任务中,需要拥有或排除某种特定的执行策略。如果某些任务依赖于其他的任务,那么会要求线程池足够大,从而确保他们依赖任务不会被放入等待队列中或被拒绝,而采用线程封闭机制的任务需要串行执行。
线程饥饿死锁
在线程池中,如果任务依赖于其他任务,那么可能产生死锁。在单线程的Executor中,如果一个任务将另一个任务提交到同一个Executor,并且等待这个被提交任务的结果,那么通常会引发死锁。第二个任务停留在工作队列中,等待第一个任务完成,而第一个任务又无法完成,因为它在等待第二个任务的完成。在更大的线程池中,如果所有正在执行任务的线程都由于等待其他仍处于工作队列中的任务而阻塞,也会发生同样的问题。这种现象叫做线程饥饿死锁(Thread Starvation Deadlock),只要线程池中的任务需要无限期等待一些必须由池中其他任务才能提供的资源或条件,例如某个任务等待另一个任务的返回值或执行结果,那么除非线程池足够大,否则将发生线程饥饿死锁。
每当提交了一个有依赖性的Executor任务时,要清楚地知道可能会出现线程饥饿死锁,因此需要在代码或配置Executor的配置文件中记录线程池的大小限制或配置限制。
运行时间较长的任务
如果任务阻塞时间过长,那么即使不出现死锁,线程池的响应性也会变得糟糕。执行时间较长的任务不仅会造成线程池堵塞,甚至会增加执行时间较短任务的服务时间。如果线程池中的数量远小于在稳定状态下执行时间较长任务的数量,那么到最后可能所有线程都会运行这些执行时间较长的任务,从而影响整体的响应性。
缓解这个问题的技术就是限定等待资源的时间,而不是无限制等待。例如Thraed.join(),BlockingQueue.put()、CountDownLatch.await()等,如果等待超时,可以把任务标识为失败,然后终止任务或将任务重新放回队列以便随后执行。
设置线程池的大小
线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。
只要避免过大和过小两种极端情况,如果线程池过大,那么大量的线程将在相对很少的CPU和内存资源上发生竞争,这不仅会导致更高的内存使用量,而且还可能耗尽资源。如果线程池过小,那么导致许多空闲的处理器无法执行工作,从而降低吞吐率。
要想正确设置线程池的大小,必须分析计算环境、资源预算和任务的特性。
对于计算密集型的任务,在拥有N个处理器的系统上,当线程池的大小为N + 1时,通常能实现最优的利用率。
对于包含I/O操作或者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。要正确地设置线程池的大小,必须估算出任务的等待时间与计算时间的比值。有个公式:1
2
3
4N = CPU的数量 = Runtime.getRuntime().availableProcessors();
U = 预期CPU利用率
W/C = 等待时间,计算时间之比(wait time / compute time)
线程池的最优大小 = N * U * (1 + W/C)
配置ThreadPoolExecutor
如果newCachedThreadPool、newFixedTheadPool和newScheduledTheadPool等工厂方法返回的ThreadPoolExecutor无法满足需求,可以通过ThreadPoolExecutor的构造函数来实例化一个对象,并根据自己需求来定制。构造函数如下:1
2
3
4
5
6
7public ThreadPoolExecutor(int corePoolSize, //线程池的基本大小
int maximumPoolSize, //最大大小
long keepAliveTime, //存活时间
TimeUnit unit, //时间单位
BlockingQueue<Runnable> workQueue, //工作队列
ThreadFactory threadFactory, //线程工厂
RejectedExecutionHandler handler) {...}
线程的创建与销毁
通过调节线程池的基本大小和存活时间,可以帮助线程池回收空闲线程占有的资源,从而使得这些资源可以用于执行其他工作。
管理队列任务
如果新请求的到达速率超过了线程池的处理速率,那么新到来的请求将会在一个由Executor管理的Runnable队列中等待,而不会像线程那样去竞争CPU资源。
ThreadPoolExecutor允许提供一个BlockingQueue来保存等待执行的任务。基本的任务排队方法有3种:无界队列、有界队列和同步移交。
一种更稳妥的资源管理策略时使用有界队列,例如ArrayBlockingQueue、有界LinkedBlockingQueue、PriorityBlockingQueue。有界队列有助于避免资源耗尽的情况发生,但又带来新的问题:当队列满后,新的任务怎么办?在使用有界的工作队列时,队列的大小和线程池的大小必须一起调节,如果线程池较小而队列较大,那么有助于减少内存使用量,降低CPU使用率,同时减少上下文切换,但代价是限制了吞吐量。
对于非常大的或者无界的线程池,可以使用SynchronousQueue来避免任务排队,它可以直接将任务从生产者移交给工作者线程。SynchronousQueue并不是一个真正的队列,而是一种在线程之间进行移交的机制。要将一个元素放入SynchronousQueue中,必须由另一个线程正在等待接受这个元素。如果没有线程正在等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor会创建一个新的线程来处理这个任务。否则,根据饱和策略,这个任务将被拒绝。直接使用移交将更高效,因为任务直接移交给执行它的线程,而不是先放到队列,然后再由工作线程从队列中提取任务。只有当线程池是无界的或者可以拒绝任务时,SynchronousQueue才有实际价值。在newCachedThreadPool中就是使用了SynchronousQueue。
当使用像LinkedBlockingQueue或ArrayBlockingQueue这样FIFO队列时,任务的执行顺序与它们的到达顺序相同。如果想进一步控制任务执行顺序,还可以使用PriorityBlockingQueue,这个队列根据优先级来安排任务,任务的优先级是通过自然顺序或者Comparator来定义的。
只有当任务相互独立时,为线程池或工作队列设置界限才是合理的 。如果任务之间有依赖性,那么有界的线程池或队列会导致线程饥饿死锁问题,此时应该使用无界的线程池,如newCachedThreadPool。
对于Executor,newCachedThreadPool工厂方法是一种很好的默认选择,他能提供比固定大小的线程池更好的排队性能。当需要限制当前任务的数量以满足资源管理需要求时,可以选择固定大小的线程池。
饱和策略
当有界队列被填满后,饱和策略开始发挥作用,ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。
当工作队列被填满后,没有预定义的饱和策略来阻塞ececute。通过使用Semaphore(信号量)来限制任务的到达率可以实现饱和策略的功能。
线程工厂
每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的。在TheadFactory中只定义了一个方法newThread,每当线程池需要创建一个新线程时都会调用这个方法。
在调用构造函数后再定制ThreadPoolExecutor
在调用完ThreadPoolExecutor的构造函数后,仍然可以通过设置函数来修改大多数传递给他的构造函数的参数,如果Executor是通过Executors中的某个工厂方法创建的,那么可以通过将结果的类型转换为ThreadPoolExecutor以访问设置器。
扩展ThreadPoolExecutor
ThreadPoolExecutor是可扩展的,它提供了几个可以在子类化中改写的方法:beforeExcute,afterExecute和terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。 在这里方法中可以添加日志、计时、监视或统计信息收集功能。
总结
对于并发执行的任务,Executor框架是一种强大且灵活的框架。它提供了大量可调节的选项。我们要根据实际情况对这些参数进行调节。
参考
- 《Java并发编程实战》