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

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

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

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

线上故障里,最让人头疼的一类,不是“直接挂掉”,而是“还能跑,但越跑越慢”。
我之前就踩过一个很典型的坑:接口偶发超时,机器 CPU 不算太高,GC 次数却越来越频繁,内存占用一路往上爬。最开始大家都怀疑是数据库慢查询、远程调用波动,结果最后定位下来,根因竟然是线程池用错了

这篇文章我不打算只讲概念,而是带你按一次真实排查思路走一遍:怎么复现、怎么定位、怎么修、修完后怎么验证


背景与问题

先说现象。

某个聚合查询接口,平时 RT 在 100ms 左右。上线一个“并发优化”版本后,出现了这些问题:

  • 高峰期接口 RT 从 100ms 飙到 3s 甚至超时
  • 应用堆内存持续增长,Full GC 次数增多
  • 线程数上涨,但 CPU 利用率并没有同步拉满
  • 重启后短暂恢复,过一段时间又恶化

听起来像不像“系统很忙”,但又说不出到底在忙什么?

进一步看代码,发现业务逻辑中为了并发调用多个下游服务,开发同学直接用了:

ExecutorService executor = Executors.newFixedThreadPool(8);

表面看没问题,但真正的问题在于:

  1. 线程池被频繁创建,没有统一复用
  2. 使用了默认无界队列
  3. 提交任务速度远大于消费速度
  4. 下游慢时,大量请求在线程池队列里堆积
  5. 每个任务对象、上下文、Future 都占内存
  6. 最终导致排队变长、接口超时、内存飙升

一句话总结:
不是线程不够,而是任务堆积失控。


前置知识

如果你对下面几个概念已经熟悉,可以直接跳到“核心原理”:

  • ThreadPoolExecutor
  • 核心线程数 / 最大线程数
  • 队列容量
  • 拒绝策略
  • Future / CompletableFuture
  • 接口超时与下游慢调用的放大效应

环境准备

本文示例基于:

  • JDK 17
  • Maven 或直接 javac/java
  • 任意本地开发环境

为了方便演示,我会给出一个可直接运行的示例程序,用来模拟:

  • 请求不断进入
  • 每个请求再拆成多个异步任务
  • 下游响应较慢
  • 错误线程池配置导致排队失控

核心原理

先把线程池误用导致故障的链路讲清楚。

1. Executors.newFixedThreadPool() 的隐藏风险

很多人以为固定线程池就“很稳”,其实它底层大致等价于:

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

注意重点:LinkedBlockingQueue 默认几乎等于无界队列

这意味着:

  • 核心线程满了以后,不会继续扩容线程
  • 新任务会一直进队列
  • 队列理论上能无限增长
  • 一旦消费跟不上,内存就会被任务对象撑起来

2. 为什么会导致接口超时

接口超时不一定是业务执行时间长,也可能是排队时间长

一个请求进来后:

  • 它先提交异步任务
  • 这些任务排队等线程执行
  • 下游又慢,任务执行时间进一步拉长
  • 上层 get()join() 一等,接口整体 RT 就被拖爆

也就是说,超时可能不是“算得慢”,而是“等得久”。

3. 为什么内存会飙升

任务进队列不是“零成本”的。每个任务通常包含:

  • Runnable / Callable 对象
  • 业务参数
  • 日志上下文
  • FutureTask
  • 可能还持有大对象引用

如果每秒进入几千个任务,而线程池只能消化几百个,队列就会像水库一样越蓄越多。


一张图看懂故障链路

flowchart TD
    A[请求流量上升] --> B[接口内提交大量异步任务]
    B --> C[固定线程池 线程数有限]
    C --> D[无界队列持续堆积]
    D --> E[任务等待时间变长]
    E --> F[接口整体RT升高]
    D --> G[Future/任务对象占用堆内存]
    G --> H[Young GC频繁]
    H --> I[Full GC增加]
    I --> F

