线程池参数千万不要这样设置,坑得我整篇文章都写错了,要注意!

你好呀,我是歪歪。

先给大家道个歉:

上周不是发布了这篇文章嘛:《三个烂怂八股文,变成两个场景题,打得我一脸懵逼。》

其中第一个关于线程池的场景,经过读者提醒可能有问题,我又一次用尽浑身解数分析了一波,发现之前确实分析的不对。

这个案例真的是再一次深入的刷新了我对于线程池运行过程的认知。

而由于我之前写过太多关于线程池的文章,对于线程池的运行过程太过于熟悉,基本熟悉到了源码信手拈来的地步。

所以我再次分析的时候,一度曾怀疑这个问题现象可能是 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,都会触发拒绝策略。

虽然不知道原因,但是经过我对各种参数进行的调整,目前我有两个线索,只有当这两个线索同时满足的时候,就会触发拒绝策略:

  1. 队列长度小于 for 循环中一次放进来的任务数。
  2. 核心线程数和最大线程数个数一致。

虽然还是不知道具体的原因,但是我可以基于上面这两个线索,把参数的值取小一点,把 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. 队列长度小于 for 循环中一次放进来的任务数。
  2. 核心线程数和最大线程数个数一致。

背后的逻辑,就这么简单,可以说是一点就透。

你看到这里,可能只花了五分钟时间。

但是当我定位到这个原因的时候,距离读者提出问题,已经过去了差不多三天时间,这期间,我走了很多弯路。

你看到的,是众多弯路中,唯一正确的一条路线。

而这一切的原因都在于我先入为主的认为,核心线程数大于提交的任务数,所以任务一定能找到对应的线程来进行处理,疏忽了任务是要先进队列的。

验证一波

我们还是简单验证一把。

在我们的场景下,队列长度为 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 同学极力推荐的特色万州烤鱼,她说只是在读书的时候吃到过这个味道。

我当时不以为然,不就是万州烤鱼吗,到处都有啊?吃了第一口之后才发现,确实是只有在温江才能吃到的改良版的味道,好吃。

吃饱之后慢悠悠的往目的地开,山上温度还是很低的,山上的雪还没完全化掉。游客也非常得少,站在山路上停下,没有一点杂音,只能听到虫鸣鸟叫,还有雪化之后,从屋檐滴到水池里面的声音,唯一的不是大自然的声音,只有偶然冒出的一声僧人击钵的空灵而悠远的声音。

很多人都说买车之后生活半径会扩大无数倍,提升生活质量,当时我不以为意,现在看来,确实是至理名言。

久在樊笼里,复得返自然。

posted @ 2024-01-29 12:55  why技术  阅读(2734)  评论(5编辑  收藏  举报