专栏名称: ImportNew
伯乐在线旗下账号,专注Java技术分享,包括Java基础技术、进阶技能、架构设计和Java技术领域动态等。
目录
相关文章推荐
厦门日报  ·  备受瞩目!福建这场县域经济盛会即将召开 ·  14 小时前  
厦门日报  ·  蛇年上班首日,百亿级新能源项目正式开工! ·  22 小时前  
厦门日报  ·  谷歌,被立案调查! ·  昨天  
51好读  ›  专栏  ›  ImportNew

深藏在“八股文”里的 bug

ImportNew  · 公众号  ·  · 2024-03-31 11:30

正文

这篇文章来盘一下我最近遇到的两个有意思的代码案例,有意思的点在于,拿到代码后,你一眼望去,没有任何毛病。 然后一顿分析,会发现破绽藏的还比较的深。


几个基础招式的一套组合拳下来,直接把我打懵逼了。


你也来看看,是不是你跺你也麻。



第一个场景


首先第一个是这样的:



一个读者给我发来的一个关于线程池使用的疑问,同时附上了一个可以复现问题的 Demo。


我打开 Demo 一看,一共就这几行代码,结合问题描述来看想着应该不是啥复杂的问题:



我拿过来 Demo,根本就没看代码,直接扔到 IDEA 里面跑了两次,想着是先看看具体报错是什么,然后再去分析代码。


但是两次程序都正常结束了。


好吧,既然没有异常,我也大概的瞅了一眼 Demo,重点关注在了 CountDownLatch 的用法上。


我是横看竖看也没看出问题,因为我一直都是这样用的,这就是正确的用法啊。


于是从拿到 Demo 到定位问题,不到两分钟,我直接得出了一个大胆的结论,那就是:常规用法,没有问题:



然后我们就结束了这次对话。


过了一会,我准备关闭 IDEA 了。鬼使神差的,我又点了一次运行。


你猜怎么着?


居然真的报错了,抛出了 rejectedExecution 异常,意思是线程池满了。



哦哟,这就有点意思了。



带大家一起盘一盘。


首先我们还是过一下代码,为了减少干扰项,便于理解,我把他给我的 Demo 稍微简化了一点,但是整体逻辑没有发生任何变化。


简化后的完整代码是这样的,你直接粘过去,引入一个 Guava 的包就能跑:


import com.google.common.collect.Lists;
import java.util.ArrayList;import java.util.List;import java.util.concurrent.*;
public class Test {
private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32));
public static void main(String[] args) { List list = new ArrayList<>(); for (int i = 0; i < 400; i++) { list.add(i); } for (int i = 0; i < 100; i++) { List> sublist = Lists.partition(list, 400 / 32); int n = sublist.size(); CountDownLatch countDownLatch = new CountDownLatch(n); for (int j = 0; j < n; j++) { threadPoolExecutor.execute(() -> { try { Thread.sleep(1000); } catch (Exception e) { e.printStackTrace(); } finally { countDownLatch.countDown(); } }); } try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("===============> 详情任务 - 任务处理完成"); } System.out.println("都执行完成了"); }}/** * * com.google.guava * guava * 31.1-jre * */

一起分析一波代码啊。


首先定义了一个线程池:


private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32));


该线程池核心大小数和最大线程数都是 64,队列长度为 32,也就是说这个线程池同时能容纳的任务数是 64+32=96。


main 方法里面是这样的:



在实际代码中,肯定是有具体的业务含义的,这里为了脱敏,就用 List 来表示一下,这个点你知道就行。


编号为 ① 的地方,是在给往 list 里面放 400 个数据,你可以认为是 400 个任务。


编号为 ② 的地方,这个 List 是 guava 的 List,含义是把 400 个任务拆分开,每一组有 400/32=12.5 个任务,向下取整,就是 12 个。


具体是什么个意思呢,我给你看一下 debug 的截图你就知道了:



400 个任务分组,每一组 12 个任务,那就可以拆出来 34 组,最后一组只有 4 个任务:



但是这都不重要,一点都不重要好吧。


因为后续他根本就没有用这个 list ,只是用到了 size 的大小,即 34 。


所以你甚至还能拿到一个更加简洁的代码:


为什么我最开始的时候不直接给你这个最简化的代码,甚至还让你多引入一个包呢?