故障定位思路

线上排查时,我一般按这个顺序走,不容易跑偏。

1. 先看监控指标

重点关注:

  • 接口 RT / 超时率
  • JVM 堆内存使用率
  • GC 次数与暂停时间
  • 活跃线程数
  • 线程池队列长度
  • 下游依赖平均耗时

如果你已经接了 Micrometer、Prometheus,这一步会轻松很多。没有的话,至少先看日志和 JVM 指标。

2. 用线程栈判断是不是“排队型慢”

执行:

jstack <pid>

常见现象:

  • 大量业务线程卡在 Future.get()CompletableFuture.join()
  • 线程池工作线程在执行慢 I/O
  • 没有明显 CPU 打满的热点计算

这通常说明:请求不是算不动,而是在等异步任务执行完成。

3. 用堆信息看是否有大量任务堆积

执行:

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

如果你看到这些对象数量异常:

  • java.util.concurrent.FutureTask
  • java.util.concurrent.LinkedBlockingQueue$Node
  • 业务自定义 Runnable / Callable
  • 请求上下文对象

那基本可以确认:线程池队列堆积了。

4. 检查线程池创建方式

这是最容易被忽略的一步。重点检查:

  • 是否每次请求都 newFixedThreadPool
  • 是否没有 shutdown
  • 是否使用无界队列
  • 是否没有超时控制
  • 是否把 I/O 密集和 CPU 密集任务混在一个池里

实战代码(可运行)

下面我们做一个最小复现。

这个程序模拟:

  • 100 个请求并发进入
  • 每个请求拆成 20 个异步任务
  • 每个异步任务执行 200ms
  • 使用错误线程池:固定线程数 + 无界队列
  • 定时打印队列长度和内存占用

1. 错误示例:固定线程池导致任务堆积

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

public class BadThreadPoolDemo {

    // 典型误用:固定线程池 + 默认无界队列
    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8);

    public static void main(String[] args) throws Exception {
        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
        monitor.scheduleAtFixedRate(() -> printStats("BAD"), 0, 1, TimeUnit.SECONDS);

        for (int i = 0; i < 100; i++) {
            final int requestId = i;
            new Thread(() -> handleRequest(requestId)).start();
            Thread.sleep(20);
        }

        Thread.sleep(30000);
        monitor.shutdownNow();
        EXECUTOR.shutdownNow();
    }

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

        try {
            for (Future<String> future : futures) {
                future.get(3, TimeUnit.SECONDS);
            }
            System.out.println("request " + requestId + " success");
        } catch (Exception e) {
            System.out.println("request " + requestId + " timeout: " + e.getClass().getSimpleName());
        }
    }

    private static String simulateRemoteCall(int requestId, int taskId) throws InterruptedException {
        Thread.sleep(200);
        return "r" + requestId + "-t" + taskId;
    }

    private static void printStats(String tag) {
        if (EXECUTOR instanceof ThreadPoolExecutor pool) {
            Runtime rt = Runtime.getRuntime();
            long usedMb = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
            System.out.printf(
                "[%s] poolSize=%d, active=%d, queue=%d, completed=%d, usedMemory=%dMB%n",
                tag,
                pool.getPoolSize(),
                pool.getActiveCount(),
                pool.getQueue().size(),
                pool.getCompletedTaskCount(),
                usedMb
            );
        }
    }
}

运行后你会看到什么

大概率会出现这些表现:

  • queue 持续增长
  • 大量请求 timeout
  • 内存占用逐步升高
  • 线程数稳定在 8,不会扩容

这就是典型的无界排队型故障


修复思路

修复不是简单把线程数调大。
真正要解决的是:让系统在高压下可控,而不是无限排队。

核心原则:

  1. 线程池统一管理,禁止请求内随手创建
  2. 使用有界队列
  3. 设置合理拒绝策略
  4. 为任务设置超时
  5. 区分 I/O 密集和 CPU 密集线程池
  6. 限制单请求拆分出来的异步任务数

