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

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

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

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

线上最烦人的问题之一,不是直接报错,而是“慢慢坏掉”。

比如服务刚上线一切正常,过一阵开始:

  • 接口 RT 持续升高
  • 超时越来越多
  • JVM 堆内存一路上涨
  • Full GC 变频繁
  • 最后甚至被 OOM 干掉

我自己就踩过一个非常典型的坑:线程池参数看起来“很合理”,实际却把请求堆进了一个几乎无限的队列里。结果不是 CPU 先打满,而是内存先顶不住;与此同时,请求排队时间越来越长,最终用户看到的就是超时。

这篇文章不讲空泛概念,而是按“现象复现 → 原理解释 → 代码修复 → 排查思路 → 最佳实践”带你完整走一遍。


背景与问题

先说一个常见场景。

我们有一个 Java Web 服务,请求进来后会把某些耗时任务丢给线程池异步处理,例如:

  • 调第三方接口
  • 生成报表
  • 图片处理
  • 数据聚合
  • 批量消息发送

很多同学图省事会这样写:

ExecutorService executor = Executors.newFixedThreadPool(8);

表面上看,线程数固定为 8,很稳。但问题在于:

Executors.newFixedThreadPool(8) 底层使用的是 无界队列 LinkedBlockingQueue

这意味着:

  • 当 8 个线程都忙时,新任务不会被拒绝
  • 而是不断进入队列等待
  • 如果任务生产速度 > 任务消费速度,队列就会越积越多
  • 队列里的每个任务对象都要占内存
  • 请求还在排队,调用方却已经快超时了

于是就形成了一个经典故障链路:

flowchart LR
A[流量升高或下游变慢] --> B[线程池工作线程被占满]
B --> C[新任务持续进入无界队列]
C --> D[队列长度暴涨]
D --> E[堆内存持续升高]
D --> F[任务等待时间变长]
F --> G[接口超时]
E --> H[频繁GC/Full GC]
H --> G

这类问题最坑的地方在于:线程池并没有“报错”,它只是“非常努力地把问题攒大了”


前置知识

在继续之前,建议你至少知道这几个概念:

  • corePoolSize:核心线程数
  • maximumPoolSize:最大线程数
  • workQueue:任务队列
  • RejectedExecutionHandler:拒绝策略
  • Future.get():同步等待任务结果
  • I/O 密集型任务 vs CPU 密集型任务

如果这些概念你还不太熟,也没关系,下面我会结合实战一起解释。


核心原理

1. ThreadPoolExecutor 的任务处理顺序

ThreadPoolExecutor 接收任务时,大致遵循这样的流程:

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

可以用这个图理解:

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

2. 为什么 newFixedThreadPool 容易埋坑

来看它的典型行为:

  • 核心线程数 = 最大线程数
  • 队列 = 无界 LinkedBlockingQueue

这会带来一个非常关键的结果:

最大线程数几乎失去意义,因为队列永远“放得下”

也就是说:

  • 前面 8 个线程忙完之前
  • 后面再多的任务都只会排队
  • 不会扩线程
  • 不会拒绝
  • 只会越排越长

3. 请求超时为什么会跟内存飙升一起出现

很多人一开始只盯着内存,其实超时往往更早发生。

举个例子:

  • 单个任务平均执行 500ms
  • 线程池 8 个线程
  • 理论吞吐大约每秒 16 个任务
  • 如果实际每秒来了 100 个任务
  • 每秒就有 84 个任务积压进队列

这些任务会发生什么?

  • 先在队列里等
  • 等到轮到它时,调用方可能已经超时
  • 但线程池里的任务很多时候还会继续执行
  • 于是资源继续被消耗,形成“无效工作”

这就是为什么有时你看到:

  • 客户端超时了
  • 服务端线程却还在忙
  • 内存还在涨
  • 下游还在被打

4. 一个常见的误区:把异步写成“伪异步”

比如:

Future<String> future = executor.submit(task);
String result = future.get(2, TimeUnit.SECONDS);

