线程池七个参数:一个无界队列引发的 OOM

newFixedThreadPool 把队列设成无界,3000 个任务直接吃满 3.2 GB。七个参数的真实作用顺序、四种拒绝策略与五个必踩的坑。

ThreadPoolExecutor 的构造函数有七个参数,但真正决定线上稳定性的其实只有三个:队列容量、最大线程数、拒绝策略。绝大多数线程池事故都出在这三个的默认值上——尤其是 Executors.newFixedThreadPool(),它把队列设成了无界。

这篇文章从一次真实的内存溢出讲起,然后逐一拆解七个参数、四种拒绝策略、五个必踩的坑,最后给出一份可以直接用的线程池配置模板。

一、事故现场

一个报表导出服务,用 Executors.newFixedThreadPool(10) 起线程池,上线半年没事。某天运营批量触发了 3000 个导出任务:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/java_pid1.hprof ...

看堆 dump,最大的对象是一个 LinkedBlockingQueue,里面存了 2980 个 ExportTask,每个 task 持有一个 20 万行的结果集,总共吃了 3.2 GB。

问题出在 newFixedThreadPool 的定义:

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());  // ← 无界队列
}

new LinkedBlockingQueue() 的默认容量是 Integer.MAX_VALUE。所以:

  • 线程数永远停在 10(因为队列永远不会满,maximumPoolSize 根本用不上)
  • 任务无限堆积,直到堆爆

这是 Executors 工厂方法的最大陷阱:它把所有边界都藏起来了,看起来简单,实际把风险留到了运行时。

阿里 Java 开发手册里那条「不允许使用 Executors 创建线程池」,说的就是这件事。

二、七个参数到底谁管什么

public ThreadPoolExecutor(
    int corePoolSize,                   // 1. 常驻线程数
    int maximumPoolSize,                // 2. 最大线程数
    long keepAliveTime,                 // 3. 空闲回收时间
    TimeUnit unit,                      // 4. 时间单位
    BlockingQueue<Runnable> workQueue,  // 5. 任务队列
    ThreadFactory threadFactory,        // 6. 线程工厂
    RejectedExecutionHandler handler    // 7. 拒绝策略
)

必须先记住任务提交的完整流程,否则调参全是盲调:

提交任务
  → 当前线程数 < corePoolSize ?        是 → 新建线程执行
  → 队列未满 ?                          是 → 入队等待
  → 当前线程数 < maximumPoolSize ?      是 → 新建线程执行
  → 执行拒绝策略

这个顺序是最反直觉的地方:队列满了才会扩容到 maximumPoolSize。

所以「无界队列 + maximumPoolSize 设很大」等于 maximumPoolSize 完全失效——队列永远不满,永远走不到第 3 步。

这个顺序也解释了一个常见困惑:为什么 corePoolSize=10, maximumPoolSize=100, queue=100,压测时线程数永远只有 10?因为队列 100 还没满。要看到线程扩到 20,得先塞满 100 个任务。

三、队列选型:三种队列三种行为

队列 容量 行为 适用
SynchronousQueue 0 不存任务,直接交给线程;没有空闲线程就扩容 短任务、高并发、CachedThreadPool 用的就是这个
ArrayBlockingQueue 固定 有界,满了触发扩容/拒绝 生产环境首选
LinkedBlockingQueue 默认 MAX_VALUE 无界(不传容量时) 必须显式传容量,否则就是 OOM 定时炸弹
PriorityBlockingQueue 无界 按优先级排序 任务有优先级时用,但同样要防无界
DelayQueue 无界 延迟执行 定时重试场景

生产环境的选择逻辑很清晰:

  • CPU 密集型:ArrayBlockingQueue(小容量) + core == max
  • IO 密集型:ArrayBlockingQueue(中等容量) + max > core,允许突发扩容
  • 任务可丢弃(比如埋点上报):ArrayBlockingQueue(小容量) + DiscardPolicy

队列容量怎么定:用 Little's Law 估算。

队列长度 ≈ 到达速率 × 平均处理时间

QPS 200、单个任务 50ms,那同时积压的任务约 10 个。留 3~5 倍余量,队列设 30~50 就够。设成 10000 只会让「积压」这件事延迟暴露,而不是消失。

四、拒绝策略:四种默认 + 一种自定义

JDK 提供四种:

策略 行为 风险
AbortPolicy(默认) 抛 RejectedExecutionException 调用方必须捕获,否则请求 500
CallerRunsPolicy 由提交任务的线程自己执行 会阻塞调用方,如果是 Tomcat 线程,等于拖慢整个请求
DiscardPolicy 静默丢弃,不报错 任务静默丢失,最难排查
DiscardOldestPolicy 丢掉队列最老的,再试一次 老任务丢了也不知道

AbortPolicy 是默认值,也是最安全的——至少你会知道出问题了。但需要在业务层兜底:

