前言
平时开发过程中,我们会经常和线程池打交道,有时还会根据不同的业务进行线程池隔离,那么了解线程池的工作原理和参数设置就是非常必要的,所以今天的主题就是探究线程池的那些事儿。
为什么使用线程池
在使用一项技术之前,了解 「why」 是至关重要的,即我们为什么要使用线程池?线程池有什么好处?
线程池是一种池化技术,使用线程池可以减少线程创建时的资源消耗,同时也可以提高响应速度,即当有任务到达时,如果线程池中有空闲可用的线程,那么直接拿线程去执行任务,节省了重新创建线程的时间开销。
线程池 API
这部分阐述如何使用线程池,也就是介绍 Executor,ExecutorService,ThreadPoolExecutor 和 Executors 之间的区别和联系,它们都是 Executor 框架的核心组成部分。
Executor
Executor 是 抽象层面的核心接口:
public interface Executor {
void execute(Runnable command);
}
- 定义单一的 execute 方法,用于提交 Runnable 任务,即将任务和执行分离开来
ExecutorService
ExecutorService 接口对 Executor 进行扩展,提供了异步执行和关闭线程池等方法,下面是部分代码:
public interface ExecutorService extends Executor {
void shutdown();
boolean isTerminated();
boolean awaitTermination(long timeout, TimeUnit unit);
<T> Future<T> submit(Callable<T> task);
}
- 使用 submit 方法,不需要等待任务完成,它会直接返回 Future 对象,调用 Future 的 get() 方法可以查询任务执行结果,如果任务还没有完成,调用 get() 方法会被阻塞
ThreadPoolExecutor
ThreadPoolExecutor 是 ExecutorService 接口的实现类,即线程池的实现类,具体继承结构如下:
ThreadPoolExecutor 有几个核心参数,需要在手动创建时进行设置:
- corePoolSize : 核心线程数
- maximumPoolSize: 线程池中允许的最大线程数
- keepAliveTime: 非核心线程存活时间。即当线程数大于corePoolSize,多余的空闲线程在终止之前,等待新任务的最长存活时间。
- unit: keepAliveTime 的单位
- workQueue: 用来暂时保存任务的阻塞队列
-
handler: 线程的拒绝策略。当线程池已经饱和,即达到了最大线程池大小,且阻塞队列也已经满了,线程池选择一种拒绝策略来处理新来的任务。ThreadPoolExecutor 内部已经提供了以下 4 种策略:
- CallerRunsPolicy : 提交任务的线程自己去执行任务
- AbortPolicy: 默认的拒绝策略,抛出 RejectedExecutionException 异常
- DiscardPolicy: 直接丢弃任务,没有任何异常抛出
- DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
-
threadFactory: 创建线程的工厂,可以为线程池中的线程设置名字,便于以后定位问题。常见的设置线程名称的做法有:
- 自定义实现 ThreadFactory 设置线程名称
- 使用 Google Guava 的 ThreadFactoryBuilder 设置线程名称
- 使用 Executors 工具类提供的默认线程池工厂 DefaultThreadFactory
Executors
Executors 实际上是一个工具类,提供一系列工厂方法创建不同类型的线程池,部分代码如下:
public class Executors {
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
}
可以看出,使用 Executors 工具类创建线程池是非常简便的,比如它创建了4种类型的 ThreadPoolExecutor :
- FixThreadPool: 固定线程数大小的线程池
- SingleThreadExecutor: 创建一个单线程的线程池,用单线程来执行任务
- CachedThreadPool: 可缓存的线程池,corePoolSize 为0,maximumPoolSize 是Integer.MAX_VALUE,对线程个数不做限制,可无限创建临时线程。
- ScheduledThreadPoolExecutor: 定长的线程池,支持定时及周期性的任务
但是在 《阿里巴巴Java开发手册》中指出不要使用 Executors工具类来创建线程池,原因如下:
所以,生产环境下还是通过手动创建 ThreadPoolExecutor ,结合实际场景来设置线程池的核心参数,同时为线程池的线程设置有意义的名称,便于定位问题。而 Executors 工具类可以用于平时写一些简单的 Demo 代码,这还是很方便的。
线程池的工作原理
在熟悉了线程池的相关 API 以后,我们来看一下线程池的核心工作原理。
往简单的讲,就是:
- 如果当前线程数 < corePoolSize 时,直接创建线程执行任务。
- 后面再来任务,就把任务放到阻塞队列中,如果阻塞队列满了就创建临时线程。
- 如果总线程数达到 maximumPoolSize,就执行拒绝策略。
往复杂的讲,那就得从 ThreadPoolExecutor 的 execute 方法入手,源码如下:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
// 1. 首先获取线程池的状态控制变量
int c = ctl.get();
//2. workerCountOf(c) 获取当前工作的线程数,如果工作线程数小于核心线程数
if (workerCountOf(c) < corePoolSize) {
//3. 创建核心线程
if (addWorker(command, true))
return;
c = ctl.get();
}
//4. 检查线程池状态是否正在运行,并尝试将任务放入阻塞队列中
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
//5. recheck再次检查线程池的状态,主要是为了判断加入到阻塞队列中的线程是否可以被执行
if (! isRunning(recheck) && remove(command))
reject(command);
//6. 验证当前线程池中的工作线程的个数,如果为0,创建一个空任务的线程来执行刚才添加的任务
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//7. 如果加入阻塞队列失败,说明队列已经满了,则创建新线程,如果创建失败,说明已达到最大线程数,则执行拒绝策略
else if (!addWorker(command, false))
reject(command);
}
根据 execute 的源码,向线程池提交任务的主要步骤如下:
-
首先获取线程池的状态控制变量 ctl,说明一下:线程池的 ctl 是一个原子的 AtomicInteger,包含两部分
- workCount 表示工作的线程数,由 ctl 的低29位保存
- runState 表示当前线程池的状态,由 ctl 的高3位保存
- 然后利用 workerCountOf(c) 获取当前工作的线程数,如果当前工作线程数小于 corePoolSize,则创建线程。
- 如果当前工作线程数大于等于 corePoolSize,则首先检查线程池状态是否正在运行。
-
如果线程池处于 Running 状态,那么尝试将任务加入 BlockingQueue
-
如果加入 BlockingzQueue 成功, 再次检查(recheck)线程池的状态,主要是为了判断加入到阻塞队列中的线程是否可以被执行
- 如果线程池没有 Running,则移除之前添加的任务,然后拒绝该任务
- 如果线程池处于 Running 状态,再次检查(recheck)当前线程池中的工作线程的个数,如果为0,则主动创建一个空任务的线程来执行任务
- 如果加入 BlockingQueue失败,则说明队列已经满了,则创建新的线程来执行任务
-
- 如果线程池处于非运行状态,尝试创建线程,如果创建失败,说明当前线程数已经达到最大线程数 maximunPoolSize,然后执行拒绝策略
上面就是 execute 方法的整个工作原理,同时我也画了一张不算标准的流程图来帮助理解,如图所示:
线程池的监控
如果在系统中大量使用线程池,则有必要对线程池的运行状况进行监控,这样当发生问题时,才便于排查。
一般有以下几种方法来监控线程池:
- 利用继承的思想。通过继承线程池来自定义线程池,重写线程池的 beforeExecute,afterExecute 和 terminated 方法收集数据,想要可视化就依靠 JMX 来做。
- 利用 SheduledExecutorService 执行定时任务去监控线程池的运行状况
- 利用 Metrics + JMX 的方式对线程池进行监控
- 在 SpringBoot 项目中利用 actuator 组件来做线程池的监控
- 在一些大型系统中,利用 Micrometer + Prometheus 等监控组件去监控线程池的各项指标
就如同探讨 「回」有几种写法一样,方法有很多。对于线程池监控,我们需要根据自己的实际场景,选择恰当的方法。下面打个样,写了一个利用 SheduledExecutorService 执行定时任务去监控线程池的例子 :
@Slf4j
public class MonitorThreadPoolStats {
public static void main(String[] args) {
ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
printThreadPoolStats(threadPool);
for (int i = 0; i <10 ; i++) {
threadPool.execute(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
}
private static void printThreadPoolStats(ThreadPoolExecutor threadPool) {
Executors.newSingleThreadScheduledExecutor()
.scheduleAtFixedRate(
() -> log.info("Thread pool monitors metrics : poolSize:{}, activeThreads:{}, completedTasks:{}, QueueTasks: {}",
threadPool.getPoolSize(), threadPool.getActiveCount(), threadPool.getCompletedTaskCount(), threadPool.getQueue().size()),
0, 1, TimeUnit.SECONDS);
}
}
如何设置线程池的大小
我们在一开始使用线程池,就要面对如何设置线程池大小的问题。线程池不宜设置得过大或过小:
- 如果设置过大,会导致大量线程在相对较少的CPU和内存资源上发生竞争。
- 如果设置过小,会导致系统无法充分利用系统资源。
参考网上的资料以及 《Java并发编程实战》的说法,可根据任务类型来确定:
- CPU 密集型任务: 这种任务主要消耗 CPU 资源,可分配少量的线程,比如一般设置为 ( CPU 核心数 + 1 )
- IO 密集型任务:这种任务主要处理I/O交互,可配置些线程,比如 CPU 核心数 * 2
当然在实际场景中,我们需要先评估业务并发量、机器配置等因素,再去设置一个合理的大小。
小结
这篇文章总结了一些线程池的知识点,比如线程池的核心 API、详细的工作原理等,当然还有很多细节地方没有深入下去,待后续有机会继续分享。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。