跳转到内容
123xiao | 无名键客

《Java开发踩坑实战:排查并修复线程池误用导致的接口雪崩与内存飙升》

字数: 0 阅读时长: 1 分钟

背景与问题

线上最怕的不是“慢一点”,而是突然全线变慢。我之前就踩过一次很典型的坑:一个原本挺稳定的 Java 接口,在某次流量上涨后开始出现下面这些症状:

  • 接口 RT 从几十毫秒飙到几秒
  • Tomcat 工作线程逐渐打满
  • 下游依赖超时增多,重试放大流量
  • JVM 内存持续上涨,Full GC 频繁
  • 最后出现接口雪崩,整个服务几乎不可用

排查后发现,根因不是数据库,也不是 Redis,而是一个被误用的线程池

很多团队会把线程池当成“性能优化开关”——觉得“同步慢,那我就异步”“主线程忙,那我就丢线程池”。但线程池不是无限缓冲区,更不是免费的吞吐倍增器。参数选错、使用姿势不对,线程池本身就会成为雪崩放大器

这篇文章就按真实排障思路来讲清楚:

  1. 这个坑是怎么发生的
  2. 为什么线程池会把接口拖垮
  3. 如何复现问题
  4. 如何定位与止血
  5. 最后怎样改成更稳的写法

现象复现

先看一个很常见、也很危险的写法:

import java.util.concurrent.*;

public class BadThreadPoolDemo {