try {
    executor.execute(task);
} catch (RejectedExecutionException e) {
    // 降级:写本地文件 / 打日志 / 返回"系统繁忙"
    metrics.counter("threadpool.rejected", "pool", "export").increment();
    throw new BizException("当前任务过多,请稍后重试");
}

我更推荐自定义策略,把「拒绝」变成可观测事件:

public class MetricsRejectHandler implements RejectedExecutionHandler {
    private final Counter rejected;
    private final MeterRegistry registry;

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        rejected.increment();
        log.warn("线程池拒绝 pool={} active={} queue={} completed={}",
                 e, e.getActiveCount(), e.getQueue().size(), e.getCompletedTaskCount());
        throw new RejectedExecutionException("线程池已满");
    }
}

关键是记录拒绝次数并设告警。threadpool.rejected > 0 就说明容量不足或下游变慢,这是最早的预警信号。

五、五个必踩的坑

坑 1:submit() 吞掉异常

executor.submit(() -> {
    throw new RuntimeException("boom");   // ← 静默消失
});

submit() 返回 Future,异常被封装进 Future,如果不调 get(),异常就永远不出现。线程池继续跑,日志里什么都没有。

对比 execute():

executor.execute(() -> {
    throw new RuntimeException("boom");   // ← 打印到 stderr / 未捕获处理器
});

规则:不需要返回值的任务用 execute()。必须用 submit() 时,务必处理 Future:

Future<?> f = executor.submit(task);
try {
    f.get();
} catch (ExecutionException e) {
    log.error("任务失败", e.getCause());   // 注意取 getCause()
}

坑 2:ThreadLocal 泄漏

线程池的线程是复用的,ThreadLocal 不清理就会把上一个任务的数据带给下一个。

executor.execute(() -> {
    UserContext.set(currentUser);   // 没有 remove
    doWork();
});
// 下一个任务在同一个线程上跑,UserContext.get() 还是上一个用户

这是数据串号事故的典型成因——A 用户能看到 B 用户的数据。

正确写法:

executor.execute(() -> {
    try {
        UserContext.set(currentUser);
        doWork();
    } finally {
        UserContext.remove();     // 必须放在 finally
    }
});

用 Spring 的 RequestContextHolder 时也一样。或者直接用 TaskDecorator 统一处理:

@Bean
public TaskDecorator contextCopyingDecorator() {
    return runnable -> {
        RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
        return () -> {
            try {
                RequestContextHolder.setRequestAttributes(attrs);
                runnable.run();
            } finally {
                RequestContextHolder.resetRequestAttributes();
            }
        };
    };
}

坑 3:线程池不隔离,一个慢接口拖垮全部

所有业务共用一个 ThreadPoolTaskExecutor,报表导出把线程占满,登录接口也一起排队。现象是「平时好好的,一有批量任务全站变慢」。

解决是按业务拆分线程池,每个池独立配置容量:

@Configuration
public class ExecutorConfig {
    @Bean("fastExecutor")     // 短任务:登录、查询
    public ThreadPoolTaskExecutor fastExecutor() {
        return build(8, 32, 200, "fast-", new AbortPolicy());
    }

    @Bean("slowExecutor")     // 长任务:导出、报表
    public ThreadPoolTaskExecutor slowExecutor() {
        return build(4, 8, 50, "slow-", new CallerRunsPolicy());
    }

    private ThreadPoolTaskExecutor build(int core, int max, int queue,
                                         String prefix, RejectedExecutionHandler h) {
        ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
        e.setCorePoolSize(core);
        e.setMaxPoolSize(max);
        e.setQueueCapacity(queue);
        e.setKeepAliveSeconds(60);
        e.setThreadNamePrefix(prefix);
        e.setRejectedExecutionHandler(h);
        e.setWaitForTasksToCompleteOnShutdown(true);   // 优雅关闭
        e.setAwaitTerminationSeconds(30);
        e.initialize();
        return e;
    }
}

setThreadNamePrefix 看着无所谓,但线上排查时 jstack 里全是 pool-1-thread-3 根本分不清是谁的线程。有了前缀,grep slow- 一眼定位。

坑 4:没有监控,出问题只能靠猜

线程池的六个核心指标必须暴露:

@Component
public class PoolMetrics {
    public PoolMetrics(List<ThreadPoolTaskExecutor> executors, MeterRegistry registry) {
        for (ThreadPoolTaskExecutor e : executors) {
            ThreadPoolExecutor p = e.getThreadPoolExecutor();
            Gauge.builder("threadpool.size", p, ThreadPoolExecutor::getPoolSize)
                 .tag("pool", e.getThreadNamePrefix()).register(registry);
            Gauge.builder("threadpool.active", p, ThreadPoolExecutor::getActiveCount)
                 .tag("pool", e.getThreadNamePrefix()).register(registry);
            Gauge.builder("threadpool.queue", p, x -> x.getQueue().size())
                 .tag("pool", e.getThreadNamePrefix()).register(registry);
            Gauge.builder("threadpool.remaining", p, x -> x.getQueue().remainingCapacity())
                 .tag("pool", e.getThreadNamePrefix()).register(registry);
        }
    }
}

