跳到正文
hello world

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()
);

因为本地批处理时,CallerRunsPolicymain 线程帮忙处理任务也没什么大问题。

反而能避免任务丢失。

这种场景我更关心:

任务能处理完;
机器不要被打爆;
日志能看清楚。

所以固定 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 目录。