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

《Java 开发踩坑实战:排查与修复线程池误用导致的接口超时、内存飙升和任务堆积》

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

背景与问题

线上接口偶发超时,最开始看起来像是“下游慢了”。但继续观察会发现几个更危险的信号:

  • 接口 RT 持续升高,超时比例上升
  • JVM 堆内存一路爬升,Full GC 变频繁
  • 机器 CPU 不一定高,但服务吞吐明显下降
  • 日志里开始出现大量排队、重试、请求堆积
  • 线程数并不夸张,可任务队列长度越来越大

这类问题,我自己踩过一次之后印象特别深:不是线程不够,而是线程池用错了

很多项目里会出现类似代码:

ExecutorService executor = Executors.newFixedThreadPool(20);

或者:

ExecutorService executor = Executors.newSingleThreadExecutor();

表面上看没问题,实际上这里埋着一个很常见的坑:默认使用无界队列
当请求速度大于线程池处理速度时,任务不会被拒绝,而是不断进入队列。结果就是:

  1. 队列越来越长
  2. 每个任务对象、参数、上下文都留在内存里
  3. 内存上涨,GC 压力变大
  4. 等待时间变长,最终接口超时
  5. 上游重试后,堆积进一步恶化

这篇文章就从“现象复现 → 定位路径 → 止血方案 → 修复方案”的角度,完整走一遍。


现象复现

先把故障模型说清楚:
假设接口收到请求后,把一个耗时任务扔给线程池执行,比如调用第三方服务、生成报表、发消息等。线程池处理速度跟不上入口流量时,如果队列是无界的,问题就会出现。

