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

《Java开发踩坑实战:排查并修复线程池误用导致的接口超时与内存飙升问题-488》

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

背景与问题

线上接口“偶发超时”这件事,很多人第一反应会怀疑数据库、Redis、网络抖动,甚至怀疑 JVM 参数不合理。
但我自己踩过几次坑之后,越来越确定一件事:线程池用错了,问题会伪装成很多别的故障。

这篇文章讲一个很典型、也很隐蔽的场景:

  • 某个接口为了提升吞吐,把多个子任务并发执行
  • 使用了 Executors.newFixedThreadPool() 或者自定义线程池
  • 提交任务时没有设置合理的队列、拒绝策略、超时控制
  • 结果在流量上来时:
    • 接口 RT 持续升高
    • Tomcat/Jetty 工作线程被拖住
    • 堆内存不断上涨
    • Full GC 变频繁
    • 最终出现超时雪崩,严重时甚至 OOM

这种问题最难受的地方在于:线程池本来是为了解决并发问题的,结果却成了放大器。

本文我会按“复现现象 → 理清原理 → 排查路径 → 修复代码 → 最佳实践”的顺序,带你完整走一遍。


背景与问题

先看一个简化后的业务模型。

一个聚合接口 /queryUserProfile,内部要并发查 3 个下游:

  • 用户基础信息
  • 订单统计
  • 优惠券信息

于是很多同学会这样写:

ExecutorService executor = Executors.newFixedThreadPool(20);

public UserProfile queryUserProfile(Long userId) throws Exception {
    Future<String> userFuture = executor.submit(() -> queryUser(userId));
    Future<String> orderFuture = executor.submit(() -> queryOrder(userId));
    Future<String> couponFuture = executor.submit(() -> queryCoupon(userId));

    String user = userFuture.get();
    String order = orderFuture.get();
    String coupon = couponFuture.get();

    return new UserProfile(user, order, coupon);
}

本地压测时看起来挺好,QPS 低的时候也没问题。
但线上一旦碰到下游变慢、偶发超时、流量高峰,这段代码就会暴露出几个连锁问题:

  1. 线程池队列持续堆积
  2. 请求线程阻塞在 Future.get()
  3. 任务对象、上下文对象无法及时释放
  4. 堆内存上涨,GC 压力激增
  5. 接口整体超时,形成反压失败

一句话概括:线程池没有帮你削峰,反而把问题缓存进了内存。


现象复现

先把常见线上症状列出来,方便你对号入座。

典型症状

  • 应用 CPU 不一定很高,但 RT 明显升高
  • 服务线程数不断增加,业务线程大量 WAITING/TIMED_WAITING
  • 堆内存持续上涨,Old 区占用回不去
  • Full GC 次数明显增多
  • 接口日志里出现大量超时
  • 线程池监控显示:
    • activeCount 接近核心线程数/最大线程数
    • queueSize 持续增长
    • completedTaskCount 增速很慢

一个高频误用点

很多人以为:

Executors.newFixedThreadPool(20)

就只是“20 个线程,挺安全”。

实际上它底层等价于:

new ThreadPoolExecutor(
    20,
    20,
    0L,
    TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<Runnable>()
);

注意这里的 LinkedBlockingQueue无界队列
这意味着:只要提交速度大于处理速度,任务就会无限堆积在队列里,最终变成内存问题。


核心原理

先把整个故障链路讲透,后面的排查和修复就顺了。

1. 无界队列会吞掉“背压”

线程池本来应该承担两件事:

  • 限制并发
  • 在超出承载时触发背压或拒绝

但如果你用了无界队列,那线程池的行为会变成:

  • 核心线程满了
  • 新任务不扩容,而是继续入队
  • 队列无限增长
  • 内存持续被任务对象占用

这相当于把“系统处理不过来”的问题,变成“先存在内存里,晚点再处理”。

听起来温和,实际上是把延迟和风险向后推,最后一起爆。

2. Future.get() 会把上层请求线程也拖住

如果接口线程提交 3 个子任务,然后直接 get() 等结果:

  • 下游快时,问题不明显
  • 下游慢时,请求线程会同步阻塞
  • 请求线程本身又是有限资源
  • 更多请求进入后,容器线程也被耗尽

于是就从“子任务线程池拥堵”演变成“整个 Web 服务拥堵”。

3. 任务排队会放大对象生命周期

一个排队中的任务,往往不只是一个 Runnable 那么简单。
它背后可能还引用着:

  • 请求参数
  • 用户上下文
  • traceId / MDC
  • 大对象缓存
  • Lambda 捕获的外部变量
  • 结果回调对象

如果队列里堆了几万、几十万个任务,这些对象就很难被 GC 回收。
所以你看到的“内存飙升”,很多时候不是传统意义上的“内存泄漏”,而是任务滞留导致的对象堆积

4. 下游慢 + 无限排队 = 延迟雪崩

当下游接口从 50ms 变成 1s 时,线程池吞吐会断崖式下降。
如果上游还持续投递任务:

  • 队列越来越长
  • 单个任务等待时间越来越久
  • 请求总 RT 越来越高
  • 超时请求越来越多
  • 但排队任务仍然在执行,继续消耗资源

这就是典型的超时不等于停止执行问题。


一图看懂故障链路

flowchart TD
    A[请求进入接口] --> B[提交多个异步子任务]
    B --> C[线程池核心线程被占满]
    C --> D[新任务进入无界队列]
    D --> E[队列持续积压]
    E --> F[Future.get阻塞请求线程]
    F --> G[接口RT升高/超时]
    E --> H[任务对象堆积]
    H --> I[堆内存上涨/GC频繁]
    G --> J[流量高峰时雪崩]
    I --> J

线程池误用与正确配置对比

classDiagram
    class BadThreadPool {
        +corePoolSize = 20
        +maximumPoolSize = 20
        +queue = LinkedBlockingQueue(unbounded)
        +rejection = default
        +risk = 内存堆积
    }

    class GoodThreadPool {
        +corePoolSize = N
        +maximumPoolSize = M
        +queue = ArrayBlockingQueue(bounded)
        +rejection = CallerRuns/Abort
        +timeout = get(timeout)
        +monitor = metrics/log
    }

定位路径

真到线上出问题时,不要上来就改线程数。
先按这个顺序排查,效率最高。

第一步:看接口 RT 和超时比例

重点关注:

  • 平均 RT
  • P95 / P99
  • 超时数
  • 错误码分布

如果 RT 拉高和超时基本同时出现,通常说明不是偶发网络毛刺,而是系统性阻塞。

第二步:看线程池指标

如果你没有暴露线程池监控,建议尽快补上。至少要有:

  • poolSize
  • activeCount
  • queueSize
  • taskCount
  • completedTaskCount
  • largestPoolSize

一个非常危险的信号是:

  • activeCount 长时间接近满值
  • queueSize 持续增长
  • completedTaskCount 增长变慢

这通常说明处理能力低于提交速度

第三步:抓线程栈

使用:

jstack <pid> > thread.dump

重点看两类线程:

  1. 业务请求线程

    • 是否大量阻塞在 FutureTask.get
    • 是否卡在下游网络调用
  2. 线程池工作线程

    • 是否忙于执行慢 SQL / HTTP 调用
    • 是否长时间 WAITING/TIMED_WAITING

你常会看到类似调用链:

java.util.concurrent.FutureTask.get
xxx.service.UserProfileService.queryUserProfile
xxx.controller.UserController.query

这说明主请求线程在等异步任务结果,但异步任务并没有及时完成。

第四步:看堆内存和对象分布

使用:

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

或者直接 dump 堆,用 MAT 分析。

重点关注:

  • FutureTask
  • LinkedBlockingQueue$Node
  • 业务 Runnable/Callable
  • 大量 Lambda 生成对象
  • 请求上下文对象

如果这些对象数量异常多,基本就能确定是任务积压

第五步:结合下游耗时看根因

线程池出问题,常常不是“线程池本身太小”,而是:

  • 下游接口突然变慢
  • 外部依赖超时设置太长
  • 重试逻辑放大请求量
  • 批量任务没有限流

线程池只是最后承受压力的地方。


实战代码(可运行)

下面我给两个版本:

  • 一个是错误示例:容易复现接口超时和内存上涨
  • 一个是修复示例:带边界队列、超时、拒绝策略和降级

错误示例:无界队列 + 无超时等待

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

public class BadThreadPoolDemo {

    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8);
    private static final Random RANDOM = new Random();

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 50000; i++) {
            final int requestId = i;
            new Thread(() -> {
                try {
                    String result = handleRequest(requestId);
                    if (requestId % 1000 == 0) {
                        System.out.println("request=" + requestId + ", result=" + result);
                    }
                } catch (Exception e) {
                    System.err.println("request=" + requestId + " error: " + e.getMessage());
                }
            }).start();
        }
    }

    public static String handleRequest(int requestId) throws Exception {
        Future<String> f1 = EXECUTOR.submit(() -> slowDependency("user-" + requestId));
        Future<String> f2 = EXECUTOR.submit(() -> slowDependency("order-" + requestId));
        Future<String> f3 = EXECUTOR.submit(() -> slowDependency("coupon-" + requestId));

        // 没有超时控制,主线程会一直等
        return f1.get() + "|" + f2.get() + "|" + f3.get();
    }

    private static String slowDependency(String name) throws InterruptedException {
        // 模拟下游偶发变慢
        int sleep = RANDOM.nextInt(100) < 10 ? 3000 : 200;
        Thread.sleep(sleep);
        return name + "-ok";
    }
}

这个例子虽然简单,但足以说明问题:

  • 工作线程只有 8 个
  • 每个请求却要提交 3 个任务
  • 下游一旦慢,请求速度立刻超过处理速度
  • 无界队列开始堆积
  • get() 会让请求线程一直等

如果你给它较小堆内存运行,例如:

java -Xms256m -Xmx256m BadThreadPoolDemo

很容易观察到内存迅速上涨、吞吐下降。


修复示例:有界队列 + 超时 + 拒绝策略 + 降级

import java.util.Random;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class GoodThreadPoolDemo {

    private static final Random RANDOM = new Random();

    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            8,
            16,
            60L,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200),
            new NamedThreadFactory("biz-pool"),
            new ThreadPoolExecutor.CallerRunsPolicy()
    );

    public static void main(String[] args) throws Exception {
        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
        monitor.scheduleAtFixedRate(() -> {
            System.out.println(String.format(
                    "poolSize=%d, active=%d, queue=%d, completed=%d",
                    EXECUTOR.getPoolSize(),
                    EXECUTOR.getActiveCount(),
                    EXECUTOR.getQueue().size(),
                    EXECUTOR.getCompletedTaskCount()
            ));
        }, 1, 1, TimeUnit.SECONDS);

        for (int i = 0; i < 2000; i++) {
            final int requestId = i;
            new Thread(() -> {
                try {
                    String result = handleRequest(requestId);
                    if (requestId % 100 == 0) {
                        System.out.println("request=" + requestId + ", result=" + result);
                    }
                } catch (Exception e) {
                    System.err.println("request=" + requestId + " error: " + e.getMessage());
                }
            }).start();
        }
    }

    public static String handleRequest(int requestId) {
        Future<String> f1 = submitSafe(() -> slowDependency("user-" + requestId));
        Future<String> f2 = submitSafe(() -> slowDependency("order-" + requestId));
        Future<String> f3 = submitSafe(() -> slowDependency("coupon-" + requestId));

        String user = getOrDefault(f1, 800, "user-default");
        String order = getOrDefault(f2, 800, "order-default");
        String coupon = getOrDefault(f3, 800, "coupon-default");

        return user + "|" + order + "|" + coupon;
    }

    private static Future<String> submitSafe(Callable<String> task) {
        try {
            return EXECUTOR.submit(task);
        } catch (RejectedExecutionException e) {
            return CompletableFuture.completedFuture("degraded");
        }
    }

    private static String getOrDefault(Future<String> future, long timeoutMs, String defaultValue) {
        try {
            return future.get(timeoutMs, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            future.cancel(true);
            return defaultValue;
        } catch (Exception e) {
            return defaultValue;
        }
    }

    private static String slowDependency(String name) throws InterruptedException {
        int sleep = RANDOM.nextInt(100) < 10 ? 3000 : 200;
        Thread.sleep(sleep);
        return name + "-ok";
    }

    static class NamedThreadFactory implements ThreadFactory {
        private final String prefix;
        private final AtomicInteger counter = new AtomicInteger(1);

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

        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r);
            t.setName(prefix + "-" + counter.getAndIncrement());
            return t;
        }
    }
}

这个版本的关键点有 4 个:

  1. 有界队列

    • ArrayBlockingQueue<>(200)
    • 避免任务无限堆积
  2. 合理扩容

    • core=8, max=16
    • 允许在压力升高时短暂扩容
  3. 拒绝策略

    • CallerRunsPolicy
    • 让调用方感知压力,形成自然背压
    • 当然,这个策略也有边界,后面会讲
  4. 结果等待超时

    • future.get(timeout)
    • 超时后取消任务并返回降级结果

修复前后时序对比

sequenceDiagram
    participant C as 客户端
    participant W as Web线程
    participant P as 业务线程池
    participant D as 下游服务

    C->>W: 请求接口
    W->>P: 提交3个任务
    P->>D: 并发调用下游
    alt 下游慢且线程池拥堵
        W-->>W: Future.get阻塞
        P-->>P: 队列持续堆积
        W-->>C: 接口超时
    else 修复后
        W->>P: 有界提交
        P->>D: 超时调用
        W-->>C: 返回部分结果/降级结果
    end

常见坑与排查

这一节我尽量讲“真坑”,不是只讲教科书。

坑 1:误用 Executors 工厂方法

很多项目里直接写:

Executors.newFixedThreadPool(20)
Executors.newCachedThreadPool()
Executors.newSingleThreadExecutor()

这些方法不是不能用,而是默认策略往往不适合线上业务场景

  • newFixedThreadPool:无界队列,容易堆积任务
  • newCachedThreadPool:线程数可无限增长,容易把机器打爆
  • newSingleThreadExecutor:单线程串行,极易成为瓶颈

建议:线上统一显式使用 ThreadPoolExecutor

坑 2:只设置线程数,不设置队列容量

很多人会仔细调整 corePoolSizemaximumPoolSize,但对队列随手一写。
实际上,队列容量往往比线程数更决定系统行为。

  • 队列太大:延迟堆积、内存膨胀
  • 队列太小:拒绝太频繁
  • 正确做法:根据 SLA、下游耗时、峰值流量估算

坑 3:异步了,但马上 get()

这种写法我见得特别多:

Future<A> a = pool.submit(() -> taskA());
Future<B> b = pool.submit(() -> taskB());
return assemble(a.get(), b.get());

它不是完全没意义,但如果上层线程必须同步等待结果,那本质上你只是把工作切到了另一个线程池。
一旦任务慢,就会出现“双重线程占用”:

  • 请求线程在等
  • 工作线程在跑

如果接口链路很长,这个成本很高。

坑 4:超时只在上层做,子任务不取消

例如接口 1 秒超时,但你提交给线程池的任务还在继续跑 5 秒。
用户已经拿到超时响应了,但资源还在消耗。

所以要做两层控制:

  • 接口总超时
  • 子任务执行超时 / 下游调用超时 / 可取消

坑 5:拒绝策略随便选

常见拒绝策略:

  • AbortPolicy:直接抛异常
  • CallerRunsPolicy:调用线程自己执行
  • DiscardPolicy:直接丢弃
  • DiscardOldestPolicy:丢最老任务

没有哪个绝对最好,要看业务。

什么时候适合 CallerRunsPolicy

适合:

  • 任务执行时间较短
  • 调用方线程可承受少量回退执行
  • 希望自然限流

不适合:

  • Web 请求线程很宝贵
  • 任务执行很慢
  • 调用链已经很长

如果你在接口线程里用了 CallerRunsPolicy,而任务本身又是慢 IO,那可能会把请求线程直接拖死。
这时更适合:

  • 快速失败
  • 返回降级
  • 配合限流

坑 6:线程池共用,互相污染

比如:

  • 查询接口
  • 导出任务
  • 消息消费
  • 定时任务

全都共用一个线程池。
结果导出一波高峰上来,把接口线程池占满,接口 RT 一起炸。

建议按业务隔离线程池。


止血方案

如果你现在已经在线上碰到了这个问题,先不要追求“一步到位最优雅”,先止血。

立即可做的 5 件事

1. 给线程池加监控和日志

至少周期性打印:

System.out.printf("active=%d, queue=%d, completed=%d%n",
        executor.getActiveCount(),
        executor.getQueue().size(),
        executor.getCompletedTaskCount());

2. 把无界队列改成有界队列

哪怕容量先保守一点,也比无限堆积强。

3. 给 Future.get() 加超时

没有超时的等待,线上迟早要还债。

4. 下游调用超时要比接口总超时更短

比如接口 SLA 1 秒:

  • 下游调用超时:300~500ms
  • 聚合处理预留:200ms
  • 超时后快速降级

5. 对高成本任务单独隔离

导出、批量查询、报表这类任务,不要和在线接口混池。


安全/性能最佳实践

这里给一套我更推荐的落地原则,适合中级 Java 工程师在项目里直接使用。

1. 线上禁用默认 Executors 快捷工厂

统一显式配置:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    unit,
    new ArrayBlockingQueue<>(queueSize),
    threadFactory,
    rejectionHandler
);

2. 线程池参数要按业务模型估算

简单经验法则:

  • CPU 密集型:线程数接近 CPU 核数
  • IO 密集型:线程数可适度大于 CPU 核数,但必须配合超时和有界队列

不要只凭感觉把线程数从 50 改到 200。
线程不是越多越快,过多线程会带来:

  • 上下文切换
  • 内存占用增加
  • 调度开销变大
  • 争用更严重

3. 队列容量要和 SLA 挂钩

这个思路很好用:

队列不是“缓存多少任务”,而是“允许系统积压多少延迟”。

例如:

  • 平均处理能力:1000 task/s
  • 你只能接受额外 200ms 排队
  • 那队列容量大约应控制在 200 左右量级,而不是 10000

4. 必须有超时、取消和降级

最少要覆盖三层:

  • 下游 RPC/HTTP/DB 超时
  • Future.get(timeout)
  • 超时后的默认返回值 / 降级逻辑

5. 线程池隔离

至少按以下维度隔离:

  • 在线接口
  • 批处理/导出
  • 消息消费
  • 定时任务
  • 第三方依赖调用

6. 给线程起可读名称

线上排查时,线程名就是你的路标。
比如:

  • user-query-pool-1
  • coupon-remote-pool-3
  • export-worker-2

比看到一堆 pool-17-thread-6 强太多。

7. 监控一定要成体系

建议监控这些指标:

  • 活跃线程数
  • 队列长度
  • 拒绝次数
  • 任务执行耗时
  • 任务等待耗时
  • 超时次数
  • 降级次数

如果只监控 RT,不监控线程池,很容易到故障后期才发现。

8. 不要忽略 MDC / ThreadLocal 污染

线程池复用线程,如果用了:

  • ThreadLocal
  • 日志 MDC
  • 用户上下文

一定要在任务结束后清理。
否则不仅有内存风险,还可能出现上下文串数据。

示例:

try {
    // 业务逻辑
} finally {
    // ThreadLocal.remove();
}

一套更实用的排查清单

如果你线上再遇到类似问题,可以直接照着做。

快速判断

  • 接口 RT 是否突然拉高?
  • 线程池 queueSize 是否持续增长?
  • 是否大量线程卡在 Future.get()
  • 堆里是否有大量 FutureTask / LinkedBlockingQueue$Node

快速止血

  • 限流
  • 降级
  • 缩短下游超时
  • 有界队列
  • 快速失败替代无限排队

根因确认

  • 下游变慢?
  • 重试过多?
  • 单线程池共享过多业务?
  • 队列无界?
  • 调用方同步等待异步结果?

持续治理

  • 线程池参数标准化
  • 监控告警补齐
  • 链路超时统一治理
  • 线程池按业务隔离

总结

这个坑的本质,不是“线程池不会用”,而是没有把线程池当成容量边界来设计

请记住这几个关键结论:

  1. newFixedThreadPool() 最大的问题不是固定线程数,而是无界队列
  2. 异步提交后立刻 get(),会把请求线程也拖进阻塞
  3. 接口超时不代表任务停止,超时后的后台执行一样会消耗资源
  4. 内存飙升很多时候不是泄漏,而是任务积压导致对象滞留
  5. 正确做法是:有界队列 + 明确拒绝策略 + 超时 + 取消 + 降级 + 隔离

如果你想把建议落到代码层,我推荐最低标准是这 6 条:

  • 不用 Executors 默认工厂方法
  • 所有线程池使用有界队列
  • 所有异步结果等待都带超时
  • 下游调用必须设置超时
  • 不同业务隔离线程池
  • 线程池指标接入监控告警

最后给一个边界提醒:
如果你的接口本质上是强依赖多个慢下游、且必须全部成功才能返回,那线程池调得再漂亮,也只能缓解,不能根治。
这时候要回到架构层考虑:

  • 是否能做缓存
  • 是否能做预计算
  • 是否能接受部分结果
  • 是否能改成异步化流程

线程池是工具,不是万能补丁。
但只要把边界和背压设计对,它至少不会再成为事故放大器。


分享到:

上一篇
《微服务架构中的分布式事务实战:基于 Saga 模式的设计、落地与避坑-98》
下一篇
《从 Prompt 到生产环境:基于 RAG 的企业知识库问答系统设计与优化实战》