线程池初始化与定义
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
线程池构造方法的入参含义分别如下:
- corePoolSize:核心线程数,必须大于或等于 0
- maximumPoolSize:最大线程数,必须大于 0 且大于或等于核心线程数
- keepAliveTime:空闲线程存活时间,必须大于或等于 0
- unit:存活时间单位 TimeUnit
- workQueue:阻塞队列,存储通过 execute() 方法提交的未能开始执行的任务。常见的选择有:
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO
- PriorityBlockingQueue:具有优先级的无界阻塞队列,较少使用
- LinkedBlockingQueue:基于链表结构的无界阻塞队列,吞吐量通常要高于 ArrayBlockingQueue,FIFO
- SynchronousQueue:不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue
- threadFactory:线程创建工厂,一般可以用默认的
- handler:饱和/拒绝策略,有以下四种策略:
ThreadPoolExecutor.AbortPolicy
:(默认)阻塞队列已满,丢弃任务并抛出 RejectedExecutionException 异常。ThreadPoolExecutor.DiscardPolicy
:阻塞队列已满,丢弃任务,但是不抛出异常。ThreadPoolExecutor.DiscardOldestPolicy
:阻塞队列已满,丢弃队列最前面的任务,然后重新尝试加入队列执行任务(重复此过程)。ThreadPoolExecutor.CallerRunsPolicy
:由发起调用的线程自己去执行该任务(如果主线程运行结束,则丢弃该任务);会降低新任务的提交速度,影响程序的整体性能。- 也可以根据实际需求自定义拒绝策略,实现
RejectedExecutionHandler
接口
当 ThreadPoolExecutor 线程池被创建的时候,里面是没有创建工作线程的,直至有任务调用了 execute() 方法时,才开始创建工作线程。除非调用 prestartAllCoreThreads() 或者 prestartCoreThread() 方法,可以手动预创建线程。调用 execute() 方法时的具体工作原理为:
- 如果当前工作线程数小于核心线程数,则创建新的线程执行任务,否则将任务加入阻塞队列;
- 如果阻塞队列满了,则根据最大线程数创建额外(非核心工作线程)的工作线程去执行任务;
- 如果工作线程数达到了线程池允许的最大线程数,则根据拒绝策略去执行。
- 非核心线程的存活时间到期的话,线程资源将会被回收。
核心线程在线程池刚创建的时候还未被创建,随着任务的执行才会创建新的核心线程。线程池启动使用后,核心线程默认不会被回收,除非通过方法 allowCoreThreadTimeOut(boolean value)
启用了空闲核心线程回收,此时 keepAliveTime 才会对核心线程也生效。调用该方法启动空闲核心线程回收时,会马上执行一次回收的操作。
除了默认的线程池 ThreadPoolExecutor() 以外,Java 还通过 Executors 定义了四种线程池:
// 1. CachedThreadPool 有缓冲的线程池,具体线程数由JVM控制,灵活创建于回收线程资源,适用于并发执行大量短期的小任务
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
}
// 2. FixedThreadPool 固定大小的线程池(所有线程都是核心线程),适用于处理CPU密集型的任务
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}
// 3. ScheduledThreadPool 定时执行任务的线程池,内部使用延时队列存储阻塞任务
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
// 4. SingleThreadExecutor 单线程的线程池,只有一个线程在工作,适用于串行执行任务的场景
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())
);
}
其中,newScheduledThreadPool 的具体实现为:
// ScheduledThreadPoolExecutor.java
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue());
}
// ThreadPoolExecutor.java
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,Executors.defaultThreadFactory(), defaultHandler);
}
本质上,以上的各类线程池还是通过 ThreadPoolExecutor 类实现的初始化。但是,在阿里巴巴 Java 开发手册中明确指出,不允许使用 Executors 创建线程池:
从上面 FixedThreadPool 和 SingleThreadPool 的构造函数可以得知,初始化 LinkedBlockingQueue 时没有指定其容量。LinkedBlockingQueue 是一个用链表实现的有界阻塞队列,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,默认最大长度为 Integer.MAX_VALUE。对于一个无边界队列来说,是可以不断的向队列中加入任务的,这种情况下就有可能因为任务过多而导致内存溢出问题。
CachedThreadPool 和 ScheduledThreadPool 这两个线程池虽然没有这种无边界的阻塞队列的情况,但这两种线程池可达到的最大线程数可能是 Integer.MAX_VALUE
,而创建这么多线程,就有非常大的概率导致 OOM。
为了避免出现以上问题,一个直接的思路就是避免使用其默认的构造实现。我们可以根据实际应用场景,通过自定义构造参数,直接调用 ThreadPoolExecutor 的构造方法来创建线程池。初始化阻塞队列时,明确指定队列大小即可。
当实际提交的线程数超过了当前允许的最大线程数时,就会抛出 java.util.concurrent.RejectedExecutionException
,这是因为当前线程池使用的队列是有边界队列,队列已经满了便无法继续处理新的请求。但是抛出异常(Exception)进行捕获总比发生错误(Error)影响程序运行要好。
同时,手动创建线程池还可以指定线程名称,这样便于对线程池的运行情况进行监控和追踪。
综上所属,生产环境中常用的线程池构造方法如下:
- 通过 ThreadPoolExecutor 构造函数实现
- 通过 Executors 工具类来创建不同类型的 ThreadPoolExecutor 线程池。
其中,更多的推荐使用第一种方法。
线程池参数的配置
线程池线程数大小是一个值得仔细斟酌设置的参数。
如果设置过小,当同一时间出现大量任务需要执行时,可能会导致大量任务在阻塞队列中排队等待,甚至会出现队列满员后任务无法处理的情况,或大量任务堆积导致的 OOM。这种场景下 CPU 资源没有得到充分的利用。
如果设置过大,大量线程可能会同时竞争 CPU 资源,导致大量的上下文切换,从而增加线程的执行时间,影响了整体的执行效率。
线程池参数的选用,大部分都是需要根据实际测试结果去调整得出最佳配置。不过有一些简单的经验值可以参考一下:
- IO 密集型任务:2N。系统大部分时间都用来处理 IO 交互,期间不会占用 CPU 资源,此时释放出来的 CPU 资源可以用于其他线程执行任务,因此可以尽可能多的配置线程。
- CPU 密集型任务:N+1。任务中存在大量复杂的运算,消耗的主要是 CPU 资源。多出来的一个线程是为了防止线程偶发的缺页中断,或者其他原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,此时多出来的一个线程就可以将 CPU 空闲时间利用起来。