你好呀,我是歪歪。

前几天看到一个 JDK 线程池的 BUG,我去了解了一下,摸清楚了它的症结所在之后,我觉得这个 BUG 是属于一种线程池方法设计不合理的地方,而且官方在知道这个 BUG 之后表示:确实是个 BUG,但是我就不修复了吧,你就当这是一个 feature 吧。

在带你细嗦这个 BUG 之前,我先问一个问题:

JDK 自带的线程池拒绝策略有哪些?

这玩意,老八股文了,存在的时间比我从业的时间都长,得张口就来:

  • AbortPolicy:丢弃任务并抛出 RejectedExecutionException 异常,这是默认的策略。
  • DiscardOldestPolicy:丢弃队列最前面的任务,执行后面的任务
  • CallerRunsPolicy:由调用线程处理该任务
  • DiscardPolicy:也是丢弃任务,但是不抛出异常,相当于静默处理。

这次的这个 BUG 触发条件之一,就藏着在这个 DiscardPolicy 里面。

但是你一去看源码,这个玩意就是个空方法啊,这能有什么 BUG?

它错就错在是一个空方法,把异常给静默处理了。

别急,等我慢慢给你摆。

啥BUG啊?

BUG 对应的链接是这个:

https://bugs.openjdk.org/browse/JDK-8286463

标题大概就是说:噢,我的老伙计们,听我说,我发现线程池的拒绝策略 DiscardPolicy 遇到 invokerAll 方法的时候,可能会导致线程一直阻塞哦。

然后在 BUG 的描述部分主要先注意这两段:

这两段透露出两个消息:

  • 1.这个 BUG 之前有人提出来过。
  • 2.Doug 和 Martin 这两位也知道这个 BUG,但是他们觉得用户可以通过编码的方式避免永远阻塞的问题。

所以我们还得先去这个 BUG 最先出现的地方看一下。也就是这个链接:

https://bugs.openjdk.org/browse/JDK-8160037

从标题上来看,这两个问题非常的相似,都有 invokerAll 和 block,但是触发的条件不一样。

一个是 DiscardPolicy 拒绝策略,一个是 shutdownNow 方法。

所以我的策略是先带你先把这个 shutdownNow 方法嗦明白了,这样你就能更好的理解 DiscardPolicy 带来的问题。

本质上,它们说的是一回事儿。

现象

在 shutdownNow 相关的这个 BUG 描述里面,提问者给到了他的测试用例,我稍微改改,就拿来就用了。

https://bugs.openjdk.org/browse/JDK-8160037

代码贴在这里,你也可以那到你本地跑一下:

public class MainTest {

    public static void main(String[] args) throws InterruptedException {
        
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            tasks.add(() -> {
                System.out.println("callable "+ finalI);
                Thread.sleep(500);
                return null;
            });
        }

        ExecutorService executor = Executors.newFixedThreadPool(2);
        Thread executorInvokerThread = new Thread(() -> {
            try {
                executor.invokeAll(tasks);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("invokeAll returned");
        });
        executorInvokerThread.start();
    }
}

然后给大家解释一下测试代码是在干啥事儿。

首先标号为 ① 的地方,是往 list 里面塞了 10 个 callable 类型的任务。

搞这么多任务干啥呢?

肯定是要往线程池里面扔,对吧。

所以,在标号为 ② 的地方,搞了一个线程和核心线程数是 2 的线程池。在线程里面调用了线程池的 invokerAll 方法:

这个方法是干啥的?

Executes the given tasks, returning a list of Futures holding their status and results when all complete.

执行给定的任务集合,在所有任务完成后返回一个包含其状态和结果的 Futures 列表。

也就是说,当线程启动后,线程池会把 list 里面的任务一个个的去执行,执行完成后返回一个 Futures 列表。

我们写代码的时候拿着这个列表就能知道这一批任务是否都执行完成了。

但是,朋友们,但是啊,注意一下,你看我的案例里面根本就不关心 invokerAll 方法的返回值。

关心的是在 invokerAll 方法执行完成后,输出的这一句话:

invokeAll returned

好,现在你来说这个程序跑起来有什么毛病?

