Skip to content

线程池实战:原理、调优与监控

本文是 Java 并发系统学习系列的 L2 核心篇。前置:并发容器全景线程池核心参数与执行流程。 学完可以配合面试题食用:线程池核心参数与执行流程

先想清楚:为什么需要线程池

线程是 JVM 里的重资源。new Thread 一次要做三件事:分配约 1MB 的栈内存、调用 OS 创建内核线程、内核把它挂到调度队列。用一次丢一次的话,频繁创建销毁的开销可能超过业务代码本身,而且线程数量不受控,流量高峰一来直接把进程打爆。

线程池解决的就是这两件事:复用(线程干完活不退出,回去等下一个任务)和限流(线程数、队列长度都有上限,超出的任务按预定义策略处理)。池化对象不止线程池一种,数据库连接池、HTTP 连接池是同一个思路:构造贵的资源,用池子管起来反复用。

JDK 里线程池的标准实现是 ThreadPoolExecutor。它内部有一个 ctl 变量(AtomicInteger),高 3 位存线程池状态(RUNNING/SHUTDOWN/STOP/TIDYING/TERMINATED),低 29 位存工作线程数,一个 CAS 同时管两件事。任务提交后走三层检查:核心线程、队列、非核心线程,三层都满了就触发拒绝策略。这套流程如果记不牢,回头看第 01 篇,这里不展开。

为什么禁止 Executors 创建线程池

阿里巴巴 Java 开发手册明确规定,不允许使用 Executors 创建线程池,而是手动 new ThreadPoolExecutor。原因不在 API 本身,在于它偷偷给你的默认值:

  • newFixedThreadPool / newSingleThreadExecutor:队列用的是 LinkedBlockingQueue,没设容量,等价于无界队列。任务消费速度跟不上生产速度时,队列无限堆积,最终堆内存耗尽。2019 年某社交 App 大规模 OOM 的事故复盘里,无界队列是常见嫌疑人之一
  • newCachedThreadPool / newScheduledThreadPool:maximumPoolSize 是 Integer.MAX_VALUE。线程数不受限,请求一飙升就疯狂建线程,一台 4C8G 的机器轻松开出上千线程,内存和 CPU 上下文切换一起崩

手动创建的好处是把每个参数摆到明面上,容量多大、满了怎么办,都得自己决策一遍。写法上就多敲几个参数:

java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
        8,                                  // corePoolSize:常驻线程
        16,                                 // maximumPoolSize:峰值线程
        60, TimeUnit.SECONDS,               // 非核心线程空闲回收时间
        new ArrayBlockingQueue<>(200),      // 有界队列,容量 200
        new ThreadFactoryBuilder()          // 给线程命名,出问题好排查
                .setNameFormat("order-pool-%d").build(),
        new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略
);

有界队列 + 明确的拒绝策略,这是生产代码的底线配置。

参数怎么定:公式只是起点,压测才是答案

网上流传两个公式:

  • CPU 密集型任务:线程数 = CPU 核数 + 1。任务一直在算,没有等待,线程多了只会加剧上下文切换,N+1 的那个 1 是为了在某个线程偶尔缺页或被调度出去时 CPU 不空转
  • IO 密集型任务:线程数 = N * (1 + 等待时间/计算时间)。核心逻辑是 CPU 只在计算段被占用,等待段(RPC、DB、HTTP)CPU 是闲的,多塞几个线程可以把等的时间填满

公式的问题在于"等待时间/计算时间"这个比值在真实系统里根本不稳定:同一个接口,缓存命中时是 5ms,缓存失效打到下游是 200ms,比值差 40 倍,线程数照谁的算?更何况多数业务线程池跑的是混合任务。

工程上靠谱的做法是拿公式估一个初值,然后上压测:用线上真实流量模型(注意任务耗时分布要接近真实,别全用 1ms 的假任务),逐步加压,观察四个数:CPU 利用率、RT P99、队列深度增速、拒绝次数。比如压出 16 线程时 CPU 到 70%、P99 仍在 SLA 内,队列基本不积压,那 16 就是个合理点;32 线程时 P99 反而涨(切换开销吃掉了并发收益),说明到顶了。

