Java 线程池实践出真知
Posted Danny_姜
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 线程池实践出真知相关的知识,希望对你有一定的参考价值。
线程池是Java工程师实现并发编程的一大利器,能够有效限制系统中执行线程的数量,重复利用已创建线程,减少资源浪费。
但是!线程池真正的难点在于实际使用阶段,主要有以下几个痛点:
如何合理配置线程池参数
如何实现自定义的线程池工作流程
如何实现线程池参数的实时配置
如何正确关闭线程池
这篇文章就着重来看一下如何解决上面这几个问题。
阅读须知
在继续阅读这篇文章内容之前,需要先确保自己对线程池的工作流程与原理有一定的理解,至少能够答出以下几个问题。
声明如下线程池:
定义一个耗时10s的异步任务:
问题:
-
如果同时向这个线程池提交10个异步任务Task,那么当时间走到第5s时,这10个异步任务在线程池中是如何分配的?
-
在问题1的基础上,当时间走到第5s时,又向线程池中提交20个异步任务Task,那线程池此时会如何处理这20个异步任务?
-
在什么情况下线程池会执行拒绝策略,不再接受新提交的异步任务?
如果回答不上来上面这几个问题,建议先点个 收藏,然后谷歌Java线程池的原理之后,再回过头来仔细品味一番这篇文章的内容。网上对线程池的介绍大多都是长篇的文字描述,读起来不免有些枯燥乏味,建议配合这篇动画演示线程池的工作机制加深理解 漫画Java线程池的工作机制
(一) 合理配置线程池参数
线程池参数的配置需要程序开发人员对线程池原理有深入的理解,并且需要有强大的项目经验作支撑。否则稍有不慎就会挖坑埋雷,不知道在什么时候就会被引爆。
corePoolSize
核心线程数设置过小
为了尽量避免资源浪费, 在实际项目里核心线程数的设置不会太大。这往往会造成提交任务数量会瞬间超出最大线程数量,从而触发的线程池的任务拒绝策略,导致较多任务被直接丢弃而无法被完成。如下所示:
聪明的你可能会立刻想到,那就将核心线程数的配置的较大一些。但是这在实际过程中会造成另外一个问题:如果核心线程数较大,当有大批量任务提交到线程池中,而线程池会并发调用下游服务器的增删改查操作,这就造成公司的服务器长时间处于紧张状态。降低了服务器的抗压能力,也提高了造成服务器崩溃的几率。
实际上,线程池是一种池化技术,目的主要是为了做资源复用以及缓存功能。如果将核心线程数设置过大,导致大部分异步任务都会创建新的核心线程去执行,这就相当于间接摒弃了池化技术的天然优势。
BlockingQueue
等待队列设置过大
上面说到了线程池是一种缓存机制,那么将BlockingQueue的长度设置的大一些,将超过核心线程数的任务缓存到BlockingQueue中,是不是就解决问题了呢?
试想一下:如果上面的异步任务Task是一个需要耗时1小时的长时间任务,当线程数达到 corePoolSize 时,由于BlockingQueue的长度过大,新提交的所有任务都会缓存到BlockingQueue中,maximumPoolSize的设置也就失效了。此时将会有大量的任务堆积在BlockingQueue中,最终上游的查询接口就会收到请求超时的结果。如下图所示:
BlockingQueue长度过大造成程序OOM!
比如如下使用 newFixedThreadPool 方法创建线程的案例: 上述代码创建了一个固定数量为 2 的线程池,并通过 for 循环向线程池中提交 100 万个任务。
通过 java -Xms4m -Xmx4m FixedThreadPoolOOM 执行上述代码,结果如下: 可以发现当任务添加到 7 万多个时,程序发生 OOM,原因就是 newFixedThreadPool 方法创建的是一个无界阻塞队列,无限制的向这个阻塞对象添加Task对象,最终肯定会造成OOM。
也正因为如此,线程池的使用需要程序开发人员有很好的经验,能够提前预估请求流量的分配情况。并根据流量的大小设置合理的corePoolSize,maximumPoolSize以及BlockingQueue。
(二) 自定义线程池工作流程
仔细查看线程池的工作流程可以发现线程池内部的优先级如下:
核心线程 -> BlockingQueue -> 非核心线程
主要实现是在ThreadPoolExecutor中的execute方法中:
也就是说一个任务提交给线程池后,首先交给核心线程执行,如果没有可用核心线程就缓存到阻塞队列BlockingQueue中,如果阻塞队列也已经满了,再创建新的非核心线程执行任务。
这种优先级的实现方式也间接导致了上文介绍的,因BlockingQueue长度过大导致的问题。有没有办法修改一下这个优先级排列呢?答案是肯定的!
实际上在Spring框架中已经有了这种实现--ThreadPoolTaskExecutor。实现思路就是使用装饰者模式对JDK中的ThreadPoolExecutor进行了一层包装,并对BlockingQueue的offer方法进行了重定向操作。
借鉴Spring的实现思路,最终实现了 DynamicThreadPool,部分实现如下:
实现好之后,分别使用JDK自带线程池和自定义线程池执行一段代码,并通过打印日志查看运行流程的区别。
ThreadPoolExecutor
核心线程 > 阻塞队列 > 非核心线程
执行上述代码,效果如下:
可以看出当任务数量大于 corePoolSize 时,新提交的任务会优先缓存在阻塞队列内。
DynamicThreadPool
核心线程 > 非核心线程 > 阻塞队列
执行后,打印日志如下:
可以看出,前5个异步任务会被同时执行完。只有超出maximumPoolSize的Task 6才会被缓存到阻塞队列中。
注意:虽然 DynamicThreadPool 已经成功重定向了线程池的工作流程,工作已经完成一半。但是此时还称不上非常智能化:在实际生产环境中,流量分配往往是不均匀的。比如大多情况下接口日均访问1000/时,但是在上下班高峰期或者某些极端情况(如双十一),接口访问量会暴增。因此需要给 DynamicThreadPool添加支持动态扩容的功能。在流量访问高峰期进行动态扩容,闲暇时将超时线程进行销毁。接着往下看!
(三) 动态设置线程池参数
在 Java线程池在美团业务中的实践 这篇文章中,美团提出了一种动态设置线程池参数的思路。主要思路是代码运行过程中,实时动态设置核心线程数、最大线程数、阻塞队列的大小。
设置核心和非核心线程数
核心线程 & 非核心线程
在ThreadPoolExecutor源码中,提供了动态设置核心线程数和最大线程数的方法:
以setCorePoolSize为方法例,在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略,setMaximumPoolSize也是同样道理。
动态设置阻塞队列长度
自定义BlockingQueue
在JDK自带线程池中并没有提供动态设置阻塞队列长度的方法,因此在自定义的DynamicThreadPool
中需要创建一个可修改的变量capacity来表示阻塞队列的容量。需要通过自定义BlockingQueue来实现阻塞队列长度的动态扩容。具体代码如下:
触发线程池扩容时机
功能实现了,那何时触发线程池扩容操作呢?
在美团的那篇文章中,介绍了将线程参数配置迁移到分布式配置中心的方式。这种方式需要程序员预估某些特定场合的流量访问,或者根据实时数据即时更新线程池参数,如下图所示:
但是这种方式还是需要程序开发者手工设置。
通过实验,我们在 DynamicThreadPool 里使用了一种算法,仿照HashMap的扩容机制,当阻塞队列的size到达capacity的70时,主动进行扩容操作,部分实现如下:
(四) 关闭线程池最佳实践
ExecutorService有一个特点:即使没有任务需要执行,ExecutorService 也不会自动被系统销毁,而是会继续存活并等待新的任务到来。
在ExecutorService中提供了shutdown 和 shutdownNow 来关闭线程池,这两个方法的区别如下:
-
shutdown 会使 ExecutorService 不再接受新的任务,但是已经 submit 的任务会继续执行
-
shutdownNow 会做同样的事,并且会通过中断( interrupt )相关线程来尝试取消已提交的任务。如果提交的任务忽略这个中断( interruption ),那么shutdownNow 方法的表现将和 shutdown 一致。
比如以下代码:
case 1
如上图红框中所示,使用shutdown尝试关闭线程池。执行上面代码会有如下打印:
shutdown
Still waiting after 10s: calling System.exit(0)…
这是因为 shutdown 方法并不产生设置线程的中断标志,因此在任务的循环中 Thread.currentThread().isInterruped() 始终返回 false。
case 2
将 shutdown 改为 shutdonwNow,打印如下:
interrupted
shutdownExiting normally...
这是因为 shutdownNow 会设置任务线程的中断标志,因此代码中任务通过检测到 Thread.currentThread().isInterruped() 会立即退出。
最佳实践
终止 ExecutorService 的一个最佳实践就是,shutdown 和 shutdownNow 两个方法一起,并结合 awaitTermination 来实现超时等待。
解释说明:
-
调用 shutdown,阻止新提交任务,并让等待队列中的任务执行完成
-
调用 awaitTermination(),保证等待队列中的任务最多执行 800 ms,以防止执行任务时间太长或被阻塞,而导致 ExecutorService 不能被销毁。
-
在 awaitTermination 等待 800 ms 后,ExecutorService 中还有任务没执行完,则调用 shutdownNow 强行终止,以释放 ExecutorService 资源。
-
结合 中,上面代码执行 awaitTermination 时所在的线程也有可能被 interrupt,因此需要 catch InterruptedException。
THE END
线程池的实践出真知就写到这里,关于文中提到的相关源码可以关注公众号,发送私信“dtpe”获取
往期精选
漫画Java线程池的工作机制
以上是关于Java 线程池实践出真知的主要内容,如果未能解决你的问题,请参考以下文章