当前位置: 首页 > 后端技术 > Java

好像是线程池的bug,但是我觉得源码设计的不合理

时间:2023-04-01 20:48:03 Java

大家好,我是伟伟。前几天看到JDK线程池的一个bug。我去查了症结所在。找到症结后,我认为这个bug属于线程池方式设计不合理,官方已经知道了这个bug。后来他说:确实是个BUG,但我不会修复,你可以把它当作一个特性。在带大家详细了解这个BUG之前,先问一个问题:JDK自带的线程池拒绝策略有哪些?这是一个陈旧的刻板印象,它存在的时间比我做生意的时间还长。我不得不张口:AbortPolicy:中止任务并抛出RejectedExecutionException异常。这是默认策略。DiscardOldestPolicy:丢弃队列前端的任务,执行下一个任务CallerRunsPolicy:调用线程处理该任务DiscardPolicy:也丢弃该任务,但不抛出异常,相当于静默处理。这次这个BUG的触发条件之一就是隐藏在这个DiscardPolicy中。但是一看源码,这个东西就是个空方法。会有什么样的错误?它的错误在于它是一个空方法,它以静默方式处理异常。别急,等我慢慢给你看。什么错误?BUG对应的链接是这样的:https://bugs.openjdk.org/brow...标题大概意思是:哦,老铁们听我说,我发现线程池拒绝策略DiscardPolicy遇到了invokerAll方法,可能会导致线程一直阻塞。然后注意BUG描述部分的这两段:这两段透露了两个信息:1.这个BUG之前已经提过。2.Doug和Martin也知道这个bug,但是他们认为用户可以通过编码来避免永久阻塞的问题。所以我们要到这个BUG最先出现的地方去。也就是这个链接:https://bugs.openjdk.org/brow...从标题来看,这两个问题很相似,都有invokerAll和block,只是触发条件不同。一个是DiscardPolicy拒绝策略,一个是shutdownNow方法。所以我的策略是先带大家了解一下shutdownNow方法,这样大家才能更好的理解DiscardPolicy带来的问题。本质上,他们说的是同一件事。该现象在shutdownNow相关BUG的描述中。提问者给出了他的测试用例。我稍微改了一下就用了。https://bugs.openjdk.org/brow...代码贴在这里,你也可以在本地运行:publicclassMainTest{publicstaticvoidmain(String[]args)throwsInterruptedException{List>tasks=newArrayList<>();对于(inti=0;i<10;i++){intfinalI=i;tasks.add(()->{System.out.println("callable"+finalI);Thread.sleep(500);returnnull;});}ExecutorService执行器=Executors.newFixedThreadPool(2);线程executorInvokerThread=newThread(()->{try{executor.invokeAll(tasks);}catch(InterruptedExceptione){e.printStackTrace();}System.out.println("invokeAllreturned");});executorInvokerThread.start();}}然后给大家解释一下测试代码是干什么的。首先,标有①的地方是将10个可调用任务塞入列表。这么多任务干什么?肯定是扔进了线程池吧?所以在标②的地方,设置了2个线程和核心线程的线程池。线程中调用了线程池的invokerAll方法:这个方法是做什么用的?执行给定的任务,在所有任务完成时返回一个Futures列表,其中包含它们的状态和结果。执行给定的任务,在所有任务完成时返回一个Futures列表,其中包含它们的状态和结果。也就是说,当线程启动时,线程池会把列表中的任务一个一个执行,执行完成后返回一个Futures列表。我们在写代码的时候,拿着这个列表就可以知道这批任务是否执行完了。但是,朋友们,但是啊,注意了,你看在我的例子中,我根本不关心invokerAll方法的返回值。我关心的是执行invokerAll方法后输出的那句话:invokeAllreturnedOK,现在告诉我这个程序运行有什么问题?你肯定看不到它,是吗?我也看不出来,因为一点问题都没有,程序可以正常运行,结束:然后,我把程序修改成这样,加上标有③的这几行代码:这里的关机方法调用线程池,目的是让线程池处理完任务后让程序退出。来吧,告诉我这个程序运行有什么问题?你肯定没有忘记它,是吗?我也没有,因为一点问题都没有,程序运行的很好End:OK,接下来,我又要开始变形了。程序变成这样:注意我这里使用的是shutdownNow方法,意思是我想马上关闭之前的线程池,然后让整个程序退出。那么这个程序有什么问题呢?肉眼看确实有问题,不过我们可以先看看运行结果:结果还是很容易观察的。“invokeAllreturned”不打印,程序不退出。那么问题来了:你觉得这是BUG吗?不管是什么原因,从现象来看,这绝对是个BUG吧?我已经调用了shutdownNow,我想要的是立即关闭线程池,然后让整个程序退出。结果任务确实没有执行,但是程序没有退出,这与我们的预期不符。所以,大胆一点,这是一个BUG!再来一张shutdownNow和shutdown方法输出的对比图,比较直观:至于这两种方法的区别,我就不说了。不知道就上网去背。反正现在BUG已经可以稳定重现了。下一步是找到根本原因。如何找到根本原因?你首先想到这个问题:程序应该退出但没有退出,是不是说明还有线程在运行,准确的说还有非守护线程在运行?没错,想到这里就很容易了。查看线程堆栈。你怎么认为?相机,朋友们。我们的老哥们,在之前的文章中经常出现的,就它:可以看到有一个线程是随便点击一下就出错了:它处于WAITING状态,导致它进入这个状态的代码是通过堆栈信息,一眼就能找到,就是invokeAll方法的第244行,也就是这行代码:atjava.util.concurrent.AbstractExecutorService.invokeAll(AbstractExecutorService.java:244)由于问题出在在invokeAll方法中,必须明白这个方法是干什么的。源码并不复杂,我主要关注我框架的部分:标①的地方将传入的任务封装为一个Future对象,先放到一个List中,然后调用execute方法,即抛给池中的线程执行。这个操作特别像直接调用线程池的submit()方法。我跟大家对比一下:标②的地方就是放在循环前面的ListofFuture。如果Future没有执行,则调用Future的get方法阻塞等待。结果。从堆栈信息来看,线程阻塞在Future的get方法中,说明Future还没有执行。为什么没有实施?好,我们回到测试代码的这个地方:10个任务丢到线程池里,2个核心线程。线程池中有没有两个可以被线程执行,剩下8个进入队列?好吧,我问你:调用shutdownNow后,worker线程是不是就被kill掉了?剩下的8个是否耗尽资源来执行?话虽如此,即使只有一个任务没有执行?invokeAll方法中的future.get()是否也必须阻塞?但是,朋友们,但是,当BUG这么明显的时候,上面的案例居然被官方推翻了。这是怎么回事?带你看官方老板的回复。哦,不好意思,不是老大,是官方大佬Martin和Doug的回复:Martin说:“老铁,我看了你的代码,好像没有错吧?”听我说,shutdownNow方法返回一个List列表,其中包含尚未执行的任务。所以你必须对shutdownNow的返回做一些事情。道格说:马丁是对的。多说一句:这就是他们被退回的原因。他们参考了这份清单。也就是说,老头在写代码的时候就考虑到了这种情况,所以他把所有未执行的任务都返回给了调用者。嗯,shutdownNow方法有一个返回值。我之前没有注意到这个细节:但是如果你仔细看返回值,它是一个列表中的Runnable。它不是Future,所以我不能调用future.cancel()方法。那么得到这个返回值后,应该如何取消任务呢?好问题。因为提问者也有这个疑问:看到大佬说要对返回值进行操作后,一脸懵逼的回答:大哥,shutdownNow方法返回的是一个List。至少对我来说,我不知道如何取消这些任务。是否应该在文档中描述?Martin大哥觉得这个返回确实有点乱,回应如下:线程池提交任务有两种方式。如果您使用execute()方法提交Runnable任务,则shutdownNow会返回尚未执行的Runnable列表。如果用submit()方法提交一个Runnable任务,它会被封装为一个FutureTask对象,所以调用shutdownNow方法返回的是一个未执行的FutureTasks列表:也就是说shutdownNow方法返回的List集合包含了这两种可能的是Runnable,也可能是FutureTask,看你扔任务到线程池时调用什么方法。FutureTask是Runnable的子类:所以,根据Martin说的和他提供的代码,我们可以修改测试用例如下:遍历shutdownNow方法返回的List集合,然后判断是否是Future,如果是,将其强制为Future,然后调用其取消方法。这样程序就可以正常运行并结束了。从这点来看,似乎不是BUG,可以通过编码来避免。反转但是,朋友们,但是啊,前面都是我的铺垫,然后剧情就开始反转了。我们回到这个链接:https://bugs.openjdk.org/brow...这个链接提到了DiscardPolicy线程池拒绝策略。只要我稍微改动一下我们的demo程序,就可以触发线程的DiscardPolicy拒绝策略,之前的bug真的是绕不过去的bug。应该怎么改?很简单,换个线程池就行了:把我们之前的线程池换成2个核心线程和无限队列长度的自定义线程池。该自定义线程池的核心线程数、最大线程数、队列长度均为1,采用的线程拒绝策略为DiscardPolicy。其他地方的代码不动,整个代码就变成这样了。我把代码贴出来给大家看看,这样大家就可以直接运行了:;对于(inti=0;i<10;i++){intfinalI=i;tasks.add(()->{System.out.println("callable"+finalI);Thread.sleep(500);returnnull;});}ExecutorServiceexecutor=newThreadPoolExecutor(1,1,1,TimeUnit.SECONDS,newArrayBlockingQueue<>(1),newThreadPoolExecutor.DiscardPolicy());//ExecutorServiceexecutor=Executors.newFixedThreadPool(2);线程executorInvokerThread=newThread(()->{try{executor.invokeAll(tasks);}catch(InterruptedExceptione){e.printStackTrace();}System.out.println("调用全部返回");});executorInvokerThread.start();线程.睡眠(800);System.out.println("关机");Listrunnables=executor.shutdownNow();for(Runnabler:runnables){if(rinstanceofFuture)((Future)r).cancel(false);}System.out.println("关机完成");}}然后我们运行程序看看结果:咦,怎么回事?我明明处理了shutdownNow的返回值,为什么程序没有输出“invokeAllreturned”而阻塞在invokeAll方法上?就算我们不知道为什么程序没有停止,但是从性能上来说,这东西肯定是个bug吧?下面小编就带大家分析一下为什么会出现这种现象。首先问你,在我们这个例子中,这个线程池最多可以容纳多少个任务?最多只能接2个任务吗?最多只能接2个任务。是不是说我不能处理8个任务,需要执行线程池的拒绝策略?但是我们的拒绝策略是什么?就是DiscardPolicy,它的实现是这样的,就是静默处理,丢弃任务,不抛异常:OK,这里你继续想,shutdownNow返回的是什么,不是在线程池中执行的吗?任务,也就是队列中的任务?但是队列里最多只有一个任务,退给你取消也没用。所以这个case与是否处理shutdownNow的返回值无关。关键是这8个任务被拒绝了,或者关键是触发了DiscardPolicy拒绝策略。触发一次和触发多次的效果是一样的。在我们自定义的线程池加上invokeAll方法中,只要有一个任务被默默处理,就认为是在开玩笑。为什么这样说呢?我们先看默认线程池拒绝策略AbortPolicy的实现:被拒绝执行后会抛出异常,然后执行finally方法,调用cancel,然后会在invokeAll方法中被捕获,所以会notblock:如果是静默处理,你无处让静默处理的Future抛出异常,也无处调用它的cancel方法,所以会一直阻塞在这里。所以,这就是BUG。那么官方是如何回应这个bug的呢?巨人马丁回答:我觉得应该在文档中说明。DiscardPolicy的拒绝策略在实际场景中很少用到,不推荐大家使用。或者,你认为它是一个功能吗?我觉得言外之意是:我知道这是BUG,但是你得用DiscardPolicy这种实际编码中不会用到的拒绝策略来谈。我觉得你是故意卡在BUG上的。我对这个答复不满意。马丁弟兄什么都不知道。我们面试的时候,有一个套路,其中一个老套路问题是:你有没有自定义线程池拒绝策略?有大神的话,在自定义线程池拒绝策略的时候,写一个花哨但等价的DiscardPolicy拒绝策略。即不入队列,不抛异常,再花哨的代码,同样的问题存在。因此,我认为还是invokeAll方法的设计问题。不应设计调用线程之外的其他线程无法访问的Future。这违反了Future对象的设计理论。所以我才说这是BUG,是设计问题。什么,你问我应该怎么设计?对不起,没有评论。