1. 先说结论:线程池为什么会把内存打爆
很多同学以为 OOM 是堆内存不够、对象太多导致的,其实线上最常见的坑之一,恰恰是线程池参数没配好。线程本身要占栈内存,默认一个线程栈 1MB,你开个几千线程,内存直接飙升。更隐蔽的是任务队列无界,任务堆积不消费,对象全堆在队列里,GC 都救不回来。
这篇文章不绕弯子,直接讲清楚 ThreadPoolExecutor 的 5 个核心参数,再复盘一个真实的 OOM 案例,最后给出可落地的配置建议。
2. 5 大核心参数,一次讲透
先看一段最基础的创建代码,参数含义都在注释里:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, // corePoolSize:核心线程数 8, // maximumPoolSize:最大线程数 30, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new LinkedBlockingQueue<>(100), // 任务队列,有界队列 new ThreadFactory() { private final AtomicInteger count = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(r, "biz-pool-" + count.getAndIncrement()); } }, new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );逐个拆开看:
- corePoolSize(核心线程数):池子里常驻的线程数量,没事不会回收。它决定了系统在低峰期能同时处理多少任务。
- maximumPoolSize(最大线程数):核心线程忙不过来,且队列满了,才会继续创建线程,最多到这个数。它决定了系统能扛的瞬时峰值。
- keepAliveTime(存活时间):非核心线程空闲多久后被回收。注意,核心线程默认不回收,除非设置 allowCoreThreadTimeOut(true)。
- workQueue(任务队列):核心线程全忙时,新任务先排队。这里强烈建议用有界队列,比如 ArrayBlockingQueue 或带容量的 LinkedBlockingQueue。
- RejectedExecutionHandler(拒绝策略):队列也满了、线程也到上限了,新任务怎么办。常见四种:AbortPolicy 抛异常、CallerRunsPolicy 让提交线程自己跑、DiscardPolicy 静默丢弃、DiscardOldestPolicy 丢最老的任务。
3. 任务提交后的执行顺序,别搞反
很多人以为线程池是先加线程再加队列,其实顺序正好相反。提交一个任务时,实际流程是这样的:
- 当前线程数小于 corePoolSize,直接新建核心线程执行。
- 核心线程满了,任务进队列排队。
- 队列也满了,才创建非核心线程,直到 maximumPoolSize。
- 线程数到上限且队列满,触发拒绝策略。
这个顺序很关键。如果你把队列设成无界,那第 3 步永远不会触发,maximumPoolSize 形同虚设,线程数永远停在 corePoolSize。
4. 线上 OOM 踩坑复盘
去年我们有个订单导出服务,平时跑得好好的,某天大促流量上来,直接 OOM 挂掉。当时 dump 堆一看,LinkedBlockingQueue 里堆了几十万个导出任务对象,每个对象还挂着大字段的查询结果,内存直接被打满。
问题根源就一句话:队列是无界的。核心线程处理不过来,任务全堆在队列里,越积越多,最终把堆内存耗尽。而且线程数一直没涨上去,因为队列没满,maximumPoolSize 根本没机会生效。
修复方式很简单,把无界队列换成有界队列,并配上合理的拒绝策略:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), // 有界队列,防止任务无限堆积 new ThreadFactory() { private final AtomicInteger count = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "export-pool-" + count.getAndIncrement()); t.setDaemon(true); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时,由提交线程自己执行,天然限流 );改成 CallerRunsPolicy 之后,队列满了就让调用方线程自己跑,相当于把压力回传给上游,起到天然限流的作用,服务再也没因为线程池 OOM 挂过。
5. 参数到底怎么配,给个参考
参数没有标准答案,得看任务类型。这里给一个粗略的参考思路:
- CPU 密集型任务:核心线程数建议设为 CPU 核数 + 1,比如 8 核机器配 9。线程太多反而频繁切换上下文,性能更差。
- IO 密集型任务:线程数可以放宽,常见公式是 CPU 核数 × 2,或者按「CPU 核数 / (1 - 阻塞系数)」估算,阻塞系数一般取 0.8 到 0.9。
- 队列容量:结合峰值 QPS 和单任务耗时估算,宁可拒绝也不要无限堆积。拒绝策略优先考虑 CallerRunsPolicy。
- 给线程起名字:一定要用自定义 ThreadFactory 命名,不然线上排查问题,看到一堆 pool-1-thread-1,根本分不清是哪个业务。
6. 写在最后
线程池用好了是利器,用不好就是定时炸弹。记住三个要点:队列必须有界、拒绝策略要选对、线程名要能认出来。下次再遇到 OOM,先别急着加堆内存,看看是不是线程池把任务全堆在队列里了。
如果你也在用线程池,欢迎在评论区聊聊你遇到过哪些坑,一起避雷。