3

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

 7 months ago
source link: https://www.cnblogs.com/thisiswhy/p/17994272
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

你好呀,我是歪歪。

先给大家道个歉:

20240126211640.png

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

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

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

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

所以我再次分析的时候,一度曾怀疑这个问题现象可能是 JDK 的 BUG,在 JDK BUG 库里面翻了一圈也没有发现有人提到过这个问题,我甚至想要发起这个问题。

最后阴差阳错的,还是定位到了问题的原因是线程池使用方面的问题,而问题的原因,最终说起来,极其简单,一点就透。

这一篇文章,歪师傅再次带大家盘一下这个问题。

先给大家上代码:

20240126211901.png

这个问题最开始是一个读者提出来,发给我的一个 Demo,这个代码已经是我精简过的了。

这个代码运行起来会触发线程池的拒绝策略:

20240126211943.png

重点看一下我们的线程池定义:

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 个任务。

完全没有理由触发拒绝策略啊?

所以,我在之前的文章中给出的结论是:

线程池里面的任务执行完成了,核心线程就一定会释放出来等着接受下一波循环的任务,但是不会立马释放出来。从释放到就绪之间,有一个时间差的存在,导致线程池核心线程数不够用,从而导致触发拒绝策略。

20240126213620.png

老实说,这个结论从纯理论的角度来说,是真的有可能的。所以我才写了一篇文章去论证它。

而且我还通过重写线程池的 afterExecute 方法,延长了“核心线程收尾的时间”来确保问题复现。

也确实复现了。

但是很遗憾,这个结论在这个案例中是错误的。

之前的文章说了:

“线程池两个工作”和“主线程继续往线程池里面扔任务的动作”之间,没有先后逻辑控制。

我的验证方式是通过延长了“核心线程收尾的时间”来确保问题复现。

但是这里有两个条件,所以其实还有一个验证方式:让“主线程继续往线程池里面扔任务的动作”足够的慢,让线程池有足够的事件去收尾,这样问题就一定不会出现。

然而我忽略了这个验证方式,一心只是想着复现问题。

所以,当读者给我这样的一个代码片段的时候,我直接就是一整个愣住了:

1706276662336.png

他在主线程中睡了 2s,目的是为了让“主线程继续往线程池里面扔任务的动作”足够的慢:

3c0d8d79d404a47fb90b577b5732cc1.png

如果按照我之前的推测,那么线程池是完全足够时间让线程就绪的。

我自己也进行了验证,而且我甚至把时间拉长到 10s,这样也确实是会触发拒绝策略:

20240126231346.png

看到这个运行结果的时候,我本能上是抗拒的,因为这一行代码的加入,运行结果和我预测的完全相反,相当于直接推翻了我前面的结论。

但是歪师傅写文章这么多年了,还是见过一些大场面的。

20240126231820.png

于是迅速开始思考原因。

最开始我怀疑这里面的 sleep 动作有问题,于是我直接改成了这样,相当于模拟线程空跑一趟,什么动作都没有做:

20240126231951.png

但是还是会抛出异常。

然后我又开始怀疑 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("都执行完成了");
    }
}

这个代码可以说已经非常简单了,除了线程池之外,没有其他的任何干扰项了。

但是,你直接粘过去跑,你会发现,还是会抛出异常:

20240126232452.png

核心线程数64,队列长度 32,每次往线程池里面扔 34 个任务,对应的任务完全没有任何耗时操作。

这样居然会触发线程池的拒绝策略?

又想起了几年前写文章时由于 idea “bug”遇到的诡异问题,甚至怀疑起了是“质子作祟”。

20240126231802.png

不知道你看到这里的时候有没有看出什么破绽,或者说新的思路。

反正我对着这份代码盯了一整天,调试了无数次,线程池的问题是真的难以调试,而且是在线程数比较多,没有排查思路的情况下,所以基本上没有什么进展。

事情的转机出现在我实在没有思路,然后开始重新复盘整个问题的时候。

再次翻看和提出这个问题的读者的聊天记录,这句话引起了我的注意:

1706283043445.png

解决问题的办法就是提高队列的容量。

我也不知道为什么,反正也没有思路,逮着个方向就顺便看看吧。

于是我直接把队列的长度从 32 提升到了 320:

20240126233201.png