你肯定看不出来对不对?

我也看不出来,因为它根本就没有任何毛病,程序可以正常运行结束:

接着,我把程序修改为这样,新增标号为 ③ 的这几行代码:

这里调用的是线程池的 shutdown 方法,目的是想等线程池把任务处理完成后,让程序退出。

来,你又说说这个程序跑起来有什么毛病?

你肯定又没有看不来对不对?

我也没有,因为它根本就没有任何毛病,程序可以正常运行结束:

好,接下来,我又要开始变形了。

程序变成这样:

注意我这里用的是 shutdownNow 方法,意思就是我想立即关闭前面的那个线程池,然后让整个程序退出。

那么这个程序有什么问题呢?

它是真的有问题,肉眼真不好看出来,但是我们可以先看一下运行结果:

结果还是很好观察的。

没有输出 “invokeAll returned”,程序也没有退出。

那么问题就来了:你说这是不是 BUG ?

咱先不管原因是啥,从现象上看,这妥妥的是 BUG 了吧?

我都调用 shutdownNow 了,想的就是立马关闭线程池,然后让整个程序退出,结果任务确实是没有执行了,但是程序也并没有退出啊,和我们预期的不符。

所以,大胆一点,这就是一个 BUG!

再来一个关于 shutdownNow 和 shutdown 方法输出对比图,更直观:

至于这两个方法之间有什么区别,我就不讲了,你要是不知道就去网上翻翻,背一下。

反正现在 BUG 已经能稳定复现了。

接下来就是找出根因了。

根因

根因怎么找呢?

你先想想这个问题:程序应该退出却没有退出,是不是说明还有线程正在运行,准确的说是还有非守护线程正在运行?

对了嘛,想到这里就好办了嘛。

看线程堆栈嘛。

怎么看?

照相机啊,朋友们。我们的老伙计了,之前的文章里面经常露面,就它:

你就这么轻轻的一点,就能看到有个线程它不对劲:

它在 WAITING 状态,而导致它进入这个状态的代码通过堆栈信息,一眼就能定位到,就是 invokeAll 方法的 244 行,也就是这一行代码:

at java.util.concurrent.AbstractExecutorService.invokeAll(AbstractExecutorService.java:244)

既然问题出在 invokeAll 这个方法里面,那就得理解这个方法在干啥了。

源码也不复杂,主要关注我框起来的这部分:

标号为 ① 的地方,是把传入进来的任务封装为一个 Future 对象,先放到一个 List 里面,然后调用 execute 方法,也就是扔到线程池里面去执行。

这个操作特别像是直接调用线程池的 submit() 方法,我给你对比一下:

标号为 ② 的地方,就是循环前面放 Future 的 List,如果 Future 没有执行完成,就调用 Future 的 get 方法,阻塞等待结果。

从堆栈信息上看,线程就阻塞在 Future 的 get 方法这里,说明这个 Future 一直没有被执行。

为什么没有被执行?

好,我们回到测试代码的这个地方:

10 个任务,往核心线程数是 2 的线程池里面扔。

是不是有两个可以被线程池里面的线程执行,剩下的 8 个进入到队列里面?

好,我问你:调用 shutdownNow 之后,工作线程是不是直接就给干没了?剩下的 8 个是不是没有资源去执行了?

话说回来,哪怕只有 1 个任务没有被执行呢?invokeAll 方法里面的 future.get() 是不是也得阻塞?

但是,朋友们,但是啊,就在 BUG 如此清晰的情况下,上面的这个案例居然被官方给推翻了。

怎么回事呢?

带你看一下官方大佬的回复。

哦,对不起,不是大佬,是官方巨佬 Martin 和 Doug 的回复:

Martin 说:老铁,我看了你的代码,感觉没毛病啊?你听我说,shutdownNow 方法返回了一个 List 列表,里面放的就是还没有被执行任务。所以你还得拿着 shutdownNow 的返回搞一些事情才行。

Doug 说:Martin 说的对。额外说一句:

that's why they are returned。

they 指的就是这个 list。也就是说老爷子写代码的时候是考虑到这个情况了的,所以把没有执行的任务都返给了调用者。

