前言

线程池和异步编排决定了服务在高峰流量和下游变慢时如何保护自身。本文围绕任务性质、下游容量、隔离、超时、拒绝和上下文传递梳理线程池与 CompletableFuture,重点是建立可观测、可降级的并发模型,而不是套用固定参数公式。

线程池不是参数题

线程池的目标是复用线程、控制并发、隔离不同任务和提供背压。核心配置应从任务性质和下游容量推导,而不是套固定公式。

参数 作用 常见误区
corePoolSize 常驻工作线程 认为设置得越大越快
maximumPoolSize 队列满后可扩到的上限 使用无界队列后该参数几乎不会生效
workQueue 缓冲突发任务并形成背压 无界队列掩盖积压,最终 OOM
keepAliveTime 非核心线程空闲回收 忽略突发恢复后的线程收缩
RejectedExecutionHandler 饱和后的动作 默认抛异常但调用方没有处理
flowchart LR
    A[提交任务] --> B{核心线程未满?}
    B -->|是| C[创建核心线程]
    B -->|否| D{队列可放入?}
    D -->|是| E[进入队列]
    D -->|否| F{未到最大线程?}
    F -->|是| G[创建非核心线程]
    F -->|否| H[拒绝策略]

建议按领域隔离:支付回调、邮件发送、报表导出、外部 HTTP 调用不能共用一个无界线程池。每个池配置有界队列、线程名、监控、拒绝后的业务降级,并把连接池/下游限流一起纳入容量估算。

参数不是独立生效的

ThreadPoolExecutor 的执行顺序是面试常考点:先创建核心线程,再入队,队列满了才创建非核心线程,线程数到达 maximumPoolSize 后才拒绝。因此,maximumPoolSize 不是“并发数”,它只在队列满时才有机会生效。

例如配置 corePoolSize=16maximumPoolSize=64,但使用 new LinkedBlockingQueue<>():16 个线程忙碌后,后续任务会持续进入无界队列,线程数通常不会增长到 64,积压最终可能耗尽堆内存。反过来,使用 SynchronousQueue 时不存放任务,提交任务需要立刻交给空闲线程,线程更容易增长到最大值,适合短时、高并发的直接移交模型,但会更早触发拒绝。

队列 特点 适用场景 风险与治理
ArrayBlockingQueue 固定数组容量,可选公平锁 容量需要严格受控的业务池 提前按可接受积压量设置容量;容量太小会频繁拒绝
LinkedBlockingQueue(capacity) 链表有界队列,最常用 普通异步任务、外部 IO 调用 必须显式传容量,不能使用默认无界构造
SynchronousQueue 不缓存任务,直接交给工作线程 突发短任务、需要快速扩线程的池 最大线程和下游连接数必须严格受限
DelayQueue 按延迟时间取任务 延迟/定时任务 不适合一般业务池;元素数量仍需监控
无界 LinkedBlockingQueue 几乎不拒绝任务 不建议作为线上业务默认选择 掩盖过载,延迟不断升高,最终 GC/OOM 或服务雪崩

队列容量不是“越大越稳”。它代表系统愿意暂存多少等待工作。若任务平均耗时 200ms、实际稳定处理能力为 1000 TPS,允许额外等待 2 秒,队列上限可以先按约 2000 个任务估算,再用压测校正。队列很长时,请求虽然没有报错,但用户看到的往往是超时,这比尽早拒绝并降级更难恢复。

从任务性质推导线程数

先区分任务,而不是直接给出“CPU 核数 * 2”:

任务类型 示例 初始思路 必须一起约束的资源
CPU 密集 图片压缩、加密、规则计算、大 JSON 转换 接近 CPU 核数,通常从 NcpuNcpu + 1 压测开始 CPU 配额、GC、上下文切换
IO 密集 调用支付/物流 HTTP、文件上传、对象存储下载 可以多于 CPU 核数,但由等待比例和下游并发决定 HTTP 连接池、数据库连接池、下游限流、超时
混合任务 导出时查库、计算、写文件 拆成 CPU 与 IO 两段或不同线程池 不要让慢 IO 占满计算线程
长时间任务 大文件导出、批处理、离线对账 使用独立批处理池或任务平台 任务持久化、进度、可恢复性、租户隔离

