线程池的 maxPoolSize 为何迟迟不生效:队列排在扩容前面

线程池把线程复用、任务排队与过载拒绝收进一个类:corePoolSize 是常驻柜台,workQueue 是等候区,maximumPoolSize 是临时加开的窗口,拒绝策略负责客满后的处置。很多人以为压力一上来就会先扩到 max,实际提交顺序是 core、队列、max,队列没满就不会新建线程,无界队列更会让 max 永远不生效。另一个常见误区是把拒绝策略当兜底随便选 Discard,任务被静默丢掉却无人知晓。读完你能说清七个参数如何协作、四种拒绝策略各自适合什么场景,以及怎样用有界队列和监控把线程池调到既不过载也不空转。

是什么

线程池(ThreadPoolExecutor)是 JDK 提供的任务执行器:它维护一组可复用的工作线程,提交的任务先尝试交给空闲线程,交不出去就放进阻塞队列排队,队列也满了才决定是扩容还是拒绝。一句话直觉:它像银行网点——corePoolSize 是常开柜台,workQueue 是等候区座位,maximumPoolSize 是客满时临时加开的窗口,keepAliveTime 是加开窗口的空闲保留时间,拒绝策略则是大堂经理在客满后的处置办法。

它出现的场景几乎覆盖所有服务端代码:请求处理、RPC 回调、批量导表、异步通知、定时补偿。直接用 new Thread 的问题是每个任务一条线程,创建销毁要陷入内核、每条线程的栈要占内存、线程总数没有上限;线程池把"线程"这个昂贵资源变成了可配额、可观测、可拒绝的资源。

核心原理

先看 execute 的判定顺序,这是整篇的核心。提交一个任务时,线程池只做四步:

  1. 工作线程数小于 corePoolSize:直接新建一个核心线程执行,任务根本不进队列。
  2. 否则尝试 workQueue.offer(task) 入队。入队成功后还要二次检查:线程池是否已不是 RUNNING(是则把任务移出队列并拒绝),以及当前工作线程数是否为 0(是则补一个非核心线程去消费)。
  3. 入队失败,也就是队列满:尝试新建线程,直到 maximumPoolSize。
  4. 仍然失败:把任务交给 RejectedExecutionHandler。

关键在第二步和第三步的先后:队列排在扩容前面。只要队列还能塞得下,线程数就停在 core,不会因为"任务变多"而涨到 max。所以 maximumPoolSize 生效的前提是队列先满,而不是并发先高。

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        1, 2, 60, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(2),
        new ThreadPoolExecutor.AbortPolicy());
