查看原文
其他

看起来是线程池的BUG,但是我认为是源码设计不合理。

歪歪 why技术 2022-09-23

你好呀,我是歪歪。

前几天看到一个 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 的实现方式:

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

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

所以,这就是 BUG。

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

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

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

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

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

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

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

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

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

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

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

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

对不起,无可奉告。

荒腔走板

最近在 B 站看完了一个叫做《人生第一次》的纪录片。

其实应该是两个纪录片。

因为我先看了几集《人生第二次》,然后回头去看了《人生第一次》。我堂堂七尺男儿,每看一集都让我有一种热泪盈眶的感觉。

人生第一次,一共十二集,标题分别是:

  • 出生|第一声哭泣之前
  • 上学|我快被妈妈搞“疯”了
  • 长大|我在爸妈看不到的地方偷偷长大
  • 当兵|19秒定生死
  • 上班|轮椅上的“亲”
  • 结婚|非你不嫁
  • 进城|干就完了
  • 买房|成年人真正的快乐
  • 相守|我喂你呀!
  • 退休|终于上学了!
  • 养老|必须靠孩子吗?
  • 告别|忘记我,也没事

从第一次上学、第一次上班、第一次走进军营,从许许多多的第一次入手,用许多“第一次”串联起我们人生的重要节点。

我特别喜欢第三集。标题是长大,副标题是我在爸妈看不到的地方偷偷长大。

讲述的是云南乡村,漭水中学里面的一群留守儿童的故事。

校长在学校开设了诗歌课,似乎是一件在学习上没有收益的事情,因为在高考语文考试中,作文的要求总会附带一句话:题材不限,诗歌除外。

校长说:这里的孩子太懂事也太安静,为了满足基本的生活需要,这些孩子们的父母外出打工。他们接受这一现实,不哭也不闹。诗歌可以打开他们的心扉。

纪录片里面有一节在篝火旁的诗歌课,主题是:十年之后的爸爸妈妈。

在小时候能经历这么一堂启迪性如此之强的课,是真的能对人的一生产生一种持续的影响。

至于这些孩子写诗歌到底有没有用?

纪录片里面的回答是:诗歌确实很难改变一个人的命运,但诗歌可以改变一个人。

我做留守儿童的那些年,也有一些想要表达的东西,但是找不到一个载体。现在我知道了,诗歌,就是一个很好的载体。

《人生第一次》和《人生第二次》都是很棒的纪录片,推荐给你,可以看看。

最后,说一句纪录片的主题曲《推开世界的门》,很好听,也推荐给你。

··················END················

推荐👍远赴鹅厂,终被毕业!所思所感,与君共勉!

推荐👍线程崩了,为什么不会导致 JVM 崩溃呢?

推荐👍 :你好好想想,你真的需要配置中心吗?

推荐👍 :暂时还没想好取什么标题。

推荐👍 :2021,我这一年。

你好呀,我是歪歪。我没进过一线大厂,没创过业,也没写过书,更不是技术专家,所以也没有什么亮眼的title。

当年高考,随缘调剂到了某二本院校计算机专业。纯属误打误撞,进入程序员的行列,之后开始了运气爆棚的程序员之路。

说起程序员之路还是有点意思,可以点击蓝字,查看我的程序员之路

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存