Java 线程池实践出真知

Posted Danny_姜

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 线程池实践出真知相关的知识,希望对你有一定的参考价值。

线程池是Java工程师实现并发编程的一大利器,能够有效限制系统中执行线程的数量,重复利用已创建线程,减少资源浪费。

但是!线程池真正的难点在于实际使用阶段,主要有以下几个痛点:

  1. 如何合理配置线程池参数

  2. 如何实现自定义的线程池工作流程

  3. 如何实现线程池参数的实时配置

  4. 如何正确关闭线程池

这篇文章就着重来看一下如何解决上面这几个问题。

阅读须知

在继续阅读这篇文章内容之前,需要先确保自己对线程池的工作流程与原理有一定的理解,至少能够答出以下几个问题。

声明如下线程池:

定义一个耗时10s的异步任务:

问题:

  1. 如果同时向这个线程池提交10个异步任务Task,那么当时间走到第5s时,这10个异步任务在线程池中是如何分配的?

  2. 在问题1的基础上,当时间走到第5s时,又向线程池中提交20个异步任务Task,那线程池此时会如何处理这20个异步任务?

  3. 在什么情况下线程池会执行拒绝策略,不再接受新提交的异步任务?

如果回答不上来上面这几个问题,建议先点个 收藏,然后谷歌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
shutdown

Exiting normally...

这是因为 shutdownNow 会设置任务线程的中断标志,因此代码中任务通过检测到 Thread.currentThread().isInterruped() 会立即退出。

最佳实践   

终止 ExecutorService 的一个最佳实践就是,shutdown 和 shutdownNow 两个方法一起,并结合 awaitTermination 来实现超时等待。

解释说明:

  1. 调用 shutdown,阻止新提交任务,并让等待队列中的任务执行完成

  2. 调用 awaitTermination(),保证等待队列中的任务最多执行 800 ms,以防止执行任务时间太长或被阻塞,而导致 ExecutorService 不能被销毁。

  3. 在 awaitTermination 等待 800 ms 后,ExecutorService 中还有任务没执行完,则调用 shutdownNow 强行终止,以释放 ExecutorService 资源。

  4. 结合 中,上面代码执行 awaitTermination 时所在的线程也有可能被 interrupt,因此需要 catch InterruptedException。

THE END

线程池的实践出真知就写到这里,关于文中提到的相关源码可以关注公众号,发送私信“dtpe”获取

往期精选

Java命令行工具之 jstat

漫画Java线程池的工作机制

彻底弄懂Lambda和高阶函数

不要让你的Java对象"逃逸"了!

源码分析Java虚拟机中锁膨胀的过程

大话Java对象在虚拟机中是什么样子?

Java虚拟机究竟是如何处理SoftReference的

以上是关于Java 线程池实践出真知的主要内容,如果未能解决你的问题,请参考以下文章

实践出真知!java反编译工具fome

面试官问:你做过什么Java线程池实践,我写了一篇博客给他看~

实践出真知——基于squid实现正向代理实践

实践出真知——基于squid实现反向代理实践

实践出真知——一文教你如何搭建docker私有仓库

万事开头难 && 实践出真知