Java 线程池:参数到底怎么配,坑都在哪

举报
yd_212140359 发表于 2026/10/19 21:32:21 2026/10/19
【摘要】 线上服务跑了三个月,某天下午接口突然集体超时。查了半天,数据库没问题,GC 也正常,最后发现是 Executors.newFixedThreadPool(10) 里堆了三万多个任务——队列无界,任务只进不出,内存一路涨到 OOM 前被限流挡住。这篇文章聊线程池,但不是 API 手册。参数含义网上到处都是,真正让人栽跟头的是"它到底按什么顺序干活"和"你配的参数为什么没生效"。 先看一个能用的...

线上服务跑了三个月,某天下午接口突然集体超时。查了半天,数据库没问题,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% 的配置错误出在这里

提交一个任务时,线程池的判断链条是:

  1. 当前线程数 < corePoolSize,直接新建一个线程执行,不管别的线程忙不忙;
  2. 核心线程满了,任务进队列排队;
  3. 队列也满了,才去创建新线程,直到线程数达到 maximumPoolSize;
  4. 线程数到顶、队列也满,触发拒绝策略。

关键在第 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 就是摆设。线上一律用有界队列,拒绝策略带日志,池子按业务隔离,这三件事做到,线程池相关的问题能少一大半。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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