看上去用了线程池,实际上主线程还是在等结果。
如果线程池排队严重,get() 很可能超时;如果业务里大量这么写,请求线程也会被拖住,进一步放大雪崩效应。


环境准备

下面的示例基于:

  • JDK 8+
  • 任意 IDE
  • 普通 main 方法即可运行

为了直观看到问题,我会先写一个有坑版本,再写一个修复版本


实战代码(可运行)

第一步:复现“线程池误用导致内存上涨和超时”

下面这个示例模拟:

  • 线程池只有 4 个工作线程
  • 使用无界队列
  • 每秒持续提交大量慢任务
  • 每个任务携带一段较大的字符串,模拟业务上下文占用内存
  • 主线程等待结果,容易超时
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.*;

public class BadThreadPoolDemo {

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

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

        for (int i = 0; i < 20000; i++) {
            final int taskId = i;

            Future<String> future = EXECUTOR.submit(() -> {
                // 模拟每个任务都携带一些较大的上下文数据
                String payload = UUID.randomUUID().toString() + createLargePayload();

                // 模拟慢任务,比如调用第三方接口/数据库
                Thread.sleep(800);

                return "task-" + taskId + " done, payload size=" + payload.length();
            });

            futures.add(future);

            if (i % 1000 == 0) {
                System.out.println("submitted: " + i);
            }
        }

        int timeoutCount = 0;
        for (Future<String> future : futures) {
            try {
                // 模拟业务线程等待结果
                future.get(200, TimeUnit.MILLISECONDS);
            } catch (TimeoutException e) {
                timeoutCount++;
            }
        }

        System.out.println("timeoutCount = " + timeoutCount);
        EXECUTOR.shutdown();
    }

    private static String createLargePayload() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 10_000; i++) {
            sb.append('x');
        }
        return sb.toString();
    }
}

这个示例会出现什么现象

如果你本地运行时给小一点堆内存,比如:

java -Xms128m -Xmx128m BadThreadPoolDemo

你可能观察到:

  • 程序提交任务很快
  • 任务执行很慢
  • Future.get() 超时数量很高
  • 内存不断上涨
  • GC 变频繁
  • 极端情况下抛出 OutOfMemoryError

为什么会这样

原因很直接:

  • 线程池只有 4 个线程
  • 每个任务执行 800ms
  • 但外部在疯狂提交
  • 多余任务全部堆进无界队列
  • 每个待执行任务对象都持有上下文数据
  • 堆内存就被任务队列拖着往上涨

第二步:用正确方式改造线程池

修复目标有三个:

  1. 限制队列长度,不能无限堆任务
  2. 设置合理拒绝策略,让系统在超载时可控退化
  3. 避免请求线程无意义阻塞,明确超时与降级逻辑

先看改造后的线程池:

import java.util.concurrent.*;

public class GoodThreadPoolDemo {

    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            4,                      // corePoolSize
            8,                      // maximumPoolSize
            60, TimeUnit.SECONDS,   // keepAliveTime
            new ArrayBlockingQueue<>(100), // 有界队列
            new NamedThreadFactory("biz-worker"),
            new ThreadPoolExecutor.CallerRunsPolicy() // 或按业务选择其他策略
    );

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 500; i++) {
            final int taskId = i;
            try {
                CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
                    try {
                        Thread.sleep(300);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    return "task-" + taskId + " done";
                }, EXECUTOR);

                String result = future.orTimeout(500, TimeUnit.MILLISECONDS)
                        .exceptionally(ex -> "fallback-" + taskId)
                        .get();

                System.out.println(result);
            } catch (RejectedExecutionException e) {
                System.out.println("task rejected: " + taskId);
            }
        }

        EXECUTOR.shutdown();
    }

    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)无界队列改成有界队列

new ArrayBlockingQueue<>(100)

效果:

  • 任务积压有上限
  • 不会无限吃内存
  • 系统能更早暴露出“处理不过来”的现实

