ThreadPoolExecutor 源码解读(四)如何正确使用线程池(总结坑点 + 核心参数调优)
一、前言
线程池的基本原理了解的差不多了,但是在实际工作中使用线程池却有很多的坑,比如:
工作队列设置的很大,甚至是无界,在高负载的情况下,任务堆积在工作队列,极易发生 OOM,同时
maximumPoolSize
参数和拒绝策略也不会派上用场。工作队列使用了有界阻塞队列,也能用上
maximumPoolSize
,但是maximumPoolSize
设置的很大,甚至是Integer.MAX_VALUE
,在高负载的情况下,使得工作线程数很快达到corePoolSize
,并填满工作队列,此时扩容启动大量新工作线程,非常消耗资源,拒绝策略也难以触发。官方提供了四种默认的拒绝策略,有直接抛异常的,有丢弃最老任务执行新任务的,也有什么也不做的,还有直接执行任务代码的(这可是串行执行任务代码),非常影响一个服务的吞吐量。而抛异常、抛弃任务等措施对于重要任务在实际工作中又不适合,所以建议根据业务情况自定义拒绝策略,而在实际工作中自定义拒绝策略往往和降级策略配合使用。
任务在
Worker
线程中执行时发生异常,但是Worker
线程却没有对异常做什么处理,只是上抛,则会导致Worker
线程退出销毁,开发者也不易察觉到。把线程池当做线程一样使用,不复用,创建大量的线程池,浪费资源。
二、强烈禁止使用 Executors 创建线程池
JUC 官方提供了Executors
工具类创建线程池,希望为使用者提供方便,但是根据大量的实践和血淋淋的教训,业界都强烈禁止使用Executors
创建线程池。理由如下:
newFixedThreadPool
和newSingleThreadExecutor
底层使用了无界阻塞队列LinkedBlockingQueue
作为工作队列,其队列容量是Integer.MAX_VALUE
,若任务执行比较耗时,且存在大对象,在高并发情况下,工作线程数马上达到核心线程数,则继续加入到工作队列,任务逐渐堆积,非常容易发生OOM
,而 OOM 会导致所有请求都无法处理,甚至影响同一个服务其他业务的正常运行。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
,需要根据服务的内存评定,比较简单;corePoolSize
和maximumPoolSize
的设置相对复杂,涉及到提交任务的类型,是 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 耗时)]
四、线程池复用和隔离
在实际使用中,如果因为调用第三方的插件无意或有意创建了很多线程池,这样会消耗大量资源,线程池也没有起到复用资源的作用。所以需要复用线程池。
但是复用线程池,不是说所有的业务都用一个线程池,可以根据业务的性质使用不同的线程池,达到隔离环境影响的目的。
五、总结
对于如何正确使用线程池总结如下:
不要使用无界工作队列,否则高负载情况容易发生 OOM。
任务代码需要自行 try-catch。
CPU 密集型,
最佳线程数=CPU 核数 +1
。IO 密集型,
最佳线程数 =CPU 核数 * 2
。IO 密集型和 CPU 密集型交叉运行,
最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
。线程池需要复用,不能盲目创建大量线程池。但是也需要根据业务不同使用不同的线程池,隔离影响。
根据实际情况自定义拒绝策略。
注:在实际中一台服务器可能运行着多个服务,所以上述计算的方式并不是很准确,只是计算出一个大概的值作为参考,然后根据实际运行情况进行上调或者下调。
PS: 如若文章中有错误理解,欢迎批评指正,同时非常期待你的评论、点赞和收藏。我是徐同学,愿与你共同进步!
参考:
王宝令 极客时间专栏《Java 并发编程实战》22 | Executor 与线程池:如何创建正确的线程池?
朱晔 极客时间专栏《Java 业务开发常见错误 100 例》03 | 线程池:业务代码最常用也最容易犯错的组件
刘超 极客时间专栏《Java 性能调优实战》18 | 如何设置线程池大小?
虽然看的是免费部分没有花钱,但是几位大佬根据多年实战经验总结的见解给了我很大帮助,非常感谢!
版权声明: 本文为 InfoQ 作者【徐同学呀】的原创文章。
原文链接:【http://xie.infoq.cn/article/32dfe562552e629ee6b12f436】。文章转载请联系作者。
评论