你好呀,我是歪歪。
这篇文章来盘一下我最近遇到的两个有意思的代码案例,有意思的点在于,拿到代码后,你一眼望去,没有任何毛病。然后一顿分析,会发现破绽藏的还比较的深。
几个基础招式的一套组合拳下来,直接把我打懵逼了。
你也来看看,是不是你跺你也麻。
首先第一个是这样的:
一个读者给我发来的一个关于线程池使用的疑问,同时附上了一个可以复现问题的 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<Integer> list = new ArrayList<>(); for (int i = 0; i < 400; i++) { list.add(i); } for (int i = 0; i < 100; i++) { List<List<Integer>> 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("都执行完成了"); } } /** * <dependency> * <groupId>com.google.guava</groupId> * <artifactId>guava</artifactId> * <version>31.1-jre</version> * </dependency> */
一起分析一波代码啊。
首先定义了一个线程池:
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 循环里面的场景,都需要多多注意一下了。
这个场景就简单很多了。
当时有个小伙伴在群里扔了一个截图:
需要注意的是, if(!lock) 他截图的时候是给错了,真实的写法是 if(lock),lock 为 true 的时候就是加锁成功,进入 if。
同时这个代码这一行是有事务的:
写一个对应的伪代码是这样的:
if(加锁成功){ try{ //save有事务注解,并且确认调用的service对象是被代理的对象,即事务的写法一定是正确的 return service.save(); } catch(Exception e){ //异常打印 } finally { //释放锁 unlock(lockKey); } }
就上面这个写法,先加锁,再开启事务,执行事务方法,接着提交事务,最后解锁,反正歪师傅横看竖看是没有发现有任何毛病的。
但是提供截图的小伙伴是这样描述的。
当他是这样写的时候,从结果来看,程序是先加锁,再开启事务,执行事务方法,然后解锁,最后才提交事务:
当时我就觉得:这现象完全超出了我的认知,绝不可能。
紧接着他提供了第二张截图:
他说这样拆开写的时候,事务就能正常生效了:
这两个写法的唯一区别就是一个是直接 return,一个是先返回了一个 resultModel 然后在 return。
在实际效果上,我认为是没有任何差异的。
但是他说这样写会导致锁释放的时机不一样。
我还是觉得:
然而突然有人冒出来说了一句: try 带着 finally 的时候,在执行 return 语句之前会先执行 finally 里面的逻辑。会不会是这个原因导致的呢?
按照这个逻辑推,先执行了 finally 里面的释放锁逻辑,再执行了 return 语句对应的表达式,也就是事务的方法。那么确实是会导致锁释放在事务执行之前。
就是这句话直接给我干懵逼了,CPU 都快烧了,感觉哪里不对,又说不上来为什么。
虽然很反直觉,但是我也记得八股文就是这样写的啊,于是我开始觉得有点意思了。
所以我搞了一个 Demo,准备本地复现一下。
当时想着,如果能复现,这可是一个违背直觉的巨坑啊,是一个很好的写作素材。
可惜,没有复现:
最后这个哥们也重新去定位了原因,发现是其他的 BUG 导致的。
另外,关于前面“try 带着 finally”的说法其实说的并不严谨,应该是当 try 中带有 return 时,会先执行 return 前的代码,然后把需要 return 的信息暂存起来,接着再执行 finally 中的代码,最后再通过 return 返回之前保存的信息。
这才是写在八股文里面的正确答案。
要永远牢记另一位伟人说过:实践是检验真理的唯一标准。
遇事不决,就搞个 Demo 跑跑。
关于这个场景,其实也很简单,拆开来看就是关于事务和锁碰撞在一起时的注意事项以及 try-return-finally 的执行顺序这两个基础八股而已。
但是当着两个糅在一起的时候,确实有那么几个瞬间让我眼前一黑,又打得我一脸懵逼。
最后,事务和锁碰撞在一起的情况,上个伪代码:
@Service public class ServiceOne{ // 设置一把可重入的公平锁 private Lock lock = new ReentrantLock(true); @Transactional(rollbackFor = Exception.class) public Result func(long seckillId, long userId) { lock.lock(); // 执行数据库操作——查询商品库存数量 // 如果 库存数量 满足要求 执行数据库操作——减少库存数量——模拟卖出货物操作 lock.unlock(); } }
如果你五秒钟没看出这个代码的问题,秒杀这个问题的话,那歪师傅推荐你个假粉丝看看这篇文章::《几行烂代码,我赔了16万。》
好了,就酱,打完收工~