写点什么

ThreadPoolExecutor 源码解读(四)如何正确使用线程池(总结坑点 + 核心参数调优)

用户头像
徐同学呀
关注
发布于: 2021 年 04 月 17 日

一、前言

线程池的基本原理了解的差不多了,但是在实际工作中使用线程池却有很多的坑,比如:


  1. 工作队列设置的很大,甚至是无界,在高负载的情况下,任务堆积在工作队列,极易发生 OOM,同时maximumPoolSize参数和拒绝策略也不会派上用场。

  2. 工作队列使用了有界阻塞队列,也能用上maximumPoolSize,但是maximumPoolSize设置的很大,甚至是Integer.MAX_VALUE,在高负载的情况下,使得工作线程数很快达到corePoolSize,并填满工作队列,此时扩容启动大量新工作线程,非常消耗资源,拒绝策略也难以触发。

  3. 官方提供了四种默认的拒绝策略,有直接抛异常的,有丢弃最老任务执行新任务的,也有什么也不做的,还有直接执行任务代码的(这可是串行执行任务代码),非常影响一个服务的吞吐量。而抛异常、抛弃任务等措施对于重要任务在实际工作中又不适合,所以建议根据业务情况自定义拒绝策略,而在实际工作中自定义拒绝策略往往和降级策略配合使用。

  4. 任务在Worker线程中执行时发生异常,但是Worker线程却没有对异常做什么处理,只是上抛,则会导致Worker线程退出销毁,开发者也不易察觉到。

  5. 把线程池当做线程一样使用,不复用,创建大量的线程池,浪费资源。

二、强烈禁止使用 Executors 创建线程池

JUC 官方提供了Executors 工具类创建线程池,希望为使用者提供方便,但是根据大量的实践和血淋淋的教训,业界都强烈禁止使用Executors 创建线程池。理由如下:


  1. newFixedThreadPoolnewSingleThreadExecutor底层使用了无界阻塞队列LinkedBlockingQueue作为工作队列,其队列容量是Integer.MAX_VALUE,若任务执行比较耗时,且存在大对象,在高并发情况下,工作线程数马上达到核心线程数,则继续加入到工作队列,任务逐渐堆积,非常容易发生OOM,而 OOM 会导致所有请求都无法处理,甚至影响同一个服务其他业务的正常运行。

  2. newCachedThreadPool使用了容量为 0 的SynchronousQueue作为工作队列,maximumPoolSize却设置为Integer.MAX_VALUE,当工作线程达到核心线程数,再提交任务想加到工作队列时失败,转而启动新的工作线程,而这个工作线程数最大值是Integer.MAX_VALUE,一个服务创建大量的线程是非常消耗资源的,甚至影响到服务其他业务的正常运行。

三、线程池核心参数调优

业界已经禁止使用Executors 创建线程池,建议使用原生的ThreadPoolExecutor,但是ThreadPoolExecutor参数很多,完全需要自己配置,虽然知道每个参数的含义,但是却不知道设置为多少合适。


线程池的优化就是降低线程池的运行延迟和提高其吞吐量。优化的思路主要有两个方向,一个是优化任务代码,另一个是让 CPU 和 IO 资源利用率最大化。


如果只有一个线程,执行 CPU 计算的时候,I/O 设备空闲;执行 I/O 操作的时候,CPU 空闲,所以 CPU 的利用率和 I/O 设备的利用率都是 50%。

如果有两个线程,当线程 A 执行 CPU 计算的时候,线程 B 执行 I/O 操作;当线程 A 执行 I/O 操作的时候,线程 B 执行 CPU 计算,这样 CPU 的利用率和 I/O 设备的利用率就都达到了 100%。


线程池参数调优,比如corePoolSize设置多大,maximumPoolSize设置多大,工作队列容量设置多大等。工作队列不宜设置为Integer.MAX_VALUE,需要根据服务的内存评定,比较简单;corePoolSizemaximumPoolSize的设置相对复杂,涉及到提交任务的类型,是 CPU 密集型,还是 IO 密集型?不同的任务类型,计算线程数方式也不同。