for (int i = 1; i <= 5; i++) {
    final int id = i;
    try {
        pool.execute(() -> {
            System.out.println("run " + id + " @ " + Thread.currentThread().getName());
            try { Thread.sleep(200); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        System.out.printf("submit %d -> pool=%d queue=%d%n",
                id, pool.getPoolSize(), pool.getQueue().size());
    } catch (RejectedExecutionException e) {
        System.out.println("reject " + id);
    }
}
pool.shutdown();

任务体足够长时,输出大致是:submit 1 时 pool=1、queue=0;submit 2 时 pool=1、queue=1;submit 3 时 pool=1、queue=2;submit 4 时 pool=2、queue=2;submit 5 直接 reject。前三个任务只占一个线程,第四个把队列顶满后才加开第二个线程,第五个才触发拒绝。这段输出把"先排队、后扩容"演示得很直白。

队列选型直接决定这套逻辑的表现。ArrayBlockingQueue 有界,满了就扩容或拒绝;LinkedBlockingQueue 默认容量是 Integer.MAX_VALUE,接近无界,max 沦为装饰,积压还会把堆吃掉;SynchronousQueue 不存储元素,只有正好有线程在等着接手时 offer 才成功,否则立刻失败,于是任务一来就扩容——newCachedThreadPool 正是 core=0、max=Integer.MAX_VALUE、60 秒回收的组合,适合短小突发任务,不适合长任务或下游很慢的场景;PriorityBlockingQueue 与 DelayQueue 同样无界,适合优先级和定时,但一样有积压风险。

所以队列容量其实是一个隐形并发参数。设 core=8、max=64、队列容量 1000,稳态并发就是 8,只有积压到 1000 才会短暂冲到 64;反过来队列只有 16,系统会更早扩容、更早拒绝,延迟更可控但拒绝率更高。调线程池,本质是在排队延迟、线程数和拒绝率三者之间选一个位置。

线程也不是一开始就全建好。默认按需创建,提交第一个任务才建第一条线程;想让核心线程提前就位可以调 prestartAllCoreThreads()。工作线程执行完手头任务后会循环取下一个:核心线程用 take() 一直等,非核心线程用 poll(keepAliveTime) 等一段时间拿不到就退出,线程数回落到 core。默认核心线程不会因空闲被回收,keepAliveTime 只约束超出 core 的那部分,除非显式 allowCoreThreadTimeOut(true)。

拒绝策略只有四种内置实现,语义差别很大。AbortPolicy 是默认值,直接抛 RejectedExecutionException,把压力显式抛给调用方,适合不能丢任务的场景,但要求调用方捕获处理;CallerRunsPolicy 在线程池未关闭时,让提交任务的线程自己跑这个任务,这是最朴素的反压,调用方被占住,生产速度自然降下来,代价是可能阻塞 Web 请求线程或 RPC 回调线程,把过载传导到入口;DiscardPolicy 什么都不做,任务静默消失,只在埋点采样这类可丢场景勉强可用;DiscardOldestPolicy 丢掉队列头最老的任务再重新提交当前任务,保新弃旧,适合只关心最新状态的场景,被丢的任务同样没有痕迹。

还有一层容易被忽略:拒绝不只发生在"队列满且线程到 max"时,线程池进入 SHUTDOWN 后提交的任务也会走拒绝策略;而 CallerRunsPolicy 与 DiscardOldestPolicy 都先判断 isShutdown(),关闭后既不执行也不放回队列,等于静默丢弃。

最后补一个源码层面的细节:ThreadPoolExecutor 用一个 AtomicInteger ctl 把运行状态和线程数打包在一起,高 3 位是 RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED,低 29 位是 worker 数量,所以工作线程上限是 2^29−1。状态和计数能一次 CAS 改完,不必维护两把锁,这也是面试里区分"背过参数"和"读过实现"的常见追问。

常见考点与误区

  • 误区一:任务一多就先扩到 maximumPoolSize。 面试常问"maximumPoolSize 什么时候生效"。正确顺序是 core、队列、max,队列没满就不会新建线程;用无界队列时 max 永远用不上。答"先扩容再排队"说明没读过 execute 的分支。
  • 误区二:把 maximumPoolSize 调大就能提高吞吐。 线程数受 CPU 核数、内存、下游连接池和锁竞争约束,越过拐点后上下文切换、线程栈和 GC 压力会让吞吐下降。线程池的容量上限往往不在池本身,而在它调用的数据库或第三方服务。
  • 误区三:拒绝策略随便选一个,反正很少触发。 DiscardPolicy 让任务无声消失,只看 completedTaskCount 甚至会以为一切正常。生产上要么 Abort 加捕获上报,要么 CallerRuns 做反压;Discard 系列必须配计数器或日志,否则故障时没有任何线索。
  • 误区四:核心线程空闲久了会被回收。 默认不会。keepAliveTime 只作用于超过 corePoolSize 的线程,除非显式开启核心线程超时;这也意味着 core 设得太大,低峰期资源会一直占着。
  • 误区五:submit 提交的任务抛异常,线程池会打日志。 submit 会把任务包成 FutureTask,异常被捕获进 Future,只有 get() 时才以 ExecutionException 抛出,不 get 就永远看不到;execute 提交才会走 UncaughtExceptionHandler,同时 worker 线程退出并被补建。想让异常可见,用 execute 加 try/catch,或在任务内部统一兜底。
  • 误区六:父任务和子任务共用一个池没问题。 父任务占满所有线程并在原地等子任务,子任务却还排在队列里,就会死锁;有界队列加满池时更糟。需要任务拆分时用 ForkJoinPool 的工作窃取,或给父子任务隔离的池。
  • 考点七:shutdown 与 shutdownNow 的区别。 shutdown 停止接收新任务,已提交的继续跑完并中断空闲线程;shutdownNow 尝试中断所有线程、清空队列,返回还没开始执行的任务列表。两者都不会自动等待结束,要配合 awaitTermination。

实践建议

  • 用 new ThreadPoolExecutor 显式构造,少用 Executors 的快捷工厂:fixed 和 single 用无界队列,cached 的 max 是 Integer.MAX_VALUE,都可能把 OOM 或线程爆炸藏到流量高峰。队列给有界容量,用自定义 ThreadFactory 起可辨识的线程名,拒绝策略写清楚并写进注释。
  • 按任务类型隔离线程池:CPU 密集用接近核数的池,常见起点是 N+1;I/O 密集按等待时间估算 N×(1+W/C),但公式只是起点,最终以压测的 P99 和下游承载为准。别让所有业务共享一个大池,一个慢接口能拖垮全部调用方。
  • 让拒绝可观测:捕获 RejectedExecutionException 打点、告警、降级;CallerRunsPolicy 只放在能承受阻塞的入口,比如异步补偿,不要放在 Web 请求线程上;Discard 系列必须计数。
  • 监控 activeCount、poolSize、queue.size()、completedTaskCount、largestPoolSize。队列长期非空说明容量不足或下游变慢,此时加 max 往往只是把问题推给数据库,先限流、再扩容。
  • 优雅关闭按 shutdown、awaitTermination、必要时 shutdownNow 的顺序做,并把返回的未执行任务落盘或补偿;线程复用的池里,ThreadLocal 必须在 finally 里 remove,否则脏数据和类加载器泄漏会跟着线程一起长寿。
  • Java 21 的虚拟线程解决的是"阻塞 I/O 下线程太贵",不是"可以无限提交":用 newVirtualThreadPerTaskExecutor,并把并发上限交给 Semaphore 或下游连接池,不必再套一个固定大小的 ThreadPoolExecutor。

一句话总结:线程池的并发上限不是 maximumPoolSize,而是 core、队列容量与拒绝策略共同画出的那条线。

相关阅读

← 返回文章志