22. 线程池参数怎么配置才合理
发布于 • 阅读量 0
22. 线程池参数怎么配置才合理
前面已经讲了线程池的几个核心参数,也看了拒绝策略。
现在问题来了:这些参数到底怎么配?
比如 PDF 水印线程池,我到底应该写成这样:
new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
还是应该把线程数改成 5、10、20?
这个问题没有一个固定答案。
线程池参数不是背公式就能配好的,它和任务类型、机器资源、任务耗时、接口承压能力都有关系。
但还是可以整理出一套基本判断方式。
先把参数再看一遍
ThreadPoolExecutor 的构造方法大概是这样:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maximumPoolSize,
keepAliveTime,
timeUnit,
workQueue,
rejectedExecutionHandler
);
这几个参数分别是:
corePoolSize:核心线程数;
maximumPoolSize:最大线程数;
keepAliveTime:非核心线程空闲多久后回收;
timeUnit:时间单位;
workQueue:任务队列;
rejectedExecutionHandler:拒绝策略。
我现在看线程池参数时,不会只看单个参数,而是把它们放在一起看。
因为这些参数是配合工作的。
线程池执行任务的基本顺序
前面已经讲过一次,这里再整理一下。
任务提交到线程池以后,大致会按这个顺序处理:
1. 当前线程数小于 corePoolSize,创建核心线程执行任务;
2. 当前线程数达到 corePoolSize,任务进入队列;
3. 队列满了,并且当前线程数小于 maximumPoolSize,创建非核心线程执行任务;
4. 队列满了,线程数也达到 maximumPoolSize,触发拒绝策略。
这个顺序很重要。
特别是第二步和第三步:
不是核心线程满了以后马上创建到最大线程数;
而是核心线程满了以后,先尝试进入队列。
只有队列满了,才会继续创建非核心线程。
所以 workQueue 的大小,会直接影响 maximumPoolSize 有没有机会生效。
先写一个观察参数变化的例子
新建类:
com.succos.threadpool.ThreadPoolParamDemo
代码如下:
package com.succos.threadpool;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolParamDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2,
4,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.AbortPolicy()
);
for (int i = 1; i <= 8; i++) {
int taskId = i;
System.out.println("准备提交任务:" + taskId);
try {
executor.execute(() -> {
System.out.println(Thread.currentThread().getName()
+ " 开始执行任务 "
+ taskId);
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
System.out.println(Thread.currentThread().getName()
+ " 执行完成任务 "
+ taskId);
});
System.out.println("任务 " + taskId
+ " 提交完成,当前线程数:"
+ executor.getPoolSize()
+ ",队列任务数:"
+ executor.getQueue().size());
} catch (Exception e) {
System.out.println("任务 " + taskId
+ " 提交失败,原因:"
+ e.getClass().getSimpleName());
}
}
executor.shutdown();
}
}
这里的配置是:
new ThreadPoolExecutor(
2,
4,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.AbortPolicy()
);
意思是:
核心线程数:2;
最大线程数:4;
队列容量:2;
拒绝策略:直接抛异常。
这段代码怎么执行
提交任务时,大概会这样走:
任务1:当前线程数 0,小于 corePoolSize 2,创建核心线程执行;
任务2:当前线程数 1,小于 corePoolSize 2,创建核心线程执行;
任务3:核心线程满了,进入队列;
任务4:队列还能放,进入队列;
任务5:队列满了,当前线程数 2 小于 maximumPoolSize 4,创建非核心线程执行;
任务6:当前线程数 3 小于 maximumPoolSize 4,继续创建非核心线程执行;
任务7:线程数已经 4,队列也满了,触发拒绝策略;
任务8:同样触发拒绝策略。
这就是线程池参数之间的配合关系。
不是说 maximumPoolSize = 4,线程池一上来就创建 4 个线程。
它是先用核心线程,再用队列,队列满了才扩到最大线程数。
corePoolSize 怎么理解
corePoolSize 是线程池的核心工作线程数。
我一般把它理解成:
这个线程池正常情况下主要保留多少个工人。
比如 PDF 水印处理,我希望平时最多 3 个 PDF 同时处理,那就可以先设置:
corePoolSize = 3
这样同一时间大概就是 3 个线程持续消费任务。
如果任务很多,其他任务先排队。
核心线程执行完任务后,一般不会马上销毁,会继续留在线程池里等新任务。
所以核心线程数不要随便设太大。
它是线程池的基础负载能力。
maximumPoolSize 怎么理解
maximumPoolSize 是线程池最多能创建多少个线程。
它一般在队列满了以后才会发挥作用。
比如:
corePoolSize = 3
maximumPoolSize = 6
workQueue = new ArrayBlockingQueue<>(100)
如果队列还没满,线程池通常不会扩到 6 个线程。
只有当:
3 个核心线程都在忙;
队列 100 个任务也满了;
还有新任务继续进来;
线程池才会考虑继续创建第 4、第 5、第 6 个线程。
所以如果队列设置得很大,maximumPoolSize 可能很少生效。
这也是为什么线程池参数要一起看,不能孤立看。
队列大小怎么理解
workQueue 是任务等待区。
比如:
new ArrayBlockingQueue<>(100)
意思是最多允许 100 个任务排队。
队列太小,任务稍微一多就容易触发拒绝策略。
队列太大,任务可能大量堆积,占用内存,也会让任务等待时间变长。
比如 PDF 处理,一个任务可能要 10 秒。
如果队列里排了 1000 个任务,后面的任务可能很久才会被处理。
用户可能一直等不到结果。
所以队列不是越大越好。
我更倾向于让队列有明确边界。
比如:
最多排队 100 个;
超过以后直接拒绝,或者让调用方降速。
系统要知道自己的上限。
keepAliveTime 是干什么的
keepAliveTime 控制的是非核心线程空闲多久后被回收。
比如:
60, TimeUnit.SECONDS
意思是非核心线程如果空闲超过 60 秒,就可以被回收。
注意,一般情况下,核心线程不会因为空闲被回收。
非核心线程才会受这个参数影响。
比如:
corePoolSize = 3
maximumPoolSize = 6
当任务很多时,线程池可能扩到 6 个线程。
其中 3 个是核心线程,另外 3 个是非核心线程。
等高峰过去以后,非核心线程空闲超过 keepAliveTime,就会被回收,线程池又回到比较小的规模。
如果我把核心线程数和最大线程数设置成一样,比如:
corePoolSize = 3
maximumPoolSize = 3
那就没有非核心线程,keepAliveTime 基本就不太明显。
CPU 密集型和 IO 密集型
配置线程数时,经常会提到两个概念:
CPU 密集型;
IO 密集型。
CPU 密集型任务,主要消耗 CPU。
比如:
大量计算;
加密解密;
图片复杂计算;
压缩算法;
模型推理中的部分计算。
这种任务线程数不适合开太多。
因为 CPU 核心有限,线程太多反而会带来频繁切换。
一般可以接近 CPU 核心数。
比如 8 核机器,可以从 8 左右开始试。
IO 密集型任务,很多时间花在等待 IO。
比如:
读写文件;
访问数据库;
调用外部接口;
上传下载文件;
网络请求。
这种任务线程数可以比 CPU 核心数多一点。
因为很多线程其实在等待,不是一直占用 CPU。
PDF 水印处理有点混合。
它既有文件读写,也有 PDF 解析、图片生成、内容写入。
所以我不会简单按一个公式来算,而是从小线程数开始压测观察。
PDF 水印线程数怎么估
对于 PDF 水印这种任务,我不会一上来就把线程数开很大。
我会先从比较保守的配置开始,比如:
ThreadPoolExecutor pdfExecutor = new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
也就是同一时间最多 3 个 PDF 处理。
然后观察:
CPU 使用率;
内存占用;
磁盘 IO;
单个 PDF 平均处理耗时;
任务排队情况;
失败率。
如果机器压力不大,处理速度还不够,再逐步把线程数调到 4、5。
我不会直接开到 20、50。
因为 PDF 处理很容易受到内存和磁盘 IO 影响。
线程太多不一定更快。
固定线程数适合入门和稳定任务
如果我希望同一时间固定处理 3 个 PDF,可以这样配置:
new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
核心线程数和最大线程数都等于 3。
这种配置的好处是简单、稳定、好理解。
它的效果就是:
最多 3 个线程执行任务;
其他任务排队;
不会临时扩容更多线程。
对于 PDF 水印这种文件处理任务,我觉得这种配置比较适合作为第一版。
先稳定,再调优。
核心线程数小于最大线程数适合有峰值的任务
如果任务有明显高峰,可以考虑:
new ThreadPoolExecutor(
3,
6,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(50),
new ThreadPoolExecutor.CallerRunsPolicy()
);
这个意思是:
正常情况下 3 个线程处理;
队列满了以后,最多扩到 6 个线程;
高峰过去后,非核心线程空闲 60 秒后回收。
这种配置适合任务量有波动的场景。
但要注意,如果队列设置得太大,线程池可能很久都不会扩到 6 个线程。
因为线程池会先让任务进队列。
所以如果希望最大线程数更容易发挥作用,队列不能无限大。
不建议用无界队列
有些线程池工具方法默认会用无界队列。
无界队列的问题是:任务几乎不会因为队列满而触发拒绝,也很少触发最大线程数扩容。
这看起来很安全,但其实只是把任务堆在内存里。
如果提交速度大于处理速度,队列会越来越长。
最后可能出现:
任务延迟越来越高;
内存越来越高;
GC 压力越来越大;
系统看起来没拒绝,但越来越慢。
所以我更倾向于用有界队列。
比如:
new ArrayBlockingQueue<>(100)
至少我知道最多排队多少个任务。
超过这个上限,就让拒绝策略处理。
PDF 接口场景怎么配
如果是一个 Web 接口,用户上传 PDF 后提交后台处理,我会比较谨慎。
如果接口只是提交任务,不在请求线程里处理 PDF,可以考虑:
new ThreadPoolExecutor(
3,
5,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.AbortPolicy()
);
然后在提交任务时捕获拒绝异常:
try {
executor.execute(() -> {
processPdf(file);
});
} catch (RejectedExecutionException e) {
// 返回系统繁忙,或者记录任务提交失败
}
这样比让请求线程自己处理 PDF 更清楚。
如果用 CallerRunsPolicy,当线程池满了以后,请求线程可能会自己去处理 PDF,接口就会卡很久。
这不一定适合 Web 场景。
所以拒绝策略也要结合调用环境看。
后台批处理场景怎么配
如果是普通后台批处理程序,比如我本地批量处理 input 目录下的 PDF,那可以简单一点:
new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
因为本地批处理时,CallerRunsPolicy 让 main 线程帮忙处理任务也没什么大问题。
反而能避免任务丢失。
这种场景我更关心:
任务能处理完;
机器不要被打爆;
日志能看清楚。
所以固定 3 个线程,加一个有界队列,已经比较稳了。
我的配置思路
如果让我给 PDF 水印项目配一个初版线程池,我会先这样写:
ThreadPoolExecutor pdfExecutor = new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
理由是:
PDF 处理比较耗资源,不适合线程数太大;
固定 3 个线程,行为比较稳定;
队列 100 个,能应对一批任务;
CallerRunsPolicy 不轻易丢任务;
学习和本地批处理阶段比较好观察。
如果后面放到 Web 服务里,我可能会把拒绝策略换成 AbortPolicy,然后明确返回系统繁忙。
如果机器资源更强,或者压测发现 3 个线程太少,再慢慢调到 4 或 5。
不要迷信公式
网上经常能看到一些线程数公式。
比如:
CPU 密集型:CPU 核心数 + 1;
IO 密集型:CPU 核心数 * 2;
这些可以作为参考,但不能当成绝对标准。
因为真实任务差异太大。
PDF 水印处理到底是偏 CPU 还是偏 IO,要看具体实现:
PDF 文件大小;
页数多少;
水印是文字还是图片;
是否生成临时图片;
磁盘速度;
是否上传远程存储;
是否写数据库。
所以最终还是要靠测试和观察。
参数不是一次配完就永远正确。
这一节小结
这一节我主要记住几点:
1. corePoolSize 决定线程池的基础处理能力;
2. maximumPoolSize 只有在队列满了以后才更容易生效;
3. workQueue 决定任务最多能排队多少;
4. keepAliveTime 主要影响非核心线程的回收;
5. 队列不要无限大,系统要有边界;
6. PDF 处理线程数不要一上来开太大,要结合 CPU、内存、磁盘 IO 观察;
7. 本地批处理可以用 CallerRunsPolicy,Web 接口更适合明确拒绝并返回提示。
用一句话总结:
线程池参数不是越大越好,而是要让任务有序排队,让机器在可控范围内持续处理。
下一节开始把线程池真正接到 PDF 水印项目里。
也就是用 ThreadPoolExecutor 批量处理 input 目录下的 PDF,并输出到 output 目录。