一个典型错误示例

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

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

    public static void main(String[] args) {
        for (int i = 0; i < 1000000; i++) {
            final int taskId = i;
            EXECUTOR.submit(() -> {
                try {
                    Thread.sleep(200);
                    System.out.println("task " + taskId + " done");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
    }
}

这个例子的问题不在于线程数小,而在于:

  • newFixedThreadPool(4) 底层是 LinkedBlockingQueue
  • 默认容量几乎等于无界
  • 任务生产速度远大于消费速度时,队列无限增长

故障链路图

flowchart TD
    A[请求进入接口] --> B[任务提交到线程池]
    B --> C{线程池忙吗}
    C -- 否 --> D[立即执行]
    C -- 是 --> E[进入无界队列]
    E --> F[队列持续增长]
    F --> G[堆内存上升/GC频繁]
    G --> H[任务等待时间变长]
    H --> I[接口超时]
    I --> J[上游重试]
    J --> F

这张图基本就是线上事故的典型闭环:慢 → 堆积 → 更慢 → 超时 → 重试 → 更严重堆积


核心原理

要解决这个坑,先得明白线程池几个关键参数到底在干什么。

ThreadPoolExecutor 的执行规则

ThreadPoolExecutor 的核心构造参数:

  • corePoolSize:核心线程数
  • maximumPoolSize:最大线程数
  • keepAliveTime:非核心线程空闲保活时间
  • workQueue:任务队列
  • threadFactory:线程工厂
  • RejectedExecutionHandler:拒绝策略

提交任务后的行为大致如下:

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

为什么 Executors 容易埋坑

很多人以为:

fixedThreadPool = 固定线程数 + 安全稳定

其实不是。它的问题在于队列默认过大甚至无界

几个常见工厂方法的隐藏行为:

  • Executors.newFixedThreadPool(n)
    使用 LinkedBlockingQueue,队列近似无界
  • Executors.newSingleThreadExecutor()
    也是无界队列
  • Executors.newCachedThreadPool()
    队列不存任务,线程数可快速膨胀到非常大
  • Executors.newScheduledThreadPool()
    定时任务也可能因为任务执行时间过长而堆积

所以很多线上规范会直接要求:不要直接使用 Executors 创建线程池,而是显式使用 ThreadPoolExecutor

为什么会导致接口超时和内存飙升

因为任务从“提交成功”到“真正执行”之间,可能已经在队列里排了很久。

比如:

  • 线程池 10 个线程
  • 每个任务处理要 500ms
  • 每秒进来 200 个任务

那线程池每秒最多处理约 20 个任务,剩下的 180 个会进队列。
如果队列无界,这些任务对象会一直堆着,堆内存自然上涨。

线程池状态视角

stateDiagram-v2
    [*] --> 提交任务
    提交任务 --> 直接执行: 线程数未到core
    提交任务 --> 入队等待: 线程数已到core且队列未满
    入队等待 --> 执行中: 工作线程取走任务
    提交任务 --> 扩容线程: 队列已满且线程数未到max
    扩容线程 --> 执行中
    提交任务 --> 拒绝执行: 队列已满且线程数已到max
    执行中 --> 完成
    完成 --> [*]

定位路径

遇到“超时 + 内存涨 + 任务堆积”时,我通常按下面这个顺序看,比较快。

1. 先确认是否是线程池问题

重点指标:

  • 活跃线程数 activeCount
  • 池中线程数 poolSize
  • 队列长度 queue.size()
  • 已完成任务数 completedTaskCount
  • 总任务数 taskCount
  • 拒绝任务数
  • 接口 RT / 超时率
  • Full GC 次数和耗时

如果看到:

  • activeCount 接近核心线程数或最大线程数
  • queue.size() 持续上涨
  • completedTaskCount 增长很慢

那基本就能判断:消费不过来,线程池已经在排队

2. 用 jstack 看线程在干什么

如果线程池工作线程大多卡在:

  • 下游 HTTP 调用
  • 数据库慢 SQL
  • 锁等待
  • IO 阻塞

说明根因可能不是线程池本身,而是任务执行时间过长
线程池只是放大器。

3. 用 jmap / MAT 看内存里是什么

如果堆里大量对象来自:

  • Runnable / FutureTask
  • 请求参数对象
  • 日志上下文、Trace 上下文
  • 大对象缓存到任务闭包里

说明队列积压已经把任务对象“挂”在内存里了。

4. 看业务层是否有重试放大

很多系统的雪崩不是慢本身,而是:

  • 接口超时
  • 上游重试
  • 每次重试又丢到同一个线程池
  • 堆积越来越严重

这时就不是单纯调大线程数能解决的。


实战代码(可运行)

下面给一个完整示例,包含:

  1. 一个错误线程池实现
  2. 一个修复后的线程池实现
  3. 指标输出,便于直观看到堆积变化

错误版本:无界队列导致任务堆积

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

public class BadPoolCase {
    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
    private static final AtomicInteger SUCCESS = new AtomicInteger();

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 50000; i++) {
            final int taskId = i;
            EXECUTOR.submit(() -> {
                try {
                    Thread.sleep(300);
                    SUCCESS.incrementAndGet();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });

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

        Thread.sleep(10000);
        EXECUTOR.shutdown();
    }
}

这个程序很容易制造“提交很快,执行很慢”的局面。
虽然它不一定立刻 OOM,但已经具备线上故障的典型特征。


修复版本:显式有界队列 + 拒绝策略 + 监控

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

public class GoodPoolCase {

    private static final AtomicLong REJECT_COUNT = new AtomicLong();

    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            4,                          // corePoolSize
            8,                          // maximumPoolSize
            60, TimeUnit.SECONDS,       // keepAliveTime
            new ArrayBlockingQueue<>(200), // 有界队列
            new NamedThreadFactory("biz-worker"),
            new ThreadPoolExecutor.CallerRunsPolicy() // 背压
    );

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

        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(
                new NamedThreadFactory("monitor")
        );

        monitor.scheduleAtFixedRate(() -> {
            System.out.println(String.format(
                    "poolSize=%d, active=%d, queue=%d, completed=%d, task=%d, reject=%d",
                    EXECUTOR.getPoolSize(),
                    EXECUTOR.getActiveCount(),
                    EXECUTOR.getQueue().size(),
                    EXECUTOR.getCompletedTaskCount(),
                    EXECUTOR.getTaskCount(),
                    REJECT_COUNT.get()
            ));
        }, 0, 1, TimeUnit.SECONDS);

        for (int i = 0; i < 5000; i++) {
            try {
                final int taskId = i;
                EXECUTOR.execute(() -> {
                    try {
                        Thread.sleep(300);
                        if (taskId % 1000 == 0) {
                            System.out.println(Thread.currentThread().getName() + " processed " + taskId);
                        }
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            } catch (RejectedExecutionException e) {
                REJECT_COUNT.incrementAndGet();
                System.err.println("task rejected: " + i);
            }
        }

        Thread.sleep(15000);
        monitor.shutdown();
        EXECUTOR.shutdown();
    }

    static class NamedThreadFactory implements ThreadFactory {
        private final String prefix;
        private int index = 1;

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

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

这个版本的关键改动:

  • 使用 ArrayBlockingQueue 限制队列容量
  • 设置 maximumPoolSize
  • 使用 CallerRunsPolicy 做简单背压
  • 加了线程池运行指标输出

这样做的核心价值不是“永不出错”,而是:

  • 问题暴露得更早
  • 系统不会悄悄把内存吃光
  • 请求高峰时会有可控退化,而不是整体拖死

推荐封装:统一线程池工厂

项目里最好不要每个业务自己手写一套。可以统一封装:

import java.util.concurrent.*;

public class ExecutorFactory {

    public static ThreadPoolExecutor newBizExecutor(
            String poolName,
            int core,
            int max,
            int queueSize
    ) {
        return new ThreadPoolExecutor(
                core,
                max,
                60L,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(queueSize),
                new NamedThreadFactory(poolName),
                new ThreadPoolExecutor.AbortPolicy()
        );
    }

    static class NamedThreadFactory implements ThreadFactory {
        private final String prefix;
        private int index = 1;

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

        @Override
        public synchronized Thread newThread(Runnable r) {
            return new Thread(r, prefix + "-" + index++);
        }
    }
}

使用时:

ThreadPoolExecutor orderExecutor = ExecutorFactory.newBizExecutor("order-pool", 8, 16, 500);

统一工厂的好处是:

  • 命名规范统一
  • 参数来源统一
  • 拒绝策略统一
  • 便于接监控
  • 减少“顺手写个 Executors”的概率

常见坑与排查

坑 1:以为线程越多越快

不是。线程不是免费资源。

如果任务是 CPU 密集型,线程过多只会增加上下文切换; 如果任务是 IO 密集型,虽然可以适当多一些线程,但也不能无限加。

排查建议:

  • CPU 打满时看是否线程过多
  • top -Hjstack 结合看线程真实状态
  • 如果线程都在阻塞 IO,优先排查下游耗时

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

这也是事故高发点。

例如:

  • 发短信
  • 导出报表
  • 订单异步处理
  • 用户画像计算

全都放一个线程池里。
结果一个慢任务堆积,就把整个线程池拖死,其他业务全部受影响。

建议:按业务隔离线程池。


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

很多人观察线程池时只看:

  • 当前多少线程
  • CPU 高不高

但真正致命的往往是队列。
因为线程数固定,问题会被隐藏在排队里。

建议重点监控:

  • queue.size()
  • taskCount - completedTaskCount
  • 单任务等待时长

坑 4:任务里塞了大对象

比如这样:

executor.submit(() -> process(bigList, hugeMap, requestBody));

如果任务排队,这些大对象会一直被引用,GC 无法释放。
尤其是把整段请求体、大批量结果集、图片字节数组塞进任务时,很容易放大内存问题。

建议:

  • 任务参数尽量轻量
  • 传 ID,不传整对象
  • 大对象尽量落盘、缓存或分片处理

坑 5:Future 提交了但没人 get,也没人兜底异常

submit() 时,异常会被吞进 Future
如果没人 get(),问题可能长期隐藏。

executor.submit(() -> {
    throw new RuntimeException("boom");
});

这段代码可能不会像你想象中那样直接打印异常。

建议:

  • 不关心返回值时,优先使用 execute()
  • 必须用 submit() 时,做好 Future 结果处理
  • 给线程池任务加统一异常日志

坑 6:拒绝策略选错

常见拒绝策略:

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

没有“通用最优解”。

什么时候适合 CallerRunsPolicy

适合希望给上游施加背压的场景,比如同步入口接口。
线程池满了,调用方自己执行,入口自然变慢,从而减缓继续提交。

什么时候不能随便用

如果调用线程本身就是:

  • Netty IO 线程
  • Servlet 请求线程
  • MQ 消费主线程

那可能把关键线程拖住,产生更大连锁反应。


止血方案

线上故障时,先别急着“优雅重构”,先止血。

短期止血 1:限流

如果入口流量超过系统处理能力,第一件事是限流。
否则无论怎么调线程池,只是在延后崩溃时间。

可用手段:

  • 网关限流
  • 接口级熔断降级
  • 非核心功能直接拒绝
  • 按租户/用户做配额控制

短期止血 2:暂停或拆分重任务

如果某类任务特别慢,比如报表导出、批量同步、补偿任务,优先暂停或切走。
先保核心链路。

短期止血 3:把无界队列改成有界队列

这是最关键的一步。
它不能立刻提升吞吐,但能防止内存继续被任务堆满。

短期止血 4:缩短超时,减少无意义等待

如果下游本来 3 秒拿不到结果就没意义,那就别等 30 秒。
长超时会让线程长期挂住,加剧池内阻塞。


安全/性能最佳实践

这一节给可直接落地的建议。

1. 永远显式声明线程池参数

不要偷懒用 Executors 默认工厂。

推荐写法:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
        8,
        16,
        60L,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(1000),
        new ThreadFactory() {
            private int i = 1;
            @Override
            public synchronized Thread newThread(Runnable r) {
                return new Thread(r, "biz-pool-" + i++);
            }
        },
        new ThreadPoolExecutor.AbortPolicy()
);

2. 按任务类型选择线程数

CPU 密集型

比如加密、压缩、复杂计算。
一般建议线程数接近 CPU 核数或 CPU 核数 + 1

IO 密集型

比如 HTTP、DB、文件 IO。
线程数可适当高一些,但前提是下游能承受,而且队列必须有界。


3. 做好监控,不要等超时了才看

最少要监控这些指标:

  • 核心线程数、最大线程数、当前线程数
  • 活跃线程数
  • 队列长度
  • 已完成任务数
  • 拒绝次数
  • 任务平均耗时、P95、P99
  • 任务等待时长
  • GC 次数和停顿时间

4. 业务隔离

建议至少按下面维度拆池:

  • 核心链路 / 非核心链路
  • 快任务 / 慢任务
  • 用户请求任务 / 后台批处理任务

5. 不要在线程池里做无限重试

错误示例:

executor.execute(() -> {
    while (true) {
        try {
            callRemote();
            break;
        } catch (Exception e) {
            // 一直重试
        }
    }
});

这种代码会把工作线程永远占住。
正确做法是:

  • 有限次数重试
  • 指数退避
  • 失败入补偿队列
  • 与主线程池解耦

6. 注意 ThreadLocal 泄漏

线程池线程会复用,如果任务里用了 ThreadLocal 但不清理,可能造成脏数据或内存泄漏。

try {
    contextHolder.set("traceId-123");
    // do work
} finally {
    contextHolder.remove();
}

线程池排障流程图

下面这张图适合做日常排查 checklist。

flowchart TD
    A[接口超时/内存上涨] --> B[看线程池监控]
    B --> C{队列是否持续增长}
    C -- 否 --> D[优先排查下游耗时/锁竞争/GC]
    C -- 是 --> E[检查是否无界队列]
    E --> F[确认活跃线程和完成任务数]
    F --> G[用jstack看线程阻塞点]
    G --> H[是否存在重试放大]
    H --> I[先限流/降级止血]
    I --> J[改为有界队列+合理拒绝策略]
    J --> K[拆分业务线程池并补齐监控]

一个更贴近线上问题的调用时序

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

    Client->>API: 发起请求
    API->>Pool: 提交异步任务
    alt 线程池可处理
        Pool->>Downstream: 调用下游
        Downstream-->>Pool: 返回结果
        Pool-->>API: 任务完成
        API-->>Client: 正常返回
    else 线程池拥堵
        Pool-->>API: 任务排队
        API-->>Client: 超时/等待过长
        Client->>API: 重试请求
        API->>Pool: 再次提交任务
    end

这张图说明了一件很现实的事:
如果没有背压和拒绝,重试会把线程池压得更快。


总结

这类故障最容易误判成“机器不够”或者“下游太慢”,但很多时候,真正的触发点是:

  • 用了 Executors.newFixedThreadPool() 之类的默认工厂
  • 队列无界
  • 任务执行慢
  • 缺少监控
  • 叠加上游重试,最终把系统拖入堆积

可以直接记住这几个结论:

  1. 线上线程池尽量不要直接用 Executors 默认工厂
  2. 队列一定要有界
  3. 线程池参数要和任务类型匹配,不是越大越好
  4. 业务要隔离,慢任务不要污染核心链路
  5. 监控至少覆盖活跃线程、队列长度、拒绝次数、任务耗时
  6. 出现堆积时,先限流止血,再修参数和架构

如果你现在维护的服务里还有这样的代码:

Executors.newFixedThreadPool(20)

建议第一时间看一眼它承载的任务类型、流量峰值和队列情况。
很多坑平时“看起来没事”,只是因为流量还没把它打出来而已。


分享到:

上一篇
《从源码到生产实践:基于 MinIO 搭建高可用开源对象存储服务的架构设计与运维指南》
下一篇
《Docker 多阶段构建与镜像瘦身实战:为中级开发者打造高效、可维护的生产级镜像》