线程池作为java.util.concurrent包中利器之一,实现了任务提交、任务执行的解耦,大幅简化了任务队列、线程的管理工作,降低了使用者多线程并发编程的难度。
正所谓”成也萧何败萧何“,线程池在其使用上的简易性,使得开发人员在实际使用过程中忽视了线程池的底层特性,一些线程池不恰当的使用方式导致线程池无法发挥其最佳性能,甚至带来安全性隐患。
今天,小步将盘点日常线程池使用过程中六大常见的"恶习"并进行分析,希望能有所帮助。
1.不恰当的线程池大小设置
首先,让我们先来看一段代码
ExecutorService fixedThreadPool =
Executors.newFixedThreadPool(Integer.MAX_VALUE);
这段代码(或者类似代码)在我从业生涯中曾"有幸"多次目睹,当我询问这些开发人员为何如此设置时,其回答基本一致:“任务太多,我担心线程不够用”。
同时,我也曾注意到一些极为“谨慎"的程序员们,他们在设置线程池大小时会显得极为"吝啬",哪怕坐拥一台256核的顶级主机,线程池大小也不会超过8。
线程池的合理大小到底该如何设置?是否有确切的公式或者规律可循?幸运的是,要设置合理的线程池大小并不困难。
首先,有一个原则我们必须要遵守,即避免”过大“和”过小“这两种极端。
-
线程池过大,大量的线程将在相对有限的CPU、内存资源上竞争,会导致更高的内存使用量,有可能会资源耗尽而导致系统崩溃
-
线程池过小,将导致空闲的CPU无任务可执行,从而降低吞吐率
线程池大小的设置与任务类型、资源约束、资源池依赖这三个要素紧密相关。
1.1 任务类型
任务类型分为计算密集型、I/O密集型、混合型(计算密集型与I/O密集型并存)三类。
当入池任务类型为计算密集型时,线程池大小的设置极为简易,当线程池大小为N+1时(N为CPU核数),通常能实现最优的利用率,在Java中,可以通过如下代码获得当前系统的CPU核数
int CPU_NUMBER = Runtime.getRuntime()
.availableProcessors();
当任务类型为I/O密集型或者混合型时,由于包含有I/O操作或者其他阻塞操作的任务,线程并不会一直保持执行,线程池的规模可以比CPU核数大很多,要相对合理的计算线程池的大小,需完成如下影响因子的计算/获取
- CPU核数
- CPU目标使用率,即在保证系统稳定运行的前提下,期望达到的CPU目标使用率。
- 任务执行过程中等待时间与计算时间比值,该值为估算值,可通过一些测试及监控工具获得,如Dark Magic算法(关注公众号"架构师跬步营" 并回复"DarkMagic"可获得算法源码)
获得上述三个影响因子的值/估算值后,基于如下公式,可得最佳线程池大小
最佳线程池大小 = CPU核数 * CPU目标使用率 *
(1+任务执行过程中等待时间与计算时间比值)
1.2 资源约束
资源约束包括CPU、内存、文件句柄、数据库连接等,在”任务类型“章节中,我们已着重分析了在任务类型不同时,CPU资源对线程池大小的影响。
对于非CPU资源约束对于线程池大小的影响评估相对更为简易,只要计算出每个任务对于各类资源的需求大小,然后用各项资源总量除以单个任务的资源需求大小,基于各项除值,取最小值即可获得可设置的最大线程池大小。
1.3 资源池依赖
当任务依赖于某种通过资源池来管理的资源时,如数据库连接、信号量等时,那么线程池的大小便受资源池大小的影响,基于单个任务执行所依赖的资源池资源数量N,可得最佳线程池大小
最佳线程池大小 = 资源池中资源数/单个任务执行所依赖资源池资源数
综上所述,线程池大小的设置是一个相对可量化的过程,基于任务类型、资源约束、资源池依赖几个因素的综合考量后,即可获得相对最佳的线程池大小。
小贴士:线程池大小的设置是一个可以量化的过程,为获得线程池的最佳性能,需对任务类型、资源约束、资源池依赖几个因素进行综合考量,切忌未加分析盲目设置!
2.入池任务类型多样化
任务类型分为计算密集型、I/O密集型及混合型,当线程池中待执行任务类型超过两种时,可能导致如下后果。
- 线程池大小的合理设置变得困难
在前述章节关于线程池大小设置已有详细解读,此处不再赘述。多种类型的任务在同一个线程池中混杂处理,将导致在资源耗损及性能表现中做到相对合理的平衡变得极为困难。 - 降低线程池的整体吞吐率
线程池中任务类型混杂时,会不可避免的降低低延时任务的吞吐率,进而影响整体吞吐率。
小贴士:为不同类型的任务创建独立的线程池。
3.入池任务执行耗时差异大
将执行时间较长与执行时间较短的任务混合在一起,极易导致线程池的”拥堵“,最终线程池中所有的线程可能都被运行时间较长的任务所占据,而这将导致新来的任务无法得到及时的处理,从而导致线程池吞吐量下降(当然,如果线程池非常大或者无限大,可能可以避免此种情况的发生,但是成本过于高昂)。
小贴士: 基于执行时长进行任务分档,为执行时间较长与执行时间较短的任务创建独立的线程池。
4.入池任务间存在依赖
如果提交给线程池的任务依赖于其他任务执行的结果、时序或者其他效果,可能会产生活跃性的问题,甚至可能产生任务死锁。
想象这样一个场景,如果线程池中所有任务需要无限期地(设置超时时间貌似可以解决问题,但是还是存在有死锁风险)等待在线程池任务队列中的任务执行结果,必然会导致死锁。
除非线程池无限大,否则与此类似(毕竟刚才举的例子有些极端)的线程池中任务间存在依赖的场景也最终将导致死锁。
另外,如果入池任务保持相对独立,当线程池性能需要得到调整时,可通过调整线程池大小即其他配置达到目的,然而一旦任务间存在依赖,调整线程池大小或者其他配置可能会带来不可预期的执行结果。
小贴士:确保任务的独立性,任务与任务之间无依赖。
5.滥用ThreadLocal
使用ThreadLocal可通过维持线程封闭性,使得每个线程可拥有某个变量的”私有版本“进而规避变量的竞争读写,因此在任务中使用ThreadLocal往往也成为了并发编程中常见的做法。
然而在线程池入池任务中使用ThreadLocal则需要格外注意,小步将在如下章节中为您盘点在任务中使用ThreadLocal的几大误区:
-
ThreadLocal生命周期超越任务生命周期
Executor的特性之一在于线程的共享机制,如果ThreadLocal生命周期超越任务生命周期,则会导致处理完任务A的线程”携带“着私有变量继续处理任务B,此时会导致不可预期的错误。而另外一种常见的场景在于当任务因为非可预期的异常退出执行时,ThreadLocal因未得到及时的清理,而导致被意外地传递至其他的任务中。
-
通过ThreadLocal在任务间传递值
小贴士:ThreadLocal生命周期应受限于任务生命周期,且在发生不可预期异常时,ThreadLocal也应得到妥善清理,不可在任务间通过ThreadLocal传递值。
6.线程池用后即"焚"
行文开始前,先看几行让人后脊背发凉的代码
public void function(List<Task> taskList){
//function在实际工程中,会被频繁调用
ExecutorService fixedThreadPool =
Executors.newFixedThreadPool(N_POOLSIZE);
for(Task task:taskList){
fixedThreadPool.submit(task);
}
fixedThreadPool.shutdown();
}
线程池的特性之一通过集中式地管理线程,实现线程的复用,如果每次使用线程池均对其进行销毁,则线程池的意义便不复存在。
小贴士:线程池的启动与关闭应与应用的启动及退出保持同步。
如上,是线程池使用的几大”恶习“,您是否有中招呢?
参考文章
https://peiquan.gitbooks.io/note/Java/other/how-to-calculate-threadpool-size.html