Full GC频繁接口卡顿?JVM排查到调优实战

凌晨两点,监控群突然炸了:报表服务接口 P99 从 80ms 飙到 8 秒,CPU 没涨,GC 曲线却像心跳一样规律。拉出 GC 日志一看,Full GC 每 10 分钟一次,每次停顿 3 秒以上。这不是个例------只要服务里有"只进不出"的缓存、大数组或者批量加载,G1 老年代迟早被塞满,Full GC 就会来敲门。本文用一个真实的 Spring Boot 4.1.0 应用,完整演示从 jstat 发现异常、JFR 定位根因、到代码修复和 JVM 参数调优的全过程,全程基于 JDK 21。

摘要: 服务频繁 Full GC、接口卡顿是 Java 生产环境最常见的性能事故。本文从 JVM 堆内存模型和 G1 垃圾收集器原理讲起,用 Spring Boot 4.1.0 应用复现"静态缓存无限膨胀"导致的 Full GC 问题,手把手演示 jstat、jcmd、JFR、jmap 等 JDK 自带工具的完整诊断流程,最后给出代码修复、G1 参数调优和 ZGC 选型建议,全文基于 JDK 21 实测,含完整可运行代码。


一、这个问题到底是什么

Full GC 频繁发生意味着 JVM 堆内存回收已经跟不上对象的产生速度,每次 Full GC 都会暂停所有业务线程(STW),接口卡顿只是它最直观的症状。 它不是某个特定框架的 bug,而是任何 Java 服务在内存管理失衡时都会踩的坑,Spring Boot 应用也不例外。

先说清楚 Full GC 是什么。JVM 的垃圾回收分两大类:Minor/Young GC (只回收新生代,快,停顿几十毫秒以内)和 Full GC(回收整个堆,包括老年代,慢,停顿可能以秒计)。在 JDK 21 默认的 G1 收集器里,Full GC 是单线程串行执行的,堆越大停得越久------一个 8GB 堆的 Full GC 停顿 3~5 秒是家常便饭。停顿期间所有请求线程冻结,表现就是:接口超时、连接池打满、监控面板上 GC 时间曲线变成脉冲。

典型的 Full GC 症状组合:

症状 说明
接口 P99 飙升 STW 期间请求全部排队,恢复后瞬间涌入
CPU 不高但响应慢 Full GC 是内存操作,不占 CPU,很容易误判为网络问题
GC 日志规律性停顿 Full GC 触发间隔稳定,说明有对象在按固定速率堆积
老年代使用率持续爬升 回收量 < 增长量,老年代水位只涨不降

什么代码会触发 Full GC?最常见的三类:第一,静态/单例容器缓存无限增长 ------static Mapstatic List 只 put 不清理,业务每次请求都在往老年代里塞对象;第二,大对象直接进老年代 ------G1 里超过 Region 大小一半的对象(Humongous 对象)跳过新生代直接进老年代,比如一次性加载 20MB 的报表数据;第三,批量加载到内存------一次查出几十万条记录组装成对象列表,内存瞬间见底。

这篇文章要解决的问题就是:当 Full GC 已经频繁发生时,怎么用 JDK 自带工具一步步定位根因,怎么修代码,以及 JVM 参数到底该怎么调。

二、底层原理到底怎么回事

Full GC 的根因是"对象存活率失控":对象进入老年代后长时间回收不掉,G1 的 Mixed GC 消化不了,最终只能触发串行 Full GC 兜底。 要理解这个过程,得先看 JDK 21 默认收集器 G1 的内存布局和工作方式。

G1 把堆切成 Region

G1(Garbage First)把整个堆划分成一个个大小相等的 Region,默认 1MB 到 32MB 之间(堆小于 4GB 时通常 1MB,堆越大 Region 越大)。Region 按角色动态分配:一部分是 Eden(新生对象分配区),一部分是 Survivor(存活对象过渡区),剩下的是 Old(老年代)。新生代和老年代不再是物理连续的一大块,而是"逻辑上归为一类、物理上分散"的 Region 集合------这正是 G1 能实现可预测停顿的基础,它只回收那些"垃圾最多"的 Region(Garbage First 名字的由来)。

对象的一生与晋升

新对象分配在 Eden Region。Eden 满了触发 Young GC,存活的复制进 Survivor,每熬过一次 GC 年龄 +1,达到阈值(默认 15)就晋升到 Old。两条路会让对象提前进老年代:

  1. Humongous 对象 :单个对象超过 Region 大小的一半(比如 Region 1MB、对象 600KB),G1 直接把它放在连续的"大对象区",跳过新生代。20MB 的 byte[] 在这个机制下等于直接住进老年代。
  2. 晋升失败:Young GC 时 Survivor 装不下存活对象,多余的直接"提前晋升"。