经验公式 Nthreads = Ncpu * (1 + W / C) 只能用于估算起点,其中 W 是平均等待时间,C 是平均计算时间。它不能替代压测,因为真实系统还受连接池、网络抖动、下游限额和容器 CPU quota 影响。

生产上更实用的过程是:先明确目标并发、P95/P99 延迟和允许排队时间;再以较小有界队列和保守最大线程压测;观察活跃线程、队列长度、拒绝数、下游耗时和 CPU;最后把线程数限制在下游实际可承受并发以内。比如 HTTP 连接池最多 50 个连接,即使线程池扩到 200 个,另外 150 个线程也可能只是在等连接,反而放大超时和线程切换。

一个可运维的线程池样例

不要使用 Executors.newFixedThreadPool()newCachedThreadPool() 作为线上默认方案:前者使用无界队列,后者最大线程数接近无界。显式创建 ThreadPoolExecutor,让容量、线程名和拒绝行为可见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ThreadFactory factory = new ThreadFactoryBuilder()
.setNameFormat("inventory-http-%d")
.setUncaughtExceptionHandler((thread, error) ->
log.error("uncaught error in {}", thread.getName(), error))
.build();

ThreadPoolExecutor inventoryExecutor = new ThreadPoolExecutor(
16,
48,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
factory,
new ThreadPoolExecutor.AbortPolicy());

inventoryExecutor.allowCoreThreadTimeOut(false);

上例的 500 不是通用推荐值,而是一个需要按“允许等待时长”和“稳定处理能力”推导并压测验证的容量。AbortPolicy 抛出 RejectedExecutionException 后,入口必须捕获并转换为明确的业务结果,例如返回“系统繁忙,请稍后重试”、写入可靠消息队列,或让上游按约定重试;不能只打日志后继续假装任务已提交成功。

若不引入 Guava 的 ThreadFactoryBuilder,可用自定义 ThreadFactory 为线程设置名称。线程名应包含领域,如 payment-callback-1report-export-1,这样在线程 dump、日志和监控中可以快速定位池的归属。

任务隔离、背压与拒绝后的业务动作

线程池隔离不是“多建几个池”这么简单,而是防止一个资源域耗尽影响另一个资源域。下面以订单服务为例:

任务池 任务 推荐拒绝动作 为什么不能和其他任务混用
order-db 下单、扣库存、写事务库 快速失败或入口限流,不能静默丢弃 队列堆积会占住数据库连接并拖垮核心交易
payment-callback 支付渠道回调处理 持久化回调事件后异步重试 回调会突发且必须最终处理,不能因为池满丢失
notify 短信、邮件、站内信 写 MQ/延迟任务,允许延后 外部通道慢不应阻塞订单完成
report-export 导出、聚合、文件生成 限制并发并提示排队,或转离线任务 单个大任务可能占用 CPU、内存和数据库很久
third-party-http 风控、物流、地图等外部调用 熔断、降级、快速失败 下游变慢时不能占满所有业务工作线程

CallerRunsPolicy 的本质是让提交方承担工作,以降低生产速度。它适合消费线程、批处理提交者等可被反压的入口;对于 Tomcat/Netty 请求线程要谨慎,执行慢任务会占住请求线程,可能把局部过载扩大成接口超时。DiscardPolicyDiscardOldestPolicy 只适用于允许丢失且有补数机制的任务,例如可再计算的预热;不能用于支付、库存、消息消费确认等任务。

真正的背压要贯穿入口到下游:网关限流限制进入速率,业务池用有界队列限制在途任务,HTTP/数据库客户端用连接池限制下游并发,超时和熔断阻断持续堆积。只把线程数调大不是扩容,而是把压力暂存在更多线程、更多连接和更长队列中。

监控、告警与故障排查

每个业务线程池至少要暴露以下指标,Spring Boot 可通过 Micrometer 注册为 Gauge/Counter,或接入线程池监控组件:

指标 说明 告警或排查信号
poolSize / activeCount 当前线程数和活跃线程数 长时间接近最大线程数,说明实际并发已饱和
queue.size / queue.remainingCapacity 排队任务数和剩余容量 队列持续增长或接近满,比瞬时峰值更危险
completedTaskCount / 任务耗时分位数 吞吐和执行耗时 吞吐下降且耗时升高,通常是下游慢或锁竞争
taskCount - completedTaskCount 粗略在途与已排队任务 持续增加表示进入速率超过处理速率
rejectedCount 被拒绝次数,需自行包装拒绝策略计数 任意关键池出现拒绝都应告警并关联入口流量
JVM CPU、GC、线程数 线程池的外部约束 CPU 高说明计算饱和;CPU 不高但队列满多为 IO/下游阻塞