程序立马就正常了:

20240126233511.png

32 不行,320 就行。

那么会不会存在一个临界值 x,当队列的长度小于 x 的时候,就会出问题,大于等于 x 的时候就一切正常呢?

按照这个思路,我用二分法,很快就定位到了这个 x= 34。

等于 34 啊,朋友,当时我都快兴奋的跳起来了。

34 和我们 for 循环一次往线程池里面扔的任务数是一样的,这里面一定是有内在联系的,虽然我现在还不知道是什么,但是至少也有一条线索了。

然后我又在队列的长度为 33 和 34 之间反复运行了很多次,确认在我的机器上运行, 33 的时候问题会必现,34 的时候程序就能正常完成。

基于这个现象,我得出了一个结论:队列长度小于 for 循环中一次放进来的任务数的时候,就会触发这个现象。

于是我一步步的多次调整参数,最终把参数修改为了这样:

20240127103503.png

线程池核心线程数还是 64,但是把队列长度修改为一,for 循环一次放两个任务进来。目的是最小程度的减少干扰项,然后神奇的事情就出现。

我现在把这个线程池定义单独拎出来:

20240127104128.png

来,你说,站在你的认知里面,隔 100ms 往这个线程池中扔两个任务进来。

会触发线程池的拒绝策略吗?

至少在我的认知里面是不可能的。

但是,它真的触发了:

20240127202730.png

而当我把核心线程数设置为 63,最大线程数保持为 64。或者核心线程数保持为 64,最大线程数修改为 65 时,其他代码都不动,程序均能正常运行。

匪夷所思,太匪夷所思了。

20240128195903.png

看到这个现象的时候,我直接开始怀疑是 JDK 的 BUG,当核心线程数和最大线程数一致的时候可能会触发,于是我用各种姿势搜了一圈,然而并没有什么收获。

同时我发现,当我保持核心线程数和最大线程数个数一致时,不管这个“个数”是 1 还是 100,都会触发拒绝策略。

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

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

虽然还是不知道具体的原因,但是我可以基于上面这两个线索,把参数的值取小一点,把 Demo 再简化一下,变成这样:

20240127205109.png

核心线程数等于最大线程数,都是 2,队列长度为 1,按理说这个队列最大可以容纳 3 个任务运行,但是一次性扔 2 个任务进去,会触发拒绝策略。

我不知道,但是现在我有一个问题必现的 Demo,而且线程池里面的线程并不多,调试起来会轻松很多。

首先我还是怀疑线程池里面的线程在下一次任务到来之前,没有进入到就绪状态。

也就是对应到 getTask 的这个部分:

java.util.concurrent.ThreadPoolExecutor#getTask

20240127211722.png

如果线程能运行到标号为 ③ 的地方,那么说明一定是就绪了,可以从队列中获取任务。

标号为 ① 的地方又是一个死循环的写法。会不会是在标号为 ② 的这一坨代码里面,有什么问题呢?

怎么验证呢?多线程场景下用 debug 还是很难定位到问题的。

我们可以用一种古老但有效的方法来进行验证:打足够多的日志。

只要我在标号为 ② 的地方,加入足够多的日志,就能帮助我分析代码到底是怎么运行的。

那么问题就来了:这个是 JDK 的源码,我怎么去加日志呢?

在我之前的这篇文章中提到过:《这篇文章关于一个源码调试方法,短小精悍,简单粗暴,但足够好用。》

把源码拷贝一份出来,原模原样的放一份到自己的项目中即可。

就像是这样:

20240127213741.png

为了区分,我把类粘过来之后,仅仅是修改了一个名字。但是你会发现有些报错的地方.

比如这里有个类型不匹配:

20240127213933.png

一看,是执行拒绝策略的方法。

不影响我们主要流程,直接参考默认的拒绝策略,抛出异常就行了:

20240127214101.png

然后就是这些拒绝策略也在报错,直接全部删除就完事了:

20240127214217.png

最后,你把程序里面的线程池换成你自己的,搞定:

20240127214329.png

现在,你就可以在 MyThreadPoolExecutor 随便加代码了:

20240127220255.png

通过控制台可以看到这个地方并没有在循环中多次循环,两个线程直接都运行到了“开始从队列中获取任务”的地方:

20240127220450.png

