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

《Java 开发踩坑实战:排查并修复 Spring Boot 项目中线程池误用导致的请求堆积与内存飙升》

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

背景与问题

线上服务“突然变慢”,很多时候并不是数据库先出问题,也不是 GC 先背锅,而是线程池配置和使用方式不对

我自己就踩过一个很典型的坑:Spring Boot 项目里为了“提升并发”,把一些耗时逻辑丢进线程池异步执行。上线初期看着没问题,请求量一上来,监控开始出现这些症状:

  • 接口 RT 持续升高,从几百毫秒涨到十几秒
  • Tomcat 工作线程占满,请求开始排队
  • JVM 堆内存快速上涨,Old 区迟迟不回落
  • Full GC 次数增多,但吞吐并没有恢复
  • 应用没立刻挂,只是“越来越慢”,属于最难受的那种故障

最后排查发现,根因不是“线程太少”,恰恰是线程池误用

  1. 用了 Executors.newFixedThreadPool(),默认队列几乎无界
  2. 每个请求都往线程池塞大量任务
  3. 任务里还有远程调用、数据库操作、日志拼接等慢动作
  4. 拒绝策略没有设计,实际上变成“无限堆积”
  5. 异步任务吃光内存后,反过来拖垮主请求线程

这类问题非常隐蔽,因为它往往不是瞬时崩,而是逐渐堆积,直到把 RT、吞吐、内存一起拉垮。


现象复现

先看一个很容易在项目里出现的错误写法。它不一定和你线上代码一模一样,但味道通常差不多。

错误示例:无界队列 + 慢任务

package com.example.demo.bad;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
public class BadController {

    // 典型误区:固定线程数 + 默认无界 LinkedBlockingQueue
    private final ExecutorService executorService = Executors.newFixedThreadPool(8);

