上面的程序运行起来后,会抛出 RejectedExecutionException,也就是线程池拒绝执行该任务。
但是我们前面分析了,for 循环的次数是线程池刚好能容纳的任务数:
按理来说不应该有问题啊?
这也就是提问的哥们纳闷的地方:
他说:我很费解啊,我提交的任务数量根本就不会超过 queueCapacity+maxThreads,为什么线程池还抛出了一个 RejectedExecutionException?而且这个问题非常的难以调试,因为在任务中添加任何形式的延迟,这个问题都不会复现。
他的言外之意就是:这个问题非常的莫名其妙,但是我可以稳定复现,只是每次复现出现问题的时机都非常的随机,我搞不定了,我觉得是一个 bug,你们帮忙看看吧。
我先不说我定位到的 Bug 的主要原因是啥吧。
先看看老爷子是怎么说的:
老爷子说他没有说服自己上面的这段程序应该被正常运行成功。
意思就是他觉得抛出异常也是正常的事情。但是他没有说为什么。
一天之后,他又补了一句话:
我先给大家翻译一下:
他说当线程池的 submit 方法和 setCorePoolSize 或者 prestartAllCoreThreads 同时存在,且在不同的线程中运行的时候,它们之间会有竞争的关系。
在新线程处于预启动但还没完全就绪接受队列中的任务的时候,会有一个短暂的窗口。在这个窗口中队列还是处于满的状态。
解决方案其实也很简单,比如可以在 setCorePoolSize 方法中把预启动线程的逻辑拿掉,但是如果是用 prestartAllCoreThreads 方法,那么还是会出现前面的问题。
但是,不管是什么情况吧,我还是不确定这是一个需要被修复的问题。
怎么样,老爷子的话看起来是不是很懵?
是的,这段话我最开始的时候读了 10 遍,都是懵的,但是当我理解到这个问题出现的原因之后,我还是不得不感叹一句:
还是老爷子总结到位,没有一句废话。
到底啥原因?
首先我们看一下示例代码里面操作线程池的这两个地方:
修改核心线程数的是一个线程,即 CompletableFuture 的默认线程池 ForkJoinPool 中的一个线程。
往线程池里面提交任务是另外一个线程,即主线程。
老爷子的第一句话,说的就是这回事:
racing,就是开车,就是开快车,就是与...比赛的意思。
这是一个多线程的场景,主线程和 ForkJoinPool 中的线程正在 race,即可能出现谁先谁后的问题。
接着我们看看 setCorePoolSize 方法干了啥事:
标号为 ① 的地方是计算新设置的核心线程数与原核心线程数之间的差值。
得出的差值,在标号为 ② 的地方进行使用。
也就是取差值和当前队列中正在排队的任务数中小的那一个。
比如当前的核心线程数配置就是 2,这个时候我要把它修改为 5。队列里面有 10 个任务在排队。
那么差值就是 5-2=3,即标号为 ① 处的 delta=3。
workQueue.size 就是正在排队的那 10 个任务。
也就是 Math.min(3,10),所以标号为 ② 处的 k=3。
含义为需要新增 3 个核心线程数,去帮忙把排队的任务给处理一下。
但是,你想新增 3 个就一定是对的吗?
会不会在新增的过程中,队列中的任务已经被处理完了,有可能根本就不需要 3 个这么多了?
所以,循环终止的条件除了老老实实的循环 k 次外,还有什么?
就是队列为空的时候:
同时,你去看代码上面的那一大段注释,你就知道,其实它描述的和我是一回事。
好,我们接着看 addWorker 里面,我想要让你看到地方:
在这个方法里面经过一系列判断后,会走入到 new Worker() 的逻辑,即工作线程。
然后把这个线程加入到 workers 里面。
workers 就是一个存放工作线程的 HashSet 集合:
你看我框起来的这两局代码,从 workers.add(w)
到 t.start()
。
从加入到集合到真正的启动,中间还有一些逻辑。
执行中间的逻辑的这一小段时间,就是老爷子说的 “window”。
there's a window while new threads are in the process of being prestarted but not yet taking tasks。
就是在新线程处于预启动,但尚未接受任务时,会有一个窗口。
这个窗口会发生啥事儿呢?
就是下面这句话:
the queue may remain (transiently) full。
队列有可能还是满的,但是只是暂时的。
接下来我们连起来看:
所以怎么理解上面被划线的这句话呢?
带入一个实际的场景,也就是前面的示例代码,只是调整一下参数:
这个线程池核心线程数是 1,最大线程数是 2,队列长度是 5,最多能容纳的任务数是 7。
另外有一个线程在执行把核心线程池从 1 修改为 2 的操作。
假设我们记线程池 submit 提交了 6 个任务,正在提交第 7 个任务的时间点为 T1。
为什么是要强调这个时间点呢?
因为当提交第 7 个任务的时候,就需要去启用非核心线程数了。
具体的源码在这里:
java.util.concurrent.ThreadPoolExecutor#execute
也就是说此时队列满了, workQueue.offer(command)
返回的是 fasle。因此要走到 addWorker(command, false)
方法中去了。
代码走到 1378 行这个时间点,是 T1。
如果 1378 行的 addWorker 方法返回 false,说明添加工作线程失败,抛出拒绝异常。
前面示例程序抛出拒绝异常就是因为这里返回了 fasle。
那么问题就变成了:为什么 1378 行中的 addWorker 执行后返回了 false 呢?
因为当前不满足这个条件了 wc >= (core ? corePoolSize : maximumPoolSize)
:
wc 就是当前线程池,正在工作的线程数。
把我们前面的条件带进去,就是这样的 wc >=(false?2:2)
。
即 wc=2。
为什么会等于 2,不应该是 1 吗?
多的哪一个是哪里来的呢?
真相只有一个:恰好此时 setCorePoolSize 方法中的 addWorker 也执行到了 workers.add(w)
,导致 wc 从 1 变成了 2。
撞车了,所以抛出拒绝异常。
那么为什么大多数情况下不会抛出异常呢?
因为从 workers.add(w)
到 t.start()
这个时间窗口,非常的短暂。
大多数情况下,setCorePoolSize 方法中的 addWorker 执行了后,就会理解从队列里面拿一个任务出来执行。
而这个情况下,另外的任务通过线程池提交进来后,发现队列还有位子,就放到队列里面去了,根本不会去执行 addWorker 方法。
道理,就是这样一个道理。
这个多线程问题确实是比较难复现,我是怎么定位到的呢?
加日志。
源码里面怎么加日志呢?
我不仅搞了一个自定义队列,还把线程池的源码粘出来了一份,这样就可以加日志了:
另外,其实我这个定位方案也是很不严谨的。
调试多线程的时候,最好是不要使用 System.out.println,有坑!
场景
我们再回头看看老爷子给出的方案:
其实它给了两个。
第一个是拿掉 setCorePoolSize 方法中的 addworker 的逻辑。
第二个是说原程序中,即提问者给的程序中,使用的是 prestartAllCoreThreads 方法,这个里面必须要调用 addWorker 方法,所以还是有一定的几率出现前面的问题。
但是,老爷子不明白为什么会这样写?
我想也许他是没有想到什么合适的场景?
其实前面提到的这个 Bug,其实在动态调整的这个场景下,还是有可能会出现的。
虽然,出现的概率非常低,条件也非常苛刻。
但是,还是有几率出现的。
万一出现了,当同事都在抠脑壳的时候,你就说:这个嘛,我见过,是个 Bug。不一定每次都出现的。
这又是一个你可以拿捏的小细节。
但是,如果你在面试的时候遇到这个问题了,这属于一个傻逼问题。
毫无意义。
属于,面试官不知道在哪看到了一个感觉很厉害的观点,一定要展现出自己很厉害的样子。
但是他不知道的是,这个题:
最后说一句
好了,看到了这里了,安排一个点赞吧。写文章很累的,需要一点正反馈。
给各位读者朋友们磕一个了:
本文已收录自个人博客,欢迎大家来玩: