一、任务与执行策略间的隐形耦合
1、线程饥饿死锁
当任务都是同类、独立的时候,线程池才会有最好的工作表现。如果将耗时的短期的任务混合在一起,除非线程池很大,否则会有“塞车”的风险;如果提交的任务要依赖其他的任务,除非池是无限的,否则有产生死锁的风险。如下代码所示,对于一个单线程化的Executor,一个任务将另一个任务提交到相同的Executor中,并等待新提交的任务的结果,第二个任务滞留在工作队列中,直到第一个任务完成,但是第一个任务在等待第一个任务完成,这就引发了死锁。在一个大的线程池中,如果所有线程执行的任务都阻塞在线程池中,等待着仍处于同一队列中的其他任务,也会发生死锁。
class TheadDeadlock{
ExecutorService exec = Executors.newSingleThreadExecutor();
class RenderPageTask implements Callable<String>{
public String call() {
Future<String> header,footer;
header = exec.submit(new LoadFileTask("header.html"));
footer = exec.submit(new LoadFileTask("footer.html"));
String page = renderBody();
return header.get() + page + footer.get();
}
}
}
2、耗时操作
如果任务由于过长的时间周期而阻塞,那么即使不可能出现死锁,线程池的响应性也会变得很差。耗时任务会造成线程池堵塞,还会延长服务时间。可以限定任务等待资源的时间来缓解耗时操作带来的影响。
二、定制线程池的大小
线程池过大:线程对稀缺的CPU和内存资源的竞争,会导致内存的高使用量,还可能耗尽资源。
线程池过小:由于存在很多可用的处理器资源却未在工作,会对吞吐量造成损失。
如果有不同类别的任务,他们拥有差别很大的行为,那么要使用多个线程池,这样每个线程池可以根据不同任务的工作负载进行调节。
对于计算密集型的任务,一个有N个处理器的系统通常使用N+1个线程的线程池来获得最优的利用率。
对于包含了I/O和其他阻塞操作的任务,不是所有的线程都会在所有的时间被调度,因此需要一个更大的线程池。
给定下列定义:
N = CPU的数量
U = 目标CPU的使用率,0 <= U <= 1
W/C = 等待时间与计算时间的比率
为保持处理器达到期望的使用率,最优的池的大小等于
Num = N * U * (1 + W/C)
可以通过Runtime.getRuntime().availableProcessors();来获得CPU的数目
CPU周期并不是唯一可以约束资源池大小的资源,其他还有:内存、文件句柄、套接字句柄和数据库连接等。计算这些类型资源池的大小:首先累加每一个任务需要的这些资源的总量,然后除以可用的总量,所得结果是池大小的上限。
三、配置ThreadPoolExecutor
1、线程的创建与销毁
核心池大小:即线程池的实现试图维护的池的大小,即使没有任务执行,池的大小也等于核心池的大小,并且直到工作队列充满前,池都不会创建更多的线程。
最大池的大小:可同时活动的线程数的上限
存活时间:如果一个线程已经闲置的时间超过了存活时间,它将成为一个被回收的候选者,如果池的大小超过了核心池的大小,线程池会终止它。
newFixedThreadPool工厂为请求的池设置了核心池的大小和最大池的大小,而且存活时间是无限的
newCachedThreadPool工厂将最大池的大小设置为Integer.MAX_VALUE,核心池的大小设置为0,超时设置为1分钟
2、管理队列任务
当新请求到达的频率超过了线程池能够处理它们的速度,这些多出来的请求会在一个由Executor管理的Runnable队列中等候。
ThreadPoolExecutor允许使用一个BlockingQueue来持有等待执行的任务。任务排队有3种基本方法:无限队列、有限队列和同步移交。
newFixedThreadPool和newSingleThreadExecutor默认使用的是一个无限的LinkedBlockingQueue。如果线程池中的所有线程都处于忙碌状态,任务将会在队列中等候。如果等候的任务超出了队列的长度,队列也会无限制地增加。
newCachedThreadPool使用SynchronousQueue,将任务直接从生产者移交给工作者线程。为了把一个元素放入到SynchronousQueue中,必须有另一个线程正在等待接受移交的任务。如果没有这样一个线程,只要当前池的大小还小于最大值,ThreadPoolExecutor就会创建一个新的线程;否则根据饱和策略,任务会被拒绝。只有当池是无限的,或者可以接受任务被拒绝,SynchronousQueue才是一个好的选择。
另外,使用有限队列比如ArrayBlockingQueue或者有限的LinkedBlockingQueue以及PriorityBlockingQueue有助于避免资源耗尽的情况发生,但是它又引入了新问题:当队列已满后,新的任务怎么办?有很多饱和策略可以处理这个问题。对于一个有界队列,队列的长度与吃的长度必须一起调节。一个大队列加一个小池,可以控制对内存和CPU的使用,还可以减少上下文切换。
当任务彼此独立时,有限线程池或者有限工作队列的使用是合理的。倘若任务之间相互依赖,有限的线程池或队列就可能引起线程饥饿死锁,就要使用一个无限的池配置。
3、饱和策略
当有限的等待队列填满后,饱和策略开始起作用。ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。JDK提供了几种不同的RejectedExecutionHandler的实现:
(1)AbortPolicy:默认的“中止”策略会引起execute抛出未检查的RejectedExecutionException,调用者可以捕获这个异常,然后编写能满足自己需求的处理代码。
(2)DiscardPolicy:“遗弃”策略会放弃这个任务
(3)DiscardOldestPolicy:“遗弃最旧的”策略选择丢弃的任务是本应该接下来就执行的任务,然后尝试重新提交新任务。如果工作队列是优先级队列,那么它选择丢弃的是优先级最高的任务,所以优先级队列和“遗弃最旧的”策略不能一块使用
(4)CallerRunsPolicy:“调用者运行”策略会把一下任务推回到调用者那里,以此减缓新任务流。当所有线程都被占用,工作队列已充满后,下一个任务会在主线程中执行。主线程调用execute执行这个任务。因为这将花费一些时间,所以主线程在一段时间内不能提交任何任务。同时这也给了工作者线程时间来追赶进度。这期间主线程也不会调用accept,所以外来的请求不会出现在应用程序中,而会在TCP层的队列中等候。如果持续高负载的话,最终会由TCP层判断它的连接请求队列是否已经排满,如果已满就开始丢弃请求任务。
(5)使用Semaphore来遏制任务的提交,使用一个非受限队列,设置Semaphore的限制范围等于池的大小加上你希望允许可排队的任务数量。如下代码所示:
class BoundedExecutor{
private final Executor exec;
private final Semaphore semaphore;
public BoundedExecutor(Executor exec,int bound){
this.exec = exec;
this.semaphore = new Semaphore(bound);
}
public void submitTask(final Runnable task) throws InterruptedException{
semaphore.acquire();
try {
exec.execute(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
try{
task.run();
}finally{
semaphore.release();
}
}
});
} catch (RejectedExecutionException e) {
// TODO Auto-generated catch block
semaphore.release();
}
}
}
四、扩展ThreadPoolExecutor
ThreadPoolExecutor提供了几个函数让子类去覆写:beforeExecute、afterExecute和terminate。执行任务的线程会调用函数beforeExecute、afterExecute,用于添加日志、时序、监视器或统计信息收集的功能。无论任务是正常从run返回,还是抛出一个异常,afterExecute都会被调用。如果beforeExecute抛出一个RuntimeException,任务将不被执行,afterExecute也不会被调用。但所有的任务都已完成且所有工作者线程也已经关闭后,会执行terminate,terminate可以用来释放Executor在生命周期里分配到的资源,还可以发出通知、记录日志或者完成统计信息。如下代码可以使用这三个函数提供日志和计时功能
class TimingThreadPool extends ThreadPoolExecutor{
private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
private final Logger log = Logger.getLogger("TimingThreadPool");
private final AtomicLong numTasks = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
protected void beforeExecute(Thread t,Runnable r){
super.beforeExecute(t,r);
log.fine(String.format("Thread %s: start %s",t,r));
startTime.set(System.nanoTime());
}
protected void afterExecute(Runnable r,Throwable t){
try{
long endTime = System.nanoTime();
long taskTime = endTime - startTime.get();
numTasks.incrementAndGet();
totalTime.addAndGet(taskTime);
log.fine(String.format("Thread %s: end %s, time=%dns",t,r,taskTime));
}finally{
super.afterExecute(r,t);
}
}
protected void terminated(){
try{
log.info(String.format("Terminated: time=%dns",totalTime.get()/numTasks.get()));
}finally{
super.terminated();
}
}
}