Java 线程池:参数到底怎么配,坑都在哪
线上服务跑了三个月,某天下午接口突然集体超时。查了半天,数据库没问题,GC 也正常,最后发现是 Executors.newFixedThreadPool(10) 里堆了三万多个任务——队列无界,任务只进不出,内存一路涨到 OOM 前被限流挡住。
这篇文章聊线程池,但不是 API 手册。参数含义网上到处都是,真正让人栽跟头的是"它到底按什么顺序干活"和"你配的参数为什么没生效"。
先看一个能用的版本
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // corePoolSize
16, // maximumPoolSize
60L, TimeUnit.SECONDS, // 空闲回收时间
new ArrayBlockingQueue<>(200), // 有界队列
new NamedThreadFactory("order-"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
七个参数,每个都能讲一段。但先记住一件事:线程池不是"先开几个线程,不够就加线程",它的顺序是 核心线程 → 队列 → 非核心线程 → 拒绝策略。
执行顺序,90% 的配置错误出在这里
提交一个任务时,线程池的判断链条是:
- 当前线程数 <
corePoolSize,直接新建一个线程执行,不管别的线程忙不忙; - 核心线程满了,任务进队列排队;
- 队列也满了,才去创建新线程,直到线程数达到
maximumPoolSize; - 线程数到顶、队列也满,触发拒绝策略。
关键在第 2 步和第 3 步的顺序。很多人以为"队列是最后的缓冲",于是配上 LinkedBlockingQueue 的默认构造器——容量是 Integer.MAX_VALUE——结果队列永远填不满,maximumPoolSize 永远用不上,线程数就卡在核心数上。
这就是开篇那个故障的全部原因。Executors.newFixedThreadPool 和 newSingleThreadExecutor 都用无界队列,所以它们的 maximumPoolSize 形同虚设;newCachedThreadPool 反过来,用的是 SynchronousQueue,来一个任务就要一个线程,线程数上限是 Integer.MAX_VALUE。
阿里巴巴开发手册里禁止用 Executors 快捷方法,理由就是这个,不是玄学。
队列选型:有界是底线
生产环境只有一条规则:队列必须有界。
ArrayBlockingQueue:数组实现,容量固定,进出共用一把锁。业务线程池的默认选择。LinkedBlockingQueue:链表实现,读写各一把锁,吞吐略高,但一定要传容量。顺手写new LinkedBlockingQueue<>(1000)。SynchronousQueue:不存任务,直接交给线程。适合任务极少、要求零排队的场景,配maximumPoolSize用才有意义。PriorityBlockingQueue:按优先级出队。注意它无界,而且任务必须实现Comparable,否则运行期才报ClassCastException。
队列容量配多大?看你能容忍多久的排队延迟。一个接口平均 50ms,队列 200,意味着最坏情况下第 200 个请求要等 10 秒——这已经超过大多数上游的超时时间了。与其让它超时,不如早点拒绝。
拒绝策略,别用默认的
AbortPolicy(默认)直接抛 RejectedExecutionException。问题是很多人没 try-catch,异常在业务代码里冒出来,变成一个莫名其妙的 500。
四种内置策略的区别:
| 策略 | 行为 |
|---|---|
AbortPolicy |
抛异常 |
CallerRunsPolicy |
由提交任务的线程自己执行 |
DiscardPolicy |
静默丢弃 |
DiscardOldestPolicy |
丢掉队首最老的任务,再提交当前任务 |
DiscardPolicy 和 DiscardOldestPolicy 都是静默失败,出问题时日志里啥都没有,排查成本极高,除非你非常确定丢任务无害,否则别用。
CallerRunsPolicy 是我最常用的一个:满了就让调用方自己跑,天然形成背压——Tomcat 的工作线程被拖住,后面的请求进不来,压力自然回传。它唯一的副作用是调用方可能被阻塞很久,如果调用方本身是定时任务或者消息消费线程,要想清楚会不会拖垮整个消费链路。
条件允许的话,建议自定义:
public class LoggingRejectHandler implements RejectedExecutionHandler {
private static final Logger log = LoggerFactory.getLogger(LoggingRejectHandler.class);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
log.warn("pool rejected task, active={}, queueSize={}, poolSize={}",
executor.getActiveCount(), executor.getQueue().size(), executor.getPoolSize());
throw new RejectedExecutionException("线程池已满");
}
}
至少拒绝的时候,日志里有现场。
线程数到底开多少
经典公式:CPU 密集 N+1,IO 密集 2N(N 是核心数)。这个公式在 2010 年还算靠谱,现在 IO 密集型的业务,2N 经常远远不够。
更实用的做法是算等待比:
线程数 = CPU 核数 × (1 + 等待时间 / 计算时间)
一次调用里,查数据库 80ms、调第三方 200ms、本地计算 20ms,等待比是 14,8 核机器理论上要 120 个线程。数字仅供参考,最终还是要压测。公式只能给你一个起点,压测才能告诉你拐点在哪。
另外,不同业务隔离。订单、支付、通知各用各的池子,别共享。共享池最怕的是某类任务突然暴涨,把别的业务全挤死——这叫线程池污染,比 OOM 更难查,因为它不报错,只是慢。
关停线程池的两行代码
pool.shutdown(); // 不再接新任务,已提交的继续跑
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // 打断正在执行的任务
}
shutdown() 之后如果不 awaitTermination,主线程直接退出,正在跑的任务可能被腰斩。shutdownNow() 只是给线程打中断标记,如果你的任务里有 while(true) 又不检查中断状态,它照样停不下来。
三个容易忽略的细节
给线程起名字。 默认名字是 pool-1-thread-1,jstack 的时候十几个池子混在一起,根本分不清谁是谁。
public class NamedThreadFactory implements ThreadFactory {
private final AtomicInteger seq = new AtomicInteger(1);
private final String prefix;
public NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + seq.getAndIncrement());
t.setDaemon(false);
return t;
}
}
线程池里的 ThreadLocal 不会自己清理。 线程是复用的,上一个任务设的值如果不删,下一个任务就读得到。之前有个项目用户 ID 放 ThreadLocal,结果 A 用户看到了 B 用户的订单,就是这么来的。用完必须 remove(),实在不行在任务外层包一层 try-finally。
核心线程默认不回收。 allowCoreThreadTimeOut(true) 可以让核心线程也按 keepAliveTime 回收,适合流量有明显波峰波谷的服务。但要注意,回收之后再提交任务会重新创建线程,创建线程本身有开销,低延迟场景反而别开。
监控
线上线程池至少暴露这几个指标:getPoolSize()、getActiveCount()、getQueue().size()、getCompletedTaskCount()。
队列长度是最灵敏的信号。正常情况下它应该长期接近 0,一旦开始持续上涨且不回落,说明消费速度跟不上生产速度,再过一会儿就是拒绝和超时。把队列长度接到监控上,比等告警响要早半小时。
写到这里,回头再看开篇那次故障,其实就一句话:队列无界,maximumPoolSize 就是摆设。线上一律用有界队列,拒绝策略带日志,池子按业务隔离,这三件事做到,线程池相关的问题能少一大半。
- 点赞
- 收藏
- 关注作者
评论(0)