凌晨两点,监控群突然炸了:报表服务接口 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 Map、static 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。两条路会让对象提前进老年代:
- Humongous 对象 :单个对象超过 Region 大小的一半(比如 Region 1MB、对象 600KB),G1 直接把它放在连续的"大对象区",跳过新生代。20MB 的
byte[]在这个机制下等于直接住进老年代。 - 晋升失败: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.generateReport 里 new 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();
}
}
computeIfAbsent 是 ConcurrentHashMap 的原子方法,并发下不会重复生成报表;定时清空保证缓存最多存活 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_seconds和jvm_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 自带工具,不需要额外付费产品。 本文的核心结论可以浓缩为四条:
- 先查代码再调参数 :静态缓存、大数组、批量加载是 Full GC 三大元凶,
jmap -histo:live一眼就能确认; - 工具链是免费的:jps、jstat、jcmd、jfr、jmap 全在 JDK 里,JFR 常开成本低于 1% CPU,是生产环境最值得养成的习惯;
- 缓存必须留出口:容量上限或过期时间至少选一个,"只进不出"的缓存必然触发 Full GC,只是时间问题;
- 参数调优做减法:MaxGCPauseMillis 设停顿目标、百分比设置堆大小、HeapDumpOnOutOfMemoryError 留现场,这三样足够,G1 的其余参数默认值就是为大多数场景调的。
最后提醒:收集器选型(G1 还是 ZGC)是锦上添花,内存治理才是雪中送炭。把本文的排查流程跑一遍,你的服务离"Full GC 自由"就不远了。