    @GetMapping("/bad")
    public String bad() {
        for (int i = 0; i < 200; i++) {
            executorService.submit(() -> {
                try {
                    // 模拟慢任务:远程调用 / IO / 大对象处理
                    byte[] payload = new byte[1024 * 256]; // 256 KB
                    Thread.sleep(2000);
                    payload[0] = 1;
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
        return "submitted";
    }
}

这个接口每请求一次,就往线程池里丢 200 个任务。线程池只有 8 个线程,但队列几乎不受控:

  • 来得及执行的任务:只有 8 个工作线程在跑
  • 来不及执行的任务:全都堆到队列里
  • 队列里排队的任务对象、参数对象、闭包引用,都会占堆内存
  • 请求一多,内存就会越来越高

复现思路

可以用压测工具简单打一下:

ab -n 2000 -c 50 http://127.0.0.1:8080/bad

或者:

wrk -t4 -c50 -d30s http://127.0.0.1:8080/bad

你通常会看到:

  • 接口本身返回很快,因为只是“提交任务”
  • 但后台任务永远处理不过来
  • JVM 内存持续增长
  • 队列长度越来越夸张
  • 最后 GC 频繁,甚至 OOM

核心原理

这个坑要真正修好,关键不是背几个线程池参数,而是理解线程池的工作机制

ThreadPoolExecutor 的任务接收顺序

线程池接收任务时,大致遵循下面这个流程:

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

这里最容易被忽略的一点是:

如果队列是无界的,那么任务几乎总是先进队列,maximumPoolSize 基本失效。

也就是说,很多人以为自己配了:

  • corePoolSize = 8
  • maximumPoolSize = 64

就能顶住高峰。
但如果队列是 LinkedBlockingQueue 默认无界,实际往往只会稳定在 8 个线程,然后无限排队。

为什么会导致内存飙升

因为任务不是抽象概念,它们都是对象:

  • Runnable / Callable 本身是对象
  • Lambda 可能持有上下文引用
  • 任务参数可能引用请求体、DTO、缓存对象
  • 如果里面构造了大对象,或者闭包捕获了大对象,排队任务就像一节一节货车往堆里塞

可以把它理解成:

  • 线程池不是“加速器”
  • 它更像“收费站”
  • 通道有限,后面车无限排队,就会把高速路堵死

Spring Boot 中更隐蔽的误用

在 Spring Boot 里,常见误用还有这几类:

  1. @Async 没有显式线程池

    • 会走默认执行器
    • 行为不符合预期,容量不可控
  2. 业务线程池和请求线程池互相等待

    • 主线程 future.get() 等异步结果
    • 异步任务又依赖其他受限资源
    • 很容易造成“伪异步”
  3. 线程池里跑阻塞 IO

    • 远程接口慢、数据库慢、消息积压
    • CPU 看着不高,但线程长期占住
  4. 把线程池当削峰缓冲区

    • 没有队列上限
    • 没有超时
    • 没有降级
    • 本质上是把流量压力从入口搬到内存里

定位路径

线上碰到这类问题时,我一般不是一上来改参数,而是先按下面路径收敛问题。

1. 看接口与线程池指标是否同步恶化

重点观察:

  • 请求 QPS
  • 接口 RT、P99
  • Tomcat maxThreads 使用率
  • 业务线程池的:
    • activeCount
    • poolSize
    • queueSize
    • completedTaskCount
  • JVM 堆使用量、GC 次数、Full GC 时间

如果你发现:

  • RT 越来越高
  • 线程池活跃线程数接近上限
  • 队列持续增长不回落
  • 堆内存同步上涨

那基本就可以锁定到“异步任务堆积”。

2. 用线程 dump 看线程在干什么

jstack <pid> > thread_dump.txt

重点看:

  • 大量业务线程是否都卡在 sleep / socketRead / http client / jdbc
  • Tomcat 线程是否在等待业务返回
  • 是否有线程池名字能对应到你的自定义执行器

3. 看堆快照里谁在占内存

jmap -dump:live,format=b,file=heap.hprof <pid>

用 MAT 或 JProfiler 打开,重点看:

  • LinkedBlockingQueue$Node
  • 大量 FutureTask
  • 大量业务 Runnable
  • 某些 DTO / byte[] / 日志字符串是否被任务引用链挂住

如果 RunnableFutureTask、队列节点很多,基本就是任务积压。

4. 检查代码里线程池创建方式

尤其搜索这些关键词:

Executors.newFixedThreadPool
Executors.newCachedThreadPool
Executors.newSingleThreadExecutor
@Async
CompletableFuture.supplyAsync

很多问题的根因不是“线程池不存在”,而是偷偷用了默认线程池


实战代码(可运行)

下面给一个可运行、可观测、可止血的 Spring Boot 示例。重点是:

  • 不使用 Executors 快捷工厂
  • 显式定义有界队列
  • 设置合理拒绝策略
  • 暴露线程池状态用于排查
  • 给异步任务加超时与异常处理

1. 线程池配置

package com.example.demo.config;

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

import java.util.concurrent.*;

@Configuration
public class ExecutorConfig {

    @Bean("bizExecutor")
    public ThreadPoolExecutor bizExecutor() {
        int corePoolSize = 8;
        int maximumPoolSize = 16;
        long keepAliveTime = 60L;
        int queueCapacity = 200;

        return new ThreadPoolExecutor(
                corePoolSize,
                maximumPoolSize,
                keepAliveTime,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(queueCapacity),
                new NamedThreadFactory("biz-exec-"),
                new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }

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

        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<>(200)有界队列,避免无限堆积
  • maximumPoolSize = 16:高峰期允许一定扩容
  • CallerRunsPolicy:队列满时让提交方自己执行,形成自然背压
  • 自定义线程名:方便 jstack 排查

2. 模拟业务服务

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class SlowBusinessService {

    public String process(String input) {
        try {
            Thread.sleep(500); // 模拟慢 IO
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "interrupted";
        }
        return "done:" + input;
    }
}

3. 正确提交异步任务

package com.example.demo.controller;

import com.example.demo.service.SlowBusinessService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
public class GoodController {

    private final ThreadPoolExecutor bizExecutor;
    private final SlowBusinessService slowBusinessService;

    public GoodController(@Qualifier("bizExecutor") ThreadPoolExecutor bizExecutor,
                          SlowBusinessService slowBusinessService) {
        this.bizExecutor = bizExecutor;
        this.slowBusinessService = slowBusinessService;
    }

    @GetMapping("/good")
    public String good(@RequestParam(defaultValue = "demo") String input) {
        try {
            return CompletableFuture
                    .supplyAsync(() -> slowBusinessService.process(input), bizExecutor)
                    .orTimeout(1, TimeUnit.SECONDS)
                    .exceptionally(ex -> "fallback")
                    .join();
        } catch (Exception e) {
            return "error";
        }
    }

    @GetMapping("/pool-stats")
    public String poolStats() {
        return String.format(
                "poolSize=%d, active=%d, core=%d, max=%d, queue=%d, completed=%d",
                bizExecutor.getPoolSize(),
                bizExecutor.getActiveCount(),
                bizExecutor.getCorePoolSize(),
                bizExecutor.getMaximumPoolSize(),
                bizExecutor.getQueue().size(),
                bizExecutor.getCompletedTaskCount()
        );
    }
}

这个版本虽然不是“绝对最强”,但已经比无界队列安全很多。至少高峰来了以后,不会无止境把任务和内存一起堆爆。


4. 启动类

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

5. Maven 依赖

<project xmlns="http://maven.apache.org/POM/4.0.0" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.5</version>
    </parent>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

核心原理补充:为什么 CallerRunsPolicy 能止血

很多同学第一次看到 CallerRunsPolicy 会疑惑:
“拒绝就拒绝,为什么要让调用方自己跑?”

因为它能形成背压

sequenceDiagram
    participant C as Client
    participant T as Tomcat线程
    participant P as 业务线程池
    participant B as 业务任务

    C->>T: 发起HTTP请求
    T->>P: 提交异步任务
    alt 队列未满
        P->>B: 工作线程执行
        B-->>T: 返回结果
        T-->>C: 响应
    else 队列已满
        T->>B: 调用方线程自己执行
        B-->>T: 返回结果
        T-->>C: 响应变慢
    end

它的效果是:

  • 正常时:线程池消化任务
  • 拥堵时:提交方线程被迫承担执行成本
  • 提交速度自然下降
  • 系统不会无限制堆积内存

当然,它也有边界:

  • 如果主请求线程不能被拖慢,就不能直接这么用
  • 如果接口必须秒回,可能更适合快速失败或降级

所以拒绝策略没有银弹,只有适合场景的权衡


常见坑与排查

这一部分我尽量写成“看到现象就能对号入座”的形式。

坑 1:用了 Executors.newFixedThreadPool() 觉得很安全

ExecutorService pool = Executors.newFixedThreadPool(16);

问题不是 fixed,而是它底层常配无界队列
结果就是:

  • 线程数固定住了
  • 队列无限长
  • 内存风险被隐藏了

建议

始终优先手动 new ThreadPoolExecutor,把这几个参数写清楚:

  • corePoolSize
  • maximumPoolSize
  • queueCapacity
  • threadFactory
  • rejectedExecutionHandler

坑 2:maximumPoolSize 配了,但从来没生效

这几乎都和队列类型有关。

如果是无界队列,线程池通常在核心线程满后直接入队,不会继续扩到 maximumPoolSize

排查办法

看队列实现:

new LinkedBlockingQueue<>()

如果没传容量,通常就要警觉。


坑 3:每个请求批量提交子任务

例如一个接口进来,循环 100 次提交任务。
在低并发时没问题,一旦请求并发上来,任务总量是乘法增长:

50 个并发请求 × 每个请求 100 个任务 = 5000 个待处理任务

建议

  • 能串行就别盲目拆分并发
  • 批量任务优先考虑限流、分批、消息队列削峰
  • 不要把请求线程池直接变成“任务投递器”

坑 4:异步了,但马上 join() / get()

CompletableFuture<String> future = CompletableFuture.supplyAsync(task, bizExecutor);
return future.join();

这属于很常见的“伪异步”:

  • 你把任务扔给线程池
  • 当前请求线程又在原地等结果
  • 多了一次线程切换
  • 如果线程池拥堵,请求线程一起被拖死

什么时候还能这么用?

  • 确实需要隔离执行资源
  • 需要超时控制和拒绝保护
  • 需要把某类慢任务从 Tomcat 线程中剥离出来

否则,盲目异步只会让问题更复杂。


坑 5:线程池里执行的任务没有超时

远程调用最怕这个:

  • 下游慢
  • 连接没及时断
  • 线程一直卡住
  • 池子很快耗尽

建议

超时要成套配置:

  • HTTP connect timeout
  • HTTP read timeout
  • 数据库超时
  • Future 超时
  • 熔断 / 降级

坑 6:没有监控队列长度

如果没有线程池指标,线上只能靠猜。
最少也要采集:

  • 活跃线程数
  • 当前线程数
  • 队列长度
  • 拒绝次数
  • 完成任务数

如果用了 Micrometer,可以把这些指标接到 Prometheus。


止血方案

线上已经出现请求堆积和内存上涨时,不建议一上来就“多加线程”。我更推荐下面这个顺序。

1. 先限制堆积规模

最优先做的是把无界队列改成有界队列。

new ArrayBlockingQueue<>(200)

这样至少能把风险控制在一个可预期范围内。

2. 再选择拒绝策略

常见选择:

  • AbortPolicy:直接拒绝,快速失败
  • CallerRunsPolicy:调用方执行,形成背压
  • 自定义策略:记录日志、打点、降级返回

如果你的接口必须保障主链路 RT,优先考虑:

new ThreadPoolExecutor.AbortPolicy()

然后在业务层捕获拒绝异常,返回“系统繁忙,请稍后重试”。

3. 给慢任务做超时和降级

不要让任务无限等下游。

4. 从源头减少任务量

例如:

  • 合并重复任务
  • 避免每次请求都异步刷日志/查库/调下游
  • 非核心操作改消息队列异步化
  • 对热点接口做限流

安全/性能最佳实践

这一部分给一些真正能落地的建议。

1. 不要混用不同性质的任务

CPU 密集和 IO 密集任务,不建议共用一个线程池。

  • CPU 密集:线程数接近 CPU 核数
  • IO 密集:线程数可以适当更高,但必须严格有界

如果混在一起,IO 阻塞很容易把 CPU 任务也拖慢。


2. 线程池容量要结合业务时间算

一个非常实用的思路:

可承受并发任务数 ≈ 线程数 + 队列容量

比如:

  • 最大线程数 16
  • 队列容量 200

那最多只允许 216 个任务处于“执行中 + 等待中”。
如果入口流量远大于这个值,就必须:

  • 限流
  • 拒绝
  • 降级
  • 削峰到 MQ

而不是继续往内存里塞。


3. 线程名一定要自定义

这件事看起来小,但线上排查时价值极大。

错误示例:

pool-1-thread-1
pool-1-thread-2

正确示例:

biz-exec-1
biz-exec-2
order-async-1
report-export-1

你在 jstack 里一眼就知道谁在堵。


4. 给线程池加监控和报警阈值

至少建议对下面指标报警:

  • 队列使用率超过 70%
  • 活跃线程接近最大线程数
  • 拒绝次数持续增加
  • 平均任务耗时突然拉长

很多事故其实在“快出问题”时已经有信号,只是没被看见。


5. 明确异步任务是否必须和请求强绑定

这是设计上的关键问题。

如果任务结果必须立刻返回给用户,那它本质还是同步链路的一部分。
这时线程池更多是资源隔离工具,不是提升吞吐的万能钥匙。

如果任务不要求立即完成,比如:

  • 发通知
  • 刷统计
  • 生成报表
  • 同步搜索索引

更适合:

  • 消息队列
  • 延迟任务
  • 后台作业系统

不要把 HTTP 请求入口当成批处理调度器。


6. 注意上下文传播和对象引用

异步任务里如果顺手把这些对象带进去:

  • HttpServletRequest
  • 大型 DTO
  • 用户上下文
  • 大对象缓存引用

就可能加重内存占用,甚至引发线程安全问题。

建议

只传递必要参数,避免把整个请求上下文塞进任务闭包。


一张图看完整排查思路

flowchart LR
    A[接口RT上升] --> B[检查线程池指标]
    B --> C{队列持续增长?}
    C -- 是 --> D[检查是否无界队列]
    D --> E[查看任务是否慢IO/批量提交]
    E --> F[抓线程Dump和HeapDump]
    F --> G[改为有界队列+拒绝策略+超时]
    C -- 否 --> H[排查数据库/下游依赖/锁竞争]

总结

这类问题的本质,不是“线程池参数没调好”这么简单,而是:

把线程池当成无限缓冲区,最终把流量压力转移成了请求堆积和内存压力。

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

  1. 不要滥用 Executors 默认工厂方法
  2. 线程池队列必须有界
  3. 拒绝策略要结合业务设计,不能默认不管
  4. 慢任务必须有超时、降级和监控
  5. 异步不是性能优化的同义词,很多时候只是延迟暴露问题

最后给一个可执行的排查顺序:

  1. 看 RT、GC、线程池队列是否同步恶化
  2. jstack 看线程都卡在哪
  3. 用堆快照确认是不是任务对象堆积
  4. 检查是否用了无界队列或默认线程池
  5. 改成有界队列 + 合理拒绝策略 + 超时控制
  6. 必要时做限流、降级、MQ 削峰

如果你的服务已经在高峰期出现“慢但不挂、GC 很忙、内存持续涨”的症状,优先怀疑线程池堆积,往往不会错。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》