2)合理利用最大线程数

因为队列是有界的:

  • 当核心线程忙、队列满时
  • 才有机会扩到 maximumPoolSize

这比“永远只排队不扩容”更符合预期。

3)显式处理拒绝

new ThreadPoolExecutor.CallerRunsPolicy()

当线程池满载时,让提交任务的线程自己执行。
这不是万能方案,但它有一个很实用的效果:

给上游施加反压,减慢任务提交速度

当然,是否适合要看业务场景,后面我们会详细说。

4)给异步结果设置超时与降级

future.orTimeout(500, TimeUnit.MILLISECONDS)
      .exceptionally(ex -> "fallback-" + taskId)

这样做的好处是:

  • 不让请求无限等
  • 超时后尽快返回降级结果
  • 降低请求线程被拖死的风险

第三步:加入监控,验证修复是否生效

线程池问题如果没有指标,排查基本靠猜。

我们至少应该打印这些关键指标:

  • 当前线程数
  • 活跃线程数
  • 队列长度
  • 已完成任务数
  • 总任务数
import java.util.concurrent.*;

public class ThreadPoolMonitorDemo {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2,
                4,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(20),
                new ThreadPoolExecutor.AbortPolicy()
        );

        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();

        monitor.scheduleAtFixedRate(() -> {
            System.out.println(
                    "poolSize=" + executor.getPoolSize()
                            + ", active=" + executor.getActiveCount()
                            + ", queue=" + executor.getQueue().size()
                            + ", completed=" + executor.getCompletedTaskCount()
                            + ", total=" + executor.getTaskCount()
            );
        }, 0, 1, TimeUnit.SECONDS);

        for (int i = 0; i < 100; i++) {
            final int taskId = i;
            try {
                executor.submit(() -> {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    System.out.println("done: " + taskId);
                });
            } catch (RejectedExecutionException e) {
                System.out.println("rejected: " + taskId);
            }
        }

        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.MINUTES);
        monitor.shutdown();
    }
}

如果你看到:

  • queue 长时间接近上限
  • active 长期等于最大线程数
  • rejected 持续增长

那就说明系统已经持续超载了。
这时候不能只怪线程池,往往还要一起看:

  • 下游依赖是否变慢
  • 单任务执行时间是否异常
  • 流量是否突增
  • 是否缺少限流

定位路径:线上怎么排查

这里给你一套我实际比较常用的排查顺序。

1. 先看现象:到底是 CPU 高,还是队列堆积

线程池问题不一定表现为 CPU 高。
很多时候恰恰是:

  • CPU 不算高
  • 但 RT 很高
  • 内存持续上升

这是因为线程都卡在:

  • 网络 I/O
  • 数据库 I/O
  • 锁等待
  • 下游接口超时

而不是在狂跑计算。

2. 看 JVM 堆和 GC

重点关注:

  • Old 区是否持续上涨
  • Full GC 是否频繁
  • Full GC 后内存是否回不去

如果回不去,要怀疑:

  • 任务对象在队列中大量堆积
  • 某些 Future / List 被业务代码长期持有
  • 大对象随任务一起排队

3. 看线程池指标

建议至少采集:

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

如果没有现成监控,哪怕先打日志也比没有强。

4. 看线程栈

使用:

jstack <pid>

关注线程在干什么:

  • 是阻塞在 LinkedBlockingQueue.take
  • 还是卡在 HTTP/数据库调用
  • 还是大量线程停在 FutureTask.get
  • 还是出现锁竞争

5. 看堆里是谁占了内存

使用:

jmap -histo:live <pid>

或者 dump 堆后用 MAT 分析。
如果看到大量:

  • FutureTask
  • LinkedBlockingQueue$Node
  • 业务任务对象
  • 大型上下文 DTO / 字符串 / byte[]

那方向就很明确了:任务排队过多


常见坑与排查

坑 1:误以为“固定线程池”就是稳定

