这是why的第 45 篇原创文章。说点不一样的线程池执行策略和线程拒绝策略,探讨怎么让线程池先用完最大线程池再把任务放到队列中。
荒腔走板
大家好,我是 why,一个四川程序猿,成都好男人。
先是本号的特色,技术分享之前先简短的荒腔走板聊聊生活。让文章的温度更多一点点。
上面的图是我在一次跑步的过程中拍的。活动之前赛事方搞了个留言活动,收集每公里路牌的一个宣传语。
我的留言有幸被选中了:
每人知道你在坚持什么,但你自己心里应该清楚。
是在说跑马拉松,也是在说其他的事情。
我记得那天的太阳,骄阳似火,路上的树荫也非常的少。苦就苦在我还报的是超级马拉松(说是超级马拉松,其实就是一个全马 42 km加最后 3 km纯上坡的马拉松)
到底有多晒,我给你看一下对比:
酷暑难耐,以至于 30 公里左右的地方我的心里出现了两个小人:
一个说:我好累啊,我跑不动了,我要退赛。
一个说:好呀好呀,我也好晒啊,退赛退赛。
我说:呸,看你们两个不争气的东西,让我带你去终点
于是在 36 公里的地方碰到了我提交的标语,非常开心,停下来拍了几张照片。给自己说:坚持不住的时候再坚持一下。
最后的 3 公里上坡,抽筋了不知道多少次。远远看见终点拱门的时候我突然想到了在敦煌的时候悟出的一句话:自己给自己的辛苦,不是辛苦,是幸福。
好了,说回文章。
违背直觉的JDK线程池
先用 JDK 线程池来开个题。
还是用我之前这个文章《如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。》“先劝退一波”这一小节里面的例题:
问:这是一个自定义线程池,假设这个时候来了 100 个比较耗时的任务,请问有多少个线程在运行?
正确回答在之前的文章中回答了,这里不在赘述。
但是我面试的时候曾经遇到过很多对于 JDK 线程池不了解的朋友。
而这些人当中大多数都有一个通病,那就是遇到不太会的问题,那就去猜。
面试者遇到这个不会的题的时候,表面上微微一笑,实际上我都已经揣摩出他们的内心活动了:
MD,这题我没背过呀,但是刚刚听面试官说核心线程数是 10,最大线程数是 30。看题也知道答案不是 10 就是 30。
选择题,百分之 50 的命中率,难道不赌一把?
等等,30 是最大线程数?最大?我感觉就是它了。
于是在电光火石一瞬间的思考后,和我对视起来,自信的说:
于是我也是微微一笑,告诉他:下去再了解一下吧,我们聊聊别的。
确实,如果完全不了解 JDK 线程池运行规则,按照直觉来说,我也会觉得应该是,不管是核心还是最大线程数,有任务来了应该先把线程池里面可用的线程用完了,然后再把任务提交到队列里面去排队。
可惜 JDK 的线程池,就是反直觉的。
那有符合我们直觉的线程池吗?
有的,你经常用的的 Tomcat ,它里面的线程池的运行过程就是先把最大线程数用完,然后再提交任务到队列里面去的。
我带你剖析一下。
Tomcat线程池
先打开 Tomcat 的 server.xml 看一下:
眼熟吧?哪一个学过 java web 的人没有配置过这个文件?哪一个配置过这个文件的人没有留意过 Executor 配置?
具体的可配置项可以查看官方文档:
http://tomcat.apache.org/tomcat-9.0-doc/config/executor.html
同时我找到一个可配置的参数的中文说明如下:
注意其中的第一个参数是 className,图片中少了个字母 c。
然后还有两个参数没有介绍,我补充一下:
1.prestartminSpareThreads:boolean 类型,当服务器启动时,是否要创建出最小空闲线程(核心线程)数量的线程,默认值为 false 。
2.threadRenewalDelay:long 类型,当我们配置了 ThreadLocalLeakPreventionListener 的时候,它会监听一个请求是否停止。当线程停止后,如果有需要,会进行重建,为了避免多个线程,该设置可以检测是否有 2 个线程同时被创建,如果是,则会按照该参数,延迟指定时间创建。 如果拒绝,则线程不会被重建。默认为 1000 ms,设定为负值表示不更新。
我们主要关注 className 参数,如果不配置,默认实现是:
org.apache.catalina.core.StandardThreadExecutor
我们先解读一下这个方法(注意,本文中 Tomcat 源码版本号为:10.0.0-M4):
org.apache.catalina.core.StandardThreadExecutor#startInternal
从 123 行到 130 行,就是构建 Tomcat 线程池的地方,很关键,我解读一下:
123行
taskqueue = new TaskQueue(maxQueueSize);
创建一个 TaskQueue 队列,这个队列是继承自 LinkedBlockingQueue 的:
该队列上的注释值得关注一下:
主要是说这是一个专门为线程池设计的一个任务队列。配合线程池使用的时候和普通队列有不一样的地方。
同时传递了一个队列长度,默认为 Integer.MAX_VALUE:
124行
TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
构建一个 ThreadFactory,其三个入参分为如下:
namePrefix:名称前缀。可以指定,其默认是“tomcat-exec-”。
daemon:是否以守护线程模式启动。默认是 true。
priority:线程优先级。是一个 1 到 10 之前的数,默认是 5。
125行
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);
构建线程池,其 6 个入参分别如下:
这个具体含义我就不解释了,和 JDK 线程池是一样的。
只是给大家看一下默认参数。
另外还需要十分注意的一点是,这里的 ThreadPoolExecuteor 是 Tomcat 的,不是 JDK 的,虽然名字一样。
看一下 Tomcat 的 ThreadPoolExecuteor注释,里面提到了两个点,一是已提交总数,二是拒绝策略。后面都会讲到。
126行
executor.setThreadRenewalDelay(threadRenewalDelay);
设置 threadRenewalDelay 参数。不是本文重点,可以先不关心。
127 - 129行
if (prestartminSpareThreads) {
executor.prestartAllCoreThreads();
}
设置是否预启动所有的核心线程池,这个参数在之前文章中也有讲到过。
prestartminSpareThreads 参数默认是 false。但是我觉得这个地方你设置为 true 也是多次一举。完全没有必要。
为什么呢?
因为在 125 行构建线程池的时候已经调用过这个方法了:
从源码可以看出,不管你调用哪一个线程池构造方法,都会去调用 prestartAllCoreThreads 方法。
所以,这算不算 Tomcat 的一个小 Bug 呢?快拿起你的键盘给它提 pr 吧。
130行
taskqueue.setParent(executor);
这行代码非常关键。没有这行代码,Tomcat 的线程池则会表现的和 JDK 的线程池一样。
拿下面的程序举例:
自定义线程池最多可以容纳 150+300 个任务。
当 24 行注释的时候,Tomcat 线程池运行的过程和 JDK 线程池的运行过程一样,运行的线程数只会是核心程序数 5。
当 24 行取消注释的时候,Tomcat 线程池就会一直创建线程个数到 150 个,然后把剩下的任务提交到自定义的 TaskQueue 队列里面去。
我再提供一个复制粘贴直接运行版本,你分别运行一下,试一试,看看结果:
public class TomcatThreadPoolExecutorTest {
public static void main(String[] args) throws InterruptedException {
String namePrefix = "why不止技术-exec-";
boolean daemon = true;
TaskQueue taskqueue = new TaskQueue(300);
TaskThreadFactory tf = new TaskThreadFactory(namePrefix, daemon, Thread.NORM_PRIORITY);
ThreadPoolExecutor executor = new ThreadPoolExecutor(5,
150, 60000, TimeUnit.MILLISECONDS, taskqueue, tf);
//taskqueue.setParent(executor);
for (int i = 0; i < 300; i++) {
try {
executor.execute(() -> {
logStatus(executor, "创建任务");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
Thread.currentThread().join();
}
private static void logStatus(ThreadPoolExecutor executor, String name) {
TaskQueue queue = (TaskQueue) executor.getQueue();
System.out.println(Thread.currentThread().getName() + "-" + name + "-:" +
"核心线程数:" + executor.getCorePoolSize() +
" 活动线程数:" + executor.getActiveCount() +
" 最大线程数:" + executor.getMaximumPoolSize() +
" 总任务数:" + executor.getTaskCount() +
" 当前排队线程数:" + queue.size() +
" 队列剩余大小:" + queue.remainingCapacity());
}
}
接着就去分析这行代码的用途,看看这一行代码,怎么就反转了 JDK 线程池的运行过程。
源码之下无秘密
如果你对 JDK 线程池的源码熟悉一点的话,你大概能猜到 Tomcat 肯定是在控制新建线程的地方做了手脚,也就是下面这个地方:
PS:需要说明一下的是,上面的截图是 JDK 线程池的 execute 方法。因为 Tomcat 线程池的提交也是复用的这个方法。但是 workQueue 不是同一个队列。
那你先把工作流程和各个参数都摸熟了,然后写个 Demo ,接着去疯狂的 Debug 吧。然后你总会找到这个地方的,而且你会发现,不难找。
好了,上面主要关注我圈起来的部分。
在截图的 1371 行,如果没有把任务成功放到队列里面(前提是线程池是运行状态),则会执行 1378 行的逻辑,而这个逻辑,就是创建非核心线程的逻辑。
所以,经过上面的推导之后,一切都清晰了,Tomcat 只需要在自定义队列的 offer 方法中做文章即可。
所以,我们重点关注一下该方法:
org.apache.tomcat.util.threads.TaskQueue#offer
为了更加直观的看出来其运行流畅,我在第 80 行打了个断点运行程序如下:
可以看到里面的几个参数,下面的讲解会用到这里面的参数:
第一个 if 判断
首先第一个 if,判断 parent 是否为空:
从断点运行参数截图可以看出,这里的 parent 就是 Tomcat 的 ThreadPoolExecutor 类。
当 parent 为 null 时,直接调用原始的 offer 方法。
所以,还记得我前面说的吗?
现在你知道为什么了吧?
源码,就是这个源码。道理,就是这么个道理。
所以,这里不为空,不满足条件,进入下一个 if 判断。
第二个 if 判断
首先,需要明确的是,能进入到第二个判断的时候,当前运行中的线程数肯定是大于等于核心线程数(因为已经在执行往队列里面放的逻辑了,说明核心线程数肯定是满了),小于最大线程数的。
其中 getPoolSize 方法是获取线程池中当前运行的线程数量:
所以,第二个 if 判断的是运行中的线程数是否等于最大线程数。如果等于,说明所有线程都在工作了,把任务扔到队列里面去。
从断点运行参数截图可以看到, 当前运行数为 5 ,最大线程数为 150。不满足条件,进入下一个 if 判断。
第三个 if 判断
首先我们看看 getSubmittedCount 获取的是个什么玩意:
getSubmittedCount 获取的是当前已经提交但是还未完成的任务的数量,其值是队列中的数量加上正在运行的任务的数量。
从断点运行参数截图可以看到,当前情况下该数据为 6。
而 parent.getPoolSize() 为 5。
不满足条件,进入下一个 if 判断。
但是这个地方需要多说一句的是,如果当已经提交但是还未完成的任务的数量小于线程池中运行线程的数量时,Tomcat 的做法是把任务放到队列里面去,而不是立即执行。
其实这样想来也是很符合逻辑且简单的做法的。
反正有空闲的线程嘛,扔到队列里面去就被空闲的线程消费了。又何必立即执行呢?破坏流程不说,还需要额外实现。
出力不讨好。没必要。
第四个 if 判断
这个判断就很关键了。
如果当前运行中的线程数量小于最大线程数,返回 false。
注意哦,前面的几个 if 判断都是不满足条件就放入队列哦。而这里是不满足条件,就返回 false。
返回 false 意味着什么?
意味着要执行 1378 行代码,去创建线程了呀。
所以,整个流程图大概就是这样:
再聊聊拒绝策略
拒绝策略需要看这个方法:
org.apache.tomcat.util.threads.ThreadPoolExecutor#execute(java.lang.Runnable, long, java.util.concurrent.TimeUnit)
看一下该方法上的注释:
如果队列满了,则会等待指定时间后再次放入队列。
如果再次放入队列的时候还是满的,则抛出拒绝异常。
这个逻辑就类似于你去上厕所,发现坑位全都被人占着。这个时候你的身体告诉你,你括弧肌最多还能在忍一分钟。
于是,你掐着表在门口,深呼吸,闭眼冥想,等了一分钟。
运气好的,再去一看:哎,有个空的坑位了,赶紧占着。
运气不好,再去一看:哎,还是没有位置,怎么办呢?抛异常吧。具体怎么抛就不说了,自行想象。
所以我们看看这个地方,Tomcat 的代码是怎么实现的:
catch 部分首先判断队列是不是 Tomcat 的自定义队列。如果是,则进入这个 if 分支。
关键的逻辑就在这个 if 判断里面了。
可以看到 172 行:
if (!queue.force(command, timeout, unit))
调用了队列的 force 方法。我们知道 BlockingQueue 是没有 force 方法的。
所以这个force 是 Tomcat 自定义队列特有的方法:
public boolean force(Runnable o, long timeout, TimeUnit unit) throws InterruptedException {
if (parent == null || parent.isShutdown())
throw new RejectedExecutionException(sm.getString("taskQueue.notRunning"));
//forces the item onto the queue, to be used if the task is rejected
return super.offer(o,timeout,unit);
}
进去一看发现:害,也不过如此嘛。就是对原生 offer 方法的一层包装而已。
如果成功加入队列则万事大吉,啥事没有。
如果没有成功加入队列,则抛出异常,并维护 submittedCount 参数。
前面说过:submittedCount 参数 = 队列中的任务个数 + 正在运行的任务数。
所以,这里需要进行减一操作。
拒绝策略就说完了。
但是这个地方的源码是我带你找到的。如果你想自己找到应该怎么操作呢?
你想啊,你是想测试拒绝策略。那只要触发其拒绝策略就行了。
比如下面这样:
给一个只能容纳 450 个任务的线程池提交 500 个任务。
然后就会抛出这个异常:
就会找到 Tomcat 线程池的 174 行:
然后你打上一个断点,玩去吧。
那我们刚刚说的,可以在门口等一分钟再进坑是怎么回事呢?
我们把参数告诉线程池就可以了,比如下面这样:
然后再去运行,因为队列满了后,触发拒绝异常,然后等 3 秒再去提交任务。而我们提交的一个任务 2 秒就能被执行完。
所以,这个场景下,所有的任务都会被正常执行。
现在你知道为了把你给它的任务尽量快速、全部的执行完成,Tomcat有多努力了吗?
小彩蛋
在看 Tomcat 自定义队列的时候我发现了作者这样的注释:
这个地方作用是把 forcedRemainingCapacity 参数设置为 0。
这个参数是在什么时候设置的呢?
就是下面这个关闭方法的时候:
org.apache.tomcat.util.threads.ThreadPoolExecutor#contextStopping
可以看到,调用 setCorePoolSize 方法之前,作者直接把 forcedRemainingCapacity 参数设置为了 0。
注释上面写的原因是JDK ThreadPoolExecutor.setCorePoolSize 方法会去检查 remainingCapacity 是否为 0。
至于为什么会去做这样的检查,Tomcat 的作者两次表示:I don't see why。I did not understand why。
so,他 fake 了 condition。
总之就是他说他也不明白为什么JDK 线程池 setCorePoolSize 方法调小核心线程池的时候要的限制队列剩余长度为 0 ,反正这样写就对了。
别问,问就是规定。
于是我去看了 JDK 线程池的 setCorePoolSize 方法,发现这个限制是在 jdk 1.6 里有,1.6 之后的版本对线程池进行了大规模的重构,取消了这个限制:
那 Tomcat 直接设置为 0 会带来什么问题呢?
正常的逻辑是队列剩余大小 = 队列长度 - 队列里排队的任务数。
而当你对其线程池(队列长度为300)进行监控的时候正常情况应该是这样:
但是当你调用 contextStopping 方法后可能会出现这样的问题:
很明显不符合上面的算法了。
好了,如果你们以后需要对 Tomcat 的线程池进行监控,且 JDK 版本在 1.6版本以上。那你可以去掉这个限制,以免误报警。
好了,恭喜你,朋友。又学到了一个基本用不上的知识点,奇怪的知识又增加了一点点。
Dubbo 线程池
这里再扩展一个 Dubbo 的线程池实现。
org.apache.dubbo.common.threadpool.support.eager.EagerThreadPoolExecutor
你可以看一下,思想还是这个思想:
但是 execute 方法有点不一样:
从代码上看,这里放入失败之后又立马调了一次 offer 方法,且没有等待时间。
也就是说两次 offer 的间隔是非常的短的。
其实我不太明白为什么这样去写,可能是作者留着口子好扩展吧?
因为如果这样写,为什么不直接调用这个方法呢?
java.util.concurrent.LinkedBlockingQueue#offer(E)
也是作者是想在极短的时间能赌一下吧?谁知道呢?
然后可以发现该线程池在拒绝策略上也做了很大的文章:
可以看到日志打印的非常详尽,warn 级别:
dumpJStack 方法,看名字也知道它是要去 Dump 线程了,保留现场:
在这个方法里面,他用了 JDK 默认的线程池,去异常 Dump 线程。
等等,阿里开发规范不是说了不建议用默认线程池吗?
其实这个规范看你怎么去拿捏。在这个场景下,用自带的线程池就能满足需求了。
而且你看第二个红框:提交之后就执行了 shutdown 方法,上面还给了个贴心警告。
必须要 shutdown 线程池,不然会导致 OOM。
这就是细节呀,朋友们。魔鬼都在细节里!
这里为什么用的 shutdown 不是 shutdownNow ?他们的区别是什么?为什么不调用 shutdown 方法会 OOM?
知识点呀,朋友们,都是知识点啊!
好了,到这里本文的分享也到了尾声。
以后当面试官问你 JDK 线程池的运行流程的时候,你答完之后画风一转,再来一个:
其实我们也可以先把最大线程数用完,然后再让任务进入队列。通过自定义队列,重写其 offer 方法就可以实现。目前我知道的 Tomcat 和 Dubbo 都提供了这样策略的线程池。
轻描淡写之间又装了一个漂亮逼。让面试官能进入下一个知识点,让你能更多的展现自己。
最后说一句(求关注)
本文主要介绍了 Tomcat 线程池的运行流程,和 JDK 线程池的流程比起来,它确实不一样。
而 Tomcat 线程池为什么要这样做呢?
其实就是因为 Tomcat 处理的多是 IO 密集型任务,用户在前面等着响应呢,结果你明明还能处理,却让用户的请求入队等待?
这样不好,不好。
说到底,又回到了任务类型是 IO 密集型还是 CPU 密集型这个话题上来。
有兴趣的可以看看我的这篇文章:《如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。》
点个赞吧,周更很累的,不要白嫖我,需要一点正反馈。
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。
我是why技术,一个不是大佬,但是喜欢分享,又暖又有料的四川好男人。
欢迎关注公众号【why不止技术】,坚持输出原创。分享技术、品味生活,愿你我共同进步。