排查“接口变慢、线程池满”时,不要先修改参数。先确认是哪一个池满、何时开始、队列是持续增长还是瞬时峰值;再用线程 dump 看线程是在等 HTTP 连接、数据库连接、锁、网络读写还是 CPU 计算;随后核对下游 P99、连接池等待、超时和重试量。若线程都卡在同一个下游,扩线程只会制造更多并发请求,应先限流、熔断、缩短超时或修复下游。

生命周期与异常处理

execute 中未捕获的运行时异常会交给线程的 UncaughtExceptionHandlersubmit 会把异常封装到 Future,如果调用方从不执行 get(),异常可能被静默忽略。对关键异步任务,应在任务内部记录带业务标识的异常,或统一检查 Future/CompletableFuture 的异常结果。

线程池中的线程会复用,任务必须在 finally 中清理 MDC、ThreadLocal、租户上下文和临时状态。不能在线程池中持有请求对象、数据库事务或过期的安全上下文。任务执行时间要有上限,网络、数据库和文件操作都要配置各自超时,不能仅依赖外层 Future 超时。

拒绝策略与背压

AbortPolicy 直接报错,适合必须由上游感知失败的任务;CallerRunsPolicy 让提交线程执行任务,能反压上游但可能拖慢 Web 请求;Discard/DiscardOldest 只适合明确允许丢弃的可再生任务。关键业务不能静默丢弃,应记录任务、重试或进入可靠消息队列。

CompletableFuture 异步编排

CompletableFuture 适合组合独立 IO,例如商品详情页并行获取商品、库存和推荐:

1
2
3
4
5
6
CompletableFuture<Product> product = supplyAsync(this::loadProduct, productExecutor);
CompletableFuture<Stock> stock = supplyAsync(this::loadStock, stockExecutor);
Page page = product.thenCombine(stock, Page::new)
.orTimeout(300, TimeUnit.MILLISECONDS)
.exceptionally(this::fallbackPage)
.join();

thenCombine 用于两个独立结果汇合;thenCompose 用于前一步结果决定下一异步调用;allOf 用于等待多任务完成。不要默认使用 commonPool 承载生产 IO:它是全局共享资源,阻塞任务会影响无关功能,应显式传入具名线程池。

超时、取消与上下文

异步超时只表示调用方不再等待,不代表底层 HTTP、SQL 或线程一定停止。需要同时配置客户端超时、数据库查询超时和任务取消策略。取消前先确认任务是否可中断、是否有副作用;支付、写库等操作要做幂等,而不能依赖 cancel(true) 回滚。

MDC、TraceId、租户和安全上下文不会自动可靠地跨线程传递。使用任务装饰器、框架提供的上下文传播或显式参数传递;不要随意把 ThreadLocal 复制到长期线程池而不清理,可能造成串请求和内存泄漏。

优雅停机

停止服务应先摘流量,拒绝新任务,等待短时间处理在途任务,超时后取消可取消任务并关闭资源。直接 shutdownNow() 可能中断到一半的写入,必须配合幂等、重试和消息确认策略。

高频面试题

问题 回答要点
无界队列有什么问题? 积压无限增长会耗尽内存,maxPoolSize 也可能失效。
maximumPoolSize 什么时候生效? 核心线程满、工作队列也满之后,才会继续创建非核心线程;无界队列下通常很难生效。
IO 线程池怎么定? 根据等待比例、下游连接数、目标延迟和压测结果定,并设置上限和隔离。
executesubmit 的异常有什么差异? execute 的未捕获异常会交给线程异常处理器;submit 将异常放进 Future,不调用 get() 可能看不到。
CallerRunsPolicy 总能解决过载吗? 它只能让提交方变慢;若提交方是 Web I/O 线程,可能放大请求超时,需结合入口限流和任务隔离。
CompletableFuture 为什么指定 executor? 避免阻塞 commonPool,隔离不同业务资源。
超时后任务一定停止吗? 不一定;要同时治理下游超时、取消和幂等。