Full GC 的触发条件

G1 平时靠两种回收维持:Young GC(只收新生代,停顿短)和 Mixed GC (新生代 + 一部分老年代 Region 一起收,目标是把停顿控制在 -XX:MaxGCPauseMillis 内)。Mixed GC 是"尽力而为"的------它每轮只挑一部分老年代 Region 来收,给停顿目标让路。当出现以下情况,G1 就兜不住了,退化成单线程 Full GC:

  • 老年代占用率超过 IHOP 阈值(Initiating Heap Occupancy Percent,默认 45%),开始并发标记,但标记完成后 Mixed GC 回收速度赶不上对象增长速度,老年代持续上涨直至空间不足;
  • Humongous 分配失败:大对象找不到足够连续空间;
  • 晋升失败(promotion failure):Young GC 后发现老年代放不下晋升对象;
  • Metaspace 空间不足

Full GC 阶段 G1 退化成类似 Serial 的单线程全堆标记-整理,STW 时间与存活对象总量、堆大小成正比,8GB 堆停 3~5 秒、32GB 堆停十几秒都不稀奇。

为什么"只进不出"的缓存必然触发 Full GC

static Map 里的对象被 GC Roots(静态字段)强引用,永远存活,Young GC、Mixed GC 都动不了它们。它们占据的老年代 Region 在 Mixed GC 里被标记为"全存活、零回收",回收效率趋近于零。随着缓存继续膨胀,老年代水位逼近上限,Mixed GC 消化不掉,IHOP 触发的并发标记也救不回来,最终进入 Full GC 死循环:Full GC 停几秒 → 恢复后缓存又长 → 再 Full GC。

诊断的核心思路就一句话:找到谁在往老年代里塞对象,以及谁占据着堆内存不放。 前者用 JFR 的分配采样,后者用 jmap 看对象直方图。

三、实战:手把手写代码

实战目标:用 Spring Boot 4.1.0 + JDK 21 复现 Full GC 问题,再用 jstat 发现异常、JFR 定位根因、jmap 确认元凶,最后用代码修复和 JVM 参数调优收尾。 下面每一步的代码都能直接复制运行。

3.1 完整项目:一个"会泄漏"的报表服务

先建一个 Maven 项目,pom.xml 用 Spring Boot 4.1.0(2026 年 8 月 Maven Central 上的最新 GA 版本),Java 版本 21:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<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>

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

    <groupId>com.example</groupId>
    <artifactId>gc-demo</artifactId>
    <version>1.0.0</version>
    <name>gc-demo</name>
    <description>JVM Full GC 排查实战 Demo</description>

    <properties>
        <java.version>21</java.version>
    </properties>

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

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

配置文件 application.yml

yaml 复制代码
server:
  port: 8080

spring:
  application:
    name: gc-demo

启动类 GcDemoApplication.java

java 复制代码
package com.example.gcdemo;

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

@SpringBootApplication
public class GcDemoApplication {

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

核心的"泄漏源" ReportService.java------一个把报表数据缓存进静态 Map、只进不出的 Service:

java 复制代码
package com.example.gcdemo;

import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class ReportService {

    // 问题代码:static 容器缓存所有报表,只进不出,对象被 GC Roots 强引用,永远无法回收
    private static final Map<String, byte[]> REPORT_CACHE = new ConcurrentHashMap<>();

    public byte[] generateReport(String date) {
        if (REPORT_CACHE.containsKey(date)) {
            return REPORT_CACHE.get(date);
        }
        // 模拟生成一份 20MB 的报表:超过 G1 Region 大小一半,属于 Humongous 大对象,直接进老年代
        byte[] report = new byte[20 * 1024 * 1024];
        REPORT_CACHE.put(date, report);
        return report;
    }

    public int cacheSize() {
        return REPORT_CACHE.size();
    }
}

Controller ReportController.java

java 复制代码
package com.example.gcdemo;

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

@RestController
public class ReportController {

    private final ReportService reportService;

    public ReportController(ReportService reportService) {
        this.reportService = reportService;
    }