    // 典型误用:线程数不大,但队列无限大
    private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
            8,
            16,
            60L,
            TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(), // 无界队列
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy()
    );

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 1_000_000; i++) {
            final int taskId = i;
            EXECUTOR.submit(() -> {
                try {
                    // 模拟下游调用变慢
                    Thread.sleep(200);
                    byte[] payload = new byte[1024 * 50]; // 每个任务占一点内存
                    if (taskId % 10000 == 0) {
                        System.out.println("task=" + taskId + ", payload=" + payload.length);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
    }
}

这个代码很“眼熟”:

  • 线程池核心线程数 8,看起来不算夸张
  • 最大线程数 16,也不算离谱
  • 问题出在 LinkedBlockingQueue<>()默认无界
  • 外部请求一快于线程池处理速度,任务就会不断堆积
  • 每个任务都携带上下文、参数、闭包引用,堆内存自然上涨

这时候接口层面会看到一种非常迷惑的现象:

  • CPU 不一定先满
  • 线程数也不一定异常夸张
  • 但内存涨、RT 涨、超时涨、拒绝少甚至没有拒绝
  • 因为任务都“礼貌地排队了”,只是排队排到系统扛不住

核心原理

线程池问题不好排,是因为它经常不是“直接挂”,而是“慢性中毒”。

1. 线程池执行逻辑决定了问题形态

ThreadPoolExecutor 接收任务时,大致遵循这个过程:

flowchart TD
    A[任务提交] --> B{工作线程 < corePoolSize?}
    B -- 是 --> C[创建核心线程执行]
    B -- 否 --> D{队列还能放?}
    D -- 是 --> E[任务入队等待]
    D -- 否 --> F{工作线程 < maxPoolSize?}
    F -- 是 --> G[创建非核心线程执行]
    F -- 否 --> H[触发拒绝策略]

关键点在这里:

如果你用的是无界队列,那么队列几乎总是“还能放”
这意味着线程池很难扩容到 maxPoolSize,任务会优先堆在队列里。

所以很多人以为自己配置了:

  • core = 8
  • max = 64

就能在压力大时自动扩到 64。
实际上,用了无界队列后,可能永远只跑 8 个线程,剩下任务全部排队


2. 为什么会引发接口雪崩

假设一个接口每秒进来 300 个请求,每个请求都往线程池里扔一个耗时 200ms 的任务。

简单估算:

  • 8 个线程,每秒最多处理约 8 / 0.2 = 40 个任务
  • 实际进入是每秒 300 个
  • 净积压约每秒 260 个

积压几分钟后,会发生:

  1. 队列越来越长
  2. 每个请求等待线程池执行的时间越来越久
  3. 上游开始超时、重试
  4. 重试导致请求更多
  5. 队列继续膨胀
  6. 堆内存升高,GC 加剧
  7. 吞吐进一步下降
  8. 雪崩形成

这本质上是一个消费能力小于生产速度的问题,而无界队列把问题“藏起来了”。


3. 为什么内存会飙升

线程池队列里排的不只是一个“指针”。

实际排队任务可能会引用:

  • 请求参数对象
  • 用户上下文
  • traceId / MDC 信息
  • Lambda 闭包捕获变量
  • 下游调用对象
  • 大对象缓存片段

如果每个任务平均占几十 KB,看起来不大;但几十万任务一排,堆内存马上就上去了。

sequenceDiagram
    participant Client as 上游请求
    participant API as 接口线程
    participant Pool as 业务线程池
    participant Downstream as 下游服务
    Client->>API: 请求进入
    API->>Pool: submit(task)
    Note over Pool: 下游变慢,任务处理不过来
    Pool-->>API: 快速返回Future/等待结果
    API->>Downstream: 实际调用延后
    Client-->>API: 请求超时
    Client->>API: 重试
    API->>Pool: submit更多任务
    Note over Pool: 队列堆积、内存上涨、GC变频繁

定位路径

线上排查我一般按这个顺序来,不容易漏。

1. 先看业务症状

先确认是不是线程池导致的,而不是下游单点故障:

  • 接口 RT 是否持续上升而非瞬时抖动
  • 超时比例是否逐步变高
  • 线程数是否平稳但吞吐下降
  • 内存曲线是否持续上涨
  • GC 次数和耗时是否明显增加

如果这些同时出现,线程池积压的概率就很高。


2. 看 JVM 和线程池指标

建议至少暴露这些指标到监控系统:

  • activeCount
  • poolSize
  • corePoolSize
  • maximumPoolSize
  • queueSize
  • completedTaskCount
  • taskCount
  • rejectCount

如果你看到这样的组合,基本就能锁定问题:

  • activeCount 接近 corePoolSize
  • poolSize 长期不上升
  • queueSize 持续增大
  • completedTaskCount 增速低
  • rejectCount 几乎为 0

这说明:任务在堆积,但没有形成有效背压


3. 用线程栈和堆分析做交叉验证

线程栈看什么

使用:

jstack <pid>

重点看:

  • 业务线程池线程是否大量处于 TIMED_WAITING
  • 是否卡在下游 HTTP / RPC / DB 调用
  • 是否有大量线程都在等待同一个资源

堆分析看什么

使用:

jmap -histo:live <pid> | head -n 50

或者导出 heap dump 用 MAT 分析。

重点看:

  • LinkedBlockingQueue$Node
  • FutureTask
  • 业务 Runnable / Callable 实现类
  • 大量请求 DTO、上下文对象是否被任务引用住

如果 LinkedBlockingQueue$NodeFutureTask 数量异常多,基本坐实队列堆积。


实战代码(可运行)

下面我给一个更完整的示例:先演示错误版本,再演示修复版本。

错误版本:无界队列 + 同步等待 Future

这个版本尤其危险,因为它看似“异步”,其实接口线程最后还是 get() 等结果,等于把请求线程和业务线程池一起拖死。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class ThreadPoolWrongUsageCase {

    private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
            4,
            8,
            60L,
            TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(), // 无界队列,问题根源
            new NamedThreadFactory("bad-pool"),
            new ThreadPoolExecutor.AbortPolicy()
    );

    public static void main(String[] args) throws Exception {
        List<Future<String>> futures = new ArrayList<>();

        for (int i = 0; i < 20000; i++) {
            final int id = i;
            Future<String> future = EXECUTOR.submit(() -> slowRemoteCall(id));
            futures.add(future);
        }

        for (Future<String> future : futures) {
            try {
                System.out.println(future.get(3, TimeUnit.SECONDS));
            } catch (TimeoutException e) {
                System.err.println("future timeout");
            }
        }

        EXECUTOR.shutdown();
    }

    private static String slowRemoteCall(int id) throws InterruptedException {
        Thread.sleep(200);
        byte[] buf = new byte[1024 * 20];
        return "ok-" + id + "-" + buf.length;
    }

    static class NamedThreadFactory implements ThreadFactory {
        private final String prefix;
        private int counter = 0;

        NamedThreadFactory(String prefix) {
            this.prefix = prefix;
        }

        @Override
        public synchronized Thread newThread(Runnable r) {
            Thread t = new Thread(r, prefix + "-" + (++counter));
            t.setDaemon(false);
            return t;
        }
    }
}

这个版本错在哪里

  1. LinkedBlockingQueue 无界,任务会无限积压
  2. 下游慢时,线程池处理不过来
  3. 主线程仍然 future.get(),异步收益几乎为零
  4. 任务对象和结果对象长时间滞留,堆占用持续增加

修复版本:有界队列 + 超时 + 背压 + 明确降级

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

public class ThreadPoolFixedCase {

    private static final AtomicLong REJECT_COUNT = new AtomicLong();

    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            8,
            16,
            60L,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200),
            new NamedThreadFactory("biz-pool"),
            new RejectedExecutionHandler() {
                @Override
                public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                    REJECT_COUNT.incrementAndGet();
                    throw new RejectedExecutionException("thread pool overloaded");
                }
            }
    );

    public static void main(String[] args) throws InterruptedException {
        int total = 5000;
        int success = 0;
        int rejected = 0;

        for (int i = 0; i < total; i++) {
            final int id = i;
            try {
                Future<String> future = EXECUTOR.submit(() -> guardedRemoteCall(id));
                try {
                    String result = future.get(300, TimeUnit.MILLISECONDS);
                    if (result != null) {
                        success++;
                    }
                } catch (TimeoutException e) {
                    future.cancel(true);
                    System.out.println("timeout for task " + id);
                } catch (ExecutionException e) {
                    System.out.println("execution failed: " + e.getMessage());
                }
            } catch (RejectedExecutionException e) {
                rejected++;
                // 模拟降级返回
                System.out.println("rejected task " + id + ", fallback");
            }

            if (i % 200 == 0) {
                printStats();
            }
        }

        EXECUTOR.shutdown();
        EXECUTOR.awaitTermination(10, TimeUnit.SECONDS);

        System.out.println("success=" + success);
        System.out.println("rejected=" + rejected);
        System.out.println("rejectCount=" + REJECT_COUNT.get());
    }

    private static String guardedRemoteCall(int id) throws InterruptedException {
        int cost = ThreadLocalRandom.current().nextInt(50, 500);
        Thread.sleep(cost);
        return "result-" + id;
    }

    private static void printStats() {
        System.out.println("poolSize=" + EXECUTOR.getPoolSize()
                + ", active=" + EXECUTOR.getActiveCount()
                + ", queue=" + EXECUTOR.getQueue().size()
                + ", completed=" + EXECUTOR.getCompletedTaskCount()
                + ", rejected=" + REJECT_COUNT.get());
    }

    static class NamedThreadFactory implements ThreadFactory {
        private final String prefix;
        private int counter = 0;

        NamedThreadFactory(String prefix) {
            this.prefix = prefix;
        }

        @Override
        public synchronized Thread newThread(Runnable r) {
            Thread t = new Thread(r, prefix + "-" + (++counter));
            t.setDaemon(false);
            return t;
        }
    }
}