好吧,shutdownNow 方法是有返回值的,我之前居然没有注意到这个细节:

但是你仔细看这个返回值,是个 list 里面装的 Runnable,它不是 Future,我就不能调用 future.cancel() 方法。

所以拿到这个返回值之后,我应该怎么取消任务呢?

这个问题问得好啊。因为提问者也有这样的疑问:

他在看到巨佬们说要对返回值做操作之后,一脸懵逼的回复说:哥老倌些,shutdownNow 方法返回的是一个List。至少对我来说,我不知道应该这么去取消这些任务。是不是应该在文档里面描述一下哦?

Martin 老哥觉得这个返回确实有点迷惑性,他做了如下回复:

线程池提交任务有两种方式。

如果你用 execute() 方法提交 Runnable 任务,那么 shutdownNow 返回的是未被执行的 Runnable 的列表。

如果你用 submit() 方法提交 Runnable 任务,那么会被封装为一个 FutureTask 对象,所以调用 shutdownNow 方法返回的是未被执行的 FutureTask 的列表:

也就是说 shutdownNow 方法返回的 List 集合,里面装的既可能是 Runnable,也可能是 FutureTask,取决于你往线程池里面扔任务的时候调用的什么方法。

FutureTask 是 Runnable 的子类:

所以,基于 Martin 老哥的说法和他提供的代码,我们可以把测试用例修改为这样:

遍历 shutdownNow 方法返回的 List 集合,然后判断是否 Future,如果是则强转为 Future,接着调用其 cancel 方法。

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

这样看来,好像也确实不是一个 BUG,可以通过编码来避免它。

反转

但是,朋友们,但是啊,前面都是我的铺垫,接下来剧情开始反转了。

我们回到这个链接中:

https://bugs.openjdk.org/browse/JDK-8286463

这个链接里面提到了 DiscardPolicy 这个线程池拒绝策略。

只要我稍微的把我们的 Demo 程序改变一点点,触发线程的 DiscardPolicy 拒绝策略,前面这个 bug 就真的是一个绕不过去的 bug 了。

应该怎么改变呢?

很简单,换个线程池就可以了:

把我们之前这个核心线程数为 2,队列长度无限长的线程池替换为一个自定义线程池。

这个自定义线程池的核心线程数、最大线程数、队列长度都是 1,采用的线程拒绝策略是 DiscardPolicy。

其他的地方代码都不动,整个代码就变成了这样,我把代码贴出来给你看看,方便你直接运行:

public class MainTest {

    public static void main(String[] args) throws InterruptedException {

        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            tasks.add(() -> {
                System.out.println("callable " + finalI);
                Thread.sleep(500);
                return null;
            });
        }
        ExecutorService executor = new ThreadPoolExecutor(
                1,
                1,
                1,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1),
                new ThreadPoolExecutor.DiscardPolicy()
        );
//        ExecutorService executor = Executors.newFixedThreadPool(2);
        Thread executorInvokerThread = new Thread(() -> {
            try {
                executor.invokeAll(tasks);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("invokeAll returned");
        });
        executorInvokerThread.start();

        Thread.sleep(800);
        System.out.println("shutdown");
        List<Runnable> runnables = executor.shutdownNow();
        for (Runnable r : runnables) {
            if (r instanceof Future) ((Future<?>)r).cancel(false);
        }
        System.out.println("Shutdown complete");
    }
}

然后我们先把程序运行起来看结果:

诶,怎么回事?

我明明处理了 shutdownNow 的返回值呢,怎么程序又没有输出 “invokeAll returned” 了,又阻塞在 invokeAll 方法上了?

就算我们不知道为什么程序没有停下来,但是从表现上看,这玩意肯定是 bug 了吧?

接下来我带你分析一下为什么会出现这个现象。

首先我问你在我们的案例里面,这个线程池最多能容纳几个任务?

是不是最多只能接收 2 个任务?

最多只能接收 2 个任务,是不是说明我有 8 个任务是处理不了的,需要执行线程池的拒绝策略?

但是我们的拒绝策略是什么?

