很多 Java 开发都会用线程池,但真正到线上环境,线程池往往不是“提速工具”,而是“事故放大器”。

你可能见过这些问题:

  • 接口突然变慢,线程池队列堆满了
  • 任务越积越多,内存一路上涨
  • 下游接口偶尔超时,结果整个服务被拖住
  • 线程数越调越大,CPU 反而更高了
  • 看起来用了异步,结果把数据库和第三方接口一起打崩

这篇文章就不讲空泛定义了,我们直接讲:

  • 线程池参数分别控制什么
  • 不同业务场景应该怎么配
  • 哪些配置最容易把系统搞崩
  • 线上系统里怎样做出更稳的线程池方案

一、先记住一句话:线程池的核心目标是稳

很多人第一次接触线程池,会把重点放在“减少线程创建开销、提升并发性能”上。

这当然没错,但在真实项目里,线程池更重要的职责其实是:

限制并发、管理资源、控制退化。

因为系统一旦进入高峰期,线程池会替你回答这些问题:

  • 还能接多少任务
  • 最多允许多少线程一起跑
  • 扛不住的时候是排队、丢弃,还是让调用方变慢
  • 慢任务会不会把关键任务一起拖死

所以线程池参数是你对系统容量和退化策略的明确表达。


二、先看 ThreadPoolExecutor 这 7 个关键参数

标准写法:

ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.SECONDS, workQueue, threadFactory, handler );

这 7 个参数里,真正决定系统稳定性的,是下面这几项:

  • corePoolSize
  • maximumPoolSize
  • workQueue
  • RejectedExecutionHandler

其他参数也重要,但这四个决定了线程池忙起来时的行为。


三、corePoolSize 到底怎么配?

1. CPU 密集型任务

比如:

  • 复杂计算
  • 大量加解密
  • 本地规则引擎计算
  • 图片处理、压缩、转码

这种任务主要吃 CPU,线程太多反而会增加线程切换成本。

经验值通常是:

corePoolSize ≈ CPU 核数 或 CPU 核数 + 1

例如机器是 8 核,可以先从 8 或 9 开始。

2. IO 密集型任务

比如:

  • 调第三方接口
  • 远程 RPC
  • 读写数据库
  • 发短信、发消息、上传文件

这种任务很多时间都在等网络或磁盘响应,所以线程数可以比 CPU 核数高。

常见经验值:

corePoolSize ≈ CPU 核数 * 2

如果等待时间特别长,可以继续提高,但前提是你确认:

  • 下游扛得住
  • 内存扛得住
  • 并发放大不会引发更大故障

一个很重要的提醒

核心线程数不是越大越好。

线程多不代表吞吐一定高。
如果你的瓶颈根本不在 CPU,而在数据库连接池、Redis、第三方接口,那你把线程池调大,只是在更快地把下游打爆。


四、maximumPoolSize 不是让你“放心往大了配”的

很多人会这么想:

“核心线程 20,最大线程 200,总归更保险吧?”

这其实非常危险。

因为 maximumPoolSize 不是系统的“备用能力”,而是系统高压下的“扩容上限”。
一旦线程池跑到这个值,说明系统已经在压力边缘了。

正确理解

  • corePoolSize:平时稳定运行的线程数
  • maximumPoolSize:高峰期最多允许扩到多少
  • 如果经常跑到 maximumPoolSize,说明系统已经不健康了

推荐思路

对于大多数业务系统:

  • CPU 密集型:maximumPoolSize 和 corePoolSize 接近
  • IO 密集型:maximumPoolSize 可以比核心线程数高一些,但不要失控

例如:

new ThreadPoolExecutor(
        8,
        16,
        60L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(300),
        namedThreadFactory("consult-msg-pool"),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

这里 8 -> 16 是一个比较克制的扩容区间。
如果你一上来就配成 8 -> 200,高峰期问题大概率会更难收场。


五、队列 workQueue 才是线程池最容易出事故的地方

线程池会不会拖垮系统,很多时候不看线程数,反而看队列。

1. 为什么不推荐直接用 Executors

这是一个很经典的工程规范:

不要直接用 Executors 创建线程池,优先手动使用 ThreadPoolExecutor。

原因就在于默认实现很容易埋坑:

  • FixedThreadPool 和 SingleThreadExecutor 常见问题是队列过大,任务容易无限堆积
  • CachedThreadPool 常见问题是线程数膨胀过快

一旦高峰流量冲进来,结果通常就是:

  • 要么队列堆满,内存上升
  • 要么线程暴增,CPU 飙高
  • 最后把整个服务拖慢

2. 队列容量怎么定?

别拍脑袋写 10000,也别觉得 Integer.MAX_VALUE 很安全。

队列容量要根据这几个因素一起看:

  • 单个任务平均执行时间
  • 峰值请求量
  • 可接受的排队时长
  • 单个任务对象占用内存
  • 下游服务承载能力

一个简单估算思路

假设:

  • 峰值每秒 200 个异步任务
  • 每个任务平均耗时 200ms
  • 核心线程数 20

那正常吞吐能力大致是:

20 / 0.2 = 100 个任务/秒

如果上游峰值 200/s,就意味着每秒多出 100 个任务进入排队。
这时候队列如果设 500,5 秒左右就可能打满。

所以你会发现,队列大小不是单独配置出来的,而是和流量、耗时、线程数一起算出来的。

3. 队列不是越大越好

队列大,短期内看起来更稳定;
但本质上只是把问题延后。

典型后果是:

  • 请求堆积更久
  • 用户感知延迟更高
  • 内存不断上涨
  • 故障从“快失败”变成“慢性死亡”

所以工程上通常更推崇:

有界队列 + 明确拒绝策略

而不是“只要不报错,先堆着再说”。


六、拒绝策略 RejectedExecutionHandler 决定系统怎么退化

线程池扛不住时,最终都会走到拒绝策略。
这时候你不是在“选一个 API”,而是在决定:

系统满了以后,应该怎么失败。

Java 自带的 4 种常见策略:

1. AbortPolicy

直接抛异常。

适合:

  • 不能静默失败的核心流程
  • 需要业务方明确感知失败并兜底

优点是问题暴露快,坏处是用户请求可能直接报错。


2. CallerRunsPolicy

由提交任务的线程自己执行。

这是我在很多业务场景里最推荐的一个策略,因为它的核心价值是:

线程池忙不过来时,让调用方一起变慢,从而形成天然背压。

适合:

  • 允许调用链路变慢,但不希望任务直接丢失
  • 希望用“调用方降速”保护系统

例如消息发送、异步通知、审计日志等场景,很适合先考虑它。


3. DiscardPolicy

直接丢弃任务,不报错。

适合:

  • 非核心、可丢失任务
  • 例如某些低价值埋点、统计类任务

但前提是你真的能接受丢。


4. DiscardOldestPolicy

丢掉队列中最老的任务,尝试提交新任务。

适合极少数“新任务比旧任务更有价值”的场景。
一般业务里我不太推荐默认使用,因为容易让任务语义变得不可控。


七、keepAliveTime 到底怎么理解?

这个参数控制的是:

非核心线程空闲多久后被回收。

例如:

keepAliveTime = 60s

意味着线程池在高峰期扩出来的那些线程,如果 60 秒没活干,就会被回收掉。

这个参数通常不需要特别激进,常见配置:

  • 30s
  • 60s
  • 120s

就够用了。

它的价值主要在于:

  • 高峰期允许扩容
  • 流量回落后及时收缩
  • 避免多余线程长期占资源

八、线程工厂 threadFactory 别偷懒,线程名就是你的排障入口

很多人直接用默认线程工厂,结果线上排查时看到一堆:

  • pool-1-thread-1
  • pool-1-thread-2

几乎没有任何业务含义。

推荐至少做到:

  • 给线程池起业务名
  • 统一线程名前缀
  • 必要时补 uncaught exception 处理

例如:

private static ThreadFactory namedThreadFactory(String prefix) {
    AtomicInteger seq = new AtomicInteger(1);
    return r -> {
        Thread thread = new Thread(r, prefix + "-" + seq.getAndIncrement());
        thread.setUncaughtExceptionHandler((t, e) ->
                log.error("thread execute error, thread={}", t.getName(), e));
        return thread;
    };
}

这样线程 dump、日志排查、监控告警都会清晰很多。


九、3 个常见业务场景,线程池到底该怎么配?

场景 1:问诊消息提醒

比如你的小程序线上问诊,用户发消息后要异步做提醒。

特点:

  • IO 密集型
  • 会调用微信接口或消息通道
  • 下游可能限流或超时
  • 允许一定程度延迟,但不能无限堆积

推荐配置:

ThreadPoolExecutor consultNotifyExecutor = new ThreadPoolExecutor(
        8,
        16,
        60L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(300),
        namedThreadFactory("consult-notify"),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

为什么这样配:

  • 线程数不宜太大,避免把微信通道或短信服务打爆
  • 队列有限,防止未发送通知无限积压
  • CallerRunsPolicy 能在高峰期让上游变慢,形成限流

场景 2:日志/审计异步落库

特点:

  • 非核心流程
  • 高峰期可以允许部分降级
  • 如果全部挤占核心资源,不划算

推荐配置:

ThreadPoolExecutor auditExecutor = new ThreadPoolExecutor(
        2,
        4,
        60L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1000),
        namedThreadFactory("audit-log"),
        new ThreadPoolExecutor.DiscardPolicy()
);

为什么可以丢:

  • 审计日志重要,但某些非强一致审计、低优先级埋点在极端高峰下可以有条件降级
  • 不应该为了保住低优先级日志,把主交易链路拖死

场景 3:订单计算或规则引擎处理

特点:

  • CPU 密集型
  • 本地计算多
  • 对线程切换敏感

推荐配置:

int cpu = Runtime.getRuntime().availableProcessors();

ThreadPoolExecutor ruleExecutor = new ThreadPoolExecutor(
        cpu,
        cpu + 1,
        30L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(100),
        namedThreadFactory("rule-engine"),
        new ThreadPoolExecutor.AbortPolicy()
);

为什么这么配:

  • 线程数尽量贴近 CPU 核数
  • 队列不宜过大,避免任务延迟堆积
  • 核心业务不能静默失败,优先快速暴露问题

十、一个真实线上案例:为什么把最大线程数从 32 改到 128,系统反而更差了?

有个很典型的误区是:

“接口慢了,那就多开点线程。”

看起来很合理,实际常常是灾难。

假设一个服务在高峰期需要并发调用第三方接口。
原本线程池配置:

corePoolSize = 16 maximumPoolSize = 32 queueCapacity = 200

接口偶尔慢,团队为了“提速”,把配置改成:

corePoolSize = 16 maximumPoolSize = 128 queueCapacity = 2000

结果发生了什么?

  • 更多线程一起去打第三方接口
  • 第三方响应更慢
  • 每个线程卡住更久
  • 队列堆积更多任务
  • 内存占用上涨
  • 调用超时进一步增加
  • 整体吞吐反而下降

这就是典型的并发放大故障。

正确改法不是“继续加线程”,而是这几步

  1. 把线程池收回合理区间
  2. 给第三方调用加超时
  3. 队列改成有界
  4. 用 CallerRunsPolicy 做背压
  5. 必要时增加限流、熔断、隔离

很多系统不是死在“线程少”,而是死在“线程太多还不受控”。


十一、给你一套更实用的线程池配置口诀

如果你不想每次都从头想,可以先记这套:

CPU 密集看核数,IO 密集看等待;队列一定要有界;最大线程别贪大;拒绝策略先想清;线程名字要可查;隔离业务比调参数更重要。

通俗来讲就是:

  • 线程数围绕任务类型配,不围绕感觉配
  • 队列必须有边界
  • 最大线程数不要用来“赌高峰”
  • 关键业务和非关键业务分池
  • 下游慢时优先让系统退化

更多推荐