错误认知:

  • 线程数固定,所以资源稳定

真实情况:

  • 线程数稳定,不代表内存稳定
  • 无界队列照样能把堆吃满

排查信号

  • 线程数不高
  • 队列持续增长
  • 内存上涨明显

坑 2:线程池开得很大,反而更慢

很多人看到超时就第一反应:加线程。

但如果任务是 I/O 密集型且依赖下游,下游本来就慢,你加线程只会:

  • 打更多请求到下游
  • 建更多连接
  • 堆更多上下文
  • 让整体雪崩更快

排查信号

  • 线程变多了
  • RT 没改善
  • 下游超时更多
  • GC 更频繁

坑 3:队列有界了,但拒绝策略没想清楚

常见拒绝策略:

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

它们没有绝对好坏,只有是否适合业务。

一个经验判断

  • 强一致、不能丢任务:优先考虑持久化队列、消息队列,而不是指望内存线程池硬扛
  • 用户请求型任务:通常更适合快速失败或降级,而不是无限等待
  • 后台非核心任务:可以按优先级选择丢弃策略

坑 4:提交了异步任务,但请求上下文对象太大

比如你把这些对象直接塞进任务:

  • 完整请求对象
  • 大型 DTO
  • 图片/文件字节数组
  • 超长日志上下文

如果队列一长,内存压力会非常明显。

建议

只传最小必要字段,不要把整个上下文都丢进去。


坑 5:任务超时了,但底层执行没停

很多同学以为:

future.get(500, TimeUnit.MILLISECONDS)

超时后任务就结束了。其实不一定。

真实情况通常是:

  • 调用方不等了
  • 但线程池中的任务可能还在继续跑
  • 如果是下游慢调用,资源还在持续占用

建议

  • 区分“等待超时”和“执行取消”
  • 尽量让下游客户端本身具备超时设置
  • 对可中断任务处理好 InterruptedException

坑 6:把所有业务共用一个大线程池

这样做一开始方便,后面容易互相拖垮。

例如:

  • 报表任务把线程池占满
  • 用户下单请求也用同一个池
  • 最后核心接口跟着一起超时

建议

按业务类型隔离线程池:

  • 核心请求池
  • 下游调用池
  • 后台任务池

安全/性能最佳实践

这里给出一组更落地的建议,不是“银弹”,但在大多数项目里都很有用。

1. 不要直接用 Executors 快速创建生产线程池

尤其是:

  • Executors.newFixedThreadPool
  • Executors.newCachedThreadPool

更推荐显式写出 ThreadPoolExecutor 参数:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
        corePoolSize,
        maximumPoolSize,
        keepAliveTime,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(queueSize),
        threadFactory,
        rejectedExecutionHandler
);

这样你会被迫思考:

  • 队列是否有界
  • 最大容量是多少
  • 满了之后怎么办

2. 按任务类型估算线程数

一个简单经验:

  • CPU 密集型:线程数接近 CPU 核数
  • I/O 密集型:线程数可以适当高一些,但必须结合压测与下游容量

不要只看本服务,还要看:

  • 数据库连接池大小
  • HTTP 连接池大小
  • 下游限流阈值
  • 平均任务耗时

3. 线程池必须有监控

至少接入:

  • 活跃线程数
  • 队列长度
  • 拒绝次数
  • 任务耗时
  • 最大耗时 / TP99

如果你只监控 JVM,而不监控线程池,就很容易“看到结果,猜不到原因”。

4. 超时要分层设置

超时不能只配在最外层接口。

建议至少有:

  • 请求超时
  • 下游 HTTP/DB 调用超时
  • 线程池等待超时
  • 熔断/限流策略

如果只有入口超时,而下游没有超时,线程还是会被一直占着。

5. 能丢的任务别硬扛,不能丢的任务别靠内存队列

这是很重要的边界。

  • 用户实时请求:优先快速失败、降级、限流
  • 重要异步任务:进 MQ / 持久化存储
  • 大批量任务:拆批、削峰、后台化

