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

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

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

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

做 Java 服务时,线程池几乎人人都用,但“会用”和“用对”差得很远。
我自己就踩过一个很典型的坑:接口 RT 突然升高,超时越来越多,机器内存也一路往上顶,GC 频繁,最后服务几乎不可用。排到最后,罪魁祸首不是数据库,不是 Redis,也不是网络抖动,而是线程池误用

这篇文章不讲抽象概念,我会按排障思路带你走一遍:

  • 现象怎么判断
  • 为什么线程池会把接口拖慢、把内存撑爆
  • 怎么复现
  • 怎么改
  • 改完以后还要盯哪些指标

背景与问题

先说一个非常常见的业务场景。

某个聚合接口会并发调用多个下游服务,比如:

  • 用户信息服务
  • 订单服务
  • 营销服务
  • 风控服务

为了缩短响应时间,开发同学通常会把这些请求丢进线程池并行执行。思路没问题,问题往往出在实现上。

典型错误写法

最常见的坑有这几种:

  1. 每次请求都 new 一个线程池
  2. 使用 Executors.newFixedThreadPool(),默认无界队列
  3. 任务里做阻塞 IO,但线程数配置过小
  4. 提交任务后不设超时,一路 Future.get() 死等
  5. 线程池和业务容量不匹配,堆积后内存飙升

实际线上现象通常是这样的:

  • 接口 TP99 从几十毫秒涨到几秒
  • Tomcat/Jetty/Netty 工作线程被拖住
  • 线程池队列长度持续增长
  • Young GC / Full GC 次数明显增加
  • 堆内存上涨,甚至 OOM
  • 下游一慢,上游整体雪崩

一个很真实的链路

flowchart LR
    A[用户请求进入接口] --> B[聚合服务提交多个异步任务]
    B --> C[线程池线程不足]
    C --> D[任务进入队列堆积]
    D --> E[请求线程等待Future结果]
    E --> F[接口RT升高/超时]
    D --> G[队列对象越堆越多]
    G --> H[堆内存飙升/GC频繁]

这类问题最麻烦的点在于:
你表面看到的是接口超时,根因却在资源调度层。


现象复现

先用一段小程序把坑复现出来。这个例子故意模拟一个错误线程池配置:

  • 核心线程数不大
  • 最大线程数看起来很大,但没意义
  • 使用无界队列
  • 下游调用很慢
  • 请求不断进入

错误示例:无界队列 + 阻塞任务

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

public class BadThreadPoolDemo {

    // 典型误用:LinkedBlockingQueue 默认近似无界
    private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
            8,
            64,
            60,
            TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(), // 无界队列,风险点
            new ThreadPoolExecutor.AbortPolicy()
    );

    public static void main(String[] args) throws Exception {
        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();

        monitor.scheduleAtFixedRate(() -> {
            ThreadPoolExecutor pool = (ThreadPoolExecutor) EXECUTOR;
            System.out.printf(
                    "active=%d, poolSize=%d, queueSize=%d, completed=%d%n",
                    pool.getActiveCount(),
                    pool.getPoolSize(),
                    pool.getQueue().size(),
                    pool.getCompletedTaskCount()
            );
        }, 0, 1, TimeUnit.SECONDS);

        // 模拟持续流量
        for (int i = 0; i < 100000; i++) {
            final int requestId = i;
            EXECUTOR.submit(() -> handleRequest(requestId));
            Thread.sleep(10); // 模拟请求不断进入
        }
    }

    private static void handleRequest(int requestId) {
        List<Future<String>> futures = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            futures.add(EXECUTOR.submit(() -> slowRemoteCall()));
        }

        for (Future<String> future : futures) {
            try {
                // 不设置超时,问题进一步放大
                future.get();
            } catch (Exception e) {
                System.err.println("requestId=" + requestId + ", error=" + e.getMessage());
            }
        }
    }

    private static String slowRemoteCall() {
        try {
            Thread.sleep(2000); // 模拟慢下游
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "OK";
    }
}

这段代码为什么危险

表面看它用了线程池,实际上有两个连环坑:

  • LinkedBlockingQueue<>无界队列
  • handleRequest() 自己在线程池里执行,又继续往同一个线程池提交子任务并等待结果

这会导致一种很经典的问题:线程池自我依赖
线程都被父任务占住了,子任务却排在队列里等线程,父任务又在等子任务结果,整体吞吐迅速下降。


核心原理

要把这个坑彻底吃透,得先理解 ThreadPoolExecutor 的调度规则。

线程池执行规则

当一个新任务提交进来时,大致流程是:

  1. 如果运行线程数 < corePoolSize,创建新线程执行
  2. 否则尝试放入队列
  3. 如果队列满了,且运行线程数 < maximumPoolSize,再创建线程
  4. 如果队列也满、线程也到上限,触发拒绝策略

注意这里最容易误判的一点:

如果你用了无界队列,队列几乎永远放得下,maximumPoolSize 基本就失效了。

也就是说,你以为自己配了:

  • core = 8
  • max = 64

实际上运行中可能长期只有 8 个线程干活,后面的任务全在队列里堆着。

调度过程图

flowchart TD
    A[提交任务] --> B{当前线程数 < corePoolSize?}
    B -- 是 --> C[创建核心线程执行]
    B -- 否 --> D{队列能否入队?}
    D -- 能 --> E[任务排队等待]
    D -- 不能 --> F{当前线程数 < maximumPoolSize?}
    F -- 是 --> G[创建非核心线程执行]
    F -- 否 --> H[执行拒绝策略]

为什么会接口超时

因为请求处理路径里,主线程通常会等待异步结果,比如:

  • Future.get()
  • CompletableFuture.join()
  • CountDownLatch.await()

当线程池排队严重时,这些等待时间会线性甚至指数放大。

为什么会内存飙升

因为队列里的每个任务都不是“一个数字”那么简单。它通常会携带:

  • 请求参数
  • 上下文对象
  • 闭包引用
  • 日志 MDC
  • 业务对象
  • 可能还有大对象快照

任务一多,堆里堆的不是线程,而是成千上万待执行任务对象

线程池自我阻塞示意

sequenceDiagram
    participant Req as 请求线程/父任务
    participant Pool as 线程池
    participant Sub as 子任务

    Req->>Pool: 提交父任务
    Pool->>Req: 父任务开始执行
    Req->>Pool: 再提交多个子任务
    Note over Pool: 线程已被父任务占满
    Sub-->>Pool: 子任务排队中
    Req->>Req: future.get() 等待子任务
    Note over Req,Pool: 父任务等子任务,子任务等线程

这个场景下,不一定是真正意义上的死锁,但会出现极差的吞吐和严重超时


定位路径

线上排这种问题,我一般不会一上来就看代码,而是先看“症状像不像线程池”。

第一步:从监控看四类指标

重点盯下面几项:

  • 接口 RT、超时率、错误率
  • JVM 堆内存、GC 次数、Full GC 耗时
  • 活跃线程数、线程池队列长度、拒绝次数
  • 下游调用耗时、连接池使用率

如果看到这样的组合,线程池要优先怀疑:

  • RT 飙升
  • CPU 不一定高
  • 堆内存上涨明显
  • 队列长度持续增大
  • 活跃线程数接近核心线程数但不扩容

第二步:jstack 看线程在干什么

执行:

jstack <pid> > threads.txt

重点搜这些关键词:

  • WAITING
  • TIMED_WAITING
  • FutureTask.get
  • CompletableFuture.join
  • LinkedBlockingQueue.take
  • 下游客户端调用栈,比如 HTTP/Redis/MySQL

如果你看到大量线程都卡在:

java.util.concurrent.FutureTask.get
java.util.concurrent.CompletableFuture.join

并且业务线程栈还显示它们来自同一个聚合接口,那就很有味道了。

第三步:jmap / MAT 看堆里是什么

导出堆:

jmap -dump:live,format=b,file=heap.hprof <pid>

用 MAT 打开后,常见特征是:

  • LinkedBlockingQueue$Node 数量很多
  • FutureTask 数量很多
  • 某些业务 Runnable/Callable 实例很多
  • 请求上下文对象被任务链路引用,迟迟不释放

第四步:核对线程池创建方式

代码里重点搜索:

Executors.newFixedThreadPool
Executors.newCachedThreadPool
new LinkedBlockingQueue<>()
Future.get()
CompletableFuture.supplyAsync()

尤其要留意:

  • 是否默认用了公共线程池 ForkJoinPool.commonPool()
  • 是否多个场景共用一个线程池
  • 是否在请求链路中嵌套提交同池任务

实战代码(可运行)

下面给一个更稳妥的改法。目标不是“绝对最优”,而是先把线上风险降下来。

修复思路:

  1. 线程池做成单例
  2. 队列有界
  3. 显式命名线程
  4. 设置拒绝策略并记录日志
  5. 异步结果必须设超时
  6. 避免在线程池任务里再同步等待同池子任务
  7. 隔离不同类型任务的线程池

推荐示例:有界线程池 + 超时控制 + 降级

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

public class GoodThreadPoolDemo {

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

    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            String result = handleRequest(i);
            System.out.println("request-" + i + ": " + result);
        }

        BIZ_POOL.shutdown();
    }

    private static String handleRequest(int requestId) {
        List<CompletableFuture<String>> futures = new ArrayList<>();

        for (int i = 0; i < 3; i++) {
            int serviceNo = i;
            CompletableFuture<String> future = CompletableFuture
                    .supplyAsync(() -> remoteCall(serviceNo), BIZ_POOL)
                    .completeOnTimeout("TIMEOUT_FALLBACK_" + serviceNo, 800, TimeUnit.MILLISECONDS)
                    .exceptionally(ex -> "ERROR_FALLBACK_" + serviceNo);

            futures.add(future);
        }

        List<String> results = futures.stream()
                .map(CompletableFuture::join)
                .collect(Collectors.toList());

        return "requestId=" + requestId + ", results=" + results;
    }

    private static String remoteCall(int serviceNo) {
        try {
            if (serviceNo == 1) {
                Thread.sleep(1200); // 故意超时
            } else {
                Thread.sleep(200);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "INTERRUPTED";
        }
        return "OK_" + serviceNo;
    }

    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<>(200):限制排队规模,避免无限吃内存
  • CallerRunsPolicy():在高压下给调用方反压,而不是继续堆积
  • completeOnTimeout():慢下游不再无限拖住接口
  • 单例线程池:避免反复创建/销毁线程
  • 线程命名:排查时 jstack 一眼能看懂

止血方案

如果你已经在线上爆了,先别急着追求“最优设计”,先止血。

可优先做的 5 件事

  1. 把无界队列改成有界队列
  2. 给异步任务统一加超时
  3. 临时降低并发扇出数量
  4. 对慢下游做降级或缓存兜底
  5. 线程池指标立刻接入监控和报警

紧急止血流程

flowchart TD
    A[发现接口超时/内存上涨] --> B[确认线程池队列长度]
    B --> C{队列持续增长?}
    C -- 是 --> D[限制队列容量]
    D --> E[对任务增加超时和降级]
    E --> F[减少并发扇出]
    F --> G[隔离慢下游线程池]
    C -- 否 --> H[继续排查下游或锁竞争]

关于拒绝策略怎么选

常见的 4 种:

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

我的经验是:

  • 核心业务:优先 CallerRunsPolicy 或显式失败 + 清晰日志
  • 非核心、可丢任务:可考虑丢弃策略,但必须有监控
  • 绝不要静默丢弃又没监控,那是给未来埋雷

常见坑与排查

下面这些坑,基本都很高频。

坑 1:以为 maximumPoolSize 一定生效

如果用了无界队列,maximumPoolSize 大概率只是摆设。

现象

  • 配了 max=100
  • 实际线程数长期只有 core 数量
  • 队列越来越长

排查

看线程池构造参数,尤其是 workQueue 类型。


坑 2:在业务线程池里嵌套等待同池任务

这类问题非常隐蔽,尤其是在聚合接口里。

典型代码

executor.submit(() -> {
    Future<String> f1 = executor.submit(this::callA);
    Future<String> f2 = executor.submit(this::callB);
    return f1.get() + f2.get();
});

风险

父任务占线程,子任务排队,吞吐急剧下降。

建议

  • 避免同池嵌套阻塞等待
  • 拆分线程池
  • 或重构为非阻塞编排

坑 3:把 IO 密集任务和 CPU 密集任务混用一个池

后果

  • 慢 IO 把线程占满
  • CPU 任务得不到执行机会
  • 整体 RT 波动很大

建议

分池:

  • IO 线程池
  • CPU 线程池
  • 定时任务线程池

坑 4:使用 Executors 工厂方法图省事

比如:

Executors.newFixedThreadPool(10)
Executors.newCachedThreadPool()

问题在于这些工厂方法屏蔽了很多关键配置,容易默认踩坑。

建议

优先显式使用:

new ThreadPoolExecutor(...)

把这些参数写清楚:

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

坑 5:没给线程池做可观测性

很多项目线程池用了几年,连最基本指标都没暴露。

至少要监控:

  • activeCount
  • poolSize
  • queueSize
  • completedTaskCount
  • taskCount
  • rejectCount
  • 任务平均耗时 / P99 耗时

安全/性能最佳实践

这一节给的是能直接落地的建议,不是“正确废话”。

1. 线程池按任务类型隔离

最少做到:

  • 请求链路业务池
  • 下游 IO 调用池
  • 定时任务池
  • 大批量后台任务池

不要一个池子包打天下。


2. 队列必须有界

这是最关键的一条。

无界队列不是“稳定”,而是把问题从“拒绝任务”变成“拖慢系统 + 吃光内存”。
有界队列虽然会触发拒绝,但它让系统在极限情况下仍然可控。


3. 每个异步任务都要有超时和降级

建议统一封装调用模板,别靠业务开发自己记忆。

public static <T> CompletableFuture<T> withTimeout(
        Supplier<T> supplier,
        Executor executor,
        long timeout,
        TimeUnit unit,
        T fallback) {
    return CompletableFuture
            .supplyAsync(supplier, executor)
            .completeOnTimeout(fallback, timeout, unit)
            .exceptionally(ex -> fallback);
}

4. 容量评估别拍脑袋

一个简单估算思路:

如果接口峰值 QPS 为 200,每次请求会并发 3 个下游调用,每个调用平均耗时 100ms。

粗略并发需求约为:

200 × 3 × 0.1 = 60

这意味着你至少要从:

  • 下游能力
  • 线程池线程数
  • 连接池大小
  • 超时时间
  • 队列容量

整体一起算,而不是只改线程池线程数。


5. 注意上下文传播带来的隐性内存占用

很多项目会在线程池任务中携带:

  • ThreadLocal
  • MDC 日志上下文
  • 用户态上下文
  • Trace 信息

如果清理不当,可能引发:

  • 内存泄漏
  • 脏上下文串请求
  • 日志 trace 错乱

建议

  • 尽量减少不必要上下文复制
  • 任务结束后显式清理 ThreadLocal
  • 使用框架提供的上下文透传方案时,评估对象大小

6. 对慢服务做舱壁隔离

如果某个下游特别慢,最好给它单独线程池,不要污染主业务池。

例如:

  • 用户服务一个池
  • 推荐服务一个池
  • 风控服务一个池

这样即使推荐服务雪崩,也不会把风控和用户查询一起拖死。


一份实用排查清单

线上遇到“接口超时 + 内存上涨”时,我建议按下面顺序查:

5 分钟内先看

  • 接口 RT、错误率是否突增
  • 线程池 active / queue / reject
  • JVM 堆使用率、GC 次数
  • 下游接口超时是否同步升高

15 分钟内确认

  • jstack 看是否大量阻塞在 Future.get/join
  • jmap 或 MAT 看是否队列/FutureTask 堆积
  • 检查是否无界队列
  • 检查是否同池嵌套提交任务

1 小时内落地修复

  • 改成有界队列
  • 加超时和降级
  • 拆分线程池
  • 暴露指标并加报警
  • 压测验证峰值行为

总结

这类问题最容易误导人的地方在于:

  • 表面现象是接口超时
  • 运维症状是内存飙升、GC 频繁
  • 真正根因却常常是线程池配置和使用方式错误

你可以记住下面这几个结论:

  1. 无界队列是接口慢和内存涨的高风险源头
  2. maximumPoolSize 在无界队列下常常不生效
  3. 同一个线程池里嵌套提交并等待结果,非常危险
  4. 异步不是免责卡,没超时、没降级、没隔离一样会雪崩
  5. 线程池必须可观测,否则出了问题只能“猜”

如果你要把文章里的建议浓缩成最小可执行版本,那就是这 4 条:

  • 不用 Executors 默认工厂方法糊弄生产环境
  • 线程池队列必须有界
  • 每个异步任务都设置超时
  • 线程池指标必须接监控报警

很多 Java 性能问题,说到底不是“不够快”,而是“失控”。
线程池一旦用错,系统不会立刻挂,而是先慢、再堆、再抖、最后一起出问题。这个坑我踩过,所以特别想提醒一句:线程池不是提速按钮,它本质上是资源调度器。先控制,再谈性能。


分享到:

上一篇
《集群架构实战:从单体拆分到高可用多节点部署的设计要点与避坑指南》
下一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实战-452》