JAVA线程池配置详解
网易面试时被面试官问到Java线程池的最优参数配置时没有答出来,面试官推荐了《Java并发编程实践》这本书。然而在阅读《Java并发编程实践》时,感觉这本书的翻译真的是一言难尽,因此决定翻译一下英文原版《Java Concurrency In Practic》书中的线程池配置章节。如翻译有错误或者不好的位置,欢迎指出,互相交流学习。
线程池最优大小
一个线程池的理想大小依赖于将要被提交的任务的类型和部署系统的特性。线程池的大小不应该被硬编码。而应该由配置机制提供,或者通过参考Runtime.getRuntime().availableProcessors()的动态计算结果。
确定线程池的大小不是一门精确科学,但是幸运的是仅仅需要避免太大和太小,如果线程池太大,则线程间会竞争稀缺的CPU和内存资源,导致更高的内存占用甚至是资源耗尽。如果线程池太小,则无法充分的使用处理器资源,使得某些时候即使有可处理的任务,处理器也处于未使用的状态。
为了确定线程池的大小,需要了解计算环境、资源预算和任务的特性。例如系统有多少个处理器?有多少内存?任务是I/O密集型还是计算密集型?任务是否需要JDBC连接等稀缺资源?如果我们需要处理的任务的种类不同,且具有非常不同的行为,则可以考虑使用多个线程池。
对于计算密集型任务,一个具有N个处理器的系统通常在线程池大小为N+1时达到最高的CPU使用率,因为即使是计算密集型的线程,也会偶尔出现页面错误(应该是指缺页)或者因为其他的原因而暂停。因此当出现这种情况时,一个额外的可运行线程可能避免CPU周期的闲置。
对于包含I/O或者其他阻塞操作的任务,我们需要一个更大的线程池,因为并不是所有线程都会被CPU同时调度。为了确定线程池的大小,必须估计任务的等待时间和计算时间的比率,这个估计不需要特别精确。另外,线程池的大小可以通过在一个基准负载下使用不同的池大小运行程序并且观察CPU的利用率来调整。给出以下定义:
N c p u N_{cpu} Ncpu :CPU核数
U c p u U_{cpu} Ucpu :目标CPU的利用率,0 <= U c p u U_{cpu} Ucpu <= 1
W C \frac{W}{C} CW :等待时间 / 计算时间
则使CPU保持理想利用率的最优池大小为:
N t h r e a d c = N c p u ∗ U c p u ∗ ( 1 + W C ) N_{threadc} = N_{cpu} * U_{cpu} * (1 + \frac{W}{C}) Nthreadc=Ncpu∗Ucpu∗(1+CW)
以上最优配置是只考虑CPU利用率的情况,通常CPU周期不是我们想用线程池管理的唯一资源。其他会约束线程池大小的资源是内存,文件句柄,套接字句柄和数据库连接。
当任务需要一个池化资源,例如数据库连接时,线程池的大小和资源池的大小互相影响。如果每一个任务都需要一个连接,那么有效的线程池大小受限于连接池的大小。与之类似,当连接池的使用者都是线程池的任务时,连接池的有效大小受限于线程池的大小。
配置ThreadPoolExecutor
ThreadPoolExecutor为Executors中的工厂方法newCachedThreadPool、newFixedThreadPool和newScheduled-ThreadExecutor提供了基本实现。ThreadPoolExecutor是允许多种定制的,灵活,鲁棒的线程池实现。
如果默认的执行策略不能满足我们的需求,我们能通过ThreadPoolExecutor的构造函数来实例化一个线程池,并定制其属性。ThreadPoolExecutor的通用构造函数如下:
// 参数:核心池大小,最大池大小,存活时间,时间单元(存活时间的单位),阻塞队列,线程工厂,拒绝策略
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { ... }
在构造函数的参数中,核心池大小,最大池大小和存活时间管理线程的创建和销毁。核心池大小是目标大小,即线程池的实现尝试将池的大小维持在这个尺寸,即使当前没有任何任务需要执行。当任务数大于核心池大小时也不会创建更多的线程,除非工作队列满了。最大池大小是池线程数量的上界。如果当前池大小超过了核心池大小,一个闲置时间长于存活时间的线程可能会被销毁。
通过调整核心池大小和存活时间,我们能促使线程池回收闲置线程的资源,用于其他更有用的工作。就像任何其他的事物一样,这里有一个权衡:如果随后对线程的需求增加时必须创建新的线程,那么销毁闲置线程会导致一个潜在的附加延迟(新线程的创建)。
工厂方法Executors.newFixedThreadPool(int nThreads)将核心池大小和最大池大小都设置为nThreads,将存活时间设置为无限。这时线程时实际使用的是核心池 + 工作队列;工厂方法Executors.newCachedThreadPool()将核心池大小设置为0,并且将最大池大小设置为Integer.MAX_VALUE,存活时间设置为1分钟。这种配置实现了无限扩展的线程池且当需求减少时线程池大小将会收缩。明确的使用ThreadPoolExecutor的构造函数可以实现其他的参数组合。
管理排队任务
有限大小线程池限制的能够并发执行的任务数(单线程executors是一个值得注意的特例:它们保证了没有任务会并发的执行,通过线程封闭提供了实现线程安全的可能性)。
如果新任务到达的速度超过了线程池的处理速度,任务将会排队。在线程池中,排队的任务在一个由Executor管理的结点类型为Runnable的队列中等待,而不是作为一个线程排队竞争CPU资源。用一个Runnable对象来表示一个等待的任务显然比用一个线程来表示等待的任务要廉价的多,但是如果客户端抛出请求的速度快于线程池处理请求的速度,资源耗尽的风险依然存在。
请求通常会爆发式的到达,即使平均请求速率相对稳定。阻塞队列能够平滑爆发式的任务请求,但如果任务持续性的快速达到,我们不得不限制任务的到达速率来避免内存溢出,即使在内存溢出之前,响应时间也会随着队列的增长变得越来越糟。
ThreadPoolExecutor允许我们提供一个阻塞队列来存储正在等待执行的任务。有3种基本的任务队列类型:
无界队列( unbounded queue),有界队列(bounded queue),同步移交(synchronous handoff)。队列的选择和其他的配置参数(例如池大小)互相影响。
newFixedThreadPool()和newSingleThreadExecutor()默认使用无界队列LinkdedBlockingQueue。如果所有工作线程处于工作状态,任务将会排队。但是如果任务到达的速度快于任务被执行的速度,队列能够无界限的增长。
一个更稳定的资源管理策略是使用有界队列,如ArrayBlockingQueue, bounded LinkedBlockingQueue或者Priority-BlockingQueue。有界队列能防止资源耗尽但是引入了一个新的问题:新任务到达时队列已经满了时要怎么做。对于有界队列而言,队列的大小和池大小必须一起调整,一个大的队列和一个小的池相结合能够减少内存占用,CPU占用,上下文切换。 当然,这是以潜在的限制吞吐量为代价。
对于非常大或者无限大的线程池,使用同步队列( SynchronousQueue)可以完全避免排队且直接将任务提交给工作线程。同步队列本质上并不是一个真正的队列,而且一种管理线程间移交的机制,为了将一个任务放入同步队列,另一个线程必须正在等待接受这个移交。如果没有任何线程正在等待新任务的移交但是当前线程池的大小小于最大线程数,ThreadPoolExecutor创建一个新的线程。否则任务将会根据饱和策略被拒绝。直接移交是更高效的,因为任务能够被直接移交给一个能执行它的线程,而不是首先在队列中排队并且等待一个工作线程从队列中将它取出来。只有当线程池大小是无限的或者拒绝执行任务是接受的情况下,同步队列才是一个可以尝试的选择。工厂方法newCachedThreadPool使用了同步队列。
使用像LinkedBlockingQueue或者ArrayBlockingQueue使任务以到达的顺序开始。为了获得对任务执行顺序的更多控制,我们能使用优先队列(PriorityBlockingQueue),优先队列根据优先级对任务进行排序,优先级能够由自然顺序定义(如果任务实现了Comparable接口)或者由一个Comparator定义。
工厂方法newCachedThreadPool是一个不错的线程池默认配置,与固定大小的线程池相比提供了更好的排队性能。当你为了资源管理的目的需要限制任务的并发数时,固定大小的线程池是一个好的选择, 比如在服务器应用程序中,线程池接受来自网络客户端的请求。如果不限制任务数,很容易过载。
只有当任务间相互独立时,限制线程池或者工作队列的大小才是合适的。如果线程池中有依赖其他任务的任务,限制了大小的线程池和队列能导致线程饥饿死锁;这种情况下,应该使用像newCachedThreadPool这样的无限大小的池配置。
饱和策略
当一个有界队列满了,饱和策略就开始发挥作用,ThreadPoolExecutor的饱和策略能够通过调用setRejectedExecutionHandler函数来修改。当一个任务被提交到一个已经被关闭的线程池时,也用到了饱和策略。类库中提供了几种RejectedExecutionHandler的实现,每一种都实现了一种不同的饱和策略:AbortPolicy, CallerRunsPolicy, DiscardPolicy, and DiscardOldestPolicy.
默认的策略是abort,abort策略导致execute方法抛出非受查异常Rejected-ExecutionException;execute方法的调用者能够捕获该异常并且实现相对应的溢出处理。如果一个新提交的任务不能排队并等待被执行,则抛弃策略会悄悄的直接丢弃这个任务。discard-oldest策略会丢弃下一个将要执行的任务,并尝试重新提交新任务(如果工作队列是一个有限队列,这将会丢弃最高优先级的任务,因此discard-oldest策略和优先级队列不是一个好的组合)。caller-runs策略实现了一种限流的方式,它既不丢弃任务也不抛出异常,而是通过将一些任务推回给调用者来减少新任务的流量。新提交的任务不在线程池中执行,而是在调用execute方法的线程中执行。例如在主线程中提交任务时,如果工作队列已满,则新提交的任务将在主线程中运行,因为这会花一些时间,所以主线程至少在短时间内不能提交更多的任务。这给工作线程一些时间来完成积压的任务。下面的代码创建了一个使用caller-runs饱和策略的固定大小的线程池。
int n_threads = Runtime.getRuntime().availableProcessors() + 1;
int CAPACITY = 100;
ThreadPoolExecutor pool = new ThreadPoolExecutor(n_threads, n_threads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(CAPACITY));
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
线程工厂
当一个线程池需要创建线程时,它通过一个线程工厂来创建线程。默认的线程工厂创建一个新的,没有任何特殊配置的非守护线程。指定线程工厂优先我们定制池线程的配置。ThreadFactory接口只有一个方法:newThread,当线程池需要创建一个线程时,会调用这个方法。
有很多使用定制的线程工厂的理由,你可能像给池线程指定一个UncaughtExceptionHandler,或者实例化一个定制的线程类,例如一个会打印调试日志的线程类。我们也可能像修改优先级或者设置池线程的守护状态。或者只是像给池线程一个更有意义的名字来简化错误日志。下面代码定制了一个带有日志记录和未捕获异常处理的线程工厂。
// 定制的线程工厂类,实现了ThreadFactory接口
public class MyThreadFactory implements ThreadFactory{
private final String poolName;
public MyThreadFactory(String poolName){
this.poolName = poolName;
}
public Thread newThread(Runnable runnable){
// 创建定制的线程
return new MyAppThread(runnable, poolName);
}
}
// 定制的线程类
public class MyAppThread extends Thread{
public static final String DEFAULT_NAME = "MyAppThread";
private static volatile boolean debugLifecycle = false;
private static final AtomicInteger created = new AtomicInteger();
private static final AtomicInteger alive = new AtomicInteger();
private static final Logger log = Logger.getAnonymousLogger();
public MyAppThread(Runnable r){this(r, DEFAULT_NAME);}
public MyAppThread(Runnable runnable, String name){
// 更新创建的线程数,并调用Thread类构造方法,线程名为poolName_线程编号
super(runnable, name + "_" + created.incrementAndGet());
// 设计线程未捕获的异常的处理方式
setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
log.log(Level.SEVERE, "UNCAUGHT in thread" + t.getName(), e);
}
});
}
public void run(){
boolean debug = debugLifecycle;
if (debug) log.log(Level.FINE, "created" + getName());
try{
alive.incrementAndGet();
super.run();
} finally{
alive.decrementAndGet();
if (debug) log.log(Level.FINE, "Exiting" + getName());
}
}
public static int getThreadCreated(){return created.get();}
public static int getThreadAlive(){return alive.get();}
public static boolean getDebug(){return debugLifecycle;}
public static void setDebug(boolean b){debugLifecycle = b;}
}
在创建后定制ThreadPoolExecutor
传给ThreadPoolExecutor的构造函数的大部分配置项都能在对象创建后通过setters修改(例如核心池大小,最大池大小,存活时间,线程工厂,饱和策略)。如果线程池是由Executors类中的一个工厂方法创建的(newSingleThreadExecutor除外),那么我们可以将工厂方法的返回结果转为ThreadPoolExecutor类型,从而进行setters的访问。如下面的例子
// newFixedThreadPool源码,实际调用了ThreadPoolExecutor的构造函数,返回的ExecutorService接口类型
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
// 直接进行类型转换,通过setter修改核心池大小
ThreadPoolExecutor executor = (ThreadPoolExecutor)Executors.newFixedThreadPool(2);
executor.setCorePoolSize(n_threads);
Executors类包含了一个工厂方法:unconfigurableExecutorService,该方法以一个实现了ExecutorService接口的线程池作为参数,并返回一个封装后的对象,封装后的对象只能调用ExecutorService接口中的方法,因此不能进行进一步的配置。
ize(n_threads);
Executors类包含了一个工厂方法:unconfigurableExecutorService,该方法以一个实现了ExecutorService接口的线程池作为参数,并返回一个封装后的对象,封装后的对象只能调用ExecutorService接口中的方法,因此不能进行进一步的配置。