这个版本的关键变化:

  • ArrayBlockingQueue有界队列
  • 设置明确的队列容量,避免无限吃内存
  • 提交失败时直接拒绝,让上游感知压力
  • Future.get() 加超时,避免无限等待
  • 超时后 cancel(true),尽快释放资源
  • 拒绝后走 fallback,而不是继续硬扛

核心原理的落地改造思路

如果把这次问题抽象一下,真正的修复不是“调大线程池”,而是建立完整的限流与背压机制。

flowchart LR
    A[请求进入] --> B{线程池容量充足?}
    B -- 是 --> C[执行任务]
    B -- 否 --> D[快速失败/降级]
    C --> E{下游是否超时?}
    E -- 否 --> F[返回结果]
    E -- 是 --> G[取消任务/熔断/降级]
    D --> H[返回兜底结果]
    G --> H

常见坑与排查

下面这些坑,我基本都见过,甚至有几个是我自己踩出来的。

坑 1:直接使用 Executors.newFixedThreadPool

很多人图省事直接写:

ExecutorService executor = Executors.newFixedThreadPool(20);

问题是它底层也是无界队列。这在低压场景没事,一到峰值流量就容易积压。

排查信号

  • 线程数稳定
  • 队列长度持续上涨
  • 没有拒绝日志
  • JVM 堆越来越高

