线程池大小设置的底层逻辑与场景化方案
@[toc]
# 写在文章开头
大部分读者可能都会看过网上的几篇文章,对于线程数的设定基本都是采用下面这个公式:
计算密集型=CPU核心数+1
IO密集型=CPU核心数*2+1
2
然而事实真的是这样吗?那么为什么tomcat服务器的核心线程数要设置为200呢?基于此问题,笔者也基于个人的经验和实践给出自己的一套方法论,希望对你有帮助。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 线程池调测实践
# 单计算任务是否可以跑满单个CPU
针对上述的公式,作者认为计算密集型的任务基本都在进行CPU运算,没有所谓的IO等待,所以设置线程池参数时,只需设置为:
CPU核心数+1
注意,这里的加1是为了保证及时因为偶发的缺页中断亦或者某些异常导致某个线程消亡,也能利用额外的一个线程跑满CPU时钟周期,以确保在单位时间内尽可能的利用到CPU。
所以我们是否可以得出这个结果,如果我们的计算密集型的任务不断的循环跑,它就能跑满单个CPU呢?
对此我们给出下面这样一段代码,在web程序启动之后直接无限循环:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class ThreadPoolApplication {
public static void main(String[] args) {
SpringApplication.run(ThreadPoolApplication.class, args);
//空跑一个循环
while (true) {
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
将程序部署到服务器上启动,可以看到在笔者16核的服务器上,Cpu6 跑满100%,很明显我们的程序霸占了这个CPU核心,由此可以印证对于CPU在单位时间内只能指向一个线程的指令:

# 密集计算任务与CPU调度的关系
有了上面的理论基础,我们将线程数设置为CPU核心数的一半,看看当前的服务器的运行情况:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class ThreadPoolApplication {
public static void main(String[] args) {
SpringApplication.run(ThreadPoolApplication.class, args);
//创建CPU核心数一半的线程
for (int i = 0; i < Runtime.getRuntime().availableProcessors() >> 1; i++) {
new Thread(() -> {
//空跑一个循环
while (true) {
}
}).start();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
和预测的结果一样,对于计算密集型的任务而言,每一个空循环的线程(即每一个线程的指令都会绑定一个CPU核心):

这也就意味着,对于计算型的任务,在满载运行的情况下,可以完全利用单个CPU核心,由此也可推出,对于计算密集型的任务,满载情况下,所有的CPU利用率都会达到100%。
对此我们不妨设置的更极端一点,尝试将线程数设置为CPU核心数的2倍:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class ThreadPoolApplication {
public static void main(String[] args) {
SpringApplication.run(ThreadPoolApplication.class, args);
//创建CPU核心数2倍的线程
for (int i = 0; i < Runtime.getRuntime().availableProcessors() << 1; i++) {
new Thread(() -> {
//空跑一个循环
while (true) {
}
}).start();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
可以看到此时利用率全满了,并且对应的负载也非常显著的提高了,在最近的一分钟可以基本已经达到CPU核心数了:

这里补充一下负载的概念,在单核情况下,负载的值为在0~1之间,这就意味着当前cpu还未满载,用一个比较通俗的比喻,假如单核CPU的负载值为0.5,这就意味着单条车道上有一半的车流经过,还可以容纳一半的车驶入:

业内普遍认为在单核CPU的场景下,负载处于0.7是比较正常标准,如果超过这个值就说明过载了。
同理,笔者的服务器为16核,按照上述所说我们的服务器可以看到有16条车道,所以当负载值小于16即说明有CPU核心未跑满载,一旦负载超过11.2(16*0.7)就意味着我们的系统可能过载了。
我们将执行计算密集型的任务的线程数设置为CPU核心数的2倍,负载不断提升已经超过了11.2,所以对于计算密集型任务,本次线程数的设置是存在问题的:
top - 00:21:38 up 1:09, 1 user, load average: 17.69, 10.45, 8.23
2
3
自此我们就印证了为什么对于计算密集型的任务,我们更简易将线程数设置为趋近于CPU核心数的原因了。
# IO密集型任务调测
为了实现IO密集型实验,笔者基于一台8核心的服务器编写好程序,将计算时间和IO时间尽可能的设置为五五开,如下所示,读者可结合自身服务器性能按需调整:
public static void main(String[] args) {
SpringApplication.run(ThreadPoolApplication.class, args);
ExecutorService threadPool = Executors.newFixedThreadPool(1);
//单线程的线程池执行一个计算和IO五五开的任务
threadPool.execute(() -> {
while (true) {
//执行循环空跑模拟计算
for (int i = 0; i < Integer.MAX_VALUE >> 4; i++) {
for (int j = 0; j < Integer.MAX_VALUE; j++) {
}
}
//休眠50ms
ThreadUtil.sleep(50);
}
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
性能监控结果如下,可以看到单核CPU利用率趋近于50%:
top - 17:29:01 up 3:05, 8 users, load average: 0.66, 0.52, 0.46
Tasks: 499 total, 1 running, 498 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 0.0 us, 0.3 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 0.0 us, 0.3 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu4 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu5 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu6 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
# 趋近于50%
%Cpu7 : 43.0 us, 0.0 sy, 0.0 ni, 57.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
2
3
4
5
6
7
8
9
10
11
基于这个比例,我们将线程数设置为CPU核心数再次运行,最终运行结果如下,可以看到所有的CPU利用率都趋近于50%:
Tasks: 494 total, 1 running, 493 sleeping, 0 stopped, 0 zombie
%Cpu0 : 53.2 us, 0.0 sy, 0.0 ni, 46.8 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 53.7 us, 0.0 sy, 0.0 ni, 46.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 49.3 us, 0.0 sy, 0.0 ni, 50.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 52.7 us, 0.0 sy, 0.0 ni, 47.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu4 : 52.7 us, 0.0 sy, 0.0 ni, 47.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu5 : 53.3 us, 0.3 sy, 0.0 ni, 46.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu6 : 53.2 us, 0.0 sy, 0.0 ni, 46.8 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu7 : 53.0 us, 0.0 sy, 0.0 ni, 47.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 7995716 total, 5055564 free, 1529664 used, 1410488 buff/cache
KiB Swap: 2097148 total, 2097148 free, 0 used. 6155340 avail Mem
2
3
4
5
6
7
8
9
10
11
12
# 结合公式落地IO密集型任务线程池配置
根据《Java并发编程实战》所说,对于IO密集型任务,线程数可按照如下公式获取
nThread=nCPU * uCPU * (1+w/c)
对应的参数含义是:
nThread:表示程序中应该使用的线程数量。 nCPU:表示系统中可用的CPU核心数量。 uCPU:表示每个CPU核心的利用率(通常是一个介于0到1之间的值)。 w/c:表示程序中等待时间(wait time)与计算时间(compute time)的比率。
因为我们的CPU为8核,我们希望全部利用,假设每个利用率为90%,按照我们IO时间和计算时间五五开来算,线程数的计算公式为:
nThread=nCPU * uCPU * (1+w/c)
= 8 * 0.9 * (1+1)
= 14.4
≈ 15
2
3
4
因此我们将线程数设置为15个再次启动并运行,可以看到最终的CPU利用率和预期的基本一致,我们可能还需要结合服务器的实际使用情况进行上下浮动调整:
top - 19:08:52 up 3:50, 8 users, load average: 1.16, 2.44, 2.65
Tasks: 499 total, 3 running, 496 sleeping, 0 stopped, 0 zombie
%Cpu0 : 89.7 us, 0.0 sy, 0.0 ni, 10.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 91.4 us, 0.3 sy, 0.0 ni, 8.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 87.0 us, 0.0 sy, 0.0 ni, 13.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 91.7 us, 0.0 sy, 0.0 ni, 8.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu4 : 87.7 us, 0.0 sy, 0.0 ni, 12.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu5 : 91.0 us, 0.0 sy, 0.0 ni, 9.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu6 : 93.7 us, 0.0 sy, 0.0 ni, 6.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu7 : 86.7 us, 0.0 sy, 0.0 ni, 13.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 7995716 total, 5025368 free, 1558680 used, 1411668 buff/cache
KiB Swap: 2097148 total, 2097148 free, 0 used. 6125500 avail Mem
2
3
4
5
6
7
8
9
10
11
12
# 关于线程池公式的更进一步讨论
# 计算密集型任务的公式的推导
关于计算密集型任务推导公式,很多读者都是死记硬背,这里读者从这里从工作机制和公式推导两个角度进行补充说明。
计算密集型任务即任务不涉及任何IO操作导致阻塞而让出CPU时间片,这意味着所有的任务都必须通过CPU完成运算才算结束,举个例子:假设我们的服务器只有一个CPU,单个任务运算耗时为200ms,如果有1000个任务需要执行,无论如何这份任务在CPU上的执行总时间都是200000ms也就是200s:

操作系统在这其中唯一能做的,也就是为了避免任务饥饿在某个任务执行100ms时将其切换,执行另外一个任务,但是两个任务总的耗时永远是400ms,且必须在CPU上执行完成,所以即使设置再多的线程也没有任何意义:

从公式的角度来说,对应计算密集型的任务w也就是程序因为IO的等待时长为0,按照极限思维来算,对应的推导过程如下
nThread= nCPU * uCPU * (1+w/c)
= nCPU * uCPU * (1+0/c)
= nCPU * uCPU
2
3
最终推导出的线程数也就是基本等同于CPU核心数*CPU利用率也就是CPU核心数,考虑到一些计算异常亦或者缺页中断等原因导致线程消亡,我们一般会按照经验法则多1个线程,这就是为什么计算密集型的公式为CPU核心数+1,同时也因为计算密集型的任务一般不会有太大的耗时,所以大部分情况下对于此类任务都没有基于CPU利用率去限制线程数。
# 为什么会出现IO密集型线程数为CPU核心数*2+1的错误说法
这个公式是很多八股文中经常会提及到的一点,按照笔者的推测,估计是某些博主没有真正的理解线程数推导公式的含义就盲目按照理想情况下所得出的,本质上nThread= nCPU * uCPU * (1+w/c)这个公式的含义是:
利用任务IO阻塞的等待时长和计算时长的占比,推导出某些任务因为任务阻塞而挂起时可以顺便执行多少计算任务
假设我们的任务查询数据库也就是IO耗时为100ms,计算时长也是100ms,按照公式计算为:
nThread= nCPU * uCPU * (1+w/c)
= nCPU * uCPU * (1+100ms/100ms)
= nCPU * uCPU * 2
2
3
可以看到最终的结果就是nCPU * uCPU * 2,是不是觉得很熟悉?没有错,大部分错误的八股文把IO密集型任务都按照计算耗时与IO阻塞耗时五五开进行推导得出nCPU * uCPU * 2,然后也学着计算密集型的套路:
- 去掉
CPU利用率 - 避免缺页中断等异常线程数+1
最终得出2* CPU +1,所以这也正是为什么笔者一直强调对于一些业界正确的实践要参考一些权威性的书籍资料去了解掌握。
反驳了CPU核心数*2+1这个公式之后,我们再来说说正确公式的由来,在上文中笔者提到该公式本质上就是利用任务IO等待耗时和计算耗时的占比,来推算IO阻塞期间可以提前执行掉多少的运算任务,假设我们现在有这样一个场景:
- 服务器CPU核心数为18
- 计算耗时为1ms
- IO耗时为200ms
实际上(1+w/c)这个过程本质上就是在计算针对这个任务,在IO阻塞期间可以提前完成多少计算操作,按照我们的计算比来说每个任务都有200ms的IO阻塞,这也就意味着在200ms的阻塞期间,理想情况下(如果CPU全心全意只执行我们这个程序的任务),阻塞期间可以处理w/c也就是200个计算操作:

基于上述单核的推导过程,我们再补充CPU核心数和利用率才有了下面的公式和计算过程:
nThread= nCPU * uCPU * (1+w/c)
= 18 * 1 * (1+200ms/1ms)
≈3600
2
3
按照当前任务的说明,我们推算:
- 理想情况下单核CPU单位时间内可以处理
1000个计算操作,换算成我们的18核服务器,也就是每秒可以处理大约18*1000也就是18000个任务。 - 基于我们推测的
3600个线程数,按照每个任务200ms的IO来算,每个线程1s内可以处理大约1000/200也就是5个任务,那么3600个线程大约也是可以达到18000的任务
为了印证第一点的预期值和我评估的线程数值一致,我们写下下面这样一段代码,查看当前线程数的设定在单位时间内是否可以处理18000个任务:
//qps 计数器
private static final AtomicInteger count = new AtomicInteger(0);
//按照 1ms cpu处理耗时,推算合理运算线程池数
private static final ExecutorService threadPool = Executors.newFixedThreadPool(3600);
public static void main(String[] args) {
int taskCount = 500_0000;
//计算每秒处理的任务数
new Thread(() -> {
while (true) {
Console.log("qps:{} ", count.get());
count.getAndSet(0);
ThreadUtil.sleep(1000);
}
}).start();
for (int i = 0; i < taskCount; i++) {
threadPool.execute(() -> {
ThreadUtil.sleep(200);
count.incrementAndGet();
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
最终输出结果如下,可以看到,在程序启动后JIT预热阶段完成后,基于我们设定的线程数是可以完成单位时间内执行18000个任务:

同理,我们再补充一个案例来推导这个公式的实效性以避免欠拟合:
- 服务器18核
- 计算耗时2ms
- IO耗时 200ms
同理按照公式推导大约需要1800个线程,结合验证:
- 单核CPU单位时间内可以处理1000/2也就是500个任务,也就是最终结果应该是
9000个任务 - 我们推算出的1800个线程,单位时间内每个线程可以处于
1000/202≈5,1800*5也可以达到9000
基于笔者的机器性能,笔者也出这样一段耗时2ms的代码:
public static int sum() {
long begin = System.currentTimeMillis();
long sum = 0;
for (int i = 0; i < 800_0000; i++) {
sum += i;
}
long end = System.currentTimeMillis();
if (sum != 1) {
return (int) (end - begin);
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
同理压测代码如下:
//qps 计数器
private static final AtomicInteger count = new AtomicInteger(0);
//按照 1ms cpu处理耗时,推算合理运算线程池数
private static final ExecutorService threadPool = Executors.newFixedThreadPool(1800);
public static void main(String[] args) {
int taskCount = 500_0000;
//计算每秒处理的任务数
new Thread(() -> {
while (true) {
Console.log("qps:{} ", count.get());
count.getAndSet(0);
ThreadUtil.sleep(1000);
}
}).start();
for (int i = 0; i < taskCount; i++) {
threadPool.execute(() -> {
sum();
ThreadUtil.sleep(200);
count.incrementAndGet();
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
最终输出结果如下,可以看到理想情况下,基于该公式是可以得到预期的结果:

# 小结
上述的线程池设置更多是基于理想情况下的调整设置,读者在进行压测调整时,还需要结合机器实际使用情况进行适当增减,所以总的来说线程池参数的设定需要符合以下几个原则:
- 计算密集型任务应该为
CPU核心数上下浮动。 IO密集型应该通过公式2得到一个预估的值并结合生产环境的情况不断测试得到一个理想的数值。- 大部分场景下我们的系统并没有太大的压力,不需要那么合适的线程数,对于这种简单的异步场景,我们只需设置为
CPU核心数即可。
我是 sharkchili ,CSDN Java 领域博客专家,开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
# 参考
别再纠结线程池池大小、线程数量了,哪有什么固定公式 | 京东云技术团队:https://zhuanlan.zhihu.com/p/657320656?utm_medium=social&utm_oi=738079643164221440&utm_psn=1734183676982435840&utm_source=wechat_session (opens new window)
Understanding Linux CPU Load - when should you be worried?:https://scoutapm.com/blog/understanding-load-averages (opens new window)