后端写得越久,越会发现大部分线上「卡住了」「没反应」的问题,最后都落在两件事上:线程池参数拍脑袋定的、异步任务之间没有做隔离。这篇把我自己踩过的和线上复盘过的整理成一份能直接照着用的笔记。
一、先记住这张图:线程池到底怎么跑
ThreadPoolExecutor 的执行顺序,是所有参数调优的地基。很多人以为「线程数没到最大就先开线程」,其实不是。
二、参数怎么定,别再拍脑袋
| 参数 | 怎么定 | 说明 |
|---|---|---|
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(); // 少了这行,迟早出串数据事故
}
四、五个必踩的坑
Executors.newFixedThreadPool/newCachedThreadPool不要用:前者是无界队列,后者最大线程数是Integer.MAX_VALUE,都会 OOM。阿里规约里也是强制级别。CompletableFuture.runAsync不传线程池:默认走ForkJoinPool.commonPool,全局共用。一旦某个慢任务把公共池占满,同 JVM 里所有用了它的地方一起卡死。- 父子任务共用一个池:父任务在等子任务完成,子任务排在队列里等线程,父任务占着的线程又释放不了 —— 经典的线程池死锁。批量场景必须父子拆分两个池。
- 线程池参数写死在代码里:应该用配置中心(Nacos/Apollo)托管,线上能临时扩容。征期这种脉冲流量,改个参数重启一次往往来不及。
allowCoreThreadTimeout忘了开:低峰期核心线程不回收,白白占着资源。
五、生产复盘:千万级短信发送把线程池压满
背景来自我主导设计的一套短信中台(某新零售营销 SCRM 体系,日均千万级发送),细节已脱敏。
现象:大促当天上午 10 点开始,商户批量发送的短信迟迟不回执,后台任务列表大量「处理中」,几分钟后接口开始超时告警。
排查过程:
jstack拉栈,发现sms-send-*线程池 32 个线程全部RUNNABLE,且栈顶都在 HTTP 调用三方通道的 socket 读上 —— 说明是三方通道变慢,不是我们自己的代码慢。thread --state BLOCKED没有阻塞线程,排除锁竞争。- 看队列:我们自己用的有界队列(2000)已经满了,拒绝策略在疯狂打日志 —— 任务在被丢。
- 查三方通道的回执耗时曲线:平时 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 | 拒绝策略里加计数和告警 |
最后是那句老话:异步能提速,但不能消除依赖的不可靠。线程池只是把「等待」挪了个位置,慢的下游照样会把压力传回来。
一起聊聊