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

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

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

背景与问题

线上接口“偶发超时”这类问题,很多人第一反应是:是不是下游慢了、数据库抖了、网络波动了。
但我自己排过几次之后,越来越确定一件事:如果接口耗时突然变长,同时 JVM 内存一路往上走,十有八九要看看线程池是不是被用歪了。

这篇文章讲一个典型场景:

  • 某接口为了“提升性能”,把部分业务逻辑改成异步执行
  • 使用了 Executors.newFixedThreadPool(...)
  • 请求高峰期开始出现:
    • 接口 RT 飙升
    • 超时增多
    • Young GC/Full GC 变频繁
    • 堆内存持续上涨
    • 最终服务几乎不可用

乍一看,线程池不是为了提升并发吗?为什么反而把服务拖垮了?

根因往往不是“用了线程池”,而是线程池参数、任务模型、拒绝策略、超时控制、上下游容量没有一起设计。
尤其是 Executors 的默认实现,很容易把问题藏起来,平时没事,一压测就炸。


先看一个典型错误用法

很多项目里都能看到这种代码:

ExecutorService executor = Executors.newFixedThreadPool(20);

public String query() throws Exception {
    Future<String> future = executor.submit(() -> {
        // 模拟远程调用或复杂计算
        Thread.sleep(2000);
        return "ok";
    });

    // 同步等待结果
    return future.get();
}

表面上:

  • 有线程池
  • 有异步提交
  • 代码还挺“高级”

但实际上这里踩了两个坑:

  1. 异步转同步:提交后立刻 get(),当前请求线程还是被阻塞了
  2. 默认队列无界newFixedThreadPool 底层是无界 LinkedBlockingQueue

结果就是:

  • 请求线程在等
  • 线程池工作线程在干活
  • 新请求继续往队列堆
  • 队列中的任务对象越来越多
  • 内存开始涨
  • 排队等待时间越来越长
  • 最终接口超时

这类问题最坑的地方是:平时流量低时完全看不出来。


现象复现

下面用一段可运行示例,复现“接口超时 + 内存飙升”的趋势。

错误示例:无界队列 + 慢任务 + 同步等待

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

public class BadThreadPoolDemo {

    // 典型误用:FixedThreadPool 底层使用无界队列
    private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(10);