建议

优先手动创建 ThreadPoolExecutor,显式指定:

  • 核心线程数
  • 最大线程数
  • 队列类型与容量
  • 拒绝策略
  • 线程工厂

坑 2:线程池里执行阻塞 IO,却按 CPU 密集型配置

比如下游 HTTP、数据库、RPC 调用都是阻塞的,但线程池却只给了很小的线程数。

表现

  • 活跃线程很快打满
  • 队列积压
  • 接口整体变慢

建议

线程池配置要基于任务类型:

  • CPU 密集型:线程数接近 CPU 核数
  • IO 密集型:线程数可以更高,但前提是有边界、有超时

不要一上来就“线程越多越好”。线程多了会增加上下文切换、连接竞争和内存开销。


坑 3:线程池异步化,但接口同步等待结果

这是最常见的“伪异步”:

Future<Result> future = executor.submit(this::callRemote);
Result result = future.get();

如果调用方马上 get(),那本质只是把等待从当前线程挪到了线程池线程,并没有减少整体阻塞。

建议

先问自己两个问题:

  1. 这个异步是否真的能和主流程并行?
  2. 如果最终必须同步等待,是否值得引入额外排队成本?

如果不能真正并行,很多时候直接同步调用反而更可控。


坑 4:没有超时,没有取消,没有降级

这三个缺一个,系统抗压能力都会差很多。

典型后果

  • 下游卡住时,线程池线程被长期占用
  • 队列积压越来越严重
  • 上游超时重试继续放大流量

建议

至少做这三件事:

  • 下游调用设置超时
  • Future.get() 设置超时
  • 超时后取消任务,并返回降级结果

坑 5:拒绝策略选错

默认的 AbortPolicy 会直接抛异常,这没错,但你得接住并处理
如果你用了 CallerRunsPolicy,要特别小心:高峰时任务可能回落到请求线程执行,导致接口线程被拖慢,进一步影响整体吞吐。

怎么选

  • 对在线接口:通常更适合快速失败 + 降级
  • 对后台任务:可考虑重试或延迟处理
  • 不要让拒绝策略悄悄改变系统行为而没人知道

止血方案

如果线上已经开始雪崩,我建议按“先止血,再修复”的思路处理。

第一阶段:立刻止血

  1. 限流

    • 在网关或接口层限制进入速率
    • 防止更多请求压入线程池
  2. 熔断/降级

    • 对非核心功能直接返回默认值
    • 对慢下游临时熔断
  3. 缩短超时

    • 包括 HTTP/RPC/DB 的调用超时
    • 避免线程长时间挂死
  4. 清理积压

    • 如果是可丢弃任务,考虑清队列
    • 如果是在线核心请求,优先扩容下游或降级入口
  5. 必要时重启

    • 这不是最优解,但在队列巨量积压、内存无法回落时,重启是有效止损手段
    • 前提是你已经做好流量控制,否则重启后还会再炸一次

第二阶段:修复根因

  • 改无界队列为有界队列
  • 给线程池补齐监控指标
  • 明确拒绝策略和降级逻辑
  • 重新评估线程池大小与下游吞吐
  • 区分不同业务线程池,避免互相污染

安全/性能最佳实践

这一部分我尽量说得“能拿去用”。