改造后的正确示例

下面给一个更稳妥的版本。

2. 修复示例:有界队列 + 拒绝策略 + 超时控制

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

public class GoodThreadPoolDemo {

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

    public static void main(String[] args) throws Exception {
        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
        monitor.scheduleAtFixedRate(() -> printStats("GOOD"), 0, 1, TimeUnit.SECONDS);

        for (int i = 0; i < 100; i++) {
            final int requestId = i;
            new Thread(() -> handleRequest(requestId)).start();
            Thread.sleep(20);
        }

        Thread.sleep(30000);
        monitor.shutdownNow();
        IO_POOL.shutdownNow();
    }

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

        for (int i = 0; i < 20; i++) {
            final int taskId = i;
            CompletableFuture<String> future = CompletableFuture
                .supplyAsync(() -> {
                    try {
                        return simulateRemoteCall(requestId, taskId);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        throw new RuntimeException(e);
                    }
                }, IO_POOL)
                .orTimeout(500, TimeUnit.MILLISECONDS)
                .exceptionally(ex -> "fallback");
            futures.add(future);
        }

        try {
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
                .get(2, TimeUnit.SECONDS);
            System.out.println("request " + requestId + " done");
        } catch (Exception e) {
            System.out.println("request " + requestId + " degraded: " + e.getClass().getSimpleName());
        }
    }

    private static String simulateRemoteCall(int requestId, int taskId) throws InterruptedException {
        Thread.sleep(200);
        return "r" + requestId + "-t" + taskId;
    }

    private static void printStats(String tag) {
        Runtime rt = Runtime.getRuntime();
        long usedMb = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
        System.out.printf(
            "[%s] poolSize=%d, active=%d, queue=%d, completed=%d, usedMemory=%dMB%n",
            tag,
            IO_POOL.getPoolSize(),
            IO_POOL.getActiveCount(),
            IO_POOL.getQueue().size(),
            IO_POOL.getCompletedTaskCount(),
            usedMb
        );
    }

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

修复前后差异图

flowchart LR
    subgraph Before[错误设计]
        A1[请求] --> B1[提交大量任务]
        B1 --> C1[固定线程池]
        C1 --> D1[无界队列]
        D1 --> E1[越堆越多]
        E1 --> F1[超时 + 内存上涨]
    end

    subgraph After[修复设计]
        A2[请求] --> B2[统一线程池]
        B2 --> C2[有界队列]
        C2 --> D2[超时控制]
        D2 --> E2[拒绝/降级]
        E2 --> F2[系统受控]
    end

核心原理再讲透一点:线程池参数怎么配

这是中级开发最容易“似懂非懂”的地方,我们拆开说。

1. corePoolSize 与 maximumPoolSize

  • corePoolSize:常驻线程数
  • maximumPoolSize:高峰时允许扩到的最大线程数

注意:
如果队列是无界的,通常根本到不了 maximumPoolSize 扩容阶段,因为任务都先排队了。

2. workQueue

这是关键中的关键。

常见队列:

  • LinkedBlockingQueue:默认可非常大,容易堆积
  • ArrayBlockingQueue:有界,适合控制上限
  • SynchronousQueue:不存任务,直接移交线程,适合强背压场景

3. RejectedExecutionHandler

线程池满了怎么办,必须提前想清楚。

常见策略:

  • AbortPolicy:直接抛异常,最明确
  • CallerRunsPolicy:调用线程自己执行,能形成背压
  • DiscardPolicy:直接丢弃,不推荐
  • DiscardOldestPolicy:丢最老任务,要慎用

我的建议:

  • 核心业务:优先 AbortPolicyCallerRunsPolicy
  • 非核心、可降级场景:结合业务做兜底

4. 为什么 CallerRunsPolicy 有时更稳