    public static void main(String[] args) throws InterruptedException {
        int requestCount = 2000;
        CountDownLatch latch = new CountDownLatch(requestCount);
        List<Thread> callers = new ArrayList<>();

        long start = System.currentTimeMillis();

        for (int i = 0; i < requestCount; i++) {
            Thread t = new Thread(() -> {
                try {
                    String result = mockApiCall();
                    if (!"ok".equals(result)) {
                        System.out.println("unexpected result");
                    }
                } catch (Exception e) {
                    System.out.println("request failed: " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
            callers.add(t);
            t.start();
        }

        latch.await();
        long cost = System.currentTimeMillis() - start;
        System.out.println("all requests done, cost(ms)=" + cost);

        EXECUTOR.shutdown();
    }

    public static String mockApiCall() throws Exception {
        Future<String> future = EXECUTOR.submit(() -> {
            // 模拟慢任务
            Thread.sleep(3000);
            return "ok";
        });

        // 看似异步,实际上同步阻塞等待
        return future.get(5, TimeUnit.SECONDS);
    }
}

这个程序会发生什么

当并发请求远大于线程池处理能力时:

  • 线程池只有 10 个工作线程
  • 每个任务执行 3 秒
  • 新任务会被不断塞进无界队列
  • 调用方线程在 future.get(5s) 上阻塞
  • 很多请求在排队阶段就已经接近超时
  • 队列任务对象不断堆积,内存上涨

定位路径

真实线上排查时,我通常不会一上来就改代码,而是按下面这条路径走,先确认是不是线程池问题。

1. 先看外部现象

典型监控信号:

  • 接口 P99/P999 延迟突然升高
  • 超时比例升高
  • CPU 不一定高,但堆内存明显上涨
  • GC 次数增多
  • Tomcat/Jetty/Netty 业务线程被大量占住

这时候要警惕:不是纯 CPU 打满,而是大量线程在等待。

2. 看线程栈

通过 jstack 看线程状态,往往能看到很多业务线程卡在:

java.util.concurrent.FutureTask.get
java.util.concurrent.ThreadPoolExecutor.getTask
java.util.concurrent.locks.AbstractQueuedSynchronizer

或者请求线程卡在:

java.util.concurrent.FutureTask.get

说明请求线程在同步等待异步任务结果。

3. 看堆内存和对象分布

如果使用 jmap -histo 或 MAT 分析堆转储,可能会看到:

  • LinkedBlockingQueue$Node 数量很多
  • FutureTask 数量很多
  • 业务请求对象、上下文对象被任务引用住,无法及时释放

这类迹象很明确:任务在队列里堆积了。

4. 看线程池运行时指标

线上最好暴露这些指标:

  • poolSize
  • activeCount
  • queueSize
  • completedTaskCount
  • taskCount

如果你发现:

  • activeCount 接近核心线程数上限
  • queueSize 持续增长
  • completedTaskCount 增速跟不上请求量

那基本就能坐实:线程池处理不过来,而且在“排队吃内存”。


核心原理

要修这个问题,必须先理解 ThreadPoolExecutor 的工作机制。很多坑都出在“以为自己懂线程池”。

线程池任务进入流程

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

关键点只有一句话:

线程池不是无限吞吐器,处理不过来的任务,不是排队,就是拒绝。

而问题常常出在这里:

  • 队列设得过大甚至无界:任务不拒绝,只会无限堆积
  • 任务执行时间长:吞吐上不去
  • 提交方没有超时/降级:请求线程被拖死
  • 业务把线程池当“削峰神器”:结果只是把洪峰搬到了内存里

为什么 newFixedThreadPool 容易踩坑

Executors.newFixedThreadPool(n) 底层等价于:

  • corePoolSize = n
  • maximumPoolSize = n
  • workQueue = new LinkedBlockingQueue<>()无界队列

这意味着:

  • 线程数永远固定
  • 队列几乎无限增长
  • 高峰时不会扩线程,只会不断排队

所以它的风险不是“线程过多”,而是:

队列无限积压,导致延迟不断放大,内存持续上涨。

为什么“异步后立刻 get”没意义

如果你这样写:

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

那本质上只是把工作从 A 线程挪到 B 线程,然后 A 线程继续等 B 线程。
这不会减少等待,只会额外引入:

  • 线程切换开销
  • 队列排队开销
  • Future 对象开销
  • 更复杂的异常链路

如果调用链最终还是同步返回,那你至少要问一句:

这段异步化,到底优化了什么?


问题链路图

sequenceDiagram
    participant Client as 客户端
    participant Biz as 接口线程
    participant Pool as 业务线程池
    participant Queue as 队列
    participant Downstream as 慢任务/下游服务

    Client->>Biz: 发起请求
    Biz->>Pool: submit(task)
    Pool-->>Queue: 任务排队
    Biz->>Pool: future.get(timeout)
    Queue->>Pool: 被工作线程取出
    Pool->>Downstream: 执行慢调用
    Downstream-->>Pool: 返回结果
    Pool-->>Biz: future完成
    Biz-->>Client: 返回响应

    Note over Queue,Biz: 高峰期队列积压,Biz等待时间变长,最终超时

实战代码:从误用到修复

下面给一个更合理的线程池写法,核心思路是:

  • 不用无界队列
  • 设置合理拒绝策略
  • 对任务执行和等待都做超时控制
  • 在无法处理时快速失败或降级
  • 暴露监控指标

修复版示例

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

public class GoodThreadPoolDemo {

    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            8,                      // corePoolSize
            16,                     // maximumPoolSize
            60L, TimeUnit.SECONDS,  // keepAliveTime
            new ArrayBlockingQueue<>(100), // 有界队列,避免无限堆积
            new NamedThreadFactory("biz-pool"),
            new ThreadPoolExecutor.AbortPolicy() // 明确拒绝,避免静默堆积
    );

    public static void main(String[] args) {
        printStats();

        for (int i = 0; i < 300; i++) {
            try {
                String result = queryWithTimeout();
                System.out.println("result=" + result);
            } catch (RejectedExecutionException e) {
                System.out.println("request rejected: 系统繁忙,请稍后再试");
            } catch (TimeoutException e) {
                System.out.println("request timeout: 执行超时,触发降级");
            } catch (Exception e) {
                System.out.println("request failed: " + e.getMessage());
            }
        }

        EXECUTOR.shutdown();
    }

    public static String queryWithTimeout() throws Exception {
        Future<String> future = null;
        try {
            future = EXECUTOR.submit(() -> {
                // 模拟业务处理
                Thread.sleep(500);
                return "ok";
            });

            // 对等待时间做限制
            return future.get(800, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            if (future != null) {
                future.cancel(true);
            }
            throw e;
        }
    }

    private static void printStats() {
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(() -> {
            System.out.println(String.format(
                    "poolSize=%d, active=%d, queue=%d, completed=%d",
                    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 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. 把无界队列改成有界队列

new ArrayBlockingQueue<>(100)

这非常关键。
有界队列的意义不是“提高性能”,而是给系统一个明确容量边界

系统满了怎么办?
要么扩容,要么拒绝,要么降级。
总之不能让它无限吃内存。

2. 拒绝策略明确化

new ThreadPoolExecutor.AbortPolicy()

AbortPolicy 会直接抛异常,提醒调用方:系统满了。
在接口场景里,这通常比“继续排队直到超时”更友好,因为至少你能快速失败。

3. 超时后取消任务

future.cancel(true);

注意边界条件:

  • 这不是“强杀线程”
  • 只有任务代码对中断敏感,才能尽快停止
  • 如果内部调用不响应中断,任务仍可能继续执行

所以这一步是必要但不充分,真正关键的是:任务本身也要支持超时与中断。


线程池参数怎么理解才不容易踩坑

很多人看到线程池参数就头大,我这里用最实战的方式解释。

参数关系图

classDiagram
    class ThreadPoolExecutor {
        int corePoolSize
        int maximumPoolSize
        BlockingQueue workQueue
        ThreadFactory threadFactory
        RejectedExecutionHandler handler
        long keepAliveTime
    }

    class BlockingQueue {
        <<interface>>
    }

    class ArrayBlockingQueue
    class LinkedBlockingQueue
    class SynchronousQueue

    BlockingQueue <|-- ArrayBlockingQueue
    BlockingQueue <|-- LinkedBlockingQueue
    BlockingQueue <|-- SynchronousQueue
    ThreadPoolExecutor --> BlockingQueue

1. corePoolSize

核心线程数。
可以理解为“常驻工人数量”。

适合:

  • 稳定负载
  • 常态并发

不适合简单拍脑袋设很大。
线程多不代表吞吐一定高,尤其 I/O、锁竞争、上下文切换很多时,盲目加线程只会更乱。

2. maximumPoolSize

最大线程数。
当队列满了之后,线程池是否还能临时扩线程。

但有个前提:队列不能是无界队列。
无界队列下,任务一直入队,maximumPoolSize 几乎形同虚设。

3. workQueue

最容易出事故的就是这里。

  • LinkedBlockingQueue:默认可很大甚至无界,容易堆积
  • ArrayBlockingQueue:固定容量,适合做边界控制
  • SynchronousQueue:不存储任务,适合直接移交,常用于高响应场景

对于接口类业务,如果你没有特别清晰的容量模型,优先考虑有界队列。

4. RejectedExecutionHandler

系统满了时怎么处理:

  • AbortPolicy:抛异常,推荐用于需要快速失败的接口
  • CallerRunsPolicy:调用方线程自己执行
  • DiscardPolicy:直接丢弃
  • DiscardOldestPolicy:丢最旧任务再尝试提交

为什么 CallerRunsPolicy 也可能踩坑

它看起来很温和,但在 Web 接口里要小心:

  • 如果请求线程自己执行慢任务
  • 那请求线程池就可能被拖住
  • 上游流量一来,整个服务入口会被反向压死

所以它更适合某些离线或内部可控场景,不一定适合在线接口。


常见坑与排查

坑 1:把线程池当成“万能提速工具”

很多异步化改造只是心理安慰:

  • 本来单线程执行 200ms
  • 改成提交线程池再等待,还是 200ms+
  • 还多了调度成本

判断方法

如果最终必须等待结果才能返回,那就要确认:

  • 这段逻辑是否真的能并行
  • 是否能与其他步骤重叠执行
  • 是否有多个独立 I/O 可以并发

如果都没有,线程池可能只是增加复杂度。


坑 2:线程池共用,互相拖垮

比如:

  • 接口 A 用这个线程池
  • 接口 B 也用这个线程池
  • 定时任务也用这个线程池
  • 消息消费还用这个线程池

结果就是某个慢任务一多,整个池子一起堵死。

建议

按业务隔离线程池,至少区分:

  • 核心接口线程池
  • 非核心异步任务线程池
  • 定时任务线程池
  • 下游调用专用线程池

坑 3:只配线程数,不配超时

即使线程池参数合理,如果下游调用本身没有超时:

  • HTTP 调用卡住
  • RPC 调用卡住
  • 数据库查询卡住

线程还是会一直占着不放。

排查思路

检查所有外部依赖是否配置:

  • 连接超时
  • 读超时
  • 调用总超时

线程池只是容器,慢调用本身不止血,线程池迟早也扛不住。


坑 4:以为取消任务就一定能停掉

future.cancel(true);

很多人以为这句能立刻停止任务。
实际上,它只是发出中断信号。

如果任务里是这种代码:

while (true) {
    // 不检查中断
}

那根本停不下来。

正确写法示例

public class InterruptibleTask implements Callable<String> {
    @Override
    public String call() throws Exception {
        while (!Thread.currentThread().isInterrupted()) {
            // 执行业务逻辑
            Thread.sleep(100);
        }
        throw new InterruptedException("task interrupted");
    }
}

坑 5:只看 CPU,不看排队

线程池问题有时候 CPU 不会特别高,因为大家都在:

  • 等锁
  • 等 I/O
  • 等 Future
  • 等队列调度

所以别看到 CPU 不高就误判“服务不忙”。

更应关注的指标

  • 接口等待时间
  • 线程池队列长度
  • 活跃线程数
  • 拒绝次数
  • 下游平均耗时/P99
  • GC 停顿时间

止血方案

如果线上已经在抖,优先做“止血”,再做彻底治理。

短期止血

  1. 立刻限制流量

    • 网关限流
    • 热点接口降级
    • 非核心功能熔断
  2. 缩短超时

    • 避免请求长时间挂起
    • 快速释放线程和连接资源
  3. 临时扩容

    • 增加实例数
    • 分摊排队压力
  4. 关闭高成本异步逻辑

    • 某些非核心计算、回写、统计可以先降级

中期修复

  1. Executors.newFixedThreadPool/newCachedThreadPool 改为显式 ThreadPoolExecutor
  2. 使用有界队列
  3. 设计拒绝策略和兜底响应
  4. 为下游依赖配置超时
  5. 暴露线程池指标并接入监控告警

长期治理

  1. 做容量评估:接口峰值 QPS、单任务平均耗时、可接受排队时间
  2. 线程池按业务隔离
  3. 引入限流、熔断、降级
  4. 对高耗时链路做异步解耦,而不是“异步后同步等待”

安全/性能最佳实践

这一节我尽量写得能直接落地。

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

更推荐显式构造:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(queueSize),
    new NamedThreadFactory("biz"),
    new ThreadPoolExecutor.AbortPolicy()
);

原因很简单:
线程数、队列、拒绝策略都得自己心里有数。


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

一个粗略经验:

  • CPU 密集型:线程数接近 CPU 核数
  • I/O 密集型:线程数可适当大一些,但要结合外部依赖容量

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

  • 下游服务并发承受能力
  • 数据库连接池大小
  • HTTP 连接池大小
  • 单机内存上限

3. 队列大小不是越大越稳

很多人会说:“队列调大点,不就不拒绝了?”

这通常只是把问题延后:

  • 拒绝少了
  • 但排队时间更长了
  • 用户超时更多了
  • 内存压力更大了

好的系统设计不是“尽量不报错”,而是:

在超出容量时,尽快、可控地失败。


4. 给线程池加监控

至少监控这些指标:

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

可以定时上报到日志、Prometheus、Micrometer 等。

示例:

public void printExecutorStats(ThreadPoolExecutor executor) {
    System.out.printf(
            "pool=%d, active=%d, queued=%d, completed=%d%n",
            executor.getPoolSize(),
            executor.getActiveCount(),
            executor.getQueue().size(),
            executor.getCompletedTaskCount()
    );
}

5. 任务要可中断、可超时、可降级

这是很多“线程池治理”最后没落地的原因:
只改池子,不改任务。

任务代码应该做到:

  • 能响应中断
  • 有明确超时
  • 失败后有默认值或降级路径

例如:

public String fallbackQuery() {
    return "system busy";
}

6. 注意上下文对象泄漏风险

任务排队时,如果 Runnable/Callable 持有大对象,比如:

  • 请求报文
  • 用户上下文
  • 大集合
  • 缓存结果

这些对象会跟着任务一起滞留在队列中,放大内存问题。

建议

  • 只传必要参数
  • 避免把整个请求上下文塞进任务
  • 大对象用完尽早释放引用

一个更实用的排查清单

如果你怀疑是线程池误用,我建议按这个顺序排:

flowchart TD
    A[接口超时告警] --> B[看RT、超时率、GC、内存]
    B --> C[看线程池指标: active/queue/reject]
    C --> D{队列持续增长?}
    D -- 是 --> E[检查是否无界队列]
    E --> F[检查任务耗时和下游超时]
    F --> G[检查是否submit后立刻get]
    G --> H[评估是否需要有界队列+拒绝策略+降级]
    D -- 否 --> I[继续看锁竞争/数据库/网络]

这张图我自己排障时也经常照着走,能少掉很多无效怀疑。


总结

这次踩坑背后的核心,不是“线程池不好用”,而是:

  1. 无界队列会隐藏容量问题
  2. 异步后立刻 get(),很容易把问题复杂化
  3. 线程池参数必须和任务耗时、下游容量、超时策略一起设计
  4. 系统满了要快速失败,而不是无限排队
  5. 监控、限流、超时、降级,缺一项都可能在线上吃亏

如果你只记住一条,我建议记这个:

对接口型业务,优先使用显式 ThreadPoolExecutor + 有界队列 + 明确拒绝策略 + 超时控制,不要把线程池当成无限缓冲区。

最后给几个可执行建议:

  • 新项目里,尽量避免直接使用 Executors.newFixedThreadPool()
  • 所有业务线程池都要可观测
  • 所有下游调用都要设超时
  • 如果异步结果必须立即等待,先质疑这段异步是否真的必要
  • 队列长度和拒绝次数,比“线程数配多少”更值得关注

线程池本来是拿来稳住系统的,别让它变成问题放大器。


分享到:

上一篇
《从某电商站点参数加密入手:中级开发者的 Web 逆向实战与自动化复现》
下一篇
《从提示工程到工作流编排:构建可落地的企业级 AI 助手实践指南》