是 DiscardPolicy,它的实现是这样的,也就是静默处理,丢弃任务,也不抛出异常:

好,到这里你又接着想,shutdownNow 返回的是什么东西,是不是线程池里面还没来得及执行的任务,也就是队列里面的任务?

但是队列里面最多也就一个任务,返回回来给你取消了也没用。

所以,这个案例和处不处理 shutdownNow 的返回值没有关系。

关键的是被拒绝的这 8 个任务,或者说关键是触发了 DiscardPolicy 拒绝策略。

触发一次和触发多次的效果都是一样的,在我们这个自定义线程池加 invokeAll 方法这个场景下,只要有任何一个任务被静默处理了,就算玩蛋。

为什么这样说呢?

我们先看看默认的线程池拒绝策略 AbortPolicy 的实现方式:

被拒绝执行之后,它是会抛出异常,然后执行 finally 方法,调用 cancel,接着在 invokeAll 方法里面会被捕捉到,所以不会阻塞:

如果是静默处理,你没有任何地方让这个被静默处理的 Future 抛出异常,也没用任何地方能调用它的 cancel 方法,所以这里就会一直阻塞。

所以,这就是 BUG。

那么针对这个 BUG,官方是怎么回复呢?

Martin 巨佬回复说:我觉得吧,应该在文档上说明一下,DiscardPolicy 这个拒绝策略,在真实的场景中很少使用,不建议大家使用。要不,你把它当作一个 feature?

我觉得言外之意就是:我知道这是一个 BUG 了,但是你非得用 DiscardPolicy 这个不会在实际编码中使用的拒绝策略来说事儿,我觉得你是故意来卡 BUG 的。

我对于这个回复是不满意的。

Martin 老哥是有所不知,我们面试的时候有一个八股文环节,其中的一个老八股题是这样的:

你有没有自定义过线程池拒绝策略?

如果有一些大聪明,在自定义线程池拒绝策略的时候,写出了一个花里胡哨的,但是又等效于 DiscardPolicy 的拒绝策略。

也就是又没放进队列,又没抛出异常,不管你代码写的多花哨,一样的是有这个问题。

所以,我觉得还是 invokeAll 方法的设计问题,一个不能在调用线程之外被其他线程访问的 Future 就不应该被设计出来。

这违背了 Future 这个对象的设计理论。

所以我才说这是 BUG,也是设计问题。

什么,你问我应该怎么设计?

对不起,无可奉告。