也就是都运行到了这个方法:

java.util.concurrent.ArrayBlockingQueue#take

20240127220652.png

这个方法很关键,指出我前一篇文章有问题的读者,也提到了这个方法:

20240127223231.png

我也想在这个 take 方法里面加点日志观察一下,同理我也把代码原模原样的粘一份出来,作为我的 MyArrayBlockingQueue,并替换线程池里面的队列:

20240127220859.png

因为可以确定线程是直接运行到 take 方法了,所以为了减少日志输出干扰,之前加的输出语句全部清除。

然后在 take 里面加这样的输出语句:

20240127221819.png

take 是消费者,对应的生产者在这个地方:

com.example.tomcatdemo.MyThreadPoolExecutor#execute

20240127222550.png

同理,我们在生产者这里加几行输出:

20240127223519.png

最终程序运行起来可以看到这样的日志输出:

20240127224449.png

线程池里面两个线程在等着队列里面来任务。

然后主线程在往队列里面提交任务。

相当于两个消费者,一个生产者。生产者生产一个,消费者立马就消费了。

这样就不会有任何毛病。

但是,还能看到这样的日志输出:

20240127224341.png

虽然两个消费者都就绪了,但是主线程往队列里面放了任务之后,任务并没有被及时消费,导致主线程放下一个任务的时候,队列满了。

对于线程池来说,队列满了意味着需要使用最大线程数了。

而在我们的案例里面,最大线程数等于核心线程数。所以没有线程拿来新增了,addWorker(command, false) 方法就会返回 false,所以触发了拒绝策略:

20240127230006.png

好,现在我再拿着 Demo 给你捋一下啊:

20240127230110.png

首先线程池的运行逻辑是:先启用核心线程,然后任务进队列,如果队列满了,再启用最大线程数。最大线程数也满了,就触发拒绝策略。

所以,当外层的第一次 for 循环的时候,提交的两个任务会直接启用最大线程数,和队列没有任何关系。

第二次 for 循环开始之后,提交的任务是先进队列,然后线程从队列里面取数据消费。

如果队列的长度只有 1,但是 for 循环一次要提交两个任务的时候,能否放成功,取决于核心线程从队列中拿(take)任务的动作,和主线程往队列里面放(offer)任务的动作,这两个动作之间的先后顺序。

20240128100827.png

如果核心线程先从队列中拿到任务,那么队列又有空间了,主线程可以继续往队列里面放任务,程序一切正常。

如果主线程往队列里面放任务的动作很快,放完第一个后,还没被消费,立马就开始放第二个,那么队列满了,即使我们知道,核心线程其实是在空闲状态,但是按照线程池的逻辑,会去开启最大线程数,发现最大线程数也没有了,所以触发了拒绝策略。

这个时候,你再回去看我们的“两个线索”的时候,你就明白过来是怎么回事了:

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

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

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

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

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

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

我们还是简单验证一把。

在我们的场景下,队列长度为 1,每次放两个任务进来。

既然现在的核心问题在于 offer 和 take 这两个动作的先后顺序上。

如果核心线程的 take 动作,先于主线程第二次 offer 的动作,那么队列有空间,就不会触发拒绝策略。

为了验证这一点,我们需要在 offer 里面加点睡眠时间,拖慢它的处理速度:

20240128101808.png

也就是这样,在 offer 方法里面,往队列里面放任务的时候,睡一下:

20240128102018.png

按照我们前面的推理,这样理论上可以达到主线程 offer 一个进去,核心线程就 take 一个出去的效果,程序一定就会正常运行结束。

20240128200434.png

对个头,不对啊!

你运行起来还是会抛出异常:

20240128102555.png

为什么,是我们又分析错了吗?

分析没错,只是临门一脚的时候,睡的地方不对。

你来看看这是一个什么宝贝:

20240128102747.png

offer 和 take 方法都要拿到锁之后才能进行入队、出队的动作。

所以睡一秒的动作,应该发在释放锁之后,否则主线程抱着着锁睡,核心线程只有干着急了:

20240128133815.png
20240128135432.png

这样,程序一定能正常运行结束。

同时,吸取了前一篇文章的教训,另外一个方向我也需要验证一下:

20240128135713.png

在 take 释放锁之后也睡一秒,模拟 take 操作慢,offer 塞满队列的情况。

