垃圾回收与 JVM 调优
学习目标
- 理解 GC Roots、可达性分析、分代假说、晋升和不同收集器的边界。
- 使用统一 GC 日志、jstat、jcmd、堆转储和 JFR 收集证据。
- 按指标和基线调整堆、收集器或对象生命周期,而不是凭经验改参数。
核心知识点
专业术语
- GC Roots:从栈、静态字段、JNI 等根出发的可达性起点。
- Minor/Young GC:主要回收年轻代对象;不同收集器术语和实现可能不同。
- 晋升(promotion):存活对象从年轻区域进入老年代或被收集器长期保留。
- Full GC:涉及整个堆或更大范围的收集/整理,通常代价更高。
- 停顿(pause):应用线程被 GC 或安全点操作暂停的时间。
白话解释与边界
GC 不是“把所有不用的对象立刻删除”,而是根据根可达性在合适时机回收空间。短命对象多、分配速率高、老对象晋升快、缓存引用泄漏和堆过小会呈现不同现象。收集器选择要同时看延迟、吞吐、堆大小、CPU 和工作负载。
JDK 20 可用 G1、Parallel、Serial、ZGC、Shenandoah 等收集器(具体发行版和平台可能不同);不要因为某个收集器名字新就直接切换。先记录基线,再一次改一个变量并回归。
常用用法
验证对象可达性:观察强引用对回收的影响
静态字段、活动线程栈和 JNI 引用可能是 GC Roots;只要从根可达,GC 就不能回收。
// 说明:验证对象可达性:观察强引用对回收的影响。
import java.util.ArrayList;
import java.util.List;
public class GcRootsDemo {
private static final List<byte[]> CACHE = new ArrayList<>();
// 初始状态:CACHE = new ArrayList<>()。
// 作用:private static final List<byte[]> CACHE = new ArrayList<>();;观察强引用对回收的影响。
public static void main(String[] args) {
CACHE.add(new byte[1024]);
// 初始状态:CACHE.add(new byte[1024])。
// 作用:CACHE.add(new byte[1024]);;观察强引用对回收的影响。
System.out.println("root keeps=" + (CACHE.size() == 1));
// 输出:root keeps=true
}
}缓存不清理会表现为保留集不断增长,应从引用链解决而不是只调大堆。
-Xlog:gc*:开启统一 GC 日志
JDK 9+ 统一日志用 -Xlog;上例同时记录 GC 和 safepoint,并滚动文件。
// 说明:-Xlog:gc*:开启统一 GC 日志。
public class GcLogOptionDemo {
public static void main(String[] args) {
System.out.println("java -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20M App");
// 输出:java -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20M App
}
}日志要和应用版本、堆参数、收集器、实例时间一起保存,否则单看停顿数字很难解释。
jstat -gcutil:快速观察利用率
jstat -gcutil 可周期查看各区域利用率和 GC 次数,适合快速确认是否频繁 Young GC、老年代逼近上限或 Full GC 增多。
// 说明:jstat -gcutil:快速观察利用率。
public class JstatHintDemo {
public static void main(String[] args) {
System.out.println("jstat -gcutil <pid> 1000 5");
// 输出:jstat -gcutil <pid> 1000 5
}
}它是采样,不替代详细 GC 日志。
jcmd GC.heap_info:查看堆概要
GC.heap_info 能给出当前收集器和堆概要;不同 JDK/收集器输出字段会变化,诊断脚本要容忍版本差异。
// 说明:jcmd GC.heap_info:查看堆概要。
public class JcmdHeapInfoDemo {
public static void main(String[] args) {
System.out.println("jcmd <pid> GC.heap_info");
// 输出:jcmd <pid> GC.heap_info
}
}jcmd GC.class_histogram:按类查看存活对象
类直方图帮助发现 String、数组、集合或业务对象数量异常;GC.class_histogram 可能触发较重的操作,生产环境先评估权限、停顿和采样时机。
// 说明:jcmd GC.class_histogram:按类查看存活对象。
public class ClassHistogramHintDemo {
public static void main(String[] args) {
System.out.println("jcmd <pid> GC.class_histogram");
// 输出:jcmd <pid> GC.class_histogram
}
}设定堆边界:共同配置初始值和最大值
-Xms 是初始堆,-Xmx 是最大堆;设置相同可以减少扩缩容波动,但不代表应用一定需要这么大。
// 说明:设定堆边界:共同配置初始值和最大值。
public class HeapOptionsDemo {
public static void main(String[] args) {
System.out.println("java -Xms512m -Xmx512m App");
// 输出:java -Xms512m -Xmx512m App
}
}容器还要给线程栈、元空间、直接内存和本地库预留空间。
-XX:HeapDumpOnOutOfMemoryError:保留 OOM 证据
堆转储记录对象和引用关系,可能很大且包含敏感数据;目录要有容量、权限和脱敏策略。
// 说明:-XX:HeapDumpOnOutOfMemoryError:保留 OOM 证据。
public class HeapDumpOptionDemo {
public static void main(String[] args) {
System.out.println("-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./dumps");
// 输出:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./dumps
}
}它适合事后分析泄漏,不是线上每分钟采集的指标。
基线:先记录指标再调参
至少记录吞吐、p95/p99 停顿、堆占用、分配速率、晋升速率和 Full GC 次数;没有基线就无法判断参数改动是否真的改善。
// 说明:基线:先记录指标再调参。
public class GcBaselineDemo {
public static void main(String[] args) {
record("p99 pause", "<baseline>");
record("allocation rate", "<baseline>");
}
static void record(String metric, String value) {
System.out.println(metric + "=" + value);
// 输出:p99 pause=<baseline>
}
}不常用但需要知道
G1Region:区域化堆与大对象
G1 将堆分成 Region,大对象可能占用连续的 Humongous Region;大量大数组、缓存或序列化缓冲会造成回收压力。应从对象生命周期和分配大小定位,而不是只调大 Region。
// 说明:运行时需在 G1 日志中查看 Humongous 分配事件及占用的连续 Region,这个提示程序本身不会创建大对象。
public class G1RegionHintDemo {
public static void main(String[] args) {
System.out.println("inspect humongous allocations in G1 GC logs");
// 输出:inspect humongous allocations in G1 GC logs
}
}-XX:MaxGCPauseMillis:目标不是硬上限
这是收集器的软目标,不能保证每次停顿都小于 200ms;工作负载、堆布局和 CPU 压力都可能使目标无法达到。要观察实际分位数,不要只看启动参数。
public class GcPauseTargetDemo {
public static void main(String[] args) {
System.out.println("-XX:MaxGCPauseMillis=200");
// 输出:-XX:MaxGCPauseMillis=200
}
}jcmd GC.heap_dump:主动生成堆转储
堆转储可能触发较长停顿并占用磁盘;文件通常含业务数据,传输和保存必须受控。优先使用 OOM 自动转储或在低风险时间手动采集。
// 说明:把 <pid> 替换为目标 JVM 进程号,GC.heap_dump 会将该进程的堆写入 ./dumps/app.hprof,采集前须确认目录空间和数据权限。
public class HeapDumpCommandDemo {
public static void main(String[] args) {
System.out.println("jcmd <pid> GC.heap_dump ./dumps/app.hprof");
// 输出:jcmd <pid> GC.heap_dump ./dumps/app.hprof
}
}选择低停顿收集器:按可用 JDK 与目标权衡
ZGC、Shenandoah 更关注低停顿,但会有并发 CPU、内存和实现版本边界;不同发行版支持细节不同。不要把“低停顿”误读为零停顿,也不要绕过基线直接换收集器。
public class LowPauseCollectorHintDemo {
public static void main(String[] args) {
System.out.println("choose a low-pause collector only after workload testing");
// 输出:choose a low-pause collector only after workload testing
}
}System.gc:只是一条建议
System.gc() 可能被忽略或造成不必要停顿,不能用作业务内存管理 API。定位泄漏应修复引用关系,测试中如需可控行为也应使用专门测试设计。
// 作用:通过 System.gc 只是一条建议。
public class SystemGcHintDemo {
public static void main(String[] args) {
System.gc();
// 初始状态:System.gc()。
// 作用:System.gc() 仅向 JVM 提出 GC 请求,不保证立即回收或一定发生。
System.out.println("gc requested");
// 输出:gc requested
}
}简单案例
import java.util.ArrayList;
import java.util.List;
public class AllocationPressureDemo {
public static void main(String[] args) {
List<byte[]> blocks = new ArrayList<>();
// 初始状态:blocks = new ArrayList<>()。
// 作用:List<byte[]> blocks = new ArrayList<>();;只是一条建议。
blocks.add(new byte[1024]);
// 作用:blocks.add(new byte[1024]);;只是一条建议。
// 初始状态:blocks.add(new byte[1024])。
blocks.add(new byte[1024]);
// 作用:blocks.add(new byte[1024]);;只是一条建议。
// 初始状态:blocks.add(new byte[1024])。
System.out.println("blocks=" + blocks.size());
// 输出:blocks=2
}
}这个案例只说明分配和强引用保留,不把一次运行结果误判为 GC 发生与否。真实问题要结合日志、直方图和引用链分析。
易混点
- GC 次数多不一定是泄漏,可能是分配速率高、堆太小或负载突增。
- Full GC 多不等于一定有内存泄漏,先看老年代占用、回收后存活量和类直方图。
- 调大堆可能降低频率却增加单次停顿,还会挤压容器中的非堆内存。
System.gc()是建议,不是同步回收命令;不要用它证明对象生命周期。- 收集器参数是目标/启发式,不是硬保证,必须用同负载下的分位数比较。
GC 次数多和内存泄漏的区别在于回收后存活量是否持续增长;不能只凭次数或一次停顿直接下结论。
课后小问
发现 Young GC 频率高,第一步应该做什么? 答案:先看分配速率、每次回收释放量、停顿和业务负载,再判断是短命对象多还是堆过小。 解析:直接调大堆可能掩盖对象创建热点;日志和 JFR 能帮助区分分配压力与泄漏。
为什么只增加 -Xmx 可能让延迟变差? 答案:更大堆可能降低回收频率,却可能增加单次扫描、整理和容器内存压力。 解析:调优目标要同时看吞吐、停顿分位数、RSS、CPU 和下游响应,而不是只看堆上限。
OOM 后为什么要保留堆转储路径? 答案:堆转储能记录对象数量和引用链,为区分泄漏、缓存增长和分配过快提供证据。 解析:文件可能很大且含敏感数据,要提前规划磁盘、权限、保留周期和脱敏流程。
本节小结
- GC 依据 GC Roots 可达性回收对象,分配、晋升、堆大小和缓存引用会形成不同症状。
- JDK 统一 GC 日志、jstat、jcmd、堆转储和 JFR 是互补证据。
- 先建立停顿、吞吐、分配速率和存活量基线,再一次调整一个变量。
- 收集器和参数是工作负载相关的选择,不存在脱离指标的“万能调优值”。
快速回顾
- 能解释 GC Roots、年轻对象、晋升和 Full GC。
- 会开启
-Xlog:gc*并用 jstat/jcmd 做快速观测。 - 能区分分配过快、泄漏、堆太小和大对象压力。
- 能写出有基线、可比较、可回滚的调优思路。
阿源的知识库