聊到线程池的使用实践,很多人第一反应是 new 一个 ThreadPoolExecutor,然后往里面 submit 任务。真不是故意泼冷水,这个操作看起来简单,但线上环境里,线程池用不好带来的问题一点不输内存泄漏。我先后在几家公司维护过交易、异步任务、消息推送这类系统,反复遇到过任务堆积、OOM、线程耗尽、接口被拖垮的情况,很多最后都定位到了线程池配置不合理。所以这篇内容不打算讲太深的理论,而是把我在实践中踩过坑、验证过可行的做法整理出来,给正在用或者打算用线程池的开发者一个参考。
1. 线程池是什么,为什么我劝你别自己 new Thread()
很多代码里有这样的场景:收到一个请求,后台需要同步一批数据,于是new Thread(() -> doSomething()).start()。小流量没事,一旦请求量上来,每个请求都创建线程,系统很快就会出现线程数量飙升、内存上涨、切换开销暴涨。我见过一个数据同步的服务,高峰期每秒几十个请求,每个请求都开三五个线程,跑几个小时以后线程数直接到几千,CPU 大部分时间花在上下文切换上,真正的业务计算反而慢下来。
1.1 每次 new Thread 到底贵在哪里
创建线程贵不只是 Java 层面 new 一个对象,更关键的是它要跟操作系统打交道。每个线程都要分配独立的调用栈,JVM 默认栈大小 1MB(64 位 Linux 上很多环境是 1MB),1000 个线程光栈内存就是 1GB,这还只是静态占用。同时线程创建会触发系统调用、内核线程的创建、线程调度器的注册,第一次运行还要经历 CPU 缓存冷启动。对比来看,从线程池里取一个已经活着、已经完成缓存的线程去执行任务,成本便宜很多。
从响应时间的角度也值得注意。一个任务的执行时间如果是 50ms,其中线程创建可能就要花 1~3ms。看起来不多,但如果任务本身只有 10ms,线程创建的开销占比就到了 30%,这就非常不划算了。所以在线程频繁创建销毁的场景里,池化的收益非常明显。
1.2 线程池解决的三个核心问题
如果我们用池化思路,本质上是在解决三件事。
第一,资源复用。一组线程反复干活,省掉频繁创建销毁的开销,降低线程栈内存和内核对象数量。
第二,限制并发。线程池可以约束同时执行的任务数量,防止无限制创建线程把 CPU、内存、数据库连接等资源耗尽。没有上限的并发,在极端流量下往往比没有队列更容易出事。
第三,任务缓冲。当瞬间任务超过线程处理能力时,任务可以先进队列,而不是直接丢弃,这给系统争取了平滑处理流量的机会。配合拒绝策略,可以在“处理不过来的情况”下做出明确选择。
理解了这三点,后面配置参数时就不会总问“为什么要有最大线程数”“为什么要队列”了。线程池本质上是给异步任务一个可管理的资源池,而不是简单的“多线程执行器”。
另外也要说一句:线程池不是所有异步场景的最优解。如果任务量很小、频率很低,比如每天凌晨跑一次、任务量只有几十个,直接用简单并发甚至串行也完全没问题,不必为了用线程池而用。引入线程池的同时也引入了队列、拒绝策略、生命周期管理等复杂度,小场景下这些复杂度不划算。
2. ThreadPoolExecutor 七个参数,别再看一遍又忘
2.1 corePoolSize、maximumPoolSize 和 keepAliveTime
ThreadPoolExecutor 构造方法里最核心的三个数字:corePoolSize、maximumPoolSize、keepAliveTime。先明确执行流程:新任务提交时,如果当前线程数小于 corePoolSize,直接新建线程执行;达到 corePoolSize 后,新任务优先进 workQueue;如果队列满了,并且线程数还没有到 maximumPoolSize,再继续创建线程,直到 maximumPoolSize;当线程数已经到了 maximumPoolSize 且队列也满了,就走拒绝策略。这个流程很多文章画过图,但我更想强调一点:队列在中间起到的是缓冲作用,它让线程数在 core 和 max 之间的增长不是一上来就无脑扩张,而是先让任务排队,等到队列也扛不住才开始扩到 max。
keepAliveTime 控制的是超过 corePoolSize 的线程空闲多久后回收。比如 core=8、max=16,任务高峰过去后,超过 8 的部分如果空闲满 30 秒就会被回收,回到常驻 8 个线程的状态。allowCoreThreadTimeOut(true) 可以连核心线程也一起回收,但日常业务中我不建议随便开,核心线程频繁销毁重建反而会影响响应速度。
2.2 workQueue 选型:有界、无界还是同步传递
队列的选择直接影响线程池行为。LinkedBlockingQueue 如果使用无参构造,容量是 Integer.MAX_VALUE,任务可以无限堆积,线程数永远不会涨到 core 之上,除非 core 满后队列堆到 OOM。很多人踩过这个坑:用了 Executors.newFixedThreadPool,以为能控制最大线程数,其实线程数永远不变,任务全在无界队列里排队,一旦流量突增,内存直接爆掉。
ArrayBlockingQueue 是有界队列,必须指定容量,它能让系统在任务过多时触发拒绝策略,而不是默默堆积。SynchronousQueue 不是一个真正意义上的存储队列,它不存任务,提交任务时必须有一个空闲线程立刻接手,否则就阻塞或触发拒绝,Executors.newCachedThreadPool 用的就是它,所以线程数可以无限膨胀。
实际业务中,我一般优先选择有界队列,容量根据系统可接受的最大排队时间来定。比如一个任务平均执行 200ms,队列容量 500,那么最坏情况下最后一个任务的等待时间大概是 500 × 200ms = 100 秒,这显然太长,就需要缩减队列或者提高处理能力。排队时间等于队列容量乘以平均任务耗时的估算方法,可以作为容量设计的第一版依据。
2.3 threadFactory 与拒绝策略:最容易偷懒也最容易出事的两环
默认线程工厂生产的线程名字是 pool-1-thread-1 这种格式,线上日志一多,根本看不出这个线程来自哪个业务,排查问题只能靠猜。我习惯在创建线程池时强制要求自定义 ThreadFactory,把线程名字统一成“业务名-池角色-序号”,比如 order-async-pool-0。这样出现 CPU 飙升、线程数异常时,jstack 一抓就能知道是哪个线程池出了问题。
线程工厂里还可以设置异常处理器。比如某个任务抛出了未捕获异常,默认情况下线程会被销毁然后重新创建,异常信息可能只在后台打印,不一定进得了业务日志系统。我们可以给线程设置 uncaughtExceptionHandler,把异常统一记到日志里,至少不会丢得无声无息。
拒绝策略是最后一道防线。默认的 AbortPolicy 会在任务被拒绝时抛出 RejectedExecutionException,但很多场景下这个异常会被业务吞掉,导致任务静默丢失。CallerRunsPolicy 不丢任务,而是让提交任务的线程自己执行,相当于用调用方线程兜底,同时提供一种自然的背压效果;缺点是调用方可能会被慢任务拖住,选择它时要评估调用链路的超时容忍度。DiscardPolicy 和 DiscardOldestPolicy 都是丢弃任务,除非任务允许丢,否则我基本不会用。
3. 参数到底怎么定:从公式到压测的一条完整路径
3.1 别再让公式背锅
网上流传很多线程数计算公式,CPU 密集型用 CPU 核数 + 1,IO 密集型用 2 × CPU 核数,还有更精细的 N × (1 + 等待时间 / 计算时间)。这些公式不是不能用,但它们只是“初始值”,不是“标准答案”。有一次我把 IO 密集型服务的核心线程数设成 2 倍核数,结果下游数据库连接池先被打满了,线程池还有大量线程在等待获取连接,性能反而更差。那一刻我才意识到:线程数不是越大越好,还要看下游资源能不能扛住。
正确路径应该是:先确定任务类型(CPU 密集、IO 密集、混合型),用公式算出一个初始范围;然后压测,观察 CPU 使用率、线程等待时间、队列积压、下游资源水位;最后根据监控数据微调。
比如请求型任务,平均耗时 200ms,其中真正的 CPU 计算只有 20ms,剩下 180ms 都在等网络、数据库、外部服务。那这个任务等待时间占比 90%,按公式算线程数大概可以到核数的 10 倍左右。但如果下游数据库连接池只有 50 个连接,线程池开 200 个线程,最终大部分线程会在获取数据库连接时阻塞。所以线程池的参数永远要放在整个资源链路里去看。
3.2 一个完整可落地的线程池配置示例
下面给一个我常用的线程池配置,大家可以直接参考结构调整:
ThreadPoolExecutor exec = new ThreadPoolExecutor( 8, // corePoolSize 16, // maximumPoolSize 30L, // keepAliveTime TimeUnit.SECONDS, new ArrayBlockingQueue<>(2000), // 有界队列,容量看排队容忍度 new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(0); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "biz-async-pool-" + seq.getAndIncrement()); t.setUncaughtExceptionHandler((thread, throwable) -> logger.error("async task uncaught error", throwable)); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() ); exec.prestartAllCoreThreads();先看 core=8、max=16 这个数字,如果服务部署在 8 核的机器上,核心线程数设为 8 比较合理,任务类型如果是 IO 密集,那么可以允许峰值时线程数上涨到 16,但不会无限涨。队列容量 2000,加上最多 16 个正在执行的线程,系统在不触发拒绝策略的情况下最多缓存 2016 个任务,如果平均任务 200ms,那这批任务最坏也需要 2016 × 200ms = 403 秒才能全部处理完,这就意味着队列容量 2000 对很多场景其实已经偏大了。所以容量一定要结合实际最大排队时长来调。
CallerRunsPolicy 的选择要分场景。如果任务提交方是业务接口的请求线程,采用这个策略后,当线程池满时请求线程会自己去跑任务,接口响应时间会明显增加;但好处是任务不会丢。对于实时性要求不太高、可以接受偶发变慢的后台任务,这种兜底方式很实用。对于异步通知这种不能丢失的任务,可以在拒绝策略里把任务写入本地表或者消息队列,等系统恢复后再补偿执行。
3.3 动态调参:线程池不是定死的
很多开发者配置完线程池就不再动了,但线上流量是有波动的。ThreadPoolExecutor 提供了 setCorePoolSize、setMaximumPoolSize、setKeepAliveTime 等动态调整方法,我们可以结合监控数据在运行时调整。
例如我习惯每隔一段时间采集一次队列积压量、activeCount、每秒完成任务数、拒绝次数。如果发现队列长期处于 80% 以上的水位,说明当前处理能力不足,可以适当调大 maximumPoolSize 或者缩小队列,让更多任务直接进入处理阶段。如果发现 activeCount 长期低于 corePoolSize,说明线程大部分时间闲着了,可以缩小 corePoolSize,减少常驻内存。
动态调参要注意一点:setMaximumPoolSize 如果设置得比当前线程数小,线程池会先把多余线程逐步回收,但不会主动中断正在运行的任务,所以操作本身不会导致任务丢失。不过调整过频会带来不稳定,建议通过配置中心下发参数,而不是在代码里写死一个定时器频繁改。
4. 实操中的坑:这些问题我不止一次在线上碰到
4.1 没 shutdown 的线程池,程序退出时全卡住
这个坑在很多独立开发的应用里尤其常见。线程池里的线程默认是非守护线程,只要还有一个活着,JVM 就不会退出。如果你在本地写一个 demo,执行完 main 方法后程序迟迟不结束,多半就是线程池没有关闭。在 Web 容器里也有类似情况,服务停机时如果线程池不优雅关闭,已有的异步任务可能会被直接打断,正在写的数据可能只写了一半。
正确的关闭姿势是把 shutdown 和等待结束配合起来。shutdown() 之后线程池不再接受新任务,但已经在队列里的任务会继续执行;shutdownNow() 会尝试中断正在执行的任务,并返回还没执行的任务列表。一般流程是:先调用 shutdown(),然后 awaitTermination(超时时间),如果超时还没结束,再调 shutdownNow(),最后再 awaitTermination 一次。
还有一点:使用 Executors 创建的一些线程池如果不调用 shutdown,进程不会退出;但有些框架(比如某些 Spring Boot 应用)在容器关闭时不会自动帮你关闭自定义线程池,需要自己通过生命周期回调处理。我踩过一次,服务已经停了,日志却还在打,查了很久才发现是自定义线程池没有在停机钩子里关闭。
4.2 队列堆满后,任务到底被谁吃掉了
之前一个同事排查一个问题,现象是某个批处理任务处理结果不完整,但日志里没有任何异常。翻代码发现,他把拒绝策略设成了 DiscardPolicy,任务被静默丢弃了。这种问题非常隐蔽,因为你不知道是任务本身失败,还是任务压根没执行。
除了见队就丢,更常见的是无界队列带来的任务堆积。无界队列不会触发拒绝策略,看起来任务都“接受”了,实际上内存持续增长,直到 OOM,然后整个服务被重启。这类堆问题不是线程池满了导致的,但它很容易和线程池混在一起被误判。
我的建议是:凡是可能丢弃任务的策略,都在自定义拒绝策略里把任务信息(任务类型、参数摘要、拒绝原因、当时线程池状态)记录到日志或者监控系统。比如重写 rejectedExecution 方法,打一条 error 日志,并把被拒绝的任务塞入一个持久化存储,等待后续补偿。这样即使真的丢弃,我们也知道丢了什么、为什么丢。
4.3 异常被吞得干干净净,排查到怀疑人生
线程池里的异常处理是一个大坑,而且分两种场景。第一种是用 execute 提交 Runnable,任务内部抛出 RuntimeException,线程池会捕获它并交给线程的 UncaughtExceptionHandler,默认行为是打印堆栈,但线程池本身不会停止。第二种是用 submit 提交任务,异常会被封装进返回的 Future 里,如果你从不调用 future.get(),那异常永远不会出现在日志里,但任务实际是失败的。
我推荐两个手段并用来避免这个问题。第一,任务内部必须自己捕获异常,并按业务日志规范记下来,不要把异常抛出到线程池层。第二,重写 ThreadPoolExecutor 的 afterExecute 方法,无论任务通过 execute 还是 submit 提交都能被监控到:
@Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); if (t == null && r instanceof Future<?>) { try { ((Future<?>) r).get(); } catch (CancellationException ce) { t = ce; } catch (ExecutionException ee) { t = ee.getCause(); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } if (t != null) { logger.error("task execute error", t); } }这段代码的逻辑是:如果已经传入了异常 t,直接用;如果没有,就尝试从 Future 里把异常取出来。因为 submit 包装后的任务,其异常不会直接传给 afterExecute 的 t 参数,必须 get 才能拿到。这个写法不是万能的,比如任务里调用了 future.get() 且自己处理了异常,那么 afterExecute 不会再感知到;但作为兜底已经很够用。
另外一个关联坑是 ThreadLocal 的泄漏。线程池里的线程是复用的,如果任务里往 ThreadLocal 写了值却不清理,下一个任务复用同一个线程时会读到上一个任务残留的数据,轻则出现脏数据,重则把用户 A 的上下文串到用户 B 的请求里。尤其是日志链路追踪用的 MDC,任务执行完一定要在 finally 里 remove。
5. 线程池在真实业务里的落地姿势
5.1 批量任务拆分、聚合与超时兜底
业务里最常见的需求是:把一个大的批量任务拆成若干子任务,并发执行,最后汇总结果。比如有 1000 个用户需要推送消息,每次推 200 个可以降低耦合和失败爆炸半径,于是拆成 5 批,每批一个任务,交给线程池执行。
如果直接用 Future.get() 依次拿结果,会有一个问题:如果第一个任务耗时很长,后面几个任务明明已经完成了,主线程还是要等第一个任务完成才能继续,白白损失并行度。更好的方案是用 ExecutorCompletionService 包装线程池,它会先完成先返回,主线程可以按完成顺序及时处理:
ExecutorCompletionService<Result> service = new ExecutorCompletionService<>(exec); for (Task t : tasks) { service.submit(() -> doTask(t)); } for (int i = 0; i < tasks.size(); i++) { Future<Result> future = service.poll(5, TimeUnit.SECONDS); if (future == null) { // 超时,记录未完成任务,继续等待或者放弃本次批量 continue; } // 异常在 future.get() 时统一处理 }使用 poll + 超时而不是阻塞 take,可以避免某一个子任务长时间不结束导致整个批量任务无限等待。对超时后仍未完成的任务,应该记录下来,走补偿或者告警。这个兜底现实里非常重要,因为线程池中的任务再可靠,也可能会因为外部依赖迟迟不返回而拖住批量流程。
5.2 线程池隔离:别让一个慢业务拖垮整个应用
一个服务里如果所有异步任务共用一个线程池,只要有一个业务的执行时间特别长,就会占满整个线程池,其他业务的请求只能排队等着。最典型的是某些定时任务调用了外部慢接口,一个任务卡十几秒,后面所有异步操作全部延迟。
解决思路是隔离,按业务的重要程度和风险程度划分多个线程池。核心链路用独立线程池,并且有界队列较小、拒绝策略明确;非核心的慢任务单独放一个池子,线程数和队列容量可以相对宽松,但不能影响核心池。
更进一步,如果不想为每个业务都维护一个 ThreadPoolExecutor,可以引入信号量做并发控制,或者用轻量级的线程池隔离方案。但我的经验是:直接拆池子最简单,也最好排查问题。每个池子的线程名带上业务前缀,监控也能分开看。线上出问题时,通过线程名和监控指标很快就能圈定是哪个链路出了问题。
5.3 CompletableFuture 搭配自定义线程池的注意点
CompletableFuture 很好用,它让异步编排变得很直观。但有一个隐患:如果不指定线程池,supplyAsync 和 thenApplyAsync 会默认使用 ForkJoinPool.commonPool()。这个公共线程池的线程数是 CPU 核数减 1,如果你在里面放了阻塞操作,比如 HTTP 调用、数据库查询,那么整个 JVM 中所有用到 common pool 的地方(包括 parallelStream)都会一起卡住。
所以只要涉及真实业务,就显式传入定义好的线程池,而不是用默认池。另一个坑是 CompletableFuture 的异常处理。异步回调里的异常不会主动抛出,必须显式调用 join()、get() 或者注册 exceptionally/whenComplete 才会捕获到。否则一段异步代码出错了,外层根本感知不到,问题会被拖到很晚才暴露。
还有一个容易被忽略的问题:异步回调链路可能跨池执行。比如 supplyAsync 在池 A,thenApplyAsync 没有指定池,就会在 ForkJoinPool.commonPool 里执行。这时候线程上下文、MDC、事务这些东西很容易出问题。建议整个链路的每一步都显式指定同一个自定义线程池,或者干脆用 CompletableFuture 但只做最简单的编排,别把线程池换来换去。
6. 我的最终建议:先把线程池当“资源”来管
6.1 少用 Executors 快捷方法,多数时候不是好选择
Executors 提供的 newFixedThreadPool、newCachedThreadPool、newScheduledThreadPool 用起来确实方便,但它们的行为很多不适合生产环境。newFixedThreadPool 的队列是无界的,任务可以无限堆积,最大线程数永远等于核心线程数,线程数看起来是固定的,实际系统却是靠堆内存去硬扛流量。newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,流量一旦异常,线程数可以膨胀到把系统打挂。newScheduledThreadPool 用无界延迟队列,同样有堆积风险。
我不是说这些方法不能用,而是在使用前必须清楚它们背后的资源模型。如果只是本地 demo、一次性脚本,用 Executors 快捷方法无可厚非;线上服务我建议一律用 ThreadPoolExecutor 显式声明参数,哪怕只是多写几行,也能逼自己想清楚每个配置。
6.2 日志、监控与优雅停机要一起考虑
最后再说一个整体建议:线程池不是写完 submit 就结束的东西,它应该被当作一个资源池来运维。线程池相关的指标至少要暴露这些:当前线程数、活跃线程数、队列积压量、完成任务数、拒绝次数、线程池状态。有监控系统就把它们上报,没有监控就写一个定时任务定期打印日志。
我个人的习惯是,在项目里封装一个简单的线程池管理组件,统一创建、统一关闭、统一上报指标。这样既避免了每个业务各写一套线程池配置,也方便在出问题时快速定位。
最后再分享一个我经常问自己的问题:如果线程池满了、任务堆积了、下游变慢了,这个系统还能不能扛住?想清楚这三问,线程池的使用实践才算真的过关。