还有一个容易被忽略的点:线程数不是孤立的,它要和下游容量匹配。你的接口 RT 100ms、QPS 目标 1000,理论上 100 个线程就够(Little's Law:线程数 ≈ QPS × RT)。下游 DB 最多扛 50 个并发连接,你开 200 线程只会把队列搬到 DB 前面排队,还可能拖垮它。先用 Little's Law 算需求,再对着下游容量封顶。

运行时动态调参与监控打点

参数定完不是一劳永逸。白天高峰和凌晨低谷流量差一个数量级,写死的参数总有一头不合适。好在 ThreadPoolExecutor 提供了运行时调整的入口:

java
// setCorePoolSize 立即生效,甚至能当场建线程
executor.setCorePoolSize(20);
executor.setMaximumPoolSize(40);   // 传给队列容量可变小;core 不能大于 max,先调大者

setCorePoolSize(20) 在池子当前只有 8 个线程时会立刻补建 12 个;如果新值比当前核心小,多余线程会在空闲后回收。这套机制配合配置中心(Nacos/Apollo)就能做动态线程池:监听配置变更,调 setter,不用重启。美团开源的 DynamicTp、开源的 Hippo4j 都是这个路数的成品。

比调参更重要的是知道池子现在什么状态。出事故时"线程池为什么满了"必须能回答,所以核心指标必须打点:

  • getActiveCount():正在干活的线程数,逼近 maximumPoolSize 说明算力到顶
  • getQueue().size():队列深度,持续增长说明消费跟不上生产,是最重要的前兆指标
  • getLargestPoolSize():历史峰值线程数,评估容量够不够
  • getCompletedTaskCount() / getTaskCount():吞吐水位
  • 拒绝次数:需要自己埋点,原 API 不提供计数

用 Micrometer 打点只需包一层 Runnable,或者起个定时任务周期上报:

java
ExecutorService monitor(ExecutorService pool, String name) {
    // 周期任务:把池子指标灌进 Micrometer,Grafana 出图
    scheduler.scheduleAtFixedRate(() -> {
        ThreadPoolExecutor e = (ThreadPoolExecutor) pool;
        Gauge.builder("tp.active", e, ThreadPoolExecutor::getActiveCount)
             .tag("pool", name).register(registry);
        Gauge.builder("tp.queue.size", e, x -> x.getQueue().size())
             .tag("pool", name).register(registry);
    }, 1, 1, TimeUnit.SECONDS);
    // 任务包装:记录拒绝
    return new ThreadPoolExecutor(
            8, 16, 60, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200),
            r -> {
                Thread t = new Thread(r, name + "-" + seq.incrementAndGet());
                return t;
            },
            (r, e) -> {                       // 自定义拒绝策略
                Counter.builder("tp.rejected").tag("pool", name)
                       .register(registry).increment();
                Metrics.counter("tp.rejected", "pool", name).increment();
                rejectedLog.warn("pool {} rejected, queue={}", name, e.getQueue().size());
                fallback.execute(r);          // 降级:丢给备份池/落盘/返回默认值
            });
}

这套数据接到 Prometheus + Grafana,配两条告警:队列深度 5 分钟持续 > 80% 容量、每分钟拒绝数 > 0。事故前半分钟就能收到电话,而不是等用户投诉。

拒绝策略选型

线程满、队列满,新任务就要被拒。JDK 自带四种,先看清行为再选:

  • AbortPolicy(默认):抛 RejectedExecutionException。调用方捕获后自行处理。适合任务不能丢且调用方能感知失败的场景,比如同步下单请求
  • CallerRunsPolicy:让提交任务的线程自己跑。天然限流——生产者被拖慢,提交速度自动降下来。适合可接受提交方被阻塞的场景,如日志、消息投递;注意别在 Netty EventLoop 里用,会卡住 IO 线程
  • DiscardPolicy:静默丢。绝大多数业务不能接受,日志埋点场景偶尔可用
  • DiscardOldestPolicy:丢队头最老的任务,给新任务腾位。适合宁可要新数据不要旧数据的场景,如行情推送

四种都不合适就自定义。自定义是主流做法,典型实现按优先级组合几种动作:先打点告警,再尝试降级(丢到备份线程池、写本地磁盘队列异步重放、返回兜底数据),全失败才真正丢弃并记 error 日志。核心原则:拒绝不是终点,是降级动作的起点,而且必须留下痕迹,否则问题无从复盘。

常见误区与小结

  • 参数照抄"CPU 核数 × 2"。你的任务里 RPC 占 80% 耗时的话,这个数可能偏小 5 倍,压测说了算
  • corePoolSize 设 0 想着"按需创建"。JDK 8 起提交任务会先建线程,但 prestartAllCoreThreads 的预热语义就没了,冷启动毛刺照样有
  • 只看 activeCount 不看队列深度。线程没满但队列天天排队,容量早就不够了,前兆指标在队列上
  • CallerRunsPolicy 一挂了之。它把压力反推给调用方,调用方是 Tomcat 线程或 Netty EventLoop 时,等于把故障放大到整个服务
  • 监控只接了 ThreadPoolExecutor 自带 JMX。默认暴露的指标不含拒绝计数和队列深度变化率,真出事时关键数据是缺的

本文把线程池从"会用"推进到"会管":参数有依据、运行时可调、状态可观测、拒绝有预案。在线程池之上还有一层异步编排——Future 太弱、回调太散,CompletableFuture 和 JDK 21 的虚拟线程把这两条路都补上了,下一篇展开。

参考

参考:JDK ThreadPoolExecutor 源码(java.util.concurrent.ThreadPoolExecutor)、阿里巴巴《Java 开发手册》泰山版并发处理章节、Little's Law 在容量估算中的应用

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1