很多人刚接触 Java 线程池ThreadPoolExecutor的时候,有过这样的疑问嘛
就是我把核心线程数(corePoolSize)设为 5,最大线程数(maximumPoolSize)设为 10,任务队列容量设为 100。
按照常理来说,如果有 6 个并发任务进来了,核心线程满了,那线程池是不是应该赶紧创建第 6 个线程,早点把任务做完?
实现上处理的根本不这样!第 6 个任务并没有触发新线程的创建,而是进队列排队去了。只有当这 100 个队列位置全部塞满,第 106 个任务进来时,线程池才会去创建第 6 个线程(非核心线程)。
不理解的是:我有闲置的算力(最大线程 10),为什么非要让任务在队列里卡着,不立刻新建线程去干活呢?
线程池是怎么跑任务的?
为了搞清楚这个问题,我们先简单看看 JDK 官方对线程池的执行逻辑是怎么设定的。
当一个任务丢给线程池,它的处理逻辑其实很简单,就四步:
核心线程没满:没得说,直接开个新线程去跑任务,哪怕有其他核心线程闲着也新建。
核心线程满了:那就把任务塞进队列里,让它排队等着。
队列也塞满了:这时候如果线程数还没到最大线程数,那就再开个临时线程来跑任务。
队列满了,最大线程数也到了:那就执行拒绝策略。
为什么是先队列,后最大线程?
线程池的本质其实是限流和缓冲,不是一上来就无脑拉满 CPU。
想想如果把逻辑反过来:核心线程满了 -> 立刻创建最大线程 -> 最大线程满了再进队列排队,会出现什么情况?
新建线程是非常重的操作。Java 新建一个线程不仅要在 JVM 层面分配内存,默认一个线程栈就是 1MB,还得通过系统调用在操作系统内核创建对应的物理线程。如果瞬间涌入上千个任务,线程池直接创建上千个线程,会引发 CPU 剧烈抖动,重则导致内存溢出 OOM。
线程多并不等于处理速度快。CPU 的核心数是有限的,几百个线程同时争抢几个 CPU 核心,系统会频繁触发上下文切换,大量CPU时间都浪费在保存和恢复线程现场上,真正用来跑业务代码的时间反而变少了,这就是为什么线程多处理的性能反而会变慢了。
所以用一个队列在中间做缓冲,是非常明智的手段。
类比一下
这种设计其实生活中很多地方都有应用,你看银行办业务:银行大堂里平时就开 3 个常规窗口(对应核心线程)。这时候来了第 4 个办业务的顾客,大堂经理肯定不会立刻从后台叫个值班经理出来,为他单独新开一个临时窗口。相反经理会指指旁边的长椅(对应工作队列),先去那坐着排个号。
只有当长椅坐满了(队列满了),大堂里人多了 ,眼看就要挤爆了,大堂经理才会赶紧让后台的值班人员出来,增开几个临时窗口(对应最大线程)加紧处理。
如果连个站脚的地方都没了(最大线程也满了,队列也爆了),那保安只能在门口挂牌子:今日业务已满,请明天再来(对应拒绝策略)。
通过这种先排队,实在不行再开临时窗口的设计,线程池能够以很小的资源开销,应对瞬时的大流量。
能不能先起最大线程,再去排队?
JDK 线程池之所以这么设计,核心是为了保护服务器资源,适合跑那种后台任务、批处理等不太在乎响应延迟的场景。
但如果你碰上像Tomcat这样的 Web 服务器,这套逻辑就非常尴尬了。
用户发起一个 HTTP 请求,对响应速度要求极高。如果核心线程满了,请求被丢进队列排队,哪怕这时候最大线程数没满,CPU 也闲着,用户也只能盯着网页一直转圈,用户体验极差。
作为 Web 服务器,我们更希望的是:能多开线程就多开线程,让任务立刻被执行,实在开不出新线程了,再丢进队列排队。
Tomcat 专门定制了自己的一套线程池。
Tomcat 怎么实现先建线程再排队?
Tomcat 并没有重新写一遍ThreadPoolExecutor,是改造了工作队列。JDK 线程池源码里尝试把任务丢进队列的地方:
// JDK ThreadPoolExecutor 源码片段 if (isRunning(c) && workQueue.offer(command)) { // 入队成功,等待核心线程消费 } else if (addWorker(command, false)) { // 入队失败,尝试创建非核心线程(也就是最大线程数以内的线程) }线程池会不会创建新线程,完全取决于workQueue.offer(command)是否返回false,也就是入队失败。
Tomcat 通过继承LinkedBlockingQueue,实现了一个TaskQueue,并重写了offer方法:
@Override public boolean offer(Runnable o) { // 1. 如果当前线程数等于最大线程数,没得新建了,直接乖乖入队 if (parent.getPoolSize() == parent.getMaximumPoolSize()) { returnsuper.offer(o); } // 2. 如果当前提交的任务数,小于等于当前已有的线程数 // 说明现有的线程够用,不需要新建线程,直接入队 if (parent.getSubmittedCount() <= parent.getPoolSize()) { returnsuper.offer(o); } // 3. 【核心逻辑】如果当前线程数少于最大线程数 // 我们希望线程池去创建新线程,而不是排队! // 所以这里直接返回 false,欺骗线程池“队列已满”,逼迫它去创建新线程! if (parent.getPoolSize() < parent.getMaximumPoolSize()) { returnfalse; } // 默认入队 returnsuper.offer(o); }通过这一段逻辑,Tomcat 成功实现了:核心线程满 -> 继续创建最大线程 -> 最大线程满 -> 进入队列排队 -> 队列满 -> 触发拒绝。
说在最后
很多开发兄弟在配置线程池参数时,喜欢顺手写个new LinkedBlockingQueue()。
别忘了这个无参构造器默认的队列容量是Integer.MAX_VALUE,接近 21 亿,这意味着你的队列几乎永远不可能被塞满。
在这种情况下,你配置的maximumPoolSize最大线程数直接成了摆设,任务全在队列里排队,一旦并发量上来,不仅响应慢,还极容易发生内存溢出。
不要盲目配置线程池,要永远根据实际业务场景来,比如:
如果是高并发、低延迟的接口,可以参考 Tomcat 的思路,定制队列优先创建线程;
如果是后台批处理、高吞吐的业务,用 JDK 默认逻辑配合一个合理大小的有界队列,才是最稳妥的方案。