    @GetMapping("/report/{date}")
    public String generate(@PathVariable String date) {
        byte[] report = reportService.generateReport(date);
        return "报表生成成功, 大小: " + report.length / 1024 / 1024 + "MB, 当前缓存: " + reportService.cacheSize() + "份";
    }
}

启动时把堆限制在 512MB(方便快速复现),加上 GC 日志和 OOM dump 参数:

bash 复制代码
java -Xms512m -Xmx512m \
  -XX:+UseG1GC \
  -Xlog:gc*:file=/tmp/gc.log \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/tmp/ \
  -jar target/gc-demo-1.0.0.jar

然后模拟业务请求------每天生成一份新报表,连续调 40 次:

bash 复制代码
for i in $(seq 1 40); do curl -s "http://localhost:8080/report/2026-08-$(printf '%02d' $i)"; echo; sleep 1; done

40 份 × 20MB = 800MB,远超 512MB 堆,Full GC 马上就来。

3.2 诊断第一步:jstat 发现异常

jstat 是 JDK 自带的 JVM 统计工具,jstat -gcutil 每秒刷一次就能看到各代使用率和 GC 次数/耗时,这是定位 Full GC 的第一手证据。 找到进程 PID:

bash 复制代码
jps -l

假设 PID 是 12345,每秒刷一次看各代水位:

bash 复制代码
jstat -gcutil 12345 1000

输出类似:

复制代码
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     CGC    CGCT     GCT
  0.00   0.00  68.21  89.42  95.12  93.80     12    0.182    4    12.436    2    0.043   12.661
  0.00   0.00  70.05  91.77  95.12  93.80     12    0.182    5    15.542    2    0.043   15.767

关键看两列:O(老年代使用率)持续在 90% 以上,FGC(Full GC 次数)在涨、FGCT(Full GC 总耗时)涨得飞快------4 次 Full GC 花了 12.4 秒,平均每次 3 秒。而 YGC 才 12 次,说明问题完全不在新生代。到这里可以下结论:Full GC 频繁,老年代水位异常,需要定位谁占了老年代。

3.3 诊断第二步:JFR 录制找分配热点

JFR(Java Flight Recorder)是 JDK 11+ 内置的采样分析器,用 jcmd 一行命令就能录制,不需要重启应用,是生产环境定位内存问题的首选工具。 录 60 秒,期间继续压几个请求:

bash 复制代码
jcmd 12345 JFR.start name=fullgc duration=60s filename=/tmp/app.jfr
# 60 秒内再压几个请求,让采样覆盖到问题代码
for i in $(seq 41 45); do curl -s "http://localhost:8080/report/2026-08-$(printf '%02d' $i)"; echo; sleep 1; done
jcmd 12345 JFR.stop name=fullgc

JDK 14+ 自带 jfr 命令行工具,直接看 GC 事件和分配情况:

bash 复制代码
jfr view gc app.jfr
jfr view allocation app.jfr

allocation 视图会列出分配内存最多的调用栈,你会看到 ReportService.generateReportnew byte[20MB] 独占鳌头------分配热点直接指向问题代码,比盲猜高效得多。注意:JFR 默认只保留最近 1 小时的滚动数据,生产环境建议 JFR.start 不带 duration 常开录制,出问题时再 dump 出来看。

3.4 诊断第三步:jmap 看对象直方图

jmap -histo 列出堆里各类对象的实例数和占用字节数,用来确认"到底谁占着内存";在决定是否 dump 堆之前先看它,能省下几 GB 的 dump 文件传输时间。 执行:

bash 复制代码
jmap -histo:live 12345 | head -30

:live 会先触发一次 Full GC 再统计存活对象。输出里 [B(byte 数组)会以压倒性优势排第一,占掉几百 MB------和 JFR 的结论对上:就是这个 20MB 大对象 + 静态缓存把老年代塞满了。

如果还想看引用链(谁引用了这些数组),再 dump 堆交给 MAT 分析:

bash 复制代码
jcmd 12345 GC.heap_dump /tmp/app.hprof

MAT 打开后对 [B 跑 "Path to GC Roots",会一路指到 ReportService.REPORT_CACHE 这个静态字段------根因 100% 实锤。

3.5 修复:代码层面解决"只进不出"

修复思路是给缓存加上限和过期:要么容量封顶,要么定期清理,让老年代里的对象能被回收。 这里改成"每天凌晨清空"的版本,零新增依赖,直接替换 ReportService.java

java 复制代码
package com.example.gcdemo;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

@Service
public class ReportService {

    private static final Map<String, byte[]> REPORT_CACHE = new ConcurrentHashMap<>();

    // 定时清理线程:只负责清缓存,避免缓存无限膨胀
    private final ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor();

    @PostConstruct
    public void startCleaner() {
        // 启动 2 小时后执行第一次,之后每隔 24 小时清空一次
        cleaner.scheduleWithFixedDelay(REPORT_CACHE::clear, 2, 24, TimeUnit.HOURS);
    }

    public byte[] generateReport(String date) {
        // computeIfAbsent 原子操作:有缓存直接返回,没有则生成
        return REPORT_CACHE.computeIfAbsent(date, d -> new byte[20 * 1024 * 1024]);
    }

    public int cacheSize() {
        return REPORT_CACHE.size();
    }

    @PreDestroy
    public void stopCleaner() {
        cleaner.shutdownNow();
    }
}

computeIfAbsentConcurrentHashMap 的原子方法,并发下不会重复生成报表;定时清空保证缓存最多存活 24 小时。如果业务要求缓存必须长期保留(比如不可再生的数据),那就要换成带容量上限的本地缓存(如 Caffeine,设置 maximumSize + expireAfterWrite),原理一样:必须给缓存一个"出口"

3.6 修复:JVM 参数调优

代码修完后,JVM 参数层面做三件事:把最大堆和初始堆对齐避免扩容抖动、给 G1 设停顿目标、保留 OOM 现场。 注意 -Xmx-XX:MaxRAMPercentage 不能同时用,容器环境推荐百分比写法:

bash 复制代码
java -XX:InitialRAMPercentage=25.0 \
  -XX:MaxRAMPercentage=50.0 \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=100 \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/logs/ \
  -Xlog:gc*:file=/logs/gc.log:time,level,tags \
  -jar gc-demo-1.0.0.jar

参数含义:

参数 作用
-XX:InitialRAMPercentage=25.0 初始堆为容器内存的 25%,避免启动后频繁扩容
-XX:MaxRAMPercentage=50.0 最大堆为容器内存的 50%,给元空间和堆外留余地
-XX:MaxGCPauseMillis=100 G1 的停顿目标 100ms,Mixed GC 会据此控制每轮回收量
-XX:+HeapDumpOnOutOfMemoryError OOM 时自动 dump 堆,保留现场
-Xlog:gc* JDK 9+ 的统一日志开关,替代老的 -verbose:gc

修复后重跑压测:FGC 列不再增长,老年代水位在 40% 附近波动,接口 P99 回到几十毫秒------问题闭环。

四、踩坑经验和最佳实践

Full GC 排查最大的坑是"上来就调参数":不先定位根因,把堆调大、把 IHOP 调低,只会把 Full GC 从"频繁"拖成"更频繁",正确顺序永远是先查代码再调参数。 以下都是生产环境踩过的真实坑。

坑 1:把堆调大反而更卡。 有人遇到 Full GC 第一反应是 -Xmx 从 2G 加到 8G。堆变大后 G1 Region 变大、Full GC 串行整理的时间更长,停顿从 1 秒变 4 秒;而且如果根因是缓存泄漏,堆再大也会被慢慢填满。堆的大小应该根据"存活对象集"定,而不是拍脑袋。先 jmap -histo 看存活对象有多少,再决定堆大小。

坑 2:-Xmx-XX:MaxRAMPercentage 同时设置。 两者冲突时行为不可预期,容器环境(K8s/ Docker)推荐只用百分比参数,让 JVM 感知容器内存上限;裸机环境用固定值。同时注意 JDK 8u191+ 才默认开启 UseContainerSupport,老版本 JDK 在容器里会看到宿主机内存,这是另一个经典事故源。

坑 3:直接 dump 堆,文件几个 GB 传不回来。 线上堆 8GB,dump 出来 6GB,传到本地分析要半小时。正确姿势:先用 jmap -histo:live 看对象直方图,能定位就定位;必须看引用链时,先开 JFR 看分配栈,最后才 dump 。dump 前确认磁盘空间,GC.heap_dump 前用 df -h 看一眼。

坑 4:大对象(Humongous)被忽略。 很多人查 Full GC 只盯着缓存,忘了 G1 里超过 Region 一半的对象直接进老年代。byte[]int[] 这类大数组在 G1 下都是"老年代钉子户",即使没有缓存泄漏,高频创建大数组也会让老年代暴涨。排查时在 jmap 直方图里重点看 [B[I[C 这些数组类型。业务上优先用流式处理、分页加载代替一次性大数组。

最佳实践清单:

  • 启动参数固定带上 -XX:+HeapDumpOnOutOfMemoryError + -XX:HeapDumpPath + -Xlog:gc*,出事才有现场;
  • 生产环境 JFR 常开(jcmd <pid> JFR.start name=default settings=profile),JFR 的 CPU 开销低于 1%,远低于事后抓瞎的成本;
  • 所有缓存必须满足"容量上限 + 过期时间"二选一,静态容器缓存一律视为坏味道;
  • 监控 GC:Prometheus + Micrometer 采集 jvm_gc_pause_secondsjvm_gc_overhead(GC 时间占比),GC overhead 超过 10% 就报警,别等 Full GC 把服务打挂;
  • 升级 JDK 版本后重新压测 GC------不同版本 G1 默认参数差异很大,旧参数可能不适用。

五、性能对比和技术选型

垃圾收集器选型一句话:追求吞吐选 Parallel,默认场景用 G1,大堆 + 低延迟刚需(亚毫秒级停顿)才上 ZGC;任何收集器都救不了泄漏的代码。 三者对比:

维度 Parallel(JDK 8 默认) G1(JDK 21 默认) ZGC
目标 最大吞吐 吞吐与停顿平衡 极致低延迟
STW 停顿 秒级(Full GC) 10~200ms 可配置 亚毫秒级
大堆(>16GB)表现 最好
CPU 开销 高(染色指针 + 读屏障)
适用场景 批处理、离线任务 大多数在线服务 大堆在线交易、游戏

G1 是 JDK 21 的默认收集器,绝大多数 Spring Boot 服务用默认即可,最多调 -XX:MaxGCPauseMillis什么时候换 ZGC? 当服务堆超过 16GB 且停顿敏感(比如支付、交易链路)时考虑。JDK 21 引入了分代 ZGC(JEP 439),用 -XX:+UseZGC -XX:+ZGenerational 开启,分代模式兼顾了低延迟和吞吐,比 JDK 15 初版好很多。但 ZGC 的 CPU 开销明显高于 G1,堆不大时换 ZGC 是亏的。

Full GC 的根治不在收集器,在内存治理:缓存有出口、大对象不进堆、批量数据流式化。收集器只是最后一道防线。

六、总结

Full GC 频繁的本质是对象存活率失控,排查顺序固定为:jstat 看水位 → JFR 看分配热点 → jmap 看对象直方图 → 修代码 → 调参数,每一步都有 JDK 自带工具,不需要额外付费产品。 本文的核心结论可以浓缩为四条:

  1. 先查代码再调参数 :静态缓存、大数组、批量加载是 Full GC 三大元凶,jmap -histo:live 一眼就能确认;
  2. 工具链是免费的:jps、jstat、jcmd、jfr、jmap 全在 JDK 里,JFR 常开成本低于 1% CPU,是生产环境最值得养成的习惯;
  3. 缓存必须留出口:容量上限或过期时间至少选一个,"只进不出"的缓存必然触发 Full GC,只是时间问题;
  4. 参数调优做减法:MaxGCPauseMillis 设停顿目标、百分比设置堆大小、HeapDumpOnOutOfMemoryError 留现场,这三样足够,G1 的其余参数默认值就是为大多数场景调的。

最后提醒:收集器选型(G1 还是 ZGC)是锦上添花,内存治理才是雪中送炭。把本文的排查流程跑一遍,你的服务离"Full GC 自由"就不远了。

相关推荐
不会就选b1 小时前
Linux之线程进阶(一)
jvm
互联网中的一颗神经元2 小时前
01. Go 内存管理全景架构
java·jvm·golang
互联网中的一颗神经元3 小时前
02. 核心概念与术语表
java·jvm·spring
名字还没想好☜16 小时前
Java 线上内存泄漏排查实战:jmap 导堆、MAT 找 GC Roots 与四类常见泄漏
java·开发语言·jvm·内存泄漏
淡海水1 天前
01-07-运行时-GC深度剖析-内存分配回收与结构选择
jvm·windows·unity·gc
2602_959960921 天前
电商大厂Java面试:从Spring Boot、JPA、微服务到Redis、Kafka、Spring Security与监控,谢飞机的爆笑三轮问答
java·jvm·spring boot·redis·面试题
一水1 天前
AI 时代审查思维:审查第一篇
java·jvm·数据库·spring
それども1 天前
JVM MetaspaceSize 参数作用
jvm
java_upp2 天前
JVM内存溢出和内存泄漏的区别及排查方法
jvm·内存泄漏·内存溢出