问题再现
先给大家上代码:
这个问题最开始是一个读者提出来,发给我的一个 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,都会触发拒绝策略。
虽然不知道原因,但是经过我对各种参数进行的调整,目前我有两个线索,只有当这两个线索同时满足的时候,就会触发拒绝策略:
虽然还是不知道具体的原因,但是我可以基于上面这两个线索,把参数的值取小一点,把 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)任务的动作,这两个动作之间的先后顺序。
如果核心线程先从队列中拿到任务,那么队列又有空间了,主线程可以继续往队列里面放任务,程序一切正常。
如果主线程往队列里面放任务的动作很快,放完第一个后,还没被消费,立马就开始放第二个,那么队列满了,即使我们知道,核心线程其实是在空闲状态,但是按照线程池的逻辑,会去开启最大线程数,发现最大线程数也没有了,所以触发了拒绝策略。
这个时候,你再回去看我们的“两个线索”的时候,你就明白过来是怎么回事了:
背后的逻辑,就这么简单,可以说是一点就透。
你看到这里,可能只花了五分钟时间。
但是当我定位到这个原因的时候,距离读者提出问题,已经过去了差不多三天时间,
这期间,我走了很多弯路。
你看到的,是众多弯路中,唯一正确的一条路线。
而这一切的原因都在于我先入为主的认为,核心线程数大于提交的任务数,所以任务一定能找到对应的线程来进行处理,疏忽了任务是要先进队列的。
验证一波
我们还是简单验证一把。
在我们的场景下,队列长度为 1,每次放两个任务进来。
既然现在的核心问题在于 offer 和 take 这两个动作的先后顺序上。
如果核心线程的 take 动作,先于主线程第二次 offer 的动作,那么队列有空间,就不会触发拒绝策略。
为了验证这一点,我们需要在 offer 里面加点睡眠时间,拖慢它的处理速度:
也就是这样,在 offer 方法里面,往队列里面放任务的时候,睡一下:
按照我们前面的推理,这样理论上可以达到主线程 offer 一个进去,核心线程就 take 一个出去的效果,程序一定就会正常运行结束。
对不对?