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

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

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

背景与问题

线上接口突然开始变慢,最开始只是偶发超时,后来监控里连着几项指标一起报警:

  • 接口 RT 从几十毫秒拉高到几秒
  • Tomcat 工作线程堆积
  • 堆内存持续上涨,Full GC 变频繁
  • CPU 不一定很高,但系统“就是越来越卡”

这类问题我踩过不止一次,最后排下来,很多时候不是“业务逻辑突然变复杂了”,而是线程池用错了

尤其是下面这几种写法,看起来很方便,实际上很容易把服务拖进坑里:

  • 使用 Executors.newFixedThreadPool(),默认搭配无界队列
  • 使用 Executors.newCachedThreadPool(),请求高峰时线程数失控
  • 把耗时 IO、重试任务、异步回调全塞进同一个线程池
  • 提交任务后不设超时、不做降级、不关注拒绝策略
  • 每次请求临时创建线程池,导致线程和对象无法及时回收

本文我就按一次真实的排查思路来讲:怎么复现、怎么定位、为什么会超时和内存飙升、最后怎么改


现象复现

先看一个非常典型的错误示例。它的问题不在“语法”,而在配置默认值

错误示例:固定线程数 + 无界队列

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

public class WrongThreadPoolDemo {
    private static final ExecutorService POOL = Executors.newFixedThreadPool(8);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100000; i++) {
            final int taskId = i;
            POOL.submit(() -> {
                try {
                    // 模拟下游慢调用
                    TimeUnit.MILLISECONDS.sleep(500);
                    if (taskId % 10000 == 0) {
                        System.out.println("done: " + taskId);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        System.out.println("tasks submitted");
    }
}

这段代码的问题是:

  • 线程数只有 8
  • 每个任务都要执行 500ms
  • 外部一下子提交 10 万个任务
  • 队列默认是 LinkedBlockingQueue近似无界

结果就是:

  1. 前 8 个任务在跑
  2. 其余大量任务在队列里排队
  3. 队列对象不断占内存
  4. 请求侧继续等待,接口 RT 越来越高
  5. GC 开始频繁,最终表现为“超时 + 内存飙升”

定位路径

排查这类问题,我通常会按这条线走,比较稳。

1. 先确认是“业务慢”还是“排队慢”

如果你有链路追踪,重点看两段时间:

  • 业务方法真正执行的耗时
  • 任务从提交到开始执行的等待耗时

很多同学一看接口超时,就盯着 SQL、Redis、HTTP 下游。但线程池误用时,常见情况是:

  • 真正执行只要 100ms
  • 但任务在队列里先排了 3 秒

这时候慢的不是业务,而是调度

2. 看线程池指标

重点看:

  • poolSize
  • activeCount
  • queueSize
  • completedTaskCount
  • taskCount

如果你发现:

  • activeCount 长时间等于最大线程数
  • queueSize 持续上涨
  • completedTaskCount 增长缓慢

基本就能判断:线程池被打满了,而且消费速度赶不上生产速度

3. 看 JVM 与线程栈

常用工具:

  • jstack
  • jmap
  • jcmd
  • Arthas
  • VisualVM / MAT

你往往会看到:

  • 大量业务线程卡在 LinkedBlockingQueue.take()poll()
  • 工作线程卡在慢 IO、RPC、数据库调用上
  • 堆中积压大量 RunnableFutureTask、业务上下文对象

4. 结合流量高峰看队列变化

如果内存曲线和请求高峰一致,并且高峰后也回不来,十有八九是:

  • 队列堆积太深
  • 任务对象持有大字段
  • 异步任务消费能力持续不足

flowchart TD
    A[接口RT升高] --> B{业务执行慢还是排队慢?}
    B -->|业务执行慢| C[排查SQL/缓存/RPC]
    B -->|排队慢| D[检查线程池参数]
    D --> E[activeCount是否打满]
    E --> F[queueSize是否持续增长]
    F --> G[检查任务类型: IO阻塞/重试/大对象]
    G --> H[确认误用线程池导致堆积]

核心原理

线程池问题之所以容易“隐蔽”,是因为很多坑都藏在默认实现里。

1. Executors.newFixedThreadPool() 的隐患

它底层大致等价于:

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

注意这个 LinkedBlockingQueue

  • 默认容量非常大,近似无界
  • 线程池会优先把新任务放入队列
  • 因为队列放得下,所以线程数不会继续扩容

这就导致一个反直觉现象:

你以为“固定线程池”很稳,实际上它可能在高峰时疯狂堆任务,把内存吃起来。

2. 为什么会引发接口超时

当请求线程把工作异步提交给线程池后,往往还会等待结果,比如:

  • Future.get()
  • CompletableFuture.join()
  • 轮询异步结果
  • 等待批量子任务汇总

如果线程池已堆满,那么任务无法及时执行,调用方就一直等,最终形成接口超时。

3. 为什么会引发内存飙升

排队的不是一个简单数字,而是一个个任务对象。每个任务可能还引用:

  • 请求参数
  • 用户上下文
  • 大对象集合
  • 序列化数据
  • 重试状态

任务越多,引用链越长,GC 越难回收。

4. 混用线程池会放大问题

很多项目里一个公共线程池负责:

  • 下游 HTTP 调用
  • 发消息
  • 写日志
  • 数据聚合
  • 定时补偿

这样一来,只要其中一个场景阻塞,整个线程池都会被拖慢,其他接口也跟着受影响。


sequenceDiagram
    participant Client as 调用方
    participant Web as 接口线程
    participant Pool as 线程池
    participant Downstream as 下游服务

    Client->>Web: 发起请求
    Web->>Pool: submit(task)
    Note over Pool: 队列已堆积
    Pool-->>Web: 任务等待排队
    Web->>Pool: Future.get(timeout)
    Pool->>Downstream: 迟迟未执行/执行很慢
    Web-->>Client: 超时响应

实战代码(可运行)

下面给一个完整示例,分为两部分:

  1. 一个“错误实现”,展示无界队列如何导致堆积
  2. 一个“修复实现”,使用有界队列、命名线程工厂、拒绝策略、超时控制

错误实现

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

public class BadCase {
    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);

    public static void main(String[] args) throws Exception {
        long begin = System.currentTimeMillis();

        for (int i = 0; i < 20000; i++) {
            final int id = i;
            EXECUTOR.submit(() -> {
                try {
                    // 模拟慢IO
                    TimeUnit.MILLISECONDS.sleep(300);
                    if (id % 5000 == 0) {
                        System.out.println("processed " + id);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        long cost = System.currentTimeMillis() - begin;
        System.out.println("submit cost(ms): " + cost);
        System.out.println("大量任务已进入无界队列,风险开始累积");
    }
}

这个程序的危险点在于:提交非常快,执行非常慢
提交快不代表系统健康,很多事故就是这么来的。

修复实现

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

public class GoodCase {

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

    public static void main(String[] args) throws Exception {
        startMonitor();

        for (int i = 0; i < 1000; i++) {
            final int id = i;
            try {
                CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> slowCall(id), EXECUTOR)
                        .orTimeout(800, TimeUnit.MILLISECONDS)
                        .exceptionally(ex -> "fallback-" + id);

                String result = future.get(1000, TimeUnit.MILLISECONDS);
                if (id % 100 == 0) {
                    System.out.println("result: " + result);
                }
            } catch (RejectedExecutionException e) {
                System.out.println("task rejected: " + id);
            } catch (TimeoutException e) {
                System.out.println("task timeout: " + id);
            }
        }

        EXECUTOR.shutdown();
        EXECUTOR.awaitTermination(10, TimeUnit.SECONDS);
    }

    private static String slowCall(int id) {
        try {
            TimeUnit.MILLISECONDS.sleep(200);
            return "ok-" + id;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "interrupted-" + id;
        }
    }

    private static void startMonitor() {
        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(
                new NamedThreadFactory("pool-monitor"));

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

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

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

        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, prefix + "-" + index.getAndIncrement());
            t.setDaemon(false);
            return t;
        }
    }
}

这个版本做了几件关键的事:

  • 有界队列ArrayBlockingQueue<>(200),避免无限堆积
  • 合理扩容:核心线程 8,最大线程 16
  • 拒绝策略CallerRunsPolicy 让调用方感受到背压
  • 超时保护orTimeout
  • 兜底降级exceptionally
  • 监控输出:实时看线程池状态

止血方案

线上已经出问题时,第一目标不是“优雅”,而是先止血。

1. 临时减小流量入口

如果能做限流,先做。因为线程池问题本质上是:

单位时间进来的任务,远大于单位时间能处理的任务。

不先控流,单纯改线程数,常常只是把事故推迟几分钟。

2. 快速切换有界队列

如果当前使用的是无界队列,优先改成有界队列,并配置明确拒绝策略。

常见选择:

  • AbortPolicy:快速失败,适合核心链路要显式报警
  • CallerRunsPolicy:把压力回传给上游,适合削峰
  • 不建议默认“悄悄吞掉任务”

3. 给异步结果加超时

没有超时的异步,最后经常会变成“同步卡死”。

例如:

future.get(800, TimeUnit.MILLISECONDS);

或者:

completableFuture.orTimeout(800, TimeUnit.MILLISECONDS);

4. 分池隔离

至少把这些任务拆开:

  • IO 密集型任务
  • CPU 密集型任务
  • 定时补偿任务
  • 低优先级异步任务

不要所有任务共用一个池子。


常见坑与排查

坑 1:以为线程越多越快

不是。
如果是数据库、HTTP、Redis 这类慢 IO,线程加太多,反而会:

  • 增加上下文切换
  • 放大连接池竞争
  • 放大下游压力
  • 让故障更难恢复

建议:线程池大小要根据任务类型、下游容量、机器核数来定,不要凭感觉拍。

坑 2:只看 CPU,不看队列

线程池事故不一定 CPU 爆满。
很多时候 CPU 很正常,但队列已经堆到危险值了。

建议:线程池监控至少包含:

  • 活跃线程数
  • 队列长度
  • 拒绝次数
  • 平均执行耗时
  • 最大等待时长

坑 3:任务里持有大对象

比如把整个请求 DTO、查询结果集、日志上下文都闭包进 Runnable 里。
队列一积压,内存马上放大。

建议

  • 任务只传必要字段
  • 避免在异步任务里捕获大对象
  • 提前做轻量化转换

坑 4:CompletableFuture 默认线程池误用

如果你不显式指定线程池,很多异步逻辑会落到公共线程池上。业务一复杂,就可能和别的模块互相影响。

CompletableFuture.supplyAsync(() -> doWork());

建议改为:

CompletableFuture.supplyAsync(() -> doWork(), customExecutor);

坑 5:拒绝策略选了也没处理

配了 AbortPolicy,但业务代码没捕获 RejectedExecutionException,结果直接 500。
这不叫保护,这叫把问题甩给用户。

建议:拒绝时要有明确降级路径。


stateDiagram-v2
    [*] --> Healthy
    Healthy --> Busy: 流量上升
    Busy --> Queuing: 活跃线程打满
    Queuing --> Timeout: 请求等待过久
    Queuing --> OOMRisk: 无界队列持续堆积
    Timeout --> Degrade: 启用超时/降级/限流
    OOMRisk --> Degrade: 切换有界队列
    Degrade --> Healthy: 流量恢复+参数修正

安全/性能最佳实践

这里给一套我比较推荐的线程池使用原则,偏实战。

1. 优先显式创建 ThreadPoolExecutor

不要图省事直接用 Executors 工厂方法。
推荐自己写清楚参数:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    coreSize,
    maxSize,
    60L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(queueCapacity),
    threadFactory,
    new ThreadPoolExecutor.AbortPolicy()
);

这样每个参数都“看得见”。

2. 一定要有界

无界队列在大部分线上业务里都很危险。
有界的意义不是“让任务少一点”,而是让系统在超载时有边界

3. 做分池隔离

可以按下面拆:

  • queryExecutor:查询聚合
  • rpcExecutor:下游远程调用
  • retryExecutor:重试补偿
  • asyncLogExecutor:低优先级日志

隔离后,一个模块堵住,不会把整个应用拖垮。

4. 配监控和报警

线程池要像数据库连接池一样看待,不是配完就完事。
至少要监控:

  • 当前线程数
  • 活跃线程数
  • 队列使用率
  • 拒绝次数
  • 任务执行时间分位数
  • 任务等待时间分位数

5. 超时、取消、降级要成套出现

只有超时,没有取消,任务可能还在后台继续跑。
只有拒绝,没有降级,用户体验仍然很差。

一套完整保护通常包括:

  • 提交前限流
  • 执行中超时
  • 超时后取消
  • 拒绝后降级
  • 下游异常后熔断

6. 线程池参数要根据场景定

CPU 密集型

适合线程数接近 CPU 核数,避免过多切换。

IO 密集型

可以适当放大线程数,但前提是:

  • 下游扛得住
  • 连接池够
  • 请求超时合理
  • 队列容量可控

7. 不要每次请求新建线程池

这是另一个经典坑:

public void handle() {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    executor.submit(() -> doWork());
}

这样会不断创建线程和队列,资源极其浪费。
线程池应该是复用型基础设施,不是一次性对象。


一份实用排查清单

出问题时可以按下面顺序检查:

  1. 接口耗时升高,是执行慢还是排队慢?
  2. 线程池活跃线程是否长期打满?
  3. 队列长度是否持续增长?
  4. 是否使用了无界队列?
  5. 是否把慢 IO 和普通任务混用一个池?
  6. 是否存在大量 Future.get() 阻塞等待?
  7. 是否没有配置超时与拒绝策略?
  8. 任务对象是否引用了大集合或大上下文?
  9. 是否有监控能看到拒绝次数和队列利用率?
  10. 下游服务是否已经成为瓶颈,导致线程长期不释放?

总结

这类故障最麻烦的地方在于:表面是接口超时,根因却是线程池配置不当
如果再叠加无界队列、慢 IO、公共线程池混用,最后很容易演变成:

  • 请求大量排队
  • 内存不断上涨
  • GC 频繁
  • 接口雪崩

真正有效的修复,不是简单“把线程调大”,而是这几件事一起做:

  • 使用 ThreadPoolExecutor 显式配置参数
  • 队列必须有界
  • 根据任务类型分池隔离
  • 设置超时、拒绝策略与降级逻辑
  • 给线程池补齐监控与报警
  • 在高峰时做限流和背压

如果你现在项目里还在大量使用 Executors.newFixedThreadPool(),建议尽快扫一遍。
很多坑平时看不出来,一到流量高峰就会原形毕露。早点改,真的能少背很多锅。


分享到:

上一篇
《分布式架构中基于 Saga 模式的跨服务事务设计与落地实践》
下一篇
《分布式架构中基于 Saga 模式的分布式事务落地实践:从服务拆分到一致性保障》