线程池核心参数与调优

举报
yd_216253085 发表于 2026/10/24 21:31:12 2026/10/24
【摘要】 线程池核心参数与调优Executors 提供的几个工厂方法看起来很方便,但 newFixedThreadPool 用无界队列、newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,在生产环境都可能引发 OOM。正确做法是直接 new ThreadPoolExecutor(...),显式指定每个参数。本文把七个参数的协作顺序讲清楚,并给出容量估算和监控的实...

线程池核心参数与调优

Executors 提供的几个工厂方法看起来很方便,但 newFixedThreadPool 用无界队列、newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,在生产环境都可能引发 OOM。正确做法是直接 new ThreadPoolExecutor(...),显式指定每个参数。本文把七个参数的协作顺序讲清楚,并给出容量估算和监控的实操方案。

一、七个参数与任务流转

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
参数 含义
corePoolSize 核心线程数,默认常驻(可用 allowCoreThreadTimeOut 回收)
maximumPoolSize 最大线程数
keepAliveTime + unit 超出核心数的线程空闲存活时间
workQueue 任务队列,决定排队策略
threadFactory 线程工厂,用于命名、设优先级、设守护线程
handler 拒绝策略

execute 的提交逻辑是理解线程池的关键:

public void execute(Runnable command) {
    if (command == null) throw new NullPointerException();
    int c = ctl.get();
    // 1. 工作线程数 < corePoolSize,直接新建线程
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    // 2. 核心已满,尝试入队
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (!isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);   // 防止队列有任务但无消费者
    }
    // 3. 入队失败(队列满),尝试扩容到 maximumPoolSize
    else if (!addWorker(command, false))
        reject(command);              // 4. 仍失败,走拒绝策略
}

顺序可以概括为:核心线程 → 队列 → 非核心线程 → 拒绝。这解释了一个常见困惑:为什么用了有界队列后,线程数迟迟不增长?因为队列没满之前根本不会创建非核心线程。想让线程数快速扩张,队列容量就要设小。

ctl 是一个 AtomicInteger,高 3 位存运行状态,低 29 位存线程数:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;   // 29
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

这样状态和线程数可以用一次 CAS 原子更新,避免两个字段不一致。

线程状态共有五种,流转关系决定了 shutdown 的行为:

RUNNING ──shutdown()──► SHUTDOWN ──队列空且线程为 0──► TIDYING ──► TERMINATED
   │                        ▲
   └──shutdownNow()──► STOP ─┘

SHUTDOWN 状态仍会执行队列中的任务,但拒绝新任务;STOP 状态直接中断所有工作线程,并清空队列。这就是为什么优雅停机要先 shutdown() 再 awaitTermination(),最后才考虑 shutdownNow()。

addWorker 里的双重检查也值得注意:创建线程前要再次确认线程池状态,避免在关闭过程中新建线程。Worker 继承 AbstractQueuedSynchronizer,用一个不可重入的锁标记"是否正在执行任务",shutdown 时通过 tryLock 判断线程是否空闲,空闲的才能被中断。

二、队列选择

队列 特性 适用场景
ArrayBlockingQueue 有界数组,公平性可选 需要背压、容量可控
LinkedBlockingQueue 默认容量 Integer.MAX_VALUE 必须显式指定容量,否则等同无界
SynchronousQueue 不存储元素,直接交给线程 高吞吐、任务短、线程数可放大
PriorityBlockingQueue 无界优先级队列 任务有优先级,慎防堆积
DelayQueue 延迟队列 定时任务、重试调度

生产环境的原则是队列必须有界。无界队列在任务生产速度超过消费速度时只会不断堆积,最终 OOM,而且线程数永远停在 corePoolSize,maximumPoolSize 形同虚设。

三、拒绝策略

JDK 内置四种:

策略 行为
AbortPolicy(默认) 抛 RejectedExecutionException
CallerRunsPolicy 由提交任务的线程执行,形成天然背压
DiscardPolicy 静默丢弃,不抛异常
DiscardOldestPolicy 丢弃队列最老的任务,再重试提交

DiscardPolicy 和 DiscardOldestPolicy 会静默丢任务,除非业务明确允许,否则不要用。CallerRunsPolicy 会让调用线程(可能是 Tomcat 的 IO 线程)执行任务,虽然能减缓提交速度,但如果任务耗时长,会拖慢整个请求链路。

更稳妥的做法是自定义拒绝策略,记录日志并落盘:

public class LoggingRejectHandler implements RejectedExecutionHandler {
    private static final Logger log = LoggerFactory.getLogger(LoggingRejectHandler.class);

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        log.error("task rejected, poolSize={}, activeCount={}, queueSize={}",
                e.getPoolSize(), e.getActiveCount(), e.getQueue().size());
        // 落库、写 MQ、或降级处理
        throw new RejectedExecutionException("task rejected");
    }
}

四、容量估算

线程数的经典公式来自 Brian Goetz 的《Java 并发编程实战》:

线程数 = CPU 核心数 × 目标 CPU 利用率 × (1 + 等待时间 / 计算时间)

实践中按任务类型简化:

任务类型 初始值 说明
CPU 密集型 N + 1 N 为核数,加 1 是为了在偶发缺页时不让 CPU 空转
IO 密集型 2N 到 4N 具体取决于等待占比,IO 阻塞越久,线程数可以越多
混合型 拆分成两个池 按 IO 与 CPU 比例拆分,避免相互影响