线程池不是消息队列,更不是无限缓冲区。

6. 给线程命名,方便排查

比如:

new NamedThreadFactory("order-query")

线上出问题时,jstack 一看线程名,定位效率会高很多。

7. 任务里避免 ThreadLocal 污染

在线程池环境中,线程会被复用。
如果任务里使用了 ThreadLocal 却没清理,容易导致:

  • 上下文串数据
  • 内存泄漏
  • 安全问题

务必在 finally 里清理。


一个推荐的线程池配置思路

下面不是固定模板,而是一个比较稳的起点:

import java.util.concurrent.*;

public class RecommendedExecutor {

    public static ThreadPoolExecutor newExecutor() {
        int core = 4;
        int max = 8;
        int queueCapacity = 200;

        return new ThreadPoolExecutor(
                core,
                max,
                60L,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(queueCapacity),
                new NamedThreadFactory("biz"),
                new ThreadPoolExecutor.AbortPolicy()
        );
    }

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

适用前提:

  • 你希望系统在过载时明确失败
  • 你愿意通过监控看到拒绝,而不是把问题拖成内存飙升
  • 你已经为上层调用设计了降级或重试边界

逐步验证清单

修复线程池问题后,我建议按下面顺序验证,不然很容易“以为改好了”。

功能层

  • 任务仍能正常执行
  • 超时后有明确降级或报错
  • 拒绝异常能被业务正确处理

性能层

  • 高并发下队列长度稳定在可控范围
  • 堆内存不再持续单调上涨
  • Full GC 次数明显下降
  • TP99 RT 没有继续恶化

运维层

  • 线程池指标已接入监控
  • 拒绝次数有报警
  • 下游超时和错误率有报警
  • 压测覆盖峰值流量与慢下游场景

线程池误用与修复的完整时序

最后用一张时序图把“问题出现”和“修复后行为”串起来。

sequenceDiagram
participant Client as 客户端
participant App as 应用服务
participant Pool as 线程池
participant Downstream as 下游服务

Client->>App: 发起请求
App->>Pool: 提交异步任务

alt 误用:无界队列
    Pool-->>App: 任务进入长队列
    Note over Pool: 工作线程已满,任务持续排队
    App-->>Client: 请求等待变长/超时
    Pool->>Downstream: 超时后任务仍可能继续执行
    Note over App,Pool: 队列堆积导致内存上涨
else 修复:有界队列+超时+降级
    Pool-->>App: 快速执行 / 排队受限 / 拒绝
    App-->>Client: 成功返回或快速降级
    Pool->>Downstream: 在受控并发下调用
    Note over App,Pool: 内存与延迟都更可控
end

总结

这类问题的本质,不是“线程池不好用”,而是:

线程池参数必须和任务特性、流量规模、下游能力一起设计。

如果你只记住三件事,我建议是这三条:

  1. 生产环境别迷信 Executors.newFixedThreadPool()

    • 最大风险不是线程数,而是无界队列
  2. 线程池一定要有界、有监控、有拒绝策略

    • 否则问题会从“短暂超载”演变成“内存飙升 + 请求雪崩”
  3. 超时要尽早暴露,过载要主动失败

    • 不要让系统靠排队“假装还能处理”

最后给一个非常务实的判断标准:

  • 如果你的任务不能丢,请优先考虑 MQ / 持久化队列
  • 如果你的请求不值得等太久,请优先考虑超时、限流、降级
  • 如果你的线程池看起来一直很忙但没报错,请马上检查队列长度和堆内存

很多线上事故,真的不是突然炸的,而是“队列默默堆着,系统慢慢死掉”。
早点把这个坑填掉,能省掉很多凌晨排障时间。


分享到:

上一篇
《Java开发踩坑实战:排查并修复线程池误用导致的接口超时与内存飙升问题-103》
下一篇
《Kubernetes 集群架构实战:从控制平面高可用到工作节点弹性扩缩容的设计与落地》