很多人第一次看到这个策略会觉得奇怪:
“线程池忙不过来,为啥还让调用方线程执行?”

因为这其实是一种自然限流

  • 请求线程自己去干活
  • 请求入口吞吐自然下降
  • 系统不会无限堆积任务

它不一定让成功率最高,但往往能让系统更稳。


一次完整调用时序

sequenceDiagram
    participant C as Client
    participant A as API线程
    participant P as 业务线程池
    participant D as 下游服务

    C->>A: 发起接口请求
    A->>P: 提交多个异步任务
    P->>D: 并发调用下游
    D-->>P: 返回结果/超时
    P-->>A: Future完成
    A-->>C: 聚合后响应

    Note over A,P: 若P队列堆积,A会长时间等待Future
    Note over P,D: 若下游慢,任务执行时间进一步变长

常见坑与排查

下面这些坑,我见过不止一次。

坑 1:每次请求都创建线程池

错误写法:

public String query() {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    // 提交任务
    return "ok";
}

问题:

  • 线程无法复用
  • 大量线程创建销毁
  • 可能没有 shutdown
  • 线程数爆炸

建议:

  • 线程池作为单例 Bean 管理
  • 在 Spring 中交给容器统一维护

坑 2:只看线程数,不看队列长度

很多人排查线程池时只看:

  • 当前线程数多少?
  • 活跃线程多少?

但真正危险的往往是:
队列已经堆了几万条任务。

排查时一定要看:

  • getQueue().size()
  • getCompletedTaskCount()
  • getTaskCount()
  • 拒绝次数

坑 3:任务里做阻塞 I/O,却按 CPU 密集配置线程池

比如:

  • 调数据库
  • 调 HTTP 接口
  • 读文件
  • 调 Redis

这些都可能阻塞线程。
如果你按“CPU 核数 + 1”去配,吞吐很可能不够。

经验上:

  • CPU 密集:线程数接近 CPU 核数
  • I/O 密集:线程数可以更高,但必须结合压测与下游承载能力

坑 4:没有超时,get() 一直等

错误写法:

future.get();

如果下游抖动,主流程就会一直卡住。

更稳妥的方式:

future.get(500, TimeUnit.MILLISECONDS);

或者用:

completableFuture.orTimeout(500, TimeUnit.MILLISECONDS)

坑 5:异常被吞掉,误以为线程池正常

异步代码里最怕“静默失败”。比如:

  • submit() 后没人取结果
  • CompletableFuture 没有异常处理
  • 线程工厂没设置可识别名称

建议:

  • 每个异步链路都有异常处理
  • 打印超时、拒绝、降级日志
  • 线程名带业务前缀,方便 jstack 排查

逐步验证清单

修完后别急着上线,我建议按下面顺序验证。

本地验证

  • 线程池是否改为统一单例
  • 队列是否有界
  • 是否配置拒绝策略
  • 异步任务是否都有超时
  • 是否有降级返回逻辑
  • 是否打印线程池核心指标

压测验证

  • 正常流量下 RT 是否稳定
  • 高峰流量下队列是否可控
  • 是否出现拒绝,拒绝比例是否在预期内
  • GC 是否明显下降
  • 内存曲线是否趋于平稳

线上观察

  • 线程池活跃数
  • 队列长度
  • 接口超时率
  • 下游平均耗时
  • 降级/拒绝次数

安全/性能最佳实践

这里给一些能直接落地的建议,不讲空话。

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

更推荐显式写出参数:

new ThreadPoolExecutor(...)

原因很简单:
你必须明确队列容量、线程上限和拒绝策略。


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

至少分开:

  • API 聚合调用线程池
  • 消息消费线程池
  • 定时任务线程池
  • CPU 密集计算线程池

不要把所有活都塞进一个池。
一个慢任务类型,可能把整个应用都拖垮。


3. 必须有超时与降级

如果你的异步任务依赖外部系统,就默认它会慢、会抖、会失败。

