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

更多JUC源码解读系列文章请持续关注JUC源码解读文章目录JDK8


一、前言

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

  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 | 如何设置线程池大小?

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

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

徐同学呀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值