Java 与 JVM 列表全部文章
Java 与 JVM
技术笔记›Java 与 JVM第 4 / 6 篇

Java 并发与线程池实战笔记:参数怎么定、坑在哪、线上打满怎么查

从 ThreadPoolExecutor 的执行流程讲到生产环境线程池打满的完整排查链路:核心参数怎么算、JUC 常用组件怎么选、Executors 为什么不能用、CompletableFuture 默认线程池的坑,以及一次千万级短信发送任务把线程池压满的真实复盘。

更多

后端写得越久,越会发现大部分线上「卡住了」「没反应」的问题,最后都落在两件事上:线程池参数拍脑袋定的、异步任务之间没有做隔离。这篇把我自己踩过的和线上复盘过的整理成一份能直接照着用的笔记。

一、先记住这张图:线程池到底怎么跑

ThreadPoolExecutor 的执行顺序,是所有参数调优的地基。很多人以为「线程数没到最大就先开线程」,其实不是。

提交任务 核心线程数未满? 是 → 直接创建线程 队列未满? 是 → 入队等待 最大线程数? 是 → 救急线程 拒绝策略 都满了才触发 顺序:核心线程 → 队列 → 最大线程 → 拒绝。队列没满,永远不会创建救急线程。 所以「核心 8、最大 200、队列 1024」这种配置,实际要队列堆满 1024 个才会开到第 9 个线程。 常用命令:jstack -l <pid> > /tmp/stack.txt 查线程栈;arthas 里 thread -n 5 看最忙的 5 个线程。 thread --state BLOCKED 直接列出阻塞线程,配合 thread <id> 看它在等哪把锁。

二、参数怎么定,别再拍脑袋

参数 怎么定 说明
corePoolSize CPU 密集 ≈ 核数;IO 密集 ≈ 核数 × 2 有公式 N × (1 + 等待时间/计算时间),但线上更靠压测收敛
maximumPoolSize 核心数的 2–4 倍,且必须配无界队列时无效 只在有界队列下才有意义
workQueue 一律用有界队列 LinkedBlockingQueue 默认 Integer.MAX_VALUE,等于无界,会慢慢吃满内存
keepAliveTime 60s 左右 只作用于「超过核心数」的那部分线程
RejectedExecutionHandler 业务侧自己实现,落库 + 告警 默认的 AbortException 抛异常就丢了任务

我习惯的写法是自定义 ThreadFactory 给线程起名字,这一步线下看不出价值,线上救过好几次:

ThreadFactory named = new ThreadFactory() {
    private final AtomicInteger i = new AtomicInteger();
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r);
        t.setName("sms-send-" + i.incrementAndGet());
        t.setUncaughtExceptionHandler((th, e) -> log.error("thread {} died", th.getName(), e));
        return t;
    }
};

线程名在 jstack 里是唯一的定位线索。没有名字的线程池,出问题时你看到的是一坨 pool-7-thread-23,根本不知道是哪个业务的。

三、JUC 组件怎么选(只列我常用的)

场景 用这个 别用
等待多个异步任务完成 CompletableFuture.allOf CountDownLatch + 手动拼结果
限流(控制并发数) Semaphore 自己写计数器
高频计数(点赞、发送量统计) LongAdder AtomicLong(高竞争下 CAS 自旋拖垮 CPU)
读多写少的缓存 StampedLock 乐观读 ReentrantReadWriteLock(写饥饿)
传递链路信息 TransmittableThreadLocal ThreadLocal(线程池复用线程会串数据)

ThreadLocal 在线程池里是事故高发区。线程复用意味着上一次请求的 ThreadLocal 值还挂着:用户在 A 租户的上下文被下一次请求读到,就是典型的「串租户」。规则很简单:try/finally 里一定 remove()。

try {
    ctx.set(tenantId);
    doBiz();
} finally {
    ctx.remove();   // 少了这行,迟早出串数据事故
}

四、五个必踩的坑

  1. Executors.newFixedThreadPool / newCachedThreadPool 不要用:前者是无界队列,后者最大线程数是 Integer.MAX_VALUE,都会 OOM。阿里规约里也是强制级别。
  2. CompletableFuture.runAsync 不传线程池:默认走 ForkJoinPool.commonPool,全局共用。一旦某个慢任务把公共池占满,同 JVM 里所有用了它的地方一起卡死。
  3. 父子任务共用一个池:父任务在等子任务完成,子任务排在队列里等线程,父任务占着的线程又释放不了 —— 经典的线程池死锁。批量场景必须父子拆分两个池。
  4. 线程池参数写死在代码里:应该用配置中心(Nacos/Apollo)托管,线上能临时扩容。征期这种脉冲流量,改个参数重启一次往往来不及。
  5. allowCoreThreadTimeout 忘了开:低峰期核心线程不回收,白白占着资源。

五、生产复盘:千万级短信发送把线程池压满

背景来自我主导设计的一套短信中台(某新零售营销 SCRM 体系,日均千万级发送),细节已脱敏。

现象:大促当天上午 10 点开始,商户批量发送的短信迟迟不回执,后台任务列表大量「处理中」,几分钟后接口开始超时告警。

排查过程:

  1. jstack 拉栈,发现 sms-send-* 线程池 32 个线程全部 RUNNABLE,且栈顶都在 HTTP 调用三方通道的 socket 读上 —— 说明是三方通道变慢,不是我们自己的代码慢。
  2. thread --state BLOCKED 没有阻塞线程,排除锁竞争。
  3. 看队列:我们自己用的有界队列(2000)已经满了,拒绝策略在疯狂打日志 —— 任务在被丢。
  4. 查三方通道的回执耗时曲线:平时 80ms,当天峰值 3s+。通道限流了。

复盘出来的三个问题:

  • 发送线程池和回执处理共用一个池,回执变慢直接把发送线程占死。
  • 三方通道超时设的是 10s,太长了,一个慢通道能吃掉整池线程。
  • 没有按通道做隔离,一个通道出问题,所有通道一起受影响。

改造:

  • 按通道拆成独立的线程池 + 独立的信号量,一个通道挂了不影响其他通道。
  • 三方超时从 10s 降到 2s,失败快速进入重试队列,由 MQ 削峰。
  • 给线程池加了监控(活跃度、队列长度、拒绝数)推到 Grafana,队列水位超过 60% 就告警。

结论:线程池不是「配完就完事」的组件,它需要隔离 + 超时 + 可观测三件套。缺任何一件,出问题都只能靠猜。

六、一份排查清单(照着走)

现象 先看什么 命令 / 工具
接口大面积超时 线程池活跃数与队列长度 arthas: thread -n 5,自定义 metrics
CPU 飙高但 QPS 没涨 是不是 CAS 自旋或空转 top -H -p <pid> → printf '%x' <tid> → jstack 里搜 nid
线程数一直在涨 线程名重复的来源,ThreadFactory 是否写对 jstack -l \| grep 'pool-' \| wc -l
数据串了 ThreadLocal 是否 remove 代码审查 + 增加上下文校验断言
任务莫名其妙消失 拒绝策略是不是 DiscardPolicy 拒绝策略里加计数和告警

最后是那句老话:异步能提速,但不能消除依赖的不可靠。线程池只是把「等待」挪了个位置,慢的下游照样会把压力传回来。

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人