看起来是线程池的BUG,但是我认为是源码设计不合理。的更多相关文章

  1. 线程池ThreadPoolExector核心ctl, execute, addWorker, reject源码分析

    线程池核心方法execute()解析: public void execute(Runnable command) {//#1 if (command == null) throw new NullP ...

  2. 线程池的介绍和使用,以及基于jvmti设计非入侵监控

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 五常大米好吃! 哈哈哈,是不你总买五常大米,其实五常和榆树是挨着的,榆树大米也好吃, ...

  3. 仿酷狗音乐播放器开发日志十九——CTreeNodeUI的bug修复二(附源码)

    转载请说明原出处,谢谢 今天本来打算把仿酷狗播放列表的子控件拖动插入功能做一下,但是仔细使用播放列表控件时发现了几个逻辑错误,由于我的播放 列表控件是基于CTreeViewUI和CTreeNodeUI ...

  4. AndFix Bug 热修复框架原理及源码解析

    作为阿里巴巴开源的 Android 应用热修复工具——AndFix,帮助 Anroid 开发者修复应用的线上问题.Andfix 是 “Android hot-fix” 的缩写. 1.什么是AndFix ...

  5. 鸿蒙内核源码分析(任务切换篇) | 看汇编如何切换任务 | 百篇博客分析OpenHarmony源码 | v41.03

    百篇博客系列篇.本篇为: v41.xx 鸿蒙内核源码分析(任务切换篇) | 看汇编如何切换任务 | 51.c.h .o 任务管理相关篇为: v03.xx 鸿蒙内核源码分析(时钟任务篇) | 触发调度谁 ...

  6. 价值100W的经验分享: 基于JSPatch的iOS应用线上Bug的即时修复方案,附源码.

    限于iOS AppStore的审核机制,一些新的功能的添加或者bug的修复,想做些节日专属的活动等,几乎都是不太可能的.从已有的经验来看,也是有了一些比较常用的解决方案.本文先是会简单说明对比大部分方 ...

  7. java基础:简单实现线程池

    前段时间自己研究了下线程池的实现原理,通过一些源码对比,发现其实核心的东西不难,于是抽丝剥茧,决定自己实现一个简单线程池,当自已实现了出一个线程池后.发现原来那么高大上的东西也可以这么简单. 先上原理 ...

  8. java多线程系列(六)---线程池原理及其使用

    线程池 前言:如有不正确的地方,还望指正. 目录 认识cpu.核心与线程 java多线程系列(一)之java多线程技能 java多线程系列(二)之对象变量的并发访问 java多线程系列(三)之等待通知 ...

  9. Java提高班(二)深入理解线程池ThreadPool

    本文你将获得以下信息: 线程池源码解读 线程池执行流程分析 带返回值的线程池实现 延迟线程池实现 为了方便读者理解,本文会由浅入深,先从线程池的使用开始再延伸到源码解读和源码分析等高级内容,读者可根据 ...

随机推荐

  1. Java指令重排序在多线程环境下的应对策略

    一.序言 指令重排在单线程环境下有利于提高程序的执行效率,不会对程序产生负面影响:在多线程环境下,指令重排会给程序带来意想不到的错误. 本文对多线程指令重排问题进行复原,并针对指令重排给出相应的解决方 ...

  2. 灵感乍现!造了个与众不同的Dubbo注册中心扩展轮子

    hello大家好呀,我是小楼. 作为一名基础组件开发,服务好每一位业务开发同学是我们的义务(KPI). 客服群里经常有业务开发同学丢来一段代码.一个报错,而我们,当然要微笑服务,耐心解答. 有的问题, ...

  3. 项目实战:rsync+sersync实现数据实时同步

    一.组网介绍 本次实验使用两台主机: qll251 角色:Rsync server + Sersync server qll252 角色: Rsync client 本次实验采用CentOS7.7系统 ...

  4. go-micro集成RabbitMQ实战和原理

    在go-micro中异步消息的收发是通过Broker这个组件来完成的,底层实现有RabbitMQ.Kafka.Redis等等很多种方式,这篇文章主要介绍go-micro使用RabbitMQ收发数据的方 ...

  5. zipper题解

    -请奆佬们洁身自好,好好打代码从我做起 - 题目大意: 给三个字符串,判断C字符串是否由A B字符串顺序组成, 题意分析: 很容易想到的是,A的长度加上B的长度为C的长度 其实进一步想,这 提供了一个 ...

  6. Oracle RAC修改监听端口号

    目录 修改OracleRAC监听端口号: 1.查看当前数据库监听状态: 2.修改集群监听端口: 3.手动修改LOCAL_LISTENER: 4.停止集群监听和SCAN: 5.修改listener.or ...

  7. 1.Docker简介

    Docker是个什么东西 假定您在开发一个项目,您使用的是一台笔记本电脑而且您的开发环境具有特定的配置.其他开发人员身处的环境配置也各有不同.您正在开发的应用依赖于您当前的配置且还要依赖于某些配置文件 ...

  8. apache tomcat 目录session应用信息漏洞

    Tomcat 是一款开源的 Web 应用服务器软件.Tomcat 属于轻量级应用服务器,在中小型系统和并发访问用户不多的场合下被普遍使用,是开发和调试 JSP 程序的首选. 漏洞描述 apache T ...

  9. 【ASP.NET Core】URL重写

    今天老周和大伙伴们聊聊有关 Url Rewrite 的事情,翻译过来就是 URL 重写. 这里不得不提一下,URL重定向与重写的不同. 1.URL重定向是客户端(通常是浏览器)向服务器请求地址A,然后 ...

  10. c# DateTime 格式化输出字符串

    DateTime 输出字符串 带 T,结尾 +08:00 $"{DateTime.Now:O}"; // 2020-12-20T16:11:18.2353338+08:00 $&q ...