你好呀,我是歪歪。
先给大家道个歉:
上周不是发布了这篇文章嘛:《三个烂怂八股文,变成两个场景题,打得我一脸懵逼。》
其中第一个关于线程池的场景,经过读者提醒可能有问题,我又一次用尽浑身解数分析了一波,发现之前确实分析的不对。
这个案例真的是再一次深入的刷新了我对于线程池运行过程的认知。
而由于我之前写过太多关于线程池的文章,对于线程池的运行过程太过于熟悉,基本熟悉到了源码信手拈来的地步。
所以我再次分析的时候,一度曾怀疑这个问题现象可能是 JDK 的 BUG,在 JDK BUG 库里面翻了一圈也没有发现有人提到过这个问题,我甚至想要发起这个问题。
最后阴差阳错的,还是定位到了问题的原因是线程池使用方面的问题,而问题的原因,最终说起来,极其简单,一点就透。
这一篇文章,歪师傅再次带大家盘一下这个问题。
问题再现
先给大家上代码:
这个问题最开始是一个读者提出来,发给我的一个 Demo,这个代码已经是我精简过的了。
这个代码运行起来会触发线程池的拒绝策略:
重点看一下我们的线程池定义:
private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32));
该线程池核心大小数和最大线程数都是 64,队列长度为 32,也就是说这个线程池同时能容纳的任务数是 64+32=96。
但是从代码可以看出,由于有 countDownLatch 的存在,可以确认 for 循环一次一定只会放 34 个任务进来。
JDK 线程池的运行原理,大家应该都是背的滚瓜烂熟了:先启用核心线程,然后任务进队列,如果队列满了,再启用最大线程数。最大线程数也满了,就触发拒绝策略。
那么按照我个人的理解,因为我们的核心线程数就是 64 个,已经完全大于 34 个任务了,所以线程池完全可以吃下这 34 个任务。
完全没有理由触发拒绝策略啊?
所以,我在之前的文章中给出的结论是:
线程池里面的任务执行完成了,核心线程就一定会释放出来等着接受下一波循环的任务,但是不会立马释放出来。从释放到就绪之间,有一个时间差的存在,导致线程池核心线程数不够用,从而导致触发拒绝策略。
老实说,这个结论从纯理论的角度来说,是真的有可能的。所以我才写了一篇文章去论证它。
而且我还通过重写线程池的 afterExecute 方法,延长了"核心线程收尾的时间"来确保问题复现。
也确实复现了。
但是很遗憾,这个结论在这个案例中是错误的。
之前的文章说了:
"线程池两个工作"和"主线程继续往线程池里面扔任务的动作"之间,没有先后逻辑控制。
我的验证方式是通过延长了"核心线程收尾的时间"来确保问题复现。
但是这里有两个条件,所以其实还有一个验证方式:让"主线程继续往线程池里面扔任务的动作"足够的慢,让线程池有足够的事件去收尾,这样问题就一定不会出现。
然而我忽略了这个验证方式,一心只是想着复现问题。
所以,当读者给我这样的一个代码片段的时候,我直接就是一整个愣住了:
他在主线程中睡了 2s,目的是为了让"主线程继续往线程池里面扔任务的动作"足够的慢:
如果按照我之前的推测,那么线程池是完全足够时间让线程就绪的。
我自己也进行了验证,而且我甚至把时间拉长到 10s,这样也确实是会触发拒绝策略:
看到这个运行结果的时候,我本能上是抗拒的,因为这一行代码的加入,运行结果和我预测的完全相反,相当于直接推翻了我前面的结论。
但是歪师傅写文章这么多年了,还是见过一些大场面的。
于是迅速开始思考原因。
最开始我怀疑这里面的 sleep 动作有问题,于是我直接改成了这样,相当于模拟线程空跑一趟,什么动作都没有做:
但是还是会抛出异常。
然后我又开始怀疑 CountDownLatch,于是我直接去掉了相关的代码,整个代码变成了这样:
public class MyTest {
private static final ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(64, 64,
0, TimeUnit.MINUTES,
new ArrayBlockingQueue<>(32));
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
Thread.sleep(100);
for (int j = 0; j < 34; j++) {
threadPoolExecutor.execute(() -> {
int a = 0;
});
}
System.out.println("===============> 详情任务 - 任务处理完成");
}
System.out.println("都执行完成了");
}
}
这个代码可以说已经非常简单了,除了线程池之外,没有其他的任何干扰项了。
但是,你直接粘过去跑,你会发现,还是会抛出异常:
核心线程数64,队列长度 32,每次往线程池里面扔 34 个任务,对应的任务完全没有任何耗时操作。
这样居然会触发线程池的拒绝策略?
又想起了几年前写文章时由于 idea "bug"遇到的诡异问题,甚至怀疑起了是"质子作祟"。
不知道你看到这里的时候有没有看出什么破绽,或者说新的思路。
反正我对着这份代码盯了一整天,调试了无数次,线程池的问题是真的难以调试,而且是在线程数比较多,没有排查思路的情况下,所以基本上没有什么进展。
峰回路转
事情的转机出现在我实在没有思路,然后开始重新复盘整个问题的时候。
再次翻看和提出这个问题的读者的聊天记录,这句话引起了我的注意:
解决问题的办法就是提高队列的容量。
我也不知道为什么,反正也没有思路,逮着个方向就顺便看看吧。
于是我直接把队列的长度从 32 提升到了 320:
程序立马就正常了:
32 不行,320 就行。
那么会不会存在一个临界值 x,当队列的长度小于 x 的时候,就会出问题,大于等于 x 的时候就一切正常呢?
按照这个思路,我用二分法,很快就定位到了这个 x= 34。
等于 34 啊,朋友,当时我都快兴奋的跳起来了。
34 和我们 for 循环一次往线程池里面扔的任务数是一样的,这里面一定是有内在联系的,虽然我现在还不知道是什么,但是至少也有一条线索了。
然后我又在队列的长度为 33 和 34 之间反复运行了很多次,确认在我的机器上运行, 33 的时候问题会必现,34 的时候程序就能正常完成。
基于这个现象,我得出了一个结论:队列长度小于 for 循环中一次放进来的任务数的时候,就会触发这个现象。
于是我一步步的多次调整参数,最终把参数修改为了这样:
线程池核心线程数还是 64,但是把队列长度修改为一,for 循环一次放两个任务进来。目的是最小程度的减少干扰项,然后神奇的事情就出现。
我现在把这个线程池定义单独拎出来:
来,你说,站在你的认知里面,隔 100ms 往这个线程池中扔两个任务进来。
会触发线程池的拒绝策略吗?
至少在我的认知里面是不可能的。
但是,它真的触发了:
而当我把核心线程数设置为 63,最大线程数保持为 64。或者核心线程数保持为 64,最大线程数修改为 65 时,其他代码都不动,程序均能正常运行。
匪夷所思,太匪夷所思了。
看到这个现象的时候,我直接开始怀疑是 JDK 的 BUG,当核心线程数和最大线程数一致的时候可能会触发,于是我用各种姿势搜了一圈,然而并没有什么收获。
同时我发现,当我保持核心线程数和最大线程数个数一致时,不管这个"个数"是 1 还是 100,都会触发拒绝策略。
虽然不知道原因,但是经过我对各种参数进行的调整,目前我有两个线索,只有当这两个线索同时满足的时候,就会触发拒绝策略:
- 队列长度小于 for 循环中一次放进来的任务数。
- 核心线程数和最大线程数个数一致。
虽然还是不知道具体的原因,但是我可以基于上面这两个线索,把参数的值取小一点,把 Demo 再简化一下,变成这样:
核心线程数等于最大线程数,都是 2,队列长度为 1,按理说这个队列最大可以容纳 3 个任务运行,但是一次性扔 2 个任务进去,会触发拒绝策略。
为什么?
我不知道,但是现在我有一个问题必现的 Demo,而且线程池里面的线程并不多,调试起来会轻松很多。
调试一波
首先我还是怀疑线程池里面的线程在下一次任务到来之前,没有进入到就绪状态。
也就是对应到 getTask 的这个部分:
java.util.concurrent.ThreadPoolExecutor#getTask
如果线程能运行到标号为 ③ 的地方,那么说明一定是就绪了,可以从队列中获取任务。
标号为 ① 的地方又是一个死循环的写法。会不会是在标号为 ② 的这一坨代码里面,有什么问题呢?
怎么验证呢?多线程场景下用 debug 还是很难定位到问题的。
我们可以用一种古老但有效的方法来进行验证:打足够多的日志。
只要我在标号为 ② 的地方,加入足够多的日志,就能帮助我分析代码到底是怎么运行的。
那么问题就来了:这个是 JDK 的源码,我怎么去加日志呢?
在我之前的这篇文章中提到过:《这篇文章关于一个源码调试方法,短小精悍,简单粗暴,但足够好用。》
把源码拷贝一份出来,原模原样的放一份到自己的项目中即可。
就像是这样:
为了区分,我把类粘过来之后,仅仅是修改了一个名字。但是你会发现有些报错的地方.
比如这里有个类型不匹配:
一看,是执行拒绝策略的方法。
不影响我们主要流程,直接参考默认的拒绝策略,抛出异常就行了:
然后就是这些拒绝策略也在报错,直接全部删除就完事了:
最后,你把程序里面的线程池换成你自己的,搞定:
现在,你就可以在 MyThreadPoolExecutor 随便加代码了:
通过控制台可以看到这个地方并没有在循环中多次循环,两个线程直接都运行到了"开始从队列中获取任务"的地方:
也就是都运行到了这个方法:
java.util.concurrent.ArrayBlockingQueue#take
这个方法很关键,指出我前一篇文章有问题的读者,也提到了这个方法:
我也想在这个 take 方法里面加点日志观察一下,同理我也把代码原模原样的粘一份出来,作为我的 MyArrayBlockingQueue,并替换线程池里面的队列:
因为可以确定线程是直接运行到 take 方法了,所以为了减少日志输出干扰,之前加的输出语句全部清除。
然后在 take 里面加这样的输出语句:
take 是消费者,对应的生产者在这个地方:
com.example.tomcatdemo.MyThreadPoolExecutor#execute
同理,我们在生产者这里加几行输出:
最终程序运行起来可以看到这样的日志输出:
线程池里面两个线程在等着队列里面来任务。
然后主线程在往队列里面提交任务。
相当于两个消费者,一个生产者。生产者生产一个,消费者立马就消费了。
这样就不会有任何毛病。
但是,还能看到这样的日志输出:
虽然两个消费者都就绪了,但是主线程往队列里面放了任务之后,任务并没有被及时消费,导致主线程放下一个任务的时候,队列满了。
对于线程池来说,队列满了意味着需要使用最大线程数了。
而在我们的案例里面,最大线程数等于核心线程数。所以没有线程拿来新增了,addWorker(command, false) 方法就会返回 false,所以触发了拒绝策略:
好,现在我再拿着 Demo 给你捋一下啊:
首先线程池的运行逻辑是:先启用核心线程,然后任务进队列,如果队列满了,再启用最大线程数。最大线程数也满了,就触发拒绝策略。
所以,当外层的第一次 for 循环的时候,提交的两个任务会直接启用最大线程数,和队列没有任何关系。
第二次 for 循环开始之后,提交的任务是先进队列,然后线程从队列里面取数据消费。
如果队列的长度只有 1,但是 for 循环一次要提交两个任务的时候,能否放成功,取决于核心线程从队列中拿(take)任务的动作,和主线程往队列里面放(offer)任务的动作,这两个动作之间的先后顺序。
如果核心线程先从队列中拿到任务,那么队列又有空间了,主线程可以继续往队列里面放任务,程序一切正常。
如果主线程往队列里面放任务的动作很快,放完第一个后,还没被消费,立马就开始放第二个,那么队列满了,即使我们知道,核心线程其实是在空闲状态,但是按照线程池的逻辑,会去开启最大线程数,发现最大线程数也没有了,所以触发了拒绝策略。
这个时候,你再回去看我们的"两个线索"的时候,你就明白过来是怎么回事了:
- 队列长度小于 for 循环中一次放进来的任务数。
- 核心线程数和最大线程数个数一致。
背后的逻辑,就这么简单,可以说是一点就透。
你看到这里,可能只花了五分钟时间。
但是当我定位到这个原因的时候,距离读者提出问题,已经过去了差不多三天时间,这期间,我走了很多弯路。
你看到的,是众多弯路中,唯一正确的一条路线。
而这一切的原因都在于我先入为主的认为,核心线程数大于提交的任务数,所以任务一定能找到对应的线程来进行处理,疏忽了任务是要先进队列的。
验证一波
我们还是简单验证一把。
在我们的场景下,队列长度为 1,每次放两个任务进来。
既然现在的核心问题在于 offer 和 take 这两个动作的先后顺序上。
如果核心线程的 take 动作,先于主线程第二次 offer 的动作,那么队列有空间,就不会触发拒绝策略。
为了验证这一点,我们需要在 offer 里面加点睡眠时间,拖慢它的处理速度:
也就是这样,在 offer 方法里面,往队列里面放任务的时候,睡一下:
按照我们前面的推理,这样理论上可以达到主线程 offer 一个进去,核心线程就 take 一个出去的效果,程序一定就会正常运行结束。
对不对?
对个头,不对啊!
你运行起来还是会抛出异常:
为什么,是我们又分析错了吗?
分析没错,只是临门一脚的时候,睡的地方不对。
你来看看这是一个什么宝贝:
offer 和 take 方法都要拿到锁之后才能进行入队、出队的动作。
所以睡一秒的动作,应该发在释放锁之后,否则主线程抱着着锁睡,核心线程只有干着急了:
这样,程序一定能正常运行结束。
同时,吸取了前一篇文章的教训,另外一个方向我也需要验证一下:
在 take 释放锁之后也睡一秒,模拟 take 操作慢,offer 塞满队列的情况。
这个情况,按照我们前面的分析,一定就会抛出异常:
至此,问题得到解决。
通过这次问题排除,也让我对于线程池参数的设置有了新的认知。
尽量不要把线程池的核心线程数和最大线程数设置的一样,把阻塞队列的长度设置得大一些,至少保证阻塞队列本身的长度大于一次提交进来的任务数,而不要做出线程数加上队列长度才勉强容纳单批次任务数,这么极端的长度参数。
另外,我也突然想到了线程池的 newFixedThreadPool 方法,不就是核心线程数等于最大线程数吗,它怎么没有问题呢?
看一下源码:
人家的队列用的是无参的 LinkedBlockingQueue,队列长度是 Integer.MAX_VALUE,当然不会有问题了。
另外,线程池里面还有这样的一个方法 newCachedThreadPool:
把核心线程数设置为 0,最大线程数放的无线大,超过 60s 空闲则回收线程,通过这个方式防止线程膨胀。
但是我的关注点其实在于它的队列,用的是 SynchronousQueue。
这个队列很有意思,它的工作过程是放一个进去之后,必须要拿走,才能放下一个。你可以理解它是一个通道,不存储任何元素,只是负责传递数据,它的队列长度是 0。
所以回到我们的场景中,如果我们的队列用的是它:
也不会触发到拒绝策略,程序也能正常运行结束。
现在我们知道的问题的原因,站在纯技术的角度,我们有非常多的方法来规避这个问题。但是具体怎么使用,还是得结合业务场景来看。
回顾
左边是最开始的代码,右边是最后定位问题的代码:
从左边到右边,我写了两篇文章,付出了很多的时间,经过了无数次的调试,一直在思维定时里面没有走出来,所以走了很多的弯路。
其实回顾整个问题的原因,一句话就能说清楚:
一次性提交的任务数量大于队列长度就有可能会触发。因为线程池核心线程都启动之后,任务提交都是先进队列。当你把最大线程数设置等于核心线程数时,根本就没有最大线程数可以用,所以会触发拒绝策略。当你把最大线程数设置大于核心线程数时,在最大线程数用完了的情况下,会触发拒绝策略。
但是,朋友,其实原因一点都不重要,当然定位到原因的时候我其实挺开心的。
我开心并不是因为找到了问题的原因,而是我觉得我在这个过程中付出的时间和无数次的调试,包括在这个过程中走过的所有弯路都是有意义的。
我写这篇文章是因为有读者读了我前一篇文章,发现有问题,告诉了我,让我有机会知道自己分析的有问题。
我写下这篇文章来记录找到问题的过程并分享出去,告诉大家我前一篇文章写的不对。
找问题的过程、方式和思考比最终的结论重要的多。这是一个相互学习,共同进步的过程,这比找到问题的原因,让我觉得更加有意义。
解决问题不厉害,因为当一个问题提出来的时候,它就已经被解决了。厉害的是带着怀疑的态度去看文章,结合自己的思考,然后提出问题。
带着质疑的眼光看代码,带着求真的态度去探索,与君共勉之。
好啦,本文的技术部分就到这里了。
下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。
荒腔走板
成都有个地方叫做崇州,崇州有个景点叫做街子古镇,街子古镇在山脚,山上 5km 远的地方有一个禅院,叫做严光禅院。
读大学的时候我骑自行车去过一次,印象比较深刻,因为盘山路,上山的路很陡,骑车很费劲,有些发卡弯,得站起来骑。
街子古镇人山人海,严光禅院香火不旺。
当年好不容易骑上去,就随便再佛祖面前许了个愿:希望 Max 同学能顺利考上研究生。
后来我给她说起这个事情的时候,她问:那你后来去还愿了没?
我说坡太陡了,难得骑,就没有再去过了。
这个周末和 Max 同学以练车的名义跑了一趟,许愿的人带着当年被许愿的人一起来一趟,就当是还愿了。
去的路上还特意拐到西财,吃了 Max 同学极力推荐的特色万州烤鱼,她说只是在读书的时候吃到过这个味道。
我当时不以为然,不就是万州烤鱼吗,到处都有啊?吃了第一口之后才发现,确实是只有在温江才能吃到的改良版的味道,好吃。
吃饱之后慢悠悠的往目的地开,山上温度还是很低的,山上的雪还没完全化掉。游客也非常得少,站在山路上停下,没有一点杂音,只能听到虫鸣鸟叫,还有雪化之后,从屋檐滴到水池里面的声音,唯一的不是大自然的声音,只有偶然冒出的一声僧人击钵的空灵而悠远的声音。
很多人都说买车之后生活半径会扩大无数倍,提升生活质量,当时我不以为意,现在看来,确实是至理名言。
久在樊笼里,复得返自然。