20240128140615.png

这个情况,按照我们前面的分析,一定就会抛出异常:

20240128140736.png

至此,问题得到解决。

通过这次问题排除,也让我对于线程池参数的设置有了新的认知。

尽量不要把线程池的核心线程数和最大线程数设置的一样,把阻塞队列的长度设置得大一些,至少保证阻塞队列本身的长度大于一次提交进来的任务数,而不要做出线程数加上队列长度才勉强容纳单批次任务数,这么极端的长度参数。

另外,我也突然想到了线程池的 newFixedThreadPool 方法,不就是核心线程数等于最大线程数吗,它怎么没有问题呢?

看一下源码:

20240128191732.png

人家的队列用的是无参的 LinkedBlockingQueue,队列长度是 Integer.MAX_VALUE,当然不会有问题了。

另外,线程池里面还有这样的一个方法 newCachedThreadPool:

20240128192040.png

把核心线程数设置为 0,最大线程数放的无线大,超过 60s 空闲则回收线程,通过这个方式防止线程膨胀。

但是我的关注点其实在于它的队列,用的是 SynchronousQueue。

这个队列很有意思,它的工作过程是放一个进去之后,必须要拿走,才能放下一个。你可以理解它是一个通道,不存储任何元素,只是负责传递数据,它的队列长度是 0。

所以回到我们的场景中,如果我们的队列用的是它:

20240128192400.png

也不会触发到拒绝策略,程序也能正常运行结束。

现在我们知道的问题的原因,站在纯技术的角度,我们有非常多的方法来规避这个问题。但是具体怎么使用,还是得结合业务场景来看。

左边是最开始的代码,右边是最后定位问题的代码:

20240128195156.png

从左边到右边,我写了两篇文章,付出了很多的时间,经过了无数次的调试,一直在思维定时里面没有走出来,所以走了很多的弯路。

其实回顾整个问题的原因,一句话就能说清楚:

一次性提交的任务数量大于队列长度就有可能会触发。因为线程池核心线程都启动之后,任务提交都是先进队列。当你把最大线程数设置等于核心线程数时,根本就没有最大线程数可以用,所以会触发拒绝策略。当你把最大线程数设置大于核心线程数时,在最大线程数用完了的情况下,会触发拒绝策略。

但是,朋友,其实原因一点都不重要,当然定位到原因的时候我其实挺开心的。

我开心并不是因为找到了问题的原因,而是我觉得我在这个过程中付出的时间和无数次的调试,包括在这个过程中走过的所有弯路都是有意义的。

我写这篇文章是因为有读者读了我前一篇文章,发现有问题,告诉了我,让我有机会知道自己分析的有问题。

我写下这篇文章来记录找到问题的过程并分享出去,告诉大家我前一篇文章写的不对。

找问题的过程、方式和思考比最终的结论重要的多。这是一个相互学习,共同进步的过程,这比找到问题的原因,让我觉得更加有意义。

解决问题不厉害,因为当一个问题提出来的时候,它就已经被解决了。厉害的是带着怀疑的态度去看文章,结合自己的思考,然后提出问题。

带着质疑的眼光看代码,带着求真的态度去探索,与君共勉之。

20240128194820.png

好啦,本文的技术部分就到这里了。

下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。

3affa8d31b563207c411ec51d86f14d.jpg

成都有个地方叫做崇州,崇州有个景点叫做街子古镇,街子古镇在山脚,山上 5km 远的地方有一个禅院,叫做严光禅院。

读大学的时候我骑自行车去过一次,印象比较深刻,因为盘山路,上山的路很陡,骑车很费劲,有些发卡弯,得站起来骑。

街子古镇人山人海,严光禅院香火不旺。

当年好不容易骑上去,就随便再佛祖面前许了个愿:希望 Max 同学能顺利考上研究生。

后来我给她说起这个事情的时候,她问:那你后来去还愿了没?

我说坡太陡了,难得骑,就没有再去过了。

这个周末和 Max 同学以练车的名义跑了一趟,许愿的人带着当年被许愿的人一起来一趟,就当是还愿了。

去的路上还特意拐到西财,吃了 Max 同学极力推荐的特色万州烤鱼,她说只是在读书的时候吃到过这个味道。

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

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

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK