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

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

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

背景与问题

有一次我接手一个“偶发超时”的接口问题,最开始大家都怀疑是数据库慢、下游服务抖动,甚至有人开始翻 Nginx 超时配置。但排查一圈后发现,真正的问题根本不在外部依赖,而是在业务代码里对线程池的误用

现象很典型:

  • 接口平均响应时间本来在 100ms ~ 200ms
  • 高峰期突然飙到 3s ~ 10s
  • 应用堆内存持续上涨,Full GC 变频繁
  • CPU 不一定打满,但服务吞吐明显下降
  • 最后甚至出现:
    • 请求堆积
    • 线程池队列暴涨
    • OOM 或被 Kubernetes 重启

这类问题最坑的地方在于:表面上看像慢请求,实际上是异步任务堆积导致的资源挤兑

一个常见的错误写法

很多项目里都能看到类似代码:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BadExecutorHolder {
    // 看起来很省事,但风险很大
    public static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(20);
}

然后在接口里这么用:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class OrderService {

    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(20);

    public String queryOrder() {
        for (int i = 0; i < 1000; i++) {
            EXECUTOR.submit(() -> {
                // 模拟耗时操作,比如远程调用、复杂计算、日志落库
                doSlowTask();
            });
        }
        return "ok";
    }

    private void doSlowTask() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

问题在哪?

Executors.newFixedThreadPool(20) 的底层使用了无界队列 LinkedBlockingQueue
线程数固定为 20,但任务提交速度远大于处理速度时,多出来的任务不会被拒绝,而是无限堆积在队列里

这就会带来两个直接后果:

  1. 接口超时:任务排队太久,业务流程迟迟得不到结果
  2. 内存飙升:队列里堆满 Runnable、上下文对象、参数引用,堆占用越来越高

现象复现

先别急着讲理论,我更喜欢先把坑复现出来。下面给一个可运行示例。

错误示例:无界队列导致任务堆积

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class WrongThreadPoolDemo {

    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8);
    private static final AtomicInteger COUNTER = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            for (int i = 0; i < 500; i++) {
                final int taskId = COUNTER.incrementAndGet();
                EXECUTOR.submit(() -> {
                    try {
                        // 模拟慢任务
                        Thread.sleep(1000);
                        if (taskId % 1000 == 0) {
                            System.out.println("done taskId=" + taskId);
                        }
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }

            System.out.println("submitted total tasks=" + COUNTER.get());
            Thread.sleep(200);
        }
    }
}

如果你给这个程序一个较小堆,比如:

java -Xms256m -Xmx256m WrongThreadPoolDemo

运行一段时间后,通常会看到:

  • 提交速度远大于消费速度
  • 堆内存不断增加
  • GC 越来越频繁
  • 最终可能 OOM

问题演化流程

flowchart TD
    A[接口请求到来] --> B[提交大量异步任务到线程池]
    B --> C{线程池线程是否空闲}
    C -- 是 --> D[立即执行]
    C -- 否 --> E[进入无界队列等待]
    E --> F[队列持续堆积]
    F --> G[堆内存占用上升]
    G --> H[GC频繁]
    H --> I[接口响应变慢/超时]
    I --> J[更多重试或请求堆积]
    J --> F

这个闭环一旦形成,就会进入恶性循环。


核心原理

要修这个问题,必须先真正理解 ThreadPoolExecutor 的行为,而不是只记住“不要用 Executors”。

ThreadPoolExecutor 的关键参数

public ThreadPoolExecutor(
    int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler
)

核心点是这几个参数的配合关系:

  • corePoolSize:核心线程数
  • maximumPoolSize:最大线程数
  • workQueue:任务队列
  • RejectedExecutionHandler:队列满、线程也到上限后的拒绝策略

提交任务时的执行顺序

线程池不是一上来就把线程开到最大,它有一套明确规则:

  1. 线程数 < corePoolSize:优先创建新线程
  2. 否则先尝试放入队列
  3. 如果队列满了,且线程数 < maximumPoolSize:继续创建线程
  4. 如果队列也满、线程也满:触发拒绝策略

这意味着:

  • 无界队列下,队列几乎永远不会满
  • 所以线程池通常只会维持在 corePoolSize
  • maximumPoolSize 形同虚设

这正是很多人误解的地方:
“我明明设置了最大线程数 200,为什么高峰时还是只有 20 个线程在跑?”

答案是:因为你配了无界队列。

线程池执行机制示意

flowchart LR
    A[submit任务] --> B{worker < corePoolSize?}
    B -- 是 --> C[创建核心线程执行]
    B -- 否 --> D{队列能放下?}
    D -- 是 --> E[任务入队等待]
    D -- 否 --> F{worker < maximumPoolSize?}
    F -- 是 --> G[创建非核心线程执行]
    F -- 否 --> H[触发拒绝策略]

为什么会引发内存飙升

队列里堆积的并不只是一个个“轻量任务”。

很多时候任务对象会持有:

  • 请求参数
  • 用户上下文
  • 大对象引用
  • DTO 列表
  • 远程调用结果缓存
  • 日志上下文 MDC

如果一次接口提交 200 个任务,每个任务又引用几十 KB 的对象,积压几万条后,堆内存很快就顶不住了。

为什么接口会超时

常见有两种场景:

场景一:接口等待异步结果

例如:

future.get(2, TimeUnit.SECONDS);

任务虽然已经 submit 了,但实际上排在队列里没执行到,get() 等不到结果,自然超时。

场景二:异步任务拖垮整体应用

即使接口本身不等待结果,大量堆积也会带来:

  • GC 变慢
  • CPU 被上下文切换和 GC 吃掉
  • 下游连接池被打满
  • 日志 I/O 变重

最后“看起来每个接口都慢了”。


定位路径

我通常会按“从外到内”的顺序排查,避免一上来就盲猜。

1. 先看监控现象

优先关注这些指标:

  • 接口 RT、TP99、超时率
  • JVM 堆使用率
  • Young GC / Full GC 次数与耗时
  • 线程数
  • CPU 使用率
  • 下游依赖耗时
  • 线程池活跃线程数、队列长度、拒绝次数

如果你们没有线程池监控,这是第一个要补的洞。

2. 看线程池是否异常堆积

如果是 Spring 项目,很多线程池会有名字。可以结合日志或监控看:

  • activeCount
  • poolSize
  • queueSize

一个典型异常特征是:

  • activeCount 不高,比如一直 20
  • queueSize 却持续上涨到几千、几万

这基本就能说明:处理不过来,而且没有背压

3. 抓线程栈

使用:

jstack <pid>

常能看到:

  • 大量业务线程在等待 Future.get()
  • 线程池 worker 正在执行慢任务
  • 某些请求线程卡在同步等待异步结果的地方

4. 看堆直方图

使用:

jmap -histo <pid>

重点关注:

  • java.util.concurrent.FutureTask
  • java.util.concurrent.LinkedBlockingQueue$Node
  • 各种业务 Runnable/Callable
  • 大量 DTO/上下文对象

如果这些对象数量异常多,基本就坐实了“任务堆积导致内存上涨”。

5. 排查代码入口

最后回到代码,找:

  • 接口里是否批量 submit
  • 是否使用 Executors.newFixedThreadPool/newSingleThreadExecutor
  • 是否同步等待异步结果
  • 是否缺失超时控制
  • 是否没有拒绝策略兜底

一次典型定位时序

sequenceDiagram
    participant U as 用户请求
    participant API as 接口线程
    participant TP as 线程池
    participant Q as 队列
    participant JVM as JVM/GC

    U->>API: 发起请求
    API->>TP: submit多个任务
    TP->>Q: 空闲线程不足,任务入队
    Q-->>TP: 队列持续变长
    API->>TP: future.get等待结果
    JVM-->>API: 堆内存上涨,GC变频繁
    TP-->>API: 任务执行延迟
    API-->>U: 超时/响应变慢

实战代码(可运行)

下面给一个“错误写法”到“修复写法”的完整对比。

错误版本

问题点:

  • 使用 Executors.newFixedThreadPool
  • 无界队列
  • 没有超时控制
  • 没有拒绝策略
  • 接口线程同步等待所有异步结果
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class BadApiService {

    private final ExecutorService executor = Executors.newFixedThreadPool(10);

    public List<String> queryBatch(List<String> ids) {
        List<Future<String>> futures = new ArrayList<>();
        for (String id : ids) {
            futures.add(executor.submit(() -> {
                Thread.sleep(500);
                return "result-" + id;
            }));
        }

        List<String> result = new ArrayList<>();
        for (Future<String> future : futures) {
            try {
                result.add(future.get()); // 无超时
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        return result;
    }
}

修复版本

修复目标:

  • 显式创建 ThreadPoolExecutor
  • 使用有界队列
  • 自定义线程名,便于定位
  • 使用合理拒绝策略
  • 对等待结果设置超时
  • 对异常做降级处理
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class GoodApiService {

    private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
            10,
            20,
            60,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200),
            new NamedThreadFactory("order-worker"),
            new ThreadPoolExecutor.CallerRunsPolicy()
    );

    public List<String> queryBatch(List<String> ids) {
        List<Future<String>> futures = new ArrayList<>();

        for (String id : ids) {
            try {
                futures.add(executor.submit(() -> simulateRemoteCall(id)));
            } catch (RejectedExecutionException e) {
                futures.add(CompletableFuture.completedFuture("fallback-" + id));
            }
        }

        List<String> result = new ArrayList<>();
        for (Future<String> future : futures) {
            try {
                result.add(future.get(800, TimeUnit.MILLISECONDS));
            } catch (TimeoutException e) {
                result.add("timeout-fallback");
            } catch (Exception e) {
                result.add("error-fallback");
            }
        }
        return result;
    }

    private String simulateRemoteCall(String id) throws InterruptedException {
        Thread.sleep(300);
        return "result-" + id;
    }

    public void printStats() {
        System.out.println("poolSize=" + executor.getPoolSize()
                + ", active=" + executor.getActiveCount()
                + ", queue=" + executor.getQueue().size()
                + ", completed=" + executor.getCompletedTaskCount());
    }

    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;
        }
    }

    public static void main(String[] args) {
        GoodApiService service = new GoodApiService();

        List<String> ids = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            ids.add(String.valueOf(i));
        }

        List<String> result = service.queryBatch(ids);
        service.printStats();
        System.out.println("result size=" + result.size());

        service.executor.shutdown();
    }
}

这个修复为什么有效

这版代码做了几件关键的事:

  1. 有界队列:避免任务无限堆积
  2. 最大线程数生效:队列满后可以扩容到 maximumPoolSize
  3. 拒绝策略兜底:至少不会悄悄把内存吃爆
  4. 超时控制:避免接口一直傻等
  5. 降级返回:保障接口可用性而不是硬超时

常见坑与排查

这部分我单独列一下,因为很多问题不是“不会配线程池”,而是“以为自己配对了”。

坑 1:以为 fixedThreadPool 很稳

newFixedThreadPool(n) 只是在线程数固定这件事上稳,不代表系统整体稳。

它最大的隐藏问题是:

  • 无界队列
  • 高峰无背压
  • 内存风险大

如果任务来源不可控,风险尤其高。

坑 2:maximumPoolSize 配了但没用

很多人会写:

new ThreadPoolExecutor(
    20, 200, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>()
)

表面看最大线程数是 200,实际上因为 LinkedBlockingQueue 默认近似无界,任务会优先入队,线程池通常不会扩到 200。

这是非常常见的误区。

坑 3:接口线程里“异步转同步”

最常见的伪异步写法就是:

Future<Result> future = executor.submit(task);
return future.get();

如果当前线程马上就 get(),那本质上还是同步等待,只不过多绕了一圈线程池,还额外增加了:

  • 线程切换
  • 队列等待
  • 超时风险

这种写法只有在“并发聚合多个任务”时才有意义,否则往往是负优化。

坑 4:任务里塞大对象

例如:

  • 把完整请求对象传进 Runnable
  • 闭包捕获大 List
  • 把上下文全量复制到异步任务

这会放大队列堆积带来的内存问题。

建议只传任务执行所需的最小字段。

坑 5:线程池没有隔离

把这些任务全扔进一个线程池:

  • 查询接口
  • 导出报表
  • 异步通知
  • 日志补偿
  • 第三方回调

结果就是一个慢任务类型拖死所有业务。

更合理的方式是按场景隔离:

  • IO 密集型池
  • CPU 密集型池
  • 核心接口池
  • 非核心降级池

坑 6:没有监控拒绝次数

很多团队修完有界队列后,又出现另一个问题:任务被拒绝,但没人知道。

如果没有监控:

  • 业务 silently fail
  • 接口看似正常,结果数据丢了
  • 排查时非常痛苦

拒绝次数一定要打点。


止血方案

线上已经报警时,优先做的是止血,而不是追求“完美改造”。

短期止血

1. 限流或降级

先把入口流量压下来,避免堆积继续扩大。

可选手段:

  • 网关限流
  • 核心接口熔断
  • 非核心功能临时关闭
  • 批量任务拆小

2. 缩小单次提交任务数

如果一个请求会提交几百个任务,先控制成几十个,通常立竿见影。

3. 给等待结果加超时

future.get(500, TimeUnit.MILLISECONDS);

哪怕先返回降级结果,也比把请求线程全部拖死强。

4. 替换无界队列

把:

Executors.newFixedThreadPool(20)

替换成显式构造的有界线程池,往往是最关键的一步。

中期修复

  • 线程池按业务隔离
  • 建立容量模型
  • 加线程池监控和告警
  • 优化任务粒度
  • 对慢下游增加超时与熔断

安全/性能最佳实践

这里给一套比较实用的原则,不求绝对标准,但足够能避开大多数坑。

1. 不直接使用 Executors 快捷工厂创建业务线程池

优先使用显式参数:

new ThreadPoolExecutor(...)

这样你能明确控制:

  • 队列大小
  • 最大线程数
  • 拒绝策略
  • 线程命名

2. 有界队列是默认选择

除非你非常确定任务量上限,否则不要轻易用无界队列。

常见选择:

  • ArrayBlockingQueue:有界、结构简单,适合稳定场景
  • LinkedBlockingQueue(容量):可指定容量,灵活一些
  • SynchronousQueue:不存储任务,适合强背压模型

3. 拒绝策略要按业务选

常见策略:

  • AbortPolicy:直接抛异常,适合必须感知失败的场景
  • CallerRunsPolicy:调用方线程自己执行,适合自然限流
  • 自定义策略:记录日志、打监控、降级处理

我个人经验是:
核心接口优先“可观测失败”,非核心任务可考虑降级或调用方回退。

4. 线程池大小要结合任务类型

粗略经验:

  • CPU 密集型:CPU核数CPU核数 + 1
  • IO 密集型:可以适当大一些,但必须配合队列、超时和下游承载能力

不要只看本机 CPU,还要看:

  • 数据库连接池
  • HTTP 连接池
  • 下游 QPS 限制
  • Redis/ES 等依赖的并发能力

5. 所有异步任务都要有超时意识

包括:

  • Future.get(timeout)
  • 下游 HTTP 超时
  • 数据库查询超时
  • 重试次数限制

没有超时,就没有真正可控的并发。

6. 监控最少要覆盖这些指标

建议为每个业务线程池暴露:

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

7. 线程命名必须可读

比如:

  • order-query-worker
  • invoice-export-worker
  • callback-retry-worker

不要留默认线程名,否则 jstack 时基本等于盲人摸象。

8. 注意上下文泄漏

异步线程里如果使用:

  • ThreadLocal
  • MDC
  • 用户上下文

一定要在任务结束后清理,否则容易出现:

  • 内存泄漏
  • 脏数据串请求
  • 日志 traceId 混乱

一个更稳妥的线程池配置思路

如果你暂时没有完整容量模型,可以先按下面思路落地:

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

public class ThreadPoolFactory {

    public static ThreadPoolExecutor createIoPool(String name) {
        return new ThreadPoolExecutor(
                16,
                32,
                60L,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(500),
                new NamedThreadFactory(name),
                new ThreadPoolExecutor.AbortPolicy()
        );
    }

    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, prefix + "-" + counter.getAndIncrement());
            t.setDaemon(false);
            return t;
        }
    }
}

这不是“万能配置”,但比无界队列的默认写法安全得多。
真正上线前,还是要结合压测结果调整:

  • 队列是否过小导致频繁拒绝
  • 最大线程数是否压垮下游
  • 超时阈值是否合理
  • 调用方是否能接受降级

总结

这次踩坑的核心教训,其实就一句话:

线程池不是“加了异步就更快”,配错了反而会把系统拖进更深的坑。

遇到“接口超时 + 内存飙升”时,我建议优先检查这几件事:

  1. 有没有使用 Executors.newFixedThreadPool() 等快捷工厂
  2. 队列是不是无界
  3. 接口里是否批量 submit 任务
  4. 是否同步等待异步结果
  5. 是否缺少超时、拒绝策略和降级
  6. 是否缺少线程池监控

如果你只能先做一件事,我建议是:

把业务线程池改成显式 ThreadPoolExecutor + 有界队列 + 可观测拒绝策略。

这一步不能解决所有性能问题,但通常能先把“无限堆积导致内存打爆”的大坑填上。剩下的,再通过压测、监控和业务隔离慢慢收口。

很多线上事故,不是因为系统扛不住压力,而是因为没有在压力来临时及时“说不”。
而线程池配置,本质上就是系统表达“说不”的一种方式。


分享到:

上一篇
《从零实现基于以太坊智能合约的链上支付结算系统:架构设计、合约安全与部署实战》
下一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实践-182》