1、CPU 密集型

CPU 密集型公式: 最佳线程数=CPU 核数 +1


对于 CPU 密集型,多线程本质上是为了提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。


所以,对于 CPU 密集型场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在实际项目中,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。

2、IO 密集型

IO 密集型相对于 CPU 密集型需要的线程数就要多一些,为什么呢?


因为 IO 设备的读写速度远低于 CPU 的执行速度,所以 IO 密集型的任务执行时间要比 CPU 密集型长很多。而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,可以多配置一些线程,根据经验具体的计算方法是:最佳线程数=2*CPU核数

3、IO 密集型和 CPU 密集型交叉运行

在实际的程序中 IO 密集型和 CPU 密集型往往是交叉运行,如果再使用上述纯 IO 密集型计算方式得到线程数,就不太合理了


比如 IO/CPU 的比率很大,比如 10 倍,2 核,较佳配置:2*(1+10)=22 个线程,而 2*CPU 核数+1 = 5,这两个差别就很大了。


在单核的情况下,对于 I/O 密集型的计算场景,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那多少个线程合适呢?是 3 个线程。CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。


通过上述例子发现,对于 I/O 密集型和 CPU 密集型交叉运行的场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,可以总结出一个公式:最佳线程数 =1 +(I/O 耗时 / CPU 耗时)。令 N=I/O 耗时 / CPU 耗时,当一个线程 执行 IO 操作时,另外 N 个线程正好执行完各自的 CPU 计算,这样 CPU 的利用率就达到了 100%。


上面的公式针对的单核 CPU ,至于多核 CPU,只需要等比扩大即可,计算公式如下:


最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

四、线程池复用和隔离

在实际使用中,如果因为调用第三方的插件无意或有意创建了很多线程池,这样会消耗大量资源,线程池也没有起到复用资源的作用。所以需要复用线程池。


但是复用线程池,不是说所有的业务都用一个线程池,可以根据业务的性质使用不同的线程池,达到隔离环境影响的目的。

五、总结

对于如何正确使用线程池总结如下:

  1. 不要使用无界工作队列,否则高负载情况容易发生 OOM。

  2. 任务代码需要自行 try-catch。

  3. CPU 密集型, 最佳线程数=CPU 核数 +1

  4. IO 密集型,最佳线程数 =CPU 核数 * 2

  5. IO 密集型和 CPU 密集型交叉运行,最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

  6. 线程池需要复用,不能盲目创建大量线程池。但是也需要根据业务不同使用不同的线程池,隔离影响。

  7. 根据实际情况自定义拒绝策略。


注:在实际中一台服务器可能运行着多个服务,所以上述计算的方式并不是很准确,只是计算出一个大概的值作为参考,然后根据实际运行情况进行上调或者下调。


PS: 如若文章中有错误理解,欢迎批评指正,同时非常期待你的评论、点赞和收藏。我是徐同学,愿与你共同进步!


参考:


  • 王宝令 极客时间专栏《Java 并发编程实战》22 | Executor 与线程池:如何创建正确的线程池?

  • 朱晔 极客时间专栏《Java 业务开发常见错误 100 例》03 | 线程池:业务代码最常用也最容易犯错的组件

  • 刘超 极客时间专栏《Java 性能调优实战》18 | 如何设置线程池大小?


虽然看的是免费部分没有花钱,但是几位大佬根据多年实战经验总结的见解给了我很大帮助,非常感谢!

发布于: 2021 年 04 月 17 日阅读数: 20
用户头像

徐同学呀

关注

公众号:徐同学呀 2018.09.24 加入

专注于源码分析及Java底层架构开发领域。持续改进,坦诚合作!我是徐同学,愿与你共同进步!

评论

发布
暂无评论
ThreadPoolExecutor源码解读(四)如何正确使用线程池(总结坑点+核心参数调优)