线程池这个话题,我在不同场合讲过很多次,每次都能碰到有人被它折腾得够呛。明明就是一个“复用线程”的工具,用好了系统稳如老狗,用不好内存暴涨、任务静默丢失、接口集体超时,光排查就能耗掉一整天。很多人刚接触 Java 并发时,记住的只是“用 Executors 或者 new ThreadPoolExecutor 建一个池子,然后把任务丢进去”,但真正到了线上,往往发现事情没有那么简单。
这篇文章我打算从线程池的设计逻辑讲起,把创建时的参数拆开揉碎,再结合我实际踩过的坑和线上排查经验,整理一份可以直接照着用的线程池使用思路。适合那种已经会写 Thread 和 Runnable、但对 ExecutorService 还是“会用但心里没底”的人,也适合想要优化现有线程池配置却不知道从哪下手的老手。我不会只给你贴一堆 API,而是告诉你每一步选择背后到底在解决什么问题。
1. 为什么线程池成了并发编程的默认答案
1.1 从“手动建线程”到线程池,到底好在哪
最早的 Java 并发代码,很多是这么写的:
for (int i = 0; i < 1000; i++) { new Thread(() -> { // 处理任务 }).start(); }如果任务量少,这么写没什么问题。但一旦任务量大、执行频繁,你就会看到几个非常麻烦的现象:线程频繁创建和销毁,CPU 光忙活上下文切换了;线程数量完全失控,堆内存里存了几千个线程对象,一个个还都占着栈空间;到了系统压力大的时候,响应时间反而因为线程竞争变得更差。
线程池解决的核心问题并不是“帮你把任务跑得更快”,而是“把线程的创建、调度、回收统一管起来,让资源保持在一个可控的水平”。它把线程变成一种可复用的资源,把任务变成排队等待处理的单元,整个并发模型就有了稳定性。其实思路和数据库连接池是一模一样的:连接复用起来,省去反复建立的成本,同时限制最大连接数防止把数据库压垮。
1.2 线程池不是“加线程”,而是一套排队机制
我见过不少人对线程池的理解是“让它多开几个线程同时干活”,这其实只看到了表象。一个线程池真正干的事,是维护了一组工作线程 + 一个任务队列 + 一套调度规则。
- 工作线程:真正执行任务的线程,数量在 corePoolSize 和 maximumPoolSize 之间浮动。
- 任务队列:当工作线程都在忙的时候,新提交的任务先到这里排队。
- 调度规则:决定什么时候创建新线程、什么时候让任务进队列、什么时候拒绝任务。
理解了这个模型,你就能明白为什么调优线程池不能只看线程数。队列的长度、拒绝策略、线程空闲时间,每一个都牵一发而动全身。后面我讲参数的时候,会用一次完整的任务入场流程把这些东西串起来。
2. 创建线程池之前必须吃透的5个参数
2.1 corePoolSize、maximumPoolSize与allowCoreThreadTimeOut
构造一个 ThreadPoolExecutor 最基本的参数就是这三个:
new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 new LinkedBlockingQueue<>(100), // 任务队列 ... );corePoolSize 是线程池“保底”的线程数量。即使这些线程全都空闲,也不会被回收,除非你显式设置了 allowCoreThreadTimeOut(true)。在默认情况下,池里的线程数量不会少于 corePoolSize。
maximumPoolSize 是线程池能创建的线程上限。这个参数很多人理解成“核心线程不够用时,我就能申请新线程”,这个说法不够准确。新线程的创建是有前提的:任务队列排满了才会创建新线程。如果队列还没满,就算核心线程全都忙不过来,也不会去创建新线程。
allowCoreThreadTimeOut 可能了解的人少一些。默认情况下核心线程没有超时回收机制,但你让它生效后,核心线程也会在空闲超过 keepAliveTime 后退出。我一般在做那种“白天高峰期忙、晚上几乎没有流量”的服务时,会把这个开关打开,让线程池在空闲时段主动把线程数降下来,省点内存和句柄。这个参数在 Executors 的快捷方法里是没法设置的,只能自己 new ThreadPoolExecutor。
2.2 任务队列、线程工厂与拒绝策略
任务队列决定的是“排队的规则”。常用的几种队列,各有各的性格:
- LinkedBlockingQueue:无界或指定容量的链表队列。Executors.newFixedThreadPool 默认用的就是无界队列,这意味着任务可以无限排队,线程数永远不会超过 corePoolSize。听起来好像很安全,实际上风险很大:积压的任务撑爆内存,或者让后面的任务等待时间长得离谱。
- ArrayBlockingQueue:有界数组队列,容量固定,可以配合 maximumPoolSize 实现“队列排满就加线程”的效果。
- SynchronousQueue:不存任务的队列,直接把手里的任务交给一个空闲线程。如果没有空闲线程,就立即触发创建新线程的逻辑。Executors.newCachedThreadPool 用的就是它,所以这种线程池的线程数可以是无限的。
线程工厂可以用来自定义线程名、是否设为守护线程、异常处理器。很多人会忽略这个参数,但它在你排查线上问题的时候特别重要。所有工作线程都带上统一前缀,比如“order-pool-1”,故障定位时一眼就能从线程栈里认出是哪个线程池出了问题。
拒绝策略是线程池最后的防线。JDK 提供了四种内置策略:
- AbortPolicy:直接抛 RejectedExecutionException,这是默认行为。
- CallerRunsPolicy:如果任务被拒绝,把任务拿回调用的线程去执行。
- DiscardPolicy:静默丢弃任务,不抛异常。
- DiscardOldestPolicy:丢弃队列里最老的任务,再尝试提交新任务。
2.3 参数之间如何联动:一次完整的新任务入场
为了把上面这些参数串起来,我一般会给新人画这样一个流程,文字描述是这样的:
- 提交一个新任务,先看当前线程数是否小于 corePoolSize。如果是,直接创建一个新线程去执行这个任务。
- 如果线程数已经达到 corePoolSize,就把任务放进任务队列等待。
- 如果队列已经满了,再看当前线程数是否小于 maximumPoolSize。如果小于,就创建一个新线程去执行这个任务。
- 如果线程数已经达到 maximumPoolSize,整个池子吃不下这个任务了,触发拒绝策略。
这个执行顺序非常关键,它不是“队列满了一定先加线程”这么简单,而是“先核心线程,再队列,再额外线程,最后拒绝”。理解这个顺序,你才能准确预测一个线程池在突发流量下的表现。举个实际例子:一个线程池 core 为 2、max 为 4、队列容量为 100。你连续提交 106 个任务,前 2 个线程开始执行,接下来 100 个进队列,队列满后再提交任务时,才会启用剩下那 2 个额外线程,从第 107 个任务开始才会有被拒绝的风险。这个结果和很多人直觉里的“任务多就自动扩线程”完全不同。
3. 创建线程池的三种方式和我的推荐
3.1 Executors快捷方法能用,但别盲用
Executors 类提供了几个现成的静态方法:
- newFixedThreadPool(n):固定线程数,使用无界队列。
- newCachedThreadPool():线程数可无限增长,使用 SynchronousQueue。
- newSingleThreadExecutor():单线程,使用无界队列。
作为快速 Demo,这些方法确实省事。但如果项目要上生产环境,我非常不建议直接用它们。newFixedThreadPool 和 newSingleThreadExecutor 的问题在于无界队列,一旦任务提交速度长期超过处理速度,队列会无限膨胀,内存很快就会被撑爆。newCachedThreadPool 的问题在于 maximumPoolSize 是 Integer.MAX_VALUE,极端情况下系统会创建出数万个线程,直接拖垮整个进程。
我之前接手过一段历史代码,里面就用 newCachedThreadPool 处理一批 IO 任务的批量推送。正常情况下流量低没什么事,结果某次数据对账任务一次性提交了几十万个小任务,线程数飙到了三万多,机器直接卡死。后来改成有界队列加合理上限的 ThreadPoolExecutor,同样一批任务,在线程数上限 200 的情况下稳定跑完,耗时只多了一点,但系统不再面临崩溃风险。
3.2 自定义ThreadPoolExecutor的正确姿势
我自己的习惯是,所有生产环境用到的线程池,都直接基于 ThreadPoolExecutor 构造,并且把参数写得明明白白。下面是我经常用的模板:
ThreadPoolExecutor pool = new ThreadPoolExecutor( 8, // corePoolSize 16, // maximumPoolSize 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue<>(1000), // 有界队列 new ThreadPoolExecutor.AbortPolicy() );选择 ArrayBlockingQueue 的原因是队列容量有上限,配合 maximumPoolSize 一起形成两个保护层。当并发峰值到来时,先把任务放到队列里;队列满了再扩容到 max;再满就快速失败,让调用方感知到压力,而不是无声无息地积压任务。这个“快速失败”的设计很重要,它把问题暴露在了提交点,而不是让巡检发现内存告警之后再去救火。
如果你需要拿到被拒绝的任务做降级处理,也比较简单。虽然 JDK 内置策略里没有“记录并转入后备队列”的选项,但你可以把 RejectedExecutionHandler 实现成自己的:
pool.setRejectedExecutionHandler((runnable, executor) -> { // 记录被拒任务 logger.warn("task rejected: {}", runnable); // 转存或者走备用的降级通道 fallbackExecutor.execute(runnable); });注意,这个 handler 是在调用 execute 的线程里被调用的,所以不要在这里做耗时操作,只做轻量级记录和路由。
3.3 线程工厂与拒绝策略的选型建议
线程工厂不要嫌麻烦就不配置。直接 new Thread 的子类都可以指定一个有意义的名字,这在运维排查线程栈时至关重要。比如我可以定制一个带服务名和编号的工厂:
ThreadFactory factory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(0); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "pay-callback-pool-" + seq.getAndIncrement()); t.setUncaughtExceptionHandler((thread, throwable) -> logger.error("thread {} failed", thread.getName(), throwable)); return t; } };拒绝策略我的推荐顺序是:默认用 AbortPolicy,让异常大声失败;对重要任务,用 CallerRunsPolicy 做背压,因为调用方线程执行被拒任务时,提交速度自然就慢下来了;静默丢任务的 DiscardPolicy 和丢老任务的 DiscardOldestPolicy,除非你明确知道丢任务没有影响,否则别碰。
4. 线程池的实际使用与监控细节
4.1 execute和submit怎么选
这两个方法的区别很多人背过:execute 接收 Runnable,没有返回值;submit 可以接收 Callable,返回 Future。但这只是表面。实际用的时候,submit 会把 Runnable 或 Callable 封装成一个 FutureTask,在这个包装过程中,任务里抛出的未检查异常不会直接打印,而是在你调用 Future.get() 的时候才抛出来。
这带来一个非常隐蔽的坑:如果你用 submit 提交任务之后从不调用 get(),任务里抛出的异常会被线程池吞掉,日志里什么都看不到。而如果你用 execute,异常会直接抛给线程,触发线程的 uncaughtExceptionHandler,至少有一个出口可以接住。所以我的建议非常明确:需要返回结果才用 submit,否则一律用 execute;如果必须用 submit 但不调用 get(),那请在任务体内部自己 try-catch,保证异常有日志。
4.2 优雅关闭线程池:shutdown、shutdownNow和awaitTermination
线程池关闭的细节,是很多人忽略但极其容易出事故的环节。我曾经遇到过系统发版时服务一直退不干净,等了好几分钟才被强制杀死,后来发现是全局线程池里还有任务在跑,主流程关闭完 JVM 之后线程池还在工作。
正确的关闭动作应该是三步:
- 先调用 shutdown(),让线程池不再接受新任务,等待已提交任务自然完成。
- 调用 awaitTermination(timeout, unit),设置一个合理的等待时间,比如 30 秒。
- 如果等待超时还没结束,再调用 shutdownNow(),尝试中断正在执行的任务,并返回还在队列里的任务列表,你可以把这些任务做补偿处理。
pool.shutdown(); if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { List<Runnable> unfinished = pool.shutdownNow(); // 对 unfinished 做持久化或重试 }shutdownNow 使用 interrupt 去打断线程,但 interrupt 只是设置中断标志,并不是强制停止。如果任务正在执行一个不响应中断的 IO 操作,shutdownNow 也不一定能立刻终止它。所以优雅关闭的等待时间要舍得给足,尤其是对核心业务线程池。
4.3 让线程池的状态“看得见”
线程池自己提供了一些监控方法,只是很多人没注意。getPoolSize()、getActiveCount()、getQueue().size()、getTaskCount() 这些方法可以拿到实时数据。如果你在做监控系统,最好每 30 秒采样一次这些指标,落库或者推送到监控平台。
这几个指标组合起来可以反映非常多的信息。比如 getActiveCount() 长期等于 maximumPoolSize,说明任务处理能力已经到顶,需要考虑扩容或者拆分;getQueue().size() 持续上涨,说明生产速度大于消费速度,这时候服务处于“亚健康”状态,迟早会触发拒绝策略;getPoolSize() 反复横跳,说明任务量波动剧烈,可以调整 keepAliveTime 减少线程频繁创建销毁。
另外,线程池里有没有线程泄漏,也可以通过监控发现。比如 activeCount 一直是 0,但 poolSize 老是居高不下,多半是任务卡在某些地方退不出来。这种问题光看业务日志往往看不出来,结合线程栈 dump 分析是最靠谱的。
5. 线上常见的线程池故障与排查实录
5.1 任务被拒绝却报了超时
有一次某服务的消息推送接口间歇性超时,调用方拿到的异常堆栈却是 RejectedExecutionException,而业务方根本没在自己的异常映射里接住这个异常,默认的兜底逻辑就把它当成系统超时报出来了。
当时快速看了一眼线程池配置:核心线程 10、最大线程 10、无界队列。按配置来说,有界队列都没给,任务怎么会拒绝?后来查了上下文才发现,这个服务有多个模块共用一个线程池,其中一个促销模块在秒杀开始后疯狂提交任务,队列积压了上百万条消息,真正执行任务的机会非常少,后续加入的任务在队列里排很久,前端的等待时间就变成了“假超时”。有些模块还直接用了自定义 handler 去吞异常,日志里完全看不到。
这个案例的教训是:无界队列不是不会拒绝,而是不会因为队列满拒绝,却会让任务延迟到“看起来像挂了”。排查类似问题时,第一步就是看队列积压量。
5.2 线程池“假死”导致接口大面积卡住
另一个印象很深的案例,是一次批量导入功能升级后,某服务在高峰期响应突然全部卡住。我们当时的处理方式是先 dump 线程栈,发现很多线程阻塞在 HttpURLConnection 读取响应的地方,而且全部集中在同一个线程池。
原因复盘起来也很简单:业务里用了同一个线程池去调外部接口,外部接口的响应在某个时段变慢,池里的线程全部被阻塞在等待响应的状态。新的任务一直排队,核心线程数又不够,最终形成了“池子里满是阻塞线程,队列里全是等待任务”的假死状态。
这类问题的典型解法有几个:给外部调用加超时,是最关键的一步,没有超时的调用就是定时炸弹;其次可以给线程池设置队列长度上限并配合拒绝策略,让系统在流量放大时快速失败而不是无限等待;另外还可以把“调用外部接口”和“本地计算”拆成两个池子,避免某个外部依赖拉垮整个线程池。
5.3 未捕获异常让任务悄悄消失
使用 submit 后忘记调用 get() 导致异常被吞,这个问题我在 4.1 提过。线上还出现过更隐蔽的版本:有人用 execute 提交任务,任务里抛了 RuntimeException,因为线程池本身不打印异常,如果没设置 uncaughtExceptionHandler,日志里什么都搜不到,只有靠成功率曲线下探才能发现有问题。
我现在的做法是双保险:一是在创建线程工厂时统一设置 uncaughtExceptionHandler;二是在每个任务的实际执行入口包一层 try-catch,至少把任务名和异常信息打到日志里去。对比起来,第二个更可靠,因为 uncaughtExceptionHandler 对 submit 包装出来的任务一样不生效,而 try-catch 可以做到无差别兜底。
6. 线程池调优与实用避坑经验
6.1 线程数到底怎么定:不要迷信公式
网上流行过一些线程数量计算公式,比如计算密集型线程数设置为 CPU 核心数加一,IO 密集型设置为 CPU 核心数乘二。这些公式可以用作初始猜测,但不能当作银弹。
比较务实的做法是两步走。第一步,按任务类型先估算一个起点:计算密集型的核心线程数可以接近 CPU 核数,IO 密集型的核心线程数可以放大一些,比如 CPU 核数的 2 到 4 倍,因为线程大部分时间在等 IO。第二步,压测后调优。我会构造接近生产环境的任务比例和并发模型,用压测数据来看线程池的活跃度、队列积压和延迟分位数,再反向调整核心线程数和队列长度。
调优的过程要一次只动一个参数。如果同时改了 core 和队列,出了问题你根本不知道是哪个变化引起的。我的经验是先从队列长度入手,因为它对延迟的影响最直观;队列长度小、最大线程数适中,配合 CallerRunsPolicy,往往能在突发流量下得到一个合理的背压效果。
6.2 池不要乱共享,上下文要自己处理
全局只用一个线程池确实省事,但不同业务的任务类型、超时要求、优先级都不一样,混在一个池子里很容易互相拖垮。举例来说,统计报表的批量任务和订单处理的实时任务放在同一个池子,一个大量 IO 的任务把线程占满,实时的订单任务就要跟着排队,这在业务上完全不可接受。
我的建议是一个业务域一个池,池与池之间隔离。异步任务的线程池尤其要注意上下文传递。如果业务依赖 ThreadLocal 里的登录用户、链路追踪 ID、请求头信息,千万不要默认这些内容在线程池里还能用。因为线程池里的工作线程是复用的,ThreadLocal 会被上一次执行的任务污染。轻量解决方案是提交任务时把关键上下文显式作为参数传进去,或者使用自己实现的 TaskDecorator 包装上下文,一层层在上游设置、下游清理。
6.3 最后再分享一些日常习惯
关于线程池,我这些年沉淀下来几条很朴素但特别好用的习惯:
- 所有线程池必须命名,命名格式建议是“业务名-动作名-pool-编号”。
- 线程池的初始化参数集中管理。最好放在一个配置类或者配置文件里,而不是散落在业务代码中。线上调整参数不打补丁,直接改配置后热更新。
- 提交任务时,尽量不要把无限循环的操作丢进线程池。如果必须要做,在循环体内加 sleep 和条件判断,留出退路。
- 队列大小、最大线程数的选择要匹配你外部依赖的承受能力。不要去追求线程池跑满,系统稳定比吞吐量更重要。
如果你正准备改造现有系统的异步逻辑,我建议先在代码里加监控指标,再动手调参。没有数据支撑的线程池调优,基本靠猜。把基础参数吃透、把池子的生命周期管好、把异常出口堵住,这三点做到位,线程池在绝大多数场景下都不会给你惹事。