如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。
前言:曾经自诩对线程池了如指掌,不料看了美团的一篇技术文章后才知道原来线程池的参数还可以动态调节。
学艺不精,一边留下了没有技术的泪水,一边站在美团这个巨人的肩上写下此文,补充并记录了自己的一点看法。
分享给大家,希望能对你有所帮助。
荒腔走板
大家好,我是 why,一个四川好男人。
今天本来应该是武汉马拉松鸣枪起跑的日子,所以先荒腔走板说几句马拉松吧。
上面的图是我跑 2019 年成都马拉松的时候拍的,是一对双胞胎陪着 80 岁的父亲跑全程马拉松。
图片中的老人叫罗广德,在他 75 岁之前的人生和其他的老人并无不同。
但是经过他儿子的影响,在 75 岁的时候开始接触跑步的。一直就没有停下脚步,世界六大马拉松赛(纽约、伦敦、柏林、芝加哥、东京、波士顿)他已经完成了五个。
本来打算今年 4 月份站上波士顿马拉松的赛道上,完成最后的挑战。
完成之后,他就是世界华人这个年龄段里第一个完成世界六大马拉松赛的大满贯跑者。
但是由于疫情的原因,波士顿马拉松延期举行了。但是没有关系,我相信老爷子的执着,我也相信他会是第一人。
他说:“人生没有太晚的开始,关键是要行动起来。现在的年轻朋友很多都缺乏锻炼,作息时间不好,我希望年轻人都行动起来,我 80 岁都能跑步,难道你们不能跑吗?”
我之前说过,在赛道上你能看到很多有趣的、感动的画面。我喜欢跑马拉松,因为跑完之后总是能带给我爆棚的正能量。
人生需要一场马拉松,你可以迟到,但是你不能缺席。
好了,说回文章。
经典面试题
这次的文章还是绕回了我写的第三篇原创文章《有的线程它死了,于是它变成一道面试题》中留下的几个问题:
哎,兜兜转转,走走停停。天道好轮回,苍天饶过谁?
在这篇文章中我主要回答上面抛出的这个问题:你这几个参数的值怎么来的呀?
要回答这个问题,我们得先说说这几个参数是什么,请看截图:
其实,官方的注释写的都非常明白了。你看文章的时一定要结合英文,因为英文是 Doug Lea(作者)他自己写的,表达的是作者自己的准确的想法。
不要瞎猜好吗?
1.corePoolSize:the number of threads to keep in the pool, even if they are idle, unless {@code allowCoreThreadTimeOut} is set
(核心线程数大小:不管它们创建以后是不是空闲的。线程池需要保持 corePoolSize 数量的线程,除非设置了 allowCoreThreadTimeOut。)
2.maximumPoolSize:the maximum number of threads to allow in the pool。
(最大线程数:线程池中最多允许创建 maximumPoolSize 个线程。)
3.keepAliveTime:when the number of threads is greater than the core, this is the maximum time that excess idle threads will wait for new tasks before terminating。
(存活时间:如果经过 keepAliveTime 时间后,超过核心线程数的线程还没有接受到新的任务,那就回收。)
4.unit:the time unit for the {@code keepAliveTime} argument
(keepAliveTime 的时间单位。)
5.workQueue:the queue to use for holding tasks before they are executed. This queue will hold only the {@code Runnable} tasks submitted by the {@code execute} method。
(存放待执行任务的队列:当提交的任务数超过核心线程数大小后,再提交的任务就存放在这里。它仅仅用来存放被 execute 方法提交的 Runnable 任务。所以这里就不要翻译为工作队列了,好吗?不要自己给自己挖坑。)
6.threadFactory:the factory to use when the executor creates a new thread。
(线程工程:用来创建线程工厂。比如这里面可以自定义线程名称,当进行虚拟机栈分析时,看着名字就知道这个线程是哪里来的,不会懵逼。)
7.handler :the handler to use when execution is blocked because the thread bounds and queue capacities are reached。
(拒绝策略:当队列里面放满了任务、最大线程数的线程都在工作时,这时继续提交的任务线程池就处理不了,应该执行怎么样的拒绝策略。)
7 个参数介绍完了,我希望当面试官问你自定义线程池可以指定哪些参数的时候,你能回答的上来。
当然,不能死记硬背,这样回答起来磕磕绊绊的,像是在背书。也最好别给我回答什么:我给你举个例子吧,就是一开始有多少多少工人....
没必要,真的,直接回答每个参数的名称和含义就行了,牛逼的话你就给我说英文也行,我也能听懂。
这玩意大家都懂,又不抽象,你举那例子干啥?拖延时间吗?
面试要求的是尽量精简、准确的回答问题,不要让面试官去你冗长的回答中提炼关键字。
一是面试官面试体验不好。面试完了后,常常是面试者在强调自己的面试体验。朋友,你多虑了,你面试体验不好,回去一顿吐槽,叫你进入下一轮面试的时候,大部分人还不是腆着个脸就来了。面试官的体验不好,那你是真的没有下一轮了。
二是面试官面试都是有一定的时间限制的,有限的面试时间内,前面太啰嗦了,能问你的问题就少了。问的问题少了,面试官写评分表的时候一想,我靠,还有好多问题没问呢,也不知道这小子能不能回答上来,算了,就不进入下一轮了吧。
好了好了,一不下心又暴露了几个面试小技巧,扯远了,说回来。
上面的 7 个参数中,我们主要需要关心的参数是: corePoolSize、maximumPoolSize、workQueue(队列长度)。
所以,文本主要讨论这个问题:
当我们自定义线程池的时候 corePoolSize、maximumPoolSize、workQueue(队列长度)该如何设置?
你以为我要给你讲分 IO 密集型任务或者分 CPU 密集型任务?
不会的,说好的是让面试官眼前一亮、虎躯一震、直呼牛皮的答案。不骗你。
美团骚操作
怎么虎躯一震的呢?
因为我看到了美团技术团队发表的一篇文章:《Java线程池实现原理及其在美团业务中的实践》
第一次看到这篇文章的时候我真是眼前一亮,看到美团的这骚操作,我真是直呼牛皮。
(哎,还是自己见的太少了。)
这篇文章写的很好,很全面,比如我之前说的线程执行流程,它配了一张图,一图胜千言:
阻塞队列成员表,一览无余:
前面都是些基础知识,文中的后半部分才抛出了一个实际问题:
线程池使用面临的核心的问题在于:线程池的参数并不好配置。
一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;
另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大。
这导致业界并没有一些成熟的经验策略帮助开发人员参考。
美团给出的对应的解决方案是什么呢?
线程池参数动态化。
尽管经过谨慎的评估,仍然不能够保证一次计算出来合适的参数,那么我们是否可以将修改线程池参数的成本降下来,这样至少可以发生故障的时候可以快速调整从而缩短故障恢复的时间呢?
基于这个思考,我们是否可以将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,线程池参数动态化前后的参数修改流程对比如下:
说实话看到这个图的时候我想起之前也有这样的想法的。
因为有一次我这边有个项目里面的定时任务用到了线程池,但是核心线程数和队列长度都设置的比较大,某一次任务触发后查出了大批数据,通过线程池提交任务,每个任务里面都会调用下游服务,导致下游服务长时间的压力过大,也没有做限流,所以影响了其对外提供的其他功能。
于是我叫运维帮我在 Apollo(配置中心)调小了核心线程数,并且重启了服务。
那一次我就在想,我们使用的是 Apollo 天然支持动态更新,那我能不能动态的修改线程池呢?
因为那个时候不知道一个构建好了的线程池,它的核心线程数和最大线程数是可以动态修改的。
所以最开始的想法是监听到参数变化后,直接弄一个新的线程池把原来的给替换掉。
但这样的问题是,偷天换日之后,原来的线程池里面的任务我怎么处理呢?
我不能等原来的线程池里面的任务执行完成后再换,因为这个时候任务一定是源源不断的过来的。
于是就卡在了这个地方。
说来惭愧,这块源码我看过几次,但还是差点火候,学艺不精,怨不得别人。
先劝退一波
为了不浪费你的时间,先检测一下你是否有阅读本文的基础知识储备:
首先,我们先自定义一个线程池:
拿着这个线程池,当这个线程池在正常工作的前提下,我先问你两个问题:
1.如果这个线程池接受到了 30 个比较耗时的任务,这个时候线程池的状态(或者说数据)是怎样的?
2.在前面 30 个比较耗时的任务还没执行完成的情况下,再来多少个任务会触发拒绝策略?
其实这就是在问你线程池的执行流程了,简单的说一下就是:
1.当接收到了 30 个比较耗时的任务时,10 个核心线程数都在工作,剩下的 20 个去队列里面排队。这个时候和最大线程数是没有关系的,所以和线程存活时间也就没有关系。
2.其实你知道这个线程池最多能接受多少任务,你就知道这个题的答案是什么了,上面的线程池中最多接受 1000(队列长度) + 30(最大线程数) = 1030 个任务。所以当已经接收了30个任务的情况下,如果再来 1000 个比较耗时的任务,这个时候队列也满了,最大线程数的线程也都在工作,这个时候线程池满载了。因此,在前面 30 个比较耗时的任务还没执行完成的情况下,再来 1001 个任务,第 1001 个任务就会触发线程池的拒绝策略了。
这两个问题你得会,如果答不上来你也别往下看了,大概率看的一脸懵逼。
我建议你先给本文点个赞,接着去网上搜一下线程池执行流程的文章(其实美团的那篇文章也写了执行流程),写个 Demo 跑一下,摸清楚了,再来看这篇文章。
巨人肩膀
对于线程池参数到底如何设置的问题美团的那篇文章提供了一个很好的思路和解决方案,展现的是一个大而全的东西。
但是,对于实施起来的细节就没有具体的展示了。
所以文本斗胆,站在巨人的肩膀上对细节处进行一些补充说明。
1.现有的解决方案的痛点。
2.动态更新的工作原理是什么?
3.动态设置的注意点有哪些?
4.如何动态指定队列长度?
5.这个过程中涉及到的面试题有哪些?
下面从这五点进行展开说明。
现有的解决方案的痛点。
现在市面上大多数的答案都是先区分线程池中的任务是 IO 密集型还是 CPU 密集型。
如果是 CPU 密集型的,可以把核心线程数设置为核心数+1。
为什么要加一呢?
《Java并发编程实战》一书中给出的原因是:即使当计算(CPU)密集型的线程偶尔由于页缺失故障或者其他原因而暂停时,这个“额外”的线程也能确保 CPU 的时钟周期不会被浪费。
看不懂是不是?没关系我也看不懂。反正把它理解为一个备份的线程就行了。
这个地方还有个需要注意的小点就是,如果你的服务器上部署的不止一个应用,你就得考虑其他的应用的线程池配置情况。
经过精密的计算,你咔一下设置为核心数,结果项目部署上去了,发现还有其他的应用在和你抢 CPU,你想想难不难受。
如果是包含 IO 操作的任务呢?这个才是我们关心的东西。
《Java并发编程实战》一书中给出的计算方式是这样的:
理想很丰满,现实很骨感。
我之前有个系统就是按照这个公式算出来的参数去配置的。
结果效果并不好,甚至让下游系统直呼受不了。
这个东西怎么说呢,还是得记住,面试的时候有用。真实场景中只能得到一个参考值,基于这个参考值,再去进行调整。
我们再看一下美团的那篇文章调研的现有解决方案列表:
第一个就是我们上面说的,和实际业务场景有所偏离。
第二个设置为 2*CPU 核心数,有点像是把任务都当做 IO 密集型去处理了。而且一个项目里面一般来说不止一个自定义线程池吧?比如有专门处理数据上送的线程池,有专门处理查询请求的线程池,这样去做一个简单的线程隔离。但是如果都用这样的参数配置的话,显然是不合理的。
第三个不说了,理想状态。流量是不可能这么均衡的,就拿美团来说,下午3,4点的流量,能和 12 点左右午饭时的流量比吗?
基于上面的这些解决方案的痛点,美团给出了动态化配置的解决方案。
动态更新的工作原理是什么?
先来一个动态更新的代码示例:
上面的程序就是自定义了一个核心线程数为 2,最大线程数为 5,队列长度为 10 的线程池。
然后给它塞 15 个耗时 10 秒的任务,直接让它 5 个最大线程都在工作,队列长度 10 个都塞满。
当前的情况下,队列里面的 10 个,前 5 个在 10 秒后会被执行,后 5 个在 20 秒后会被执行。
再加上最大线程数正在执行的 5 个,15 个任务全部执行完全需要 3 个 10 秒即 30 秒的时间。
这个时候,如果我们把核心线程数和最大线程数都修改为 10。
那么 10 个任务会直接被 10 个最大线程数接管,10 秒就会被处理完成。
剩下的 5 个任务会在 10 秒后被执行完成。
所以,15 个任务执行完成需要 2 个 10 秒即 20 秒的时间处理完成了。
看一下上面程序的打印日志:
效果实现了,我先看一下原理是什么。
先看 setCorePoolSize 方法:
这个方法在美团的文章中也说明了:
在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略。
对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idel的时候也会被回收;
对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务,setCorePoolSize具体流程如下:
看了美团的那篇文章后,我又去看了 Spring 的 ThreadPoolTaskExecutor类 (就是对JDK ThreadPoolExecutor 的一层包装,可以理解为装饰者模式)的 setCorePoolSize 方法: 注释上写的清清楚楚,可以在线程池运行时修改该参数。
而且,你再品一品 JDK 的源码,其实源码也体现出了有修改的含义的,两个值去做差值,只是第一次设置的时候原来的值为 0 而已。
哎,当时没有细细研究,恨自己看源码的时候不仔细。
接着看 setMaximumPoolSize 源码:
这个地方就很简单了,逻辑不太复杂。
1.首先是参数合法性校验。
2.然后用传递进来的值,覆盖原来的值。
3.判断工作线程是否是大于最大线程数,如果大于,则对空闲线程发起中断请求。
经过前面两个方法的分析,我们知道了最大线程数和核心线程数可以动态调整。
动态设置的注意点有哪些?
调整的时候可能会出现核心线程数调整之后无效的情况,比如下面这种:
改变之前的核心线程数是 2,最大线程数为 5,我们动态修改核心线程数为 10。
但是从日志还是可以看出,修改之后核心线程数确实变成了 10,但活跃线程数还是为 5。
而且我调用了 prestartCoreThread 方法,该方法见名知意,你也知道是启动所有的核心线程数,所有不存在线程没有创建的问题。
这是为什么呢?
源码之下无秘密,我带你去看一眼:
java.util.concurrent.ThreadPoolExecutor#getTask
在这个方法中我们可以看到,如果工作线程数大于最大线程数,则对工作线程数量进行减一操作,然后返回 null。
所以,这个地方的实际流程应该是: 创建新的工作线程 worker,然后工作线程数进行加一操作。 运行创建的工作线程 worker,开始获取任务 task。 工作线程数量大于最大线程数,对工作线程数进行减一操作。 返回 null,即没有获取到 task。 清理该任务,流程结束。
这样一加一减,所以真正在执行任务的工作线程数的数量一直没有发生变化,也就是最大线程数。
怎么解决这个问题呢?
答案已经呼之欲出啦。
设置核心线程数的时候,同时设置最大线程数即可。其实可以把二者设置为相同的值:
这样,活动线程数就能正常提高了。
有的小伙伴就会问了:如果调整之后把活动线程数设置的值太大了,岂不是业务低峰期我们还需要人工把值调的小一点?
不存在的,还记得前面介绍 corePoolSize 参数的含义时的注解吗:
当 allowCoreThreadTimeOut 参数设置为 true 的时候,核心线程在空闲了 keepAliveTime 的时间后也会被回收的,相当于线程池自动给你动态修改了。
如何动态指定队列长度?
前面介绍了最大线程数和核心线程数的动态设置,但是你发现了吗,并没有设置队列长度的 set 方法啊?
有的小机灵鬼说先获取 Queue 对象出来再看一下呢?
还是没有,这可咋整呢?
首先我们看一下为什么没有提供队列长度的 set 方法呢:
因为队列的 capacity 是被 final 修饰了呀。
但是美团的那篇文章明明说了,他们也支持队列的动态调整呀:
可是没有详细说明,但是别着急,接着看后面的内容可以发现他们有一个名字为 ResizableCapacityLinkedBlockIngQueue 的队列:
很明显,这是一个自定义队列了。
我们也可以按照这个思路自定义一个队列,让其可以对 Capacity 参数进行修改即可。
操作起来也非常方便,把 LinkedBlockingQueue 粘贴一份出来,修改个名字,然后把 Capacity 参数的 final 修饰符去掉,并提供其对应的 get/set 方法。
然后在程序里面把原来的队列换掉:
运行起来看看效果:
可以看到,队列大小确实从 10 变成了 100,队列使用度从 100% 降到了 9%。
我后来去看了美团的那篇文章下面的评论,有个评论是这样的:
果然不出我所料。
这个过程中涉及到的面试题有哪些?
问题一:线程池被创建后里面有线程吗?如果没有的话,你知道有什么方法对线程池进行预热吗?
线程池被创建后如果没有任务过来,里面是不会有线程的。如果需要预热的话可以调用下面的两个方法:
全部启动:
仅启动一个:
问题二:核心线程数会被回收吗?需要什么设置?
核心线程数默认是不会被回收的,如果需要回收核心线程数,需要调用下面的方法:
allowCoreThreadTimeOut 该值默认为 false。
最后说一句(求关注)
点个赞吧,周更很累的,不要白嫖我,需要一点正反馈。
才疏学浅,难免会有纰漏,如果你发现了错误的地方,由于本号没有留言功能,还请你加我微信给我指出来,我对其加以修改。(我每篇技术文章都有这句话,我是认真的说的。)
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。
我是why技术,一个不是大佬,但是喜欢分享,又暖又有料的四川好男人。
欢迎关注公众号【why技术】,坚持输出原创。分享技术、品味生活,愿你我共同进步。