这些只是起点,必须用压测校准。可观测的指标包括:getActiveCount()、getQueue().size()、任务平均耗时、拒绝次数。判断标准:

  • 队列长期非空且活跃线程数等于 maximumPoolSize → 容量不足,或下游变慢。
  • 活跃线程数长期远低于 corePoolSize → 核心线程过多,浪费资源。
  • 拒绝次数持续增长 → 需要扩容、限流或加长队列(加长队列只是延后问题)。

JDK 19 起可以用 Thread.ofVirtual() 创建虚拟线程,IO 密集型场景可以大幅降低线程数需求,但 JDK 8/17 项目仍需按上述方式调优。

五、可运行的完整示例

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class PoolDemo {
    public static void main(String[] args) throws Exception {
        int cores = Runtime.getRuntime().availableProcessors();
        AtomicInteger seq = new AtomicInteger();

        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                cores,                       // corePoolSize
                cores * 2,                   // maximumPoolSize
                60L, TimeUnit.SECONDS,       // 非核心线程空闲存活
                new ArrayBlockingQueue<>(200), // 有界队列
                r -> {
                    Thread t = new Thread(r, "biz-pool-" + seq.incrementAndGet());
                    t.setDaemon(false);
                    return t;
                },
                new ThreadPoolExecutor.CallerRunsPolicy()
        );
        pool.allowCoreThreadTimeOut(true);   // 核心线程也可回收

        for (int i = 0; i < 500; i++) {
            final int id = i;
            pool.execute(() -> {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        pool.shutdown();
        if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
            pool.shutdownNow();
        }
        System.out.println("finished");
    }
}

线程工厂给线程起有意义的名字,是排查问题时最省力的一步。线程 dump 里看到 biz-pool-3 比 pool-1-thread-3 有用得多。

六、监控与动态调整

ThreadPoolExecutor 暴露了一组 getter,可以直接接入 Micrometer 或 Prometheus:

ThreadPoolExecutor pool = ...;

int poolSize   = pool.getPoolSize();          // 当前线程数
int active     = pool.getActiveCount();       // 活跃线程数
long completed = pool.getCompletedTaskCount();// 已完成任务数
int queueSize  = pool.getQueue().size();      // 队列长度
int largest    = pool.getLargestPoolSize();   // 历史峰值线程数

队列长度是最灵敏的指标。可以起一个定时任务,队列持续超过阈值就告警。

setCorePoolSize 和 setMaximumPoolSize 是运行时安全的,可以结合配置中心做动态调整。但注意调小 corePoolSize 只会影响后续,已有线程不会立即销毁,要等空闲回收。

七、并发场景分析

线程池隔离。不同业务共用一个池,一个慢接口会占满所有线程,拖垮其他接口。按业务划分池,至少把核心业务与非核心业务分开。

父子任务死锁。父任务提交子任务到同一个池并等待结果,池满时子任务排不上队,父任务占着线程不放,形成死锁。

// 危险:同一线程池内提交并等待
Future<Integer> f = pool.submit(() -> compute());
f.get();   // 池满时可能永久阻塞

解决方式是父子任务使用不同池,或改用 ForkJoinPool(工作窃取能缓解这个问题)。

submit 吞异常。execute 抛出的异常会传给 UncaughtExceptionHandler,而 submit 会把异常包进 Future,不调用 get() 就永远发现不了。

pool.submit(() -> { throw new RuntimeException("boom"); });  // 异常被吞
pool.execute(() -> { throw new RuntimeException("boom"); }); // 会打印堆栈

用 submit 时务必处理返回的 Future,或在任务内部 try-catch 兜底。

shutdown 与 shutdownNow 的区别。前者停止接收新任务,等待已提交任务执行完;后者中断所有线程并返回未执行的任务列表。只调 shutdown 不 awaitTermination,主线程可能先退出。

八、常见坑 / 最佳实践

  1. 禁用 Executors 的快捷工厂方法。newFixedThreadPool 和 newSingleThreadExecutor 用无界队列,newCachedThreadPool 线程数无上限,都可能 OOM。直接 new ThreadPoolExecutor。
  2. 队列必须有界,并配合合适的拒绝策略。无界队列让 maximumPoolSize 失去意义。
  3. 线程工厂必须命名。格式建议 业务名-用途-序号,方便 jstack 定位。
  4. 按业务隔离线程池,避免故障扩散。核心链路与非核心链路分开。
  5. submit 要处理 Future,或改用 execute 加内部 try-catch,避免异常静默丢失。
  6. shutdown 后要 awaitTermination,优雅停机时给足超时时间,再 shutdownNow 兜底。
  7. 监控队列长度和拒绝次数,这两个指标比 CPU 使用率更早暴露问题。

九、小结

  • 任务流转顺序是核心线程 → 队列 → 非核心线程 → 拒绝策略,有界队列会让线程数增长得更快。
  • ctl 用一个 AtomicInteger 同时保存运行状态和线程数,保证原子更新。
  • 线程数估算从 N+1(CPU 密集)或 2N~4N(IO 密集)起步,用压测和监控校准。
  • 队列必须是有界的,拒绝策略要能记录和告警,不要静默丢弃。
  • 线程池要按业务隔离,并监控队列长度、活跃线程数和拒绝次数。
  • submit 会吞异常,shutdown 后要 awaitTermination。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。