关于后端:看起来是线程池的BUG但是我认为是源码设计不合理

2次阅读

共计 7396 个字符,预计需要花费 19 分钟才能阅读完成。

前几天看到一个 JDK 线程池的 BUG,我去理解了一下,摸清楚了它的症结所在之后,我感觉这个 BUG 是属于一种线程池办法设计不合理的中央,而且官网在晓得这个 BUG 之后示意:的确是个 BUG,然而我就不修复了吧,你就当这是一个 feature 吧。

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

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

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

AbortPolicy:抛弃工作并抛出 RejectedExecutionException 异样,这是默认的策略。
DiscardOldestPolicy:抛弃队列最后面的工作,执行前面的工作
CallerRunsPolicy:由调用线程解决该工作
DiscardPolicy:也是抛弃工作,然而不抛出异样,相当于静默解决。

这次的这个 BUG 触发条件之一,就藏着在这个 DiscardPolicy 外面。
然而你一去看源码,这个玩意就是个空办法啊,这能有什么 BUG?

它错就错在是一个空办法,把异样给静默解决了。
别急,等我缓缓给你摆。

啥 BUG 啊?
BUG 对应的链接是这个:

bugs.openjdk.org/browse/JDK-…

题目大略就是说:噢,我的老伙计们,听我说,我发现线程池的回绝策略 DiscardPolicy 遇到 invokerAll 办法的时候,可能会导致线程始终阻塞哦。
而后在 BUG 的形容局部次要先留神这两段:

这两段走漏出两个音讯:

1. 这个 BUG 之前有人提出来过。
2.Doug 和 Martin 这两位也晓得这个 BUG,然而他们感觉用户能够通过编码的形式防止永远阻塞的问题。

所以咱们还得先去这个 BUG 最先呈现的中央看一下。也就是这个链接:

bugs.openjdk.org/browse/JDK-…

从题目上来看,这两个问题十分的类似,都有 invokerAll 和 block,然而触发的条件不一样。
一个是 DiscardPolicy 回绝策略,一个是 shutdownNow 办法。
所以我的策略是先带你先把这个 shutdownNow 办法嗦明确了,这样你就能更好的了解 DiscardPolicy 带来的问题。
实质上,它们说的是一回事儿。
景象
在 shutdownNow 相干的这个 BUG 形容外面,提问者给到了他的测试用例,我略微改改,就拿来就用了。

bugs.openjdk.org/browse/JDK-…

代码贴在这里,你也能够那到你本地跑一下:
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,能够通过编码来防止它。
反转
然而,敌人们,然而啊,后面都是我的铺垫,接下来剧情开始反转了。
咱们回到这个链接中:

bugs.openjdk.org/browse/JDK-…

这个链接外面提到了 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,也是设计问题。
什么,你问我应该怎么设计?
对不起,无可奉告。

正文完
 0