线程池是多线程编程的基础设施:复用线程避免频繁创建销毁的开销(线程创建约 1ms + 栈空间约 1MB),控制并发度防止资源耗尽(无限创建线程 → OOM 或系统崩溃)。但线程池的参数设计是一门需要理解的学问——不是拍脑袋填数字。Java 的 ThreadPoolExecutor 有七个参数,每个参数的选择都会影响线程池的行为。本篇讲清参数体系与设计方法。
七个参数:每个参数在管什么
corePoolSize(核心线程数):线程池的常驻线程数——即使空闲也不回收(除非设置 allowCoreThreadTimeOut)。maximumPoolSize(最大线程数):线程池的最大线程数——队列满时才会创建超过 corePoolSize 的线程。keepAliveTime + unit:非核心线程的空闲存活时间——超过后回收。workQueue(工作队列):核心线程忙时,新任务先入队等待;队列满时才创建非核心线程。threadFactory(线程工厂):自定义线程的名称、优先级、守护属性——给线程起名字是排查问题的第一步。handler(拒绝策略):队列满且线程数达最大时新任务的处理方式。
参数间的联动逻辑(Java ThreadPoolExecutor 的执行顺序):新任务提交 → 核心线程有空闲则执行 → 核心线程满则入队 → 队列满则创建非核心线程(直到 maximumPoolSize) → 线程数达最大则执行拒绝策略。这个顺序有一个著名的反直觉:先入队再扩线程——与很多开发者直觉的「先扩线程再入队」相反——这意味着如果你用一个无界队列(如 LinkedBlockingQueue),队列永远不会满,非核心线程永远不会被创建(maximumPoolSize 参数形同虚设)。
队列的选择:有界 vs 无界
无界队列(LinkedBlockingQueue 默认):队列不限长度——好处是任务永不拒绝;致命缺点:队列无限增长导致内存溢出(OOM),且 maximumPoolSize 参数失效(队列不满就不创建新线程)。适用场景:任务量可控且处理速度稳定(如固定频率的定时任务)。有界队列(ArrayBlockingQueue):队列有上限——队列满时触发创建非核心线程 → 线程满时触发拒绝策略。适用场景:需要防止资源耗尽(生产环境推荐)——有界队列是线程池「自我保护」的前提。SynchronousQueue:不存储任务——每个入队操作必须等待一个取队操作——效果是「有任务就创建新线程(直到最大)」,没有排队——Executors.newCachedThreadPool 使用此队列(线程数无上限——慎用)。队列大小的确定:与「任务能容忍的等待时间」相关——队列大小 = 处理速率 × 可容忍等待时间(Little's Law 的应用)。
拒绝策略:四种选择与自定义
AbortPolicy(默认):抛出 RejectedExecutionException——调用方感知到过载(这是最好的默认策略:让调用方知道系统过载,而不是静默丢弃)。CallerRunsPolicy:由提交任务的线程自己执行——起到「反压」效果:提交线程被占住执行任务,无法继续提交新任务,自然降低了提交速率——但它阻塞的是调用方线程(如果是 Web 请求线程,会阻塞用户请求)。DiscardPolicy:静默丢弃——不推荐(默默丢任务是最难排查的 bug 来源)。DiscardOldestPolicy:丢弃队列中最老的任务,腾位置给新任务——适合「只关心最新数据」的场景(如实时监控数据——旧数据已无价值)。自定义策略:将任务持久化到消息队列或数据库,延迟重试——最适合不可丢弃的业务任务。
大小的确定:CPU 密集 vs IO 密集
CPU 密集型任务(计算、加密、压缩):线程数 = CPU 核心数 + 1(+1 是为了在某个线程因页缺失暂停时 CPU 不空闲)。IO 密集型任务(数据库查询、HTTP 调用、文件读写):线程数 = CPU 核心数 × (1 + 等待时间/计算时间)——线程大部分时间在等 IO,多线程可以提高吞吐;但公式只是起点不是终点——最佳值需要通过压力测试确定(本库《连接池》的调优纪律同样适用)。一个重要的实践建议:不同类型的任务用不同的线程池隔离——CPU 密集与 IO 密集混用同一个池会互相干扰(IO 任务的等待占满了线程,CPU 任务得不到调度)——线程池隔离是微服务架构中「舱壁隔离模式」在单机层面的实现。
线程池的监控:排队长度与活跃线程数
线程池的运行状态需要监控才能发现瓶颈——两个关键指标。队列长度(queue size):排队等待执行的任务数——持续增长说明「处理速度跟不上提交速度」,线程池需要扩容或任务需要优化。活跃线程数(active count):正在执行任务的线程数——接近最大线程数说明线程池已满,新任务只能排队。监控的方法:定时任务每 10 秒采样一次 executor.getQueue().size() 与 executor.getActiveCount(),写入 Metrics 系统(Prometheus + Grafana 面板)。监控的告警规则:队列长度持续增长(5 分钟内从 100 增长到 1000)→ 告警「线程池积压」;活跃线程数 == 最大线程数且持续 5 分钟 → 告警「线程池满载」。监控的扩展:Spring Boot 的 Actuator 自动暴露了 ThreadPoolExecutor 的指标(通过 Micrometer),可以直接接入 Prometheus——不需要手动编写采样代码。线程池监控的价值:在队列长度告警时及时扩容或优化,比等到队列溢出触发拒绝策略再排查要主动得多。
Java 21 的虚拟线程:线程池的范式转变
Java 21 正式引入了虚拟线程(Virtual Threads,Project Loom)——它改变了「线程池」这个概念的必要性。虚拟线程 vs 平台线程:平台线程是操作系统线程的包装(创建成本高,1MB 栈空间,受限于 OS 线程数);虚拟线程由 JVM 管理(创建成本极低,栈空间按需分配,可创建百万级虚拟线程)——虚拟线程是「轻量的可阻塞线程」。对线程池的影响:虚拟线程的轻量性意味着不需要「复用」(创建比复用更便宜),不需要「池化」(百万级虚拟线程不耗资源)——Thread.ofVirtual() 直接创建,用完即弃,不需要线程池。对 IO 密集型任务的影响:虚拟线程在 IO 阻塞时自动「卸载」(释放底层平台线程给其他虚拟线程使用)——IO 密集型任务不再需要「线程池大小 = CPU × (1 + 等待/计算)」的调优——百万个虚拟线程同时等待 IO 也不会耗尽平台线程。仍然需要线程池的场景:CPU 密集型任务仍然需要控制并发度(虚拟线程不能增加 CPU 核心数),以及需要限制对下游的并发数(保护下游不被打垮)。Java 21 的虚拟线程是「线程池存在意义」的范式挑战——IO 密集场景不再需要池化,CPU 密集场景仍然需要限制并发。
Java 21 虚拟线程对线程池的范式挑战
Java 21 正式引入了虚拟线程(Virtual Threads Project Loom)——它改变了线程池这个概念的必要性。虚拟线程与平台线程的区别:平台线程是操作系统线程的包装创建成本高每个约 1MB 栈空间受限于操作系统线程数;虚拟线程由 JVM 管理创建成本极低栈空间按需分配可创建百万级虚拟线程。虚拟线程是轻量的可阻塞线程。对线程池的影响:虚拟线程的轻量性意味着不需要复用(创建比复用更便宜)不需要池化(百万级虚拟线程不耗资源)——Thread.ofVirtual() 直接创建用完即弃不需要线程池。对 IO 密集型任务的影响:虚拟线程在 IO 阻塞时自动卸载释放底层平台线程给其他虚拟线程使用——IO 密集型任务不再需要线程池大小调优。仍然需要线程池的场景:CPU 密集型任务仍然需要控制并发度以及需要限制对下游的并发数保护下游不被打垮。
线程池的隔离:不同任务用不同的池
不同类型的任务混用同一个线程池会互相干扰——IO 任务的等待占满了线程 CPU 任务得不到调度。线程池隔离的最佳实践:按任务类型分池——IO 密集型任务用一个池(连接池大小可以更大因为线程大部分时间在等 IO)、CPU 密集型任务用另一个池(线程数等于 CPU 核心数避免上下文切换开销)、定时任务用独立的调度池。线程池隔离的实现:Spring 的 ThreadPoolTaskExecutor 配置多个 Bean 每个 Bean 用 @Qualifier 注入到对应的服务——或者使用 Hystrix/Resilience4j 的舱壁隔离(每个下游服务独立的线程池或信号量)。线程池隔离是舱壁隔离模式在单机层面的实现——一个任务的过载不会拖垮其他任务的线程池。
线程池的监控指标
线程池的运行状态需要监控才能发现瓶颈。两个关键指标:队列长度排队等待执行的任务数持续增长说明处理速度跟不上提交速度。活跃线程数正在执行任务的线程数接近最大线程数说明线程池已满。监控方法:定时任务每 10 秒采样一次写入 Metrics 系统。告警规则:队列长度持续增长告警活跃线程数接近最大告警。Spring Boot 的 Actuator 自动暴露了 ThreadPoolExecutor 的指标可以直接接入 Prometheus 不需要手动编写采样代码。
小结
线程池设计收成四条:七个参数的联动逻辑——先核心线程 → 再入队 → 队列满才扩非核心线程 → 线程满才拒绝,无界队列会让 maximumPoolSize 形同虚设;队列选择——生产环境用有界队列(自我保护),无界队列的 OOM 是最常见的线程池事故;拒绝策略——AbortPolicy 让调用方感知过载(好的默认),CallerRunsPolicy 起到反压效果,静默丢弃不推荐;大小确定——CPU 密集 = 核心数 + 1,IO 密集 = 核心数 × (1 + 等待/计算),公式是起点压测是终点。线程池的最佳实践一句话:给线程起名字、用有界队列、隔离不同类型的任务——这三条纪律可以避免 90% 的线程池事故。
参考资料
- Java ThreadPoolExecutor 官方文档 —— 参数语义的权威说明
- 本库前篇:《一篇读懂限流器》《操作系统内功 · 第 1 篇|进程与线程》
- Brian Goetz, 《Java Concurrency in Practice》—— Java 并发编程的圣经
读者留言
COMMENTS 暂无还没有留言,来说第一句?