因为歪师傅就是想体现这个简化代码的过程。


按照我写文章的经验,在定位问题的时候,一定要尽量多的减少干扰项。排除干扰项的过程,也是梳理问题的过程,很多问题在排除干扰项的时候,就逐渐的能摸清楚大概是怎么回事儿。


如果你遇到一个让你摸不着头脑的问题,那就先从排除干扰项做起。


好了,说回我们的代码。现在我们的代码就只有这几行了,核心逻辑就是我圈起来的这个方法:



而圈起来这个部分,主要是线程池结合 CountDownLatch 的使用。


对于 CountDownLatch 我一般只关注两个地方。


  • 第一个是 new 的时候传入的“令牌数”和调用 countDown 方法的次数能不能匹配上。只有保持一致,程序才能正常运行。

  • 第二个地方就是 countDown 方法的调用是不是在 finally 方法里面。

  • 这两个点,在 Demo 中都是正确的。


所以现在从程序分析不出来问题,我们怎么办?



那就从异常信息往回推算。


我们的异常信息是什么?


触发了线程池拒绝策略:



什么时候会出现线程池拒绝策略呢?


核心线程数用完了,队列满了,最大线程数也用完了的时候。


但是按理来说,由于有 countDownLatch.await() 的存在,在执行完 for 循环中的 34 次 countDownLatch.countDown() 方法之前,主线程一定是阻塞等待的。


而 countDownLatch.countDown() 方法在 finally 方法中调用,如果主线程继续运行,执行外层的 for 循环,放新的任务进来,那说明线程池里面的任务也一定执行完成了。


线程池里面的任务执行完成了,那么核心线程就一定会释放出来等着接受下一波循环的任务。


这样捋下来,感觉还是没毛病啊?



除非线程池里面的任务执行完成了,核心线程就一定会释放出来等着接受下一波循环的任务,但是不会立马释放出来


什么意思呢?


就是当一个核心线程执行完成任务之后,到它进入下一次可以开始处理任务的状态之间,有时间差。



而由于这个时间差的存在,导致第一波的核心线程虽然全部执行完成了 countDownLatch.countDown(),让主线程继续运行下去。但是,在线程池中还有少量线程未再次进入“可以处理任务”的状态,还在进行一些收尾的工作。


从而导致,第二波任务进来的时候,需要开启新的核心线程数来执行。


放进来的任务速度,快于核心线程的“收尾工作”的时间,最终导致线程池满了,触发拒绝策略。


需要说明的是,这个原因都是基于我个人的猜想和推测。这个结论不一定真的正确,但是伟人曾经说过:大胆假设,小心求证。


所以,为了证明这个猜想,我需要找到实锤证据。


从哪里找实锤呢?


源码之下,无秘密。


当我有了这个猜想之后,我立马就想到了线程池的这个方法:


java.util.concurrent.ThreadPoolExecutor#runWorker




标号为 ① 的地方是执行线程 run 方法,也就是这一行代码执行完成之后,一个任务就算是执行完成了。对应到我们的 Demo 也就是这部分执行完成了:



这部分执行完成了,countDownLatch.countDown() 方法也执行完成了。

但是这个核心线程还没跑完呢,它还要继续往下走,执行标号为 ② 和 ③ 处的收尾工作。


在核心线程执行“收尾工作”时,主线程又咔咔就跑起来了,下一波任务就扔进来了。

这不就是时间差吗?



另外,我再问一个问题:线程池里面的一个线程是什么时候处于“来吧,哥们,我可以处理任务了”的状态的?


是不是要执行到红框框着的这个地方 WAITING 着:


java.util.concurrent.ThreadPoolExecutor#getTask



那在执行到这个红框框之前,还有一大坨代码呢,它们不是收尾工作,属于“就绪准备工作”。



现在我们再捋一捋啊。


线程池里面的一个线程在执行完成任务之后,到下一次可以执行任务的状态之间,有一个“收尾工作”和“就绪准备工作”,这两个工作都是非常快就可以执行完成的。


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


从程序上讲,这是两个独立的线程逻辑,谁先谁后,都有可能。


如果“两个工作”先完成,那么后面扔进来的任务一定是可以复用线程的,不会触发新开线程的逻辑,也就不会触发拒绝策略。


如果“主线程继续往线程池里面扔任务的动作”先完成,那么就会先开启新线程,从而有可能触发拒绝策略。