1. 不要混用业务类型不同的任务

不要把下面这些任务全丢到一个池子里:

  • 用户接口请求
  • 批处理任务
  • 消息消费
  • 慢 SQL 补偿任务

因为慢任务一旦积压,会拖垮快任务。
隔离永远比“共享一个大池子”更安全。


2. 线程池参数要按容量估算,而不是拍脑袋

一个简单估算方式:

  • 假设目标吞吐 QPS = 200
  • 平均任务耗时 RT = 100ms
  • 理论并发需求约 200 × 0.1 = 20

这只是起点,不是最终值。你还要考虑:

  • 峰值流量
  • 下游抖动
  • 超时比例
  • 机器 CPU / 内存
  • 每个任务占用的上下文对象大小

线程池容量和队列容量一定要结合压测来定。


3. 监控必须覆盖“队列长度”

很多团队只看:

  • CPU
  • 内存
  • RT
  • 错误率

但线程池最关键的预警指标之一是:

  • 队列长度
  • 队列增长速度
  • 拒绝次数
  • 活跃线程占比

因为线程池问题往往在 CPU 打满前,就已经开始伤害 RT 了。


4. 给线程起有业务含义的名字

这是个小事,但排障时极有用。

new NamedThreadFactory("order-query-pool")

当你在 jstack 里看到:

  • order-query-pool-1
  • order-query-pool-2

比看到一堆 pool-7-thread-3 好定位太多。


5. 尽量避免任务中持有大对象

比如:

  • 超大请求体
  • 大量查询结果列表
  • 图片/二进制数据
  • 整个 Spring 上下文对象链路引用

任务一旦排队,这些大对象也会被一起“锁”在堆里,导致内存无法回收。


6. 警惕 ThreadLocal 和上下文泄漏

线程池线程会被复用,所以如果任务里用了 ThreadLocal,一定要 remove()

public class ThreadLocalDemo {
    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

    public static void execute() {
        try {
            TRACE_ID.set("trace-123");
            // 业务逻辑
        } finally {
            TRACE_ID.remove();
        }
    }
}

否则一个线程长期复用,残留上下文不仅会串数据,还可能形成隐蔽内存问题。


一个更稳的排查清单

如果你怀疑是线程池问题,可以按下面这个顺序执行:

stateDiagram-v2
    [*] --> 观察现象
    观察现象 --> 查看线程池监控
    查看线程池监控 --> 分析队列长度
    分析队列长度 --> 抓线程栈
    抓线程栈 --> 看下游阻塞点
    看下游阻塞点 --> 导出堆信息
    导出堆信息 --> 确认任务积压对象
    确认任务积压对象 --> 临时止血
    临时止血 --> 调整线程池参数
    调整线程池参数 --> 压测验证
    压测验证 --> [*]

这套路径的好处是:
从外部症状到内部证据逐层收敛,不会一上来就怀疑错方向。


总结

这次线程池踩坑,根因其实很朴素:

  • 任务处理能力不足
  • 却用了无界队列把压力硬吞下去
  • 再叠加同步等待、下游变慢、缺少超时和降级
  • 最终从“接口变慢”演变成“接口雪崩 + 内存飙升”

你可以记住这几个最实用的结论:

  1. 线上接口线程池尽量不用无界队列
  2. 线程池不是缓存,更不是流量黑洞
  3. 异步不是目的,吞吐闭环和背压才是关键
  4. 要有超时、拒绝、降级、监控四件套
  5. 先止血,再修参数,最后靠压测验证

如果你现在的项目里还有下面这种代码:

Executors.newFixedThreadPool(20)

或者:

new LinkedBlockingQueue<>()

建议尽快回头看一眼。
平时它可能安安静静,一到流量峰值,就很可能把你最核心的接口一起带崩。


分享到:

上一篇
《Spring Boot 中基于 Actuator + Micrometer + Prometheus 的应用监控与告警实战》
下一篇
《大模型推理优化实战:从 KV Cache、量化到批处理吞吐提升的工程方法》