建议组合:

  • 单任务超时
  • 整体请求超时
  • 默认值 / 降级数据
  • 熔断限流

4. 给线程池做监控埋点

建议至少暴露这些指标:

  • 当前线程数
  • 活跃线程数
  • 队列长度
  • 完成任务数
  • 拒绝任务数
  • 平均任务耗时

在 Spring Boot 中,可以结合 Micrometer 自定义指标。


5. 避免单请求拆分过多子任务

并发不是越多越好。
如果一个请求拆 50 个子任务,100 个请求就是 5000 个任务,线程池非常容易被打爆。

更好的方式:

  • 合并下游请求
  • 做批量接口
  • 控制单请求并发度
  • 分页或分段处理

6. 注意上下文对象的额外内存占用

任务排队时,真正吃内存的未必只是线程池本身,还有:

  • 大参数对象
  • ThreadLocal 上下文
  • traceId / MDC
  • 缓存中的中间结果

所以看到内存升高时,不要只盯着“线程数”。


Spring 项目中的推荐写法

如果你在 Spring Boot 中开发,建议使用配置化线程池。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.*;

@Configuration
public class ThreadPoolConfig {

    @Bean(name = "ioExecutor")
    public ExecutorService ioExecutor() {
        return new ThreadPoolExecutor(
            8,
            16,
            60L,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200),
            new NamedThreadFactory("io-exec"),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }

    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) {
            return new Thread(r, prefix + "-" + (++counter));
        }
    }
}

业务里注入使用:

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

public class QueryService {

    private final ExecutorService ioExecutor;

    public QueryService(ExecutorService ioExecutor) {
        this.ioExecutor = ioExecutor;
    }

    public String query() throws Exception {
        CompletableFuture<String> f1 = CompletableFuture
            .supplyAsync(() -> "A", ioExecutor)
            .orTimeout(300, TimeUnit.MILLISECONDS)
            .exceptionally(ex -> "A_FALLBACK");

        CompletableFuture<String> f2 = CompletableFuture
            .supplyAsync(() -> "B", ioExecutor)
            .orTimeout(300, TimeUnit.MILLISECONDS)
            .exceptionally(ex -> "B_FALLBACK");

        return f1.get() + "_" + f2.get();
    }
}

边界条件:不是所有场景都适合“有界队列 + CallerRuns”

这点很重要,别学成固定套路。

适合的场景

  • Web 接口聚合调用
  • 可接受部分降级
  • 希望系统在高压下保持可控

需要谨慎的场景

  • 强实时任务
  • 不能让调用线程被占住的主链路
  • 丢任务成本极高的异步处理

这时你可能更需要:

  • 消息队列削峰
  • 业务层限流
  • 专门调度系统
  • 更细粒度的任务分发模型

总结

这类故障最迷惑人的地方就在于:
表面是接口超时和内存升高,根因却是线程池误用导致的任务堆积。

你可以记住这条排查主线:

  1. 先看 RT、内存、GC、线程数
  2. 再看线程池队列是否堆积
  3. jstack 看是不是大量卡在 Future.get()/join()
  4. jmap -histo 看有没有 FutureTask 和队列节点暴涨
  5. 检查线程池是不是无界队列、是否被频繁创建、是否缺少超时和拒绝策略

最后给你几个最实用的建议:

  • 业务线程池不要直接用 Executors.newFixedThreadPool()
  • 必须使用有界队列
  • 必须定义拒绝策略
  • 异步任务必须有超时
  • 线程池要按业务类型隔离
  • 上线前一定压测,别凭感觉配参数

如果你最近也遇到“接口越来越慢、内存越来越高、重启暂时恢复”的问题,优先去查线程池,真的很容易一枪命中。


分享到:

上一篇
《Web3 中级实战:基于 EIP-712 与钱包签名实现安全的链上登录与授权流程》
下一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-211》