Skip to content

垃圾回收与 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 就不能回收。

java
// 说明:验证对象可达性:观察强引用对回收的影响。
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,并滚动文件。

java
// 说明:-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 增多。

java
// 说明: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/收集器输出字段会变化,诊断脚本要容忍版本差异。

java
// 说明: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 可能触发较重的操作,生产环境先评估权限、停顿和采样时机。

java
// 说明: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 是最大堆;设置相同可以减少扩缩容波动,但不代表应用一定需要这么大。

java
// 说明:设定堆边界:共同配置初始值和最大值。
public class HeapOptionsDemo {
    public static void main(String[] args) {
        System.out.println("java -Xms512m -Xmx512m App");
// 输出:java -Xms512m -Xmx512m App
    }
}

容器还要给线程栈、元空间、直接内存和本地库预留空间。

-XX:HeapDumpOnOutOfMemoryError:保留 OOM 证据 ​

堆转储记录对象和引用关系,可能很大且包含敏感数据;目录要有容量、权限和脱敏策略。

java
// 说明:-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 次数;没有基线就无法判断参数改动是否真的改善。

java
// 说明:基线:先记录指标再调参。
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。

java
// 说明:运行时需在 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 压力都可能使目标无法达到。要观察实际分位数,不要只看启动参数。

java
public class GcPauseTargetDemo {
    public static void main(String[] args) {
        System.out.println("-XX:MaxGCPauseMillis=200");
// 输出:-XX:MaxGCPauseMillis=200
    }
}

jcmd GC.heap_dump:主动生成堆转储 ​

堆转储可能触发较长停顿并占用磁盘;文件通常含业务数据,传输和保存必须受控。优先使用 OOM 自动转储或在低风险时间手动采集。

java
// 说明:把 <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、内存和实现版本边界;不同发行版支持细节不同。不要把“低停顿”误读为零停顿,也不要绕过基线直接换收集器。

java
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。定位泄漏应修复引用关系,测试中如需可控行为也应使用专门测试设计。

java
// 作用:通过 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
    }
}

简单案例 ​

java
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 次数多和内存泄漏的区别在于回收后存活量是否持续增长;不能只凭次数或一次停顿直接下结论。

课后小问 ​

  1. 发现 Young GC 频率高,第一步应该做什么? 答案:先看分配速率、每次回收释放量、停顿和业务负载,再判断是短命对象多还是堆过小。 解析:直接调大堆可能掩盖对象创建热点;日志和 JFR 能帮助区分分配压力与泄漏。

  2. 为什么只增加 -Xmx 可能让延迟变差? 答案:更大堆可能降低回收频率,却可能增加单次扫描、整理和容器内存压力。 解析:调优目标要同时看吞吐、停顿分位数、RSS、CPU 和下游响应,而不是只看堆上限。

  3. OOM 后为什么要保留堆转储路径? 答案:堆转储能记录对象数量和引用链,为区分泄漏、缓存增长和分配过快提供证据。 解析:文件可能很大且含敏感数据,要提前规划磁盘、权限、保留周期和脱敏流程。

本节小结 ​

  • GC 依据 GC Roots 可达性回收对象,分配、晋升、堆大小和缓存引用会形成不同症状。
  • JDK 统一 GC 日志、jstat、jcmd、堆转储和 JFR 是互补证据。
  • 先建立停顿、吞吐、分配速率和存活量基线,再一次调整一个变量。
  • 收集器和参数是工作负载相关的选择,不存在脱离指标的“万能调优值”。

快速回顾 ​

  • 能解释 GC Roots、年轻对象、晋升和 Full GC。
  • 会开启 -Xlog:gc* 并用 jstat/jcmd 做快速观测。
  • 能区分分配过快、泄漏、堆太小和大对象压力。
  • 能写出有基线、可比较、可回滚的调优思路。