告警阈值设定:

指标 告警条件 含义
queue / capacity > 80% 持续 1 分钟 消费能力不足
active / max > 90% 持续 30 秒 线程即将打满
rejected 增量 > 0 已经开始拒绝请求

坑 5:keepAliveTime 对 corePool 不生效

默认情况下,核心线程即使空闲也不会被回收。想回收必须加:

executor.allowCoreThreadTimeOut(true);

不过生产环境一般不建议——线程反复创建销毁的成本比常驻更高。这个参数主要用在「白天忙、夜里闲」的服务,配合较长的 keepAliveTime(比如 300 秒)省一点内存。

六、一份可直接用的配置模板

综合上面所有结论,这是我在用的模板,参数按「IO 密集型 + 有界队列 + 失败可见」设定:

public final class ThreadPools {
    /** 通用短任务:HTTP 调用、RPC */
    public static ThreadPoolExecutor io(String name, int core, int queue) {
        return build(name, core, core * 4, queue, false);
    }

    /** CPU 密集:加解密、压缩、计算 */
    public static ThreadPoolExecutor cpu(String name, int queue) {
        int cpu = Runtime.getRuntime().availableProcessors();
        return build(name, cpu + 1, cpu + 1, queue, false);
    }

    private static ThreadPoolExecutor build(String name, int core, int max,
                                            int queue, boolean callerRuns) {
        return new ThreadPoolExecutor(
            core, max, 60L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(queue),
            new NamedThreadFactory(name),
            callerRuns ? new CallerRunsPolicy() : new RejectCounter(name)
        );
    }

    static class NamedThreadFactory implements ThreadFactory {
        private final AtomicInteger seq = new AtomicInteger(1);
        private final String prefix;
        NamedThreadFactory(String p) { this.prefix = p + "-"; }

        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, prefix + seq.getAndIncrement());
            t.setDaemon(false);
            t.setUncaughtExceptionHandler((th, e) ->
                log.error("线程池未捕获异常 thread={}", th.getName(), e));
            return t;
        }
    }
}

三个细节值得注意:

  1. cpu + 1 是 CPU 密集型的经典取值。多出的 1 个用于在某线程偶尔缺页时顶上。纯计算任务设成 2 × cpu 反而会因为上下文切换变慢。
  2. new ArrayBlockingQueue<> 而不是 LinkedBlockingQueue。前者必须传容量,从类型上强制你考虑边界。
  3. UncaughtExceptionHandler。没有它,线程里逃逸的异常可能彻底静默——尤其是 ScheduledExecutorService 里抛异常会导致后续调度静默停止,这是另一个高频坑。

七、定时任务线程池的特别提醒

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
    throw new RuntimeException("偶发错误");    // ← 之后再也不执行了
}, 0, 10, TimeUnit.SECONDS);

scheduleAtFixedRate 的行为是:任务抛异常就取消后续所有执行,而且不打印任何日志。现象是「定时任务跑了第一次就不跑了」,排查半天找不到原因。

修复:

scheduler.scheduleAtFixedRate(() -> {
    try {
        doWork();
    } catch (Throwable t) {          // 捕获 Throwable,连 Error 也不能漏
        log.error("定时任务失败", t);
    }
}, 0, 10, TimeUnit.SECONDS);

Spring 的 @Scheduled 默认也是这个行为(单线程 + 异常即中断)。所以 @Scheduled 方法内部一定要自己 try-catch,并且配置 spring.task.scheduling.pool.size > 1,否则一个慢任务会阻塞所有其他定时任务。

八、参数确定的三步走

不要凭经验值拍脑袋,按这个流程走:

第 1 步:压测拿数据

# 用 jstack 采样,统计线程状态分布
for i in $(seq 1 10); do jstack 1 | grep -c 'RUNNABLE'; sleep 1; done
# 再统计队列长度
watch -n1 'jcmd 1 Thread.print | grep -c "waiting on condition"'

第 2 步:按公式定初值

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

IO 等待 90%、核数 8 → 8 × 1 × (1 + 9) = 80。这是理论上界,实际取值要小一些(因为下游有连接池限制)。

第 3 步:看运行数据迭代

上线后观察 active / max 和 queue / capacity:

  • 队列长期 > 50%:加线程或加下游能力
  • 队列长期 < 5% 且线程空闲:缩容
  • 拒绝 > 0:先确认是流量突增还是下游变慢

小结

线程池的所有问题可以归结成一句话:要有界,要可见,要隔离。

  • 有界:队列必须有容量,Executors 的工厂方法一律不用
  • 可见:拒绝次数、队列长度、活跃线程数必须打点告警
  • 隔离:快任务和慢任务分池,别让导出把登录拖死

三个参数的实际作用顺序也要记牢:corePoolSize 决定常驻、队列决定缓冲、maximumPoolSize 只有在队列满时才起作用。搞错这个顺序,配什么参数都是盲调。

查看「Java 与 JVM」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。