
线程池这个东西Java 程序员绕不开面试躲不掉线上出了毛病还得背锅。我在项目里用 ThreadPoolExecutor 踩过不少坑也看过各种同事写出花式线程池今天把这些年的经验沉淀一下从原理到配置到排查一次说清楚。不管你是刚学 Java 基础还是已经写了几年业务代码这份指南应该都能帮你在“会用线程池”这件事上更近一步。1. 线程池到底解决了什么问题1.1 为什么不能“随用随 new Thread”很多刚接触 Java 并发的人都有过这样的经历处理一个耗时的 IO 操作直接new Thread(() - { ... }).start()代码简单直接跑起来也没毛病直到有一天流量上来了。线程不是免费的午餐。JVM 每创建一个线程都要向操作系统申请内存作为线程栈默认栈大小通常是 1MB不同平台和 JVM 参数有差异。线程创建和销毁本身就是一次系统调用会涉及用户态和内核态的切换频繁创建销毁线程CPU 资源就耗在这上面了。而且线程数量一旦多起来上下文切换的开销会成倍增长最终出现“线程越多处理越慢”的诡异现象。进程的线程切换和食堂打饭很像。窗口就那么几个排队的人越多切换队伍消耗的时间越长真正打上饭的效率反而上不去。线程池的思路其实特别朴素——把线程统一管理起来复用已创建的线程避免反复创建销毁的开销同时通过队列容纳暂时处理不过来的任务再用拒绝策略守住系统不被拖垮。本质上它就是一套“线程资源池化 任务队列缓冲 饱和保护机制”的组合。1.2 核心参数逐个拆解既然要用线程池就必须把 ThreadPoolExecutor 构造函数的七个参数彻底搞明白public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)corePoolSize核心线程数线程池里保底存在的线程数量即使它们空闲着也不会被回收除非设置了allowCoreThreadTimeOut(true)。新任务到达时如果当前线程数小于核心线程数会优先创建线程而不是把任务丢进队列。这一点很关键很多人误以为“先丢队列”实际是先填满核心线程。maximumPoolSize最大线程数线程池最多能有多少个线程。当队列也满了的时候线程池才会继续创建线程直到达到这个上限。keepAliveTime空闲存活时间超过核心线程数的那部分线程在空闲指定时间后会被回收销毁。如果任务波动很大这个参数能让线程池在高峰过后自动“瘦身”。workQueue任务队列核心线程都忙的时候新任务会放进这个队列里排队等待。队列类型和容量直接决定了线程池的行为模式。threadFactory线程工厂创建新线程的工厂主要用来给线程取一个有辨识度的名字、设置是否是守护线程。这个参数太容易被忽略但排障时救命靠的就是它。handler拒绝策略线程数达到 maximumPoolSize 且队列已满再也塞不下任务的时候新任务会走这个策略。1.3 任务执行的完整流程一张流程图比一大段话更直观但这里我们用文字梳理一遍执行链路提交一个新任务到线程池。判断当前线程数是否小于 corePoolSize是——直接创建核心线程执行任务否——走第 3 步。判断任务队列是否还能容纳能——把任务放入队列否——走第 4 步。判断当前线程数是否小于 maximumPoolSize是——创建非核心线程执行任务否——走第 5 步。执行拒绝策略。这个流程里最容易产生误解的点在第 3 步是先到队列还是先创建新线程答案很明确当核心线程数满了之后会先尝试排队队列满了才会开新线程。如果你配了一个无边界的队列那么 maximumPoolSize 这个参数实际上就白配了因为队列永远不会满线程数永远不会超过核心数。2. 阻塞队列选择与核心参数配置实战2.1 四种常见阻塞队列的对比阻塞队列是线程池的“蓄水池”它的类型直接决定了线程池的脾气秉性。实际工作中见过和用过的队列主要有下面几种队列类型特点适用场景ArrayBlockingQueue有界队列容量固定必须指定大小推荐系统、订单处理等需要控制内存的严谨场景LinkedBlockingQueue链式队列默认无界容量Integer.MAX_VALUE也可以指定容量Executors.newFixedThreadPool 用的就是它SynchronousQueue不存储元素每个插入操作必须等待一个移除操作Executors.newCachedThreadPool 用的就是它PriorityBlockingQueue无界优先队列任务可以加优先级有优先级需求的场景但不推荐用于线程池有界队列和无界队列的差异是核心。无界队列有一种隐藏风险任务永远能进队列线程不够就排队但内存会被堆满最终 OOM。你可能会想OOM 总比丢任务好吧——但你不知道任务堆积到几百万个的时候系统可能已经把别的服务拖垮了这就是“劣币驱逐良币”的线程池版本。SynchronousQueue 比较特殊它压根不存任务每个来得任务都必须立刻有一个线程接住没有线程就创建新线程。所以用 SynchronousQueue 的 CachedThreadPool会呈现出“线程数随任务波动”的特征如果任务持续高速进来线程会飙到一个很可怕的数量。2.2 核心线程数到底怎么定这个问题几乎每一场面试都会问但答案不能是背出来的公式。网上的“CPU 密集 CPU 核数 1IO 密集 CPU 核数 * 2”只是一个粗糙的起点真正落地要看业务特征。CPU 密集型任务比如加解密、大数运算、复杂计算线程数接近 CPU 核数即可核数是 N 的话N1 或者 N 就行让每个线程尽量少参与上下文切换。IO 密集型任务比如 RPC 调用、数据库读写、文件操作线程大部分时间都在等待 IO 返回阻塞越多需要的线程越多。理论上可以用线程数 CPU 核数 * (1 等待时间 / 计算时间)来估算但实践中你几乎很难精确拿到等待时间和计算时间的比值所以更实用的做法是先按一个经验值起步比如 CPU 核数 * 2 到 4再通过压测调优。另外核心线程数不是拍脑袋填一次就不管了。系统流量是有周期性的同一个接口在白天和凌晨的 TPS 差一倍很正常线程池参数也可以配合动态配置中心做调整。我在生产环境里就维护过一套动态线程池核心线程数、最大线程数都可以运营维操作实时修改这个后面再细说。2.3 线程工厂和拒绝策略这两个“小参数”的大作用线程工厂大部分人都懒得自定义但我觉得这是所有参数里“投入产出比”最高的一个。不自定义的话线程名字会叫pool-1-thread-1线上查日志时看到这个名字你想知道是哪个业务模块的线程池只能靠猜。自定义之后名字就能变成order-async-pool-thread-5一眼看出是订单异步线程池。拒绝策略有四种现实里用到的多集中在两种AbortPolicy默认直接抛 RejectedExecutionException这种方式比较暴力但至少让你知道任务确实被丢了。如果调用方能容忍这种异常可以用。CallerRunsPolicy调用者运行如果线程池处理不过来任务不会丢而是由提交任务的线程自己来执行。这个策略能起到天然的“背压”作用——调用线程既得提交任务又得执行任务它的提交速度自然就降下来了。DiscardPolicy 和 DiscardOldestPolicy直接丢弃一个抛掉新任务一个丢队列头部的旧任务。不建议在生产环境静默丢弃。我自己的习惯是如果业务不允许丢任务优先考虑 CallerRunsPolicy让调用方跟着一起扛而不是直接把异常抛出去阻断业务流程。3. Executors 内置线程池的坑与自定义线程池3.1 四种内置线程池到底能不能用Java 提供了 Executors 工具类里面有几个现成的线程池用起来确实方便一行代码就完事。但是如果你拿它们直接怼到生产环境就要做好半夜被报警电话吵醒的准备。FixedThreadPoolExecutors.newFixedThreadPool(n)核心线程数 最大线程数 n队列用的是无界 LinkedBlockingQueue。表面看很稳线程数固定但实际上队列成了无底洞。我曾经见过一个任务提交特别密集的系统用 FixedThreadPool 处理推送消息结果队列里积压了几百万条消息内存直接打满OOM 之后进程重启重启后队列清空之前积压的消息全没了。CachedThreadPoolExecutors.newCachedThreadPool()核心线程数 0最大线程数 Integer.MAX_VALUE用了 SynchronousQueue。这个线程池的特长是空闲线程 60 秒自动回收任务来了立刻能执行但是遇到突发流量它会疯狂创建线程。线程数真到了几千甚至上万光是上下文切换就能把 CPU 耗死。ScheduledThreadPool核心线程数固定最大线程数 Integer.MAX_VALUE用于定时任务和延时任务。它可以跑 scheduleAtFixedRate 这类方法但调度任务的线程池和业务线程池还是分开为好混用容易出问题。SingleThreadExecutor线程数固定为 1保证任务按顺序执行适合需要强顺序的场景但它本质上也是无界队列。我的建议是Executors 适合面试题里当反例适合快速写个 demo不适合生产环境核心链路。在真实系统里我基本都是手动 new ThreadPoolExecutor参数自己定制。3.2 自定义线程池的完整示例给一个可以直接抄作业的配置这个配置在我的项目里经过多次压测配合动态调整扛住了双十一峰值的流量ThreadPoolExecutor orderAsyncPool new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue(500), // 有界队列容量500 new NamedThreadFactory(order-async), new ThreadPoolExecutor.CallerRunsPolicy() );顺手提供一个 NamedThreadFactory 的实现你可以直接用在项目里import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public class NamedThreadFactory implements ThreadFactory { private final String prefix; private final AtomicInteger seq new AtomicInteger(1); public NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { Thread t new Thread(r, prefix -thread- seq.getAndIncrement()); t.setDaemon(false); return t; } }这块配置的核心思路上限就是“有界 有名字 不静默丢弃”。有界队列保证内存不会被无限制撕开有名字保证出了问题能快速定位到哪个线程池不静默丢弃保证任务要么执行要么明确报错不会无影无踪。3.3 动态调整参数线程池的“免死金牌”固定参数在线上有一个尴尬你调优把核心线程数从 8 调到 16得重启应用才能生效。重启意味着流量抖动意味着发布窗口这在一个 7*24 小时的服务里成本很高。ThreadPoolExecutor 提供了一个setCorePoolSize方法可以在运行期动态调整参数。你可以把这个能力暴露给配置中心比如 Apollo 或者 Nacos推送新的配置过来后调用一次即可。实际操作中还要注意一个坑setCorePoolSize传入的值如果小于当前线程数多余的线程会在空闲后回收不是立刻销毁所以“立即生效”是有延迟的。但无论如何动态调整这个能力在线上的价值非常大尤其是大促前临时扩容线程池不用重新发版这体验谁用谁知道。4. 线程池使用中的坑与排查技巧4.1 线程池和 ThreadLocal 结合的隐患这个坑非常隐蔽。线程池里的线程是复用的ThreadLocal 在普通线程里可能没什么问题但换到线程池场景就完全不一样了。假设你的业务代码里给线程设置了 ThreadLocal 值比如 traceId用户在提交任务时设置了 traceId但是线程池里的线程是复用的上一轮任务的 ThreadLocal 值没有被清理下一轮任务进来后读到的可能是上一次任务设置的脏数据。轻则日志串号重则业务逻辑判断错误。解决方案也不复杂任务执行完以后在 finally 块里显式调用ThreadLocal.remove()清理值。如果用的是线程池包裹的框架比如 Hystrix、Sentinel它们内部有自己的 Context 传递逻辑也要理清楚它们的清理时机。4.2 线程池的优雅关闭不能糊弄很多项目里对线程池的关闭处理是直接在应用停机时调用shutdown()然后就不管了也不等待队列里的任务执行完结果停机后日志里出现一堆未完成任务。线程池关闭有shutdown()和shutdownNow()两种shutdown()不再接受新任务但会等队列里的任务执行完毕这是一个“软关闭”。shutdownNow()尝试停止正在执行的任务返回队列里未执行的任务列表。注意它是“尝试”如果线程里有不可中断的操作它并不能强制终止线程。生产环境里推荐的做法是pool.shutdown(); // 先温柔地拒绝新任务 if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { pool.shutdownNow(); // 30秒后还没结束强制中断 }这段代码的意思也很直白先给任务 30 秒时间收尾收不完的直接硬打断。如果你的业务允许任务被中断这套流程是够用的如果有不允许中断的任务那就要在任务本身的逻辑里做好标记让它们自己尽快退出。4.3 线程池监控别等出事了才想起来看线程池是个黑盒子任务在里面排队还是在执行你不做监控就什么都不知道。我见过不少线上故障事后一看监控才发现队列里的任务数早就堆了几万但是没人配置告警直到 OOM 才被报警系统捞起来。既然核心线程数、队列这些参数都可查那就把它们接进监控体系。我这边的方法是写一个定时任务定期采集线程池的关键指标getActiveCount()正在执行任务的线程数。getQueue().size()队列积压的任务数。getTaskCount()历史上总共接收的任务数。getCompletedTaskCount()已经完成的任务数。把这些指标推给监控平台设置队列积压阈值告警比如队列超过 200 触发预警超过 400 触发严重告警这样至少能在系统被打挂之前提前介入。还有一个点容易被忽略任务执行时长监控。线程池执行任务是异步的调用方根本感知不到任务是不是卡住了。我习惯在每个 Runnable 的包装层里记录任务开始时间和结束时间单次任务执行超过某个阈值就打印 WARN 日志这样能及时发现某个下游接口变慢导致的连锁反应。4.4 线程池父子线程之间真的“没关系”这里说的“有关系”是指数据传递比如需要在子线程里获取当前用户信息、traceId用 ThreadLocal 在父子线程之间是有隔离的。子线程里的 ThreadLocal 是全新的读不到父线程设置的值这个坑在异步化改造时特别容易踩到。网上有一种解决方案是 InheritableThreadLocal它能让子线程继承父线程的值。但我劝你不要在真实项目里随便用它因为线程池是复用线程的第一次提交任务时子线程继承了父线程的值第二次提交任务时这个值如果没被覆盖读到的就是第一次的脏数据。更靠得住的方案是引入 TransmittableThreadLocalTTL这是阿里开源的一个工具专门解决线程池场景下 ThreadLocal 值传递的问题。它会在线程池提交任务时自动把父线程的上下文“快照”到任务上再在任务执行前“还原”到子线程本地执行后再清理。如果你的项目用了异步线程池并且有 traceId、用户上下文传递的需求TTL 几乎是最省力的选择。5. 面试题与代码审查中的高频雷区5.1 背答案没有用这些追问才是重点线程池是 Java 面试高频题考法已经从背诵七参数升级成了场景考核。面试官如果问“线程池核心线程数怎么设置”回答“CPU 密集 N1IO 密集 2N”只能拿基础分。真正的进阶回答是结合业务场景说清楚思路先分任务类型是 CPU 密集还是 IO 密集再考虑依赖的中间件有哪些比如下游数据库连接池大小会不会成为瓶颈最后说压测调优的流程先给一个初始值再通过线程池监控指标逐步调整。这几句话说下来面试官能看出你是真的在项目里调过参数。另一个高频追问是“线程池的线程数会自动调整吗”。核心线程数设 8来的任务多了会不会自动扩容到最大线程数答案是不会。只有当任务队列满了才会创建超出核心线程数的线程。如果队列容量是 1000核心线程 8 个那么前 8 个活了的线程先执行后面来的 1000 个任务都会在队列里排队只有排队都排不下才触发扩容。很多人在这一步说错。5.2 代码审查时一眼扫过的线程池“坏味道”看别人的代码时如果遇到下面这些情况基本可以直接打回每次调用方法时都 new 一个 ThreadPoolExecutor任务执行完又不关闭线程永远积累在 JVM 里散不掉。线程池是 static 的但参数没有任何配置来源全部写死在代码里换环境就没法调整。业务代码里 catch 了 RejectedExecutionException 后什么也不做任务悄悄丢了。把 Executors 的 newCachedThreadPool 用在定时任务或者高频任务上不限制线程数上限。线程池的队列选了无界队列还洋洋得意说“永远不会丢任务”。每一条我都见过对应的事故案例。线程池这东西真的不是 new 一下就完事了它是一个需要尊重、需要设计、需要监控的基础组件。5.3 一个真实的故障复盘最后讲一个我印象最深的生产事故供你引以为戒。某次大促前业务方接了一个新的营销活动需要异步发送营销短信。开发同学图省事直接用Executors.newFixedThreadPool(10)开了一个线程池队列无界核心线程 10。按正常流量这个配置完全够用。结果活动上线后突然涌进大量请求短时间内任务积压到几十万条无界队列疯狂占用内存最终整个应用 OOM服务全部不可用。复盘时就发现无界队列把“背压”能力完全抹掉了系统不知道自己在超负荷运转任务不断往里塞直到内存爆炸。后来改成了有界队列 CallerRunsPolicy 动态线程池参数 队列积压监控同样流量级别下系统虽然响应慢了但服务一直稳稳地活着。这个教训让我彻底改变了线程池的使用习惯也给团队定了一条规矩线程池必须配队列容量上限必须配线程工厂名字必须配拒绝策略必须接监控告警。至于核心线程数是多少那是调优问题随时可以改但前面这四样缺一不可。线程池没有最好的配置只有最合适的配置。每个人的业务场景不一样同样的参数在你的系统里跑得风生水起在他的系统里可能就是灾难。关键还是要把原理吃透把监控建好把压测做足。踩过几次坑之后你自然会形成一套自己的线程池使用心法到时候再回头看你写的第一个 new ThreadPoolExecutor可能会忍不住想笑。