专栏名称: ImportNew
伯乐在线旗下账号,专注Java技术分享,包括Java基础技术、进阶技能、架构设计和Java技术领域动态等。
目录
相关文章推荐
气象北京  ·  本周强冷空气进京 ... ·  昨天  
北京吃货小分队  ·  北京这座宫殿 · 560年首次开放 ·  5 天前  
气象北京  ·  2025年春节期间北京地区天气预报 ·  4 天前  
气象北京  ·  森林体验指数预报(2025-2-1) ·  4 天前  
51好读  ›  专栏  ›  ImportNew

Bug 复盘:线程池参数设置踩坑

ImportNew  · 公众号  ·  · 2024-02-22 11:30

正文

问题再现


先给大家上代码:



这个问题最开始是一个读者提出来,发给我的一个 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 一个出去的效果,程序一定就会正常运行结束。


对不对?








请到「今天看啥」查看全文