所以最终的执行结果可能是不报错,也可能是抛出异常。


同时也回答了这个问题:为什么提高线程池的队列长度,就不抛出异常了?



因为队列长度越长,核心线程数不够的时候,任务大不了在队列里面堆着。而且只会堆一小会儿,但是这一小会,给了核心线程足够的时间去完成“两个工作”,然后就能开始消耗队列里面的任务。


另外,提出问题的小伙伴说换成 Tomcat 的线程池就不会被拒绝了:



也是同理,因为 Tomcat 的线程池重写了拒绝策略,一个任务被拒绝之后会进行重试,尝试把任务仍回到队列中去,重试是有可能会成功的。


对应的源码是这个部分:


org.apache.tomcat.util.threads.ThreadPoolExecutor#execute(java.lang.Runnable, long, java.util.concurrent.TimeUnit)


这就是我从源码中找到的实锤。


但是我觉得锤的还不够死,我得想办法让这个问题必现一下。


怎么弄呢?


如果要让问题必现,那么就是延长“核心线程完成两个工作”的时间,让主线程扔任务的动作”的动作先于它完成。


很简单,看这里,afterExecute 方法:



线程池给你留了一个统计数据的口子,我们就可以基于这个口子搞事情嘛,比如睡一下下:


private static final ThreadPoolExecutor threadPoolExecutor =    new ThreadPoolExecutor(        64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32)) {      @Override      protected void afterExecute(Runnable r, Throwable t) {        try {          Thread.sleep(5000);        } catch (InterruptedException e) {          throw new RuntimeException(e);        }      }    };


由于收尾任务的时间过长,这样“主线程扔任务的动作”有极大概率的是先执行的,导致触发拒绝策略:



到这里,这个问题其实就算是分析完成了。


但是我还想分享一个我在验证过程中的一个验证思路,虽然这个思路最终并没有得到我想要的结论,但是技多不压身,你抽空学学,以后万一用得上呢。


前面说了,在我重写了 afterExecute 方法之后,一定会触发拒绝策略。


那么我在触发拒绝策略的时候,dump 一把线程,通过 dump 文件观察线程状态,是不是就可以看到线程池里面的线程,可能还在 RUNNING 状态,但是是在执行“两个工作”呢?


于是就有了这样的代码:



我自定义了一个拒绝策略,在触发拒绝策略的时候,dump 一把线程池:



但是很不幸,最终 dump 出来的结果并不是我期望的,线程池里面的线程,不是在 TIMED_WAITING 状态就是在 WAITING 状态,没有一个是 RUNNING 的。

为什么?


很简单,因为在触发拒绝策略之后,dump 完成之前,这之间代码执行的时间,完全够线程池里面的线程完成“两个工作”。


虽然你 dump 了,但是还是晚了一点。


这一点,可以通过在 dump 前面输出一点日志进行观察验证:



虽然我没有通过 dump 文件验证到我的观点,但是你可以学习一下这个手段。

在正常的业务逻辑中触发拒绝策略的时候,可以 dump 一把,方便你分析。


那么问题就来了?


怎么去 dump 呢?


关键代码就这一行:


JVMUtil.jstack(jStackStream);



这个方法其实是 Dubbo 里面的一个工具,我只是引用了一下 Dubbo 的包:



但是你完全可以把这个工具类粘出去,粘到你的项目中去。


你的代码很好,现在它是我的了。



最后,我还是必须要再补充一句:


以上从问题的定位到问题的复现,都是基于我个人的分析,从猜测出发,最终进行验证的。有可能我猜错了,那么整个论证过程可能都是错的。你可以把 Demo 粘过去跑一跑,带着怀疑一切的眼光去审视它,如果你有不同的看法,可以告诉我,我也学习一下。


最后,你想想整个过程。


拆开了看,无非是线程池和 CountDownLatch 的八股文的考察,这两个玩意都是面试热点考察部分,大家应该都背的滚瓜烂熟。


在实际工作中,这两个东西碰撞在一起也是经常有的写法,但是没想到的是,在套上一层简单的 for 循环之后,完全就变成了一个复杂的问题了。


这玩意着实是把我打懵逼了。以后把 CountDownLatch 放在 for 循环里面的场景,都需要多多注意一下了。


第二个场景





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