跳到正文

JVM 内存模型与 GC ​

一句话:JVM 用分代假设把对象按存活时间分开,用可达性分析找垃圾,用不同收集器在吞吐、停顿和内存之间取舍;代价是停顿不可能完全消失,只能被压缩和分摊。

适用 JDK 17 / JDK 21。JDK 8 的默认收集器和日志参数不同,文中单独标注。

为什么需要 ​

手动 free 的三个经典问题是忘记释放、重复释放和释放后继续使用。JVM 选择自动回收,但自动回收必须回答两个问题:哪些对象还活着,回收时应用能停多久。

第一个问题用可达性分析解决:从 GC Roots 出发,沿引用链能到达的对象存活,到不了的是垃圾。第二个问题决定了收集器的形态:Serial 全停但实现简单,CMS 并发但产生碎片,G1 按 Region 回收并设停顿目标,ZGC 把标记和移动都并发化。

核心机制 ​

内存区域 ​

区域放什么线程会不会 OOM是否参与 GC
堆对象实例、数组共享会,Java heap space主战场
元空间类元数据、方法信息、运行时常量池共享会,Metaspace类卸载时回收
虚拟机栈栈帧、局部变量、操作数栈私有会,StackOverflowError 或栈 OOM随栈帧出栈
程序计数器当前字节码位置私有不会不参与

JDK 8 起永久代被元空间取代,元空间在本地内存而不是堆内。-XX:MaxMetaspaceSize 默认不限制(JDK 8+),生产必须设上限,否则类加载泄漏会吃光容器内存。

分代与对象晋升 ​

堆通常分为 Eden、两个 Survivor 和老年代,默认比例 Eden:S0:S1=8:1:1(-XX:SurvivorRatio=8,JDK 8+)。新对象在 Eden 分配;Young GC 后存活对象复制到 Survivor,年龄加一;达到 -XX:MaxTenuringThreshold=15(JDK 8+)后晋升老年代。

年龄上限是 15 不是 16,因为对象头里的分代年龄只占 4 bit,最大值就是 15。G1 不要求对象必须在 Survivor 间来回复制,但仍保留年龄语义。

对象提前进入老年代还有几条路径:Survivor 放不下、动态年龄判定(同龄对象总大小超过 Survivor 一半)、大对象分配。-XX:PretenureSizeThreshold 只对部分收集器有效,不要把它当成通用开关。

收集器怎么选 ​

收集器默认场景特点主要代价
Serial小堆、单核、客户端单线程复制/标记整理,实现简单全停
ParallelJDK 8 服务端默认多线程吞吐优先停顿随堆增长
G1JDK 9+ 服务端默认Region 化,按 MaxGCPauseMillis 设目标有 Remembered Set 和并发标记开销
ZGC低延迟、大堆并发标记/移动,停顿通常亚毫秒级吞吐和内存开销更高
Shenandoah低延迟(部分发行版)并发压缩发行版和版本支持差异大

-XX:MaxGCPauseMillis=200 是 G1 的默认停顿目标(JDK 9+),它是软目标,不是保证值。G1 的 -XX:InitiatingHeapOccupancyPercent=45 控制并发标记启动阈值,调低会更早标记、增加 CPU 开销。

CMS 在 JDK 9 废弃,JDK 14 移除(JEP 363)。ZGC 在 JDK 15 转正(JEP 377);JDK 21 的分代 ZGC 需要 -XX:+ZGenerational,JDK 23 起分代成为默认模式。

实战场景 ​

一、堆监控一切正常,Pod 却被 OOMKilled ​

现象:网关服务上线动态路由后,RSS 每天涨约 300MB,运行六天后被容器 OOM 杀掉;-Xmx 4G 的堆监控长期只用 1.5G,GC 正常。JVM 里没有 OutOfMemoryError 日志,只有 kubectl describe pod 的 Reason: OOMKilled。

排查:元空间与线程栈、直接内存都在堆外,不受 -Xmx 管。

定位层取证据判定
堆 vs 进程jcmd VM.native_memory summary(需 -XX:NativeMemoryTracking)与 RSS 对比堆外占大头即与堆无关
元空间jstat -gcmetacapacity、-XX:MaxMetaspaceSize 是否设置JDK 8+ 默认不限制,泄漏会吃光容器内存
类加载数量jcmd GC.class_stats 或 ClassLoader 数量、已加载类数趋势持续增长即代理类/脚本编译泄漏
代理类来源反复生成的 CGLIB/Poetry 类名($$EnhancerBySpringCGLIB$$、Groovy/SpEL 编译产物)每次调用生成新类即泄漏点
线程栈线程数 × -Xss(默认 1MB 量级)线程泄漏同样吃堆外
直接内存-XX:MaxDirectMemorySize、Netty/NIO 使用量默认等于最大堆,常与容器 limit 冲突

本次命中的是动态路由每变更一次就用 Groovy 编译一个新 Class,旧的因类加载器未被回收而无法卸载。

对症做法:

bash
# 1. 堆外三项全部显式设置,且总和要低于容器 limit 并留余量
-XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:MaxDirectMemorySize=768m
# 容器 4GiB:堆 2G + 元空间 512M + 直接内存 768M + 线程栈/CodeCache/余量
-Xss512k                      # 线程数多时栈大小也要收

# 2. 打开 NMT 用于事后核对(有少量开销,可只在预发常驻)
-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary.diff
java
// 3. 编译型脚本/表达式要缓存,不能每次请求生成类
private static final Map<String, Script> COMPILED = new ConcurrentHashMap<>();
static Script compile(String src) {
    return COMPILED.computeIfAbsent(src, key -> engine.compile(key));   // 上限要有,避免无界
}

// 4. 类加载器泄漏的判据:卸载不发生。动态类加载要配独立 ClassLoader,
//    并把无引用时的卸载纳入验证(-verbose:class / jcmd GC.class_stats)
text
5. 监控项要包含“已加载类数量”和 RSS,而不只有堆使用率:
   类数量单调增长就是泄漏,与堆是否满无关。

复盘要点:判据是“RSS 与(堆 + 元空间 + 直接内存 + 线程数×Xss)之和可解释”,差额能被 NMT 说明;以及“已加载类数量在配置变更后回到基线”。JDK 8 起永久代被元空间取代且默认不限制,这条默认值就是这类事故的入口,生产必须显式设上限。

二、G1 目标停顿 200ms,实际 P99 两秒 ​

现象:交易服务堆 8G、-XX:MaxGCPauseMillis=200(G1 默认目标),Young GC 平时 40ms。大促当天 Young GC 频率从 30 秒一次变成 2 秒一次,单次耗时升到 300ms,并出现两次 1.5 秒的全停顿,接口 P99 从 120ms 涨到 2s。

排查:停顿目标是软目标,不是保证值;分配速率和晋升速率才是输入。

定位层取证据判定
Young 区大小GC 日志里 Pause Young ... (concurrent) ... X->Yms 与 region 数G1 会随目标自动缩小 Young 区,导致频率升高
分配速率jstat -gcutil/GC 日志的 Eden 填充速度大促分配速率可达平时 5~10 倍
晋升与并发标记-XX:InitiatingHeapOccupancyPercent=45 是否触发过晚老年代涨得快 → 并发标记来不及
to-space 耗尽日志出现 to-space exhausted / Evacuation Failure必然伴随长停顿
Mixed GC 是否跟上Mixed 回收次数与老年代占用曲线只 Young 不 Mixed 即堆积
大对象Humongous 分配(超过 region 一半)直接进老年代并加剧碎片
Full GCPause Full(单线程/多线程取决于版本)出现即说明并发阶段没兜住

对症做法:

bash
# 1. 给 G1 更多回旋空间:抬高最大停顿目标、允许更大的 Young 区
-XX:MaxGCPauseMillis=100               # 目标越小,Young 区被压得越小、频率越高
-XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=50
-XX:InitiatingHeapOccupancyPercent=35   # 更早启动并发标记,避免老年代填满
-XX:+ExplicitGCInvokesConcurrent        # System.gc() 走并发而非全停

# 2. 高分配速率场景(大对象/临时对象多)先改代码:
#    复用缓冲区、避免每请求构建大 Map、批处理里减少中间对象
# 3. 延迟敏感且堆大(>8G)时评估 ZGC:并发标记/移动,停顿通常亚毫秒级,
#    代价是吞吐与内存开销更高,需要压测确认
-Xms8g -Xmx8g -XX:+UseZGC -XX:+ZGenerational
text
4. 大对象治理:
   导出/报表类的大字节数组走分块流式,不要一次 new byte[64MB];
   缓存条目数与大小都要有上限(见下一场景)。
5. 容量结论要按“分配速率”而不是“对象数量”给:
   压测时记录 youngGC 频率、晋升速率、Mixed 回收速率三个数字,
   三者关系失衡就是停顿变长的前兆。

复盘要点:判据是“Young GC 频率与单次耗时的乘积(GC 时间占比)低于预算(例如 3%,经验值需校准)”,以及“不出现 to-space exhausted”。调 GC 参数属于第二顺位;第一顺位永远是减少分配。MaxGCPauseMillis 被设得越小反而越糟,这点最容易记反。

三、无上限的本地缓存,每小时一次 Full GC ​

现象:为了减少 RPC 调用加了本地缓存(Map/Caffeine 未配 maximumSize)。两周后老年代占用呈锯齿:缓慢爬升到一个高点后触发长时间 GC,每小时一次,抖动幅度 400ms~1.2s,接口 P99 与 GC 时间点完全对齐。

排查:缓存对象的存活时间被人为延长到跨越 Young GC,直接晋升。

定位层取证据判定
老年代增长形态jstat -gcold 或堆使用曲线的锯齿周期周期性回落到 GC 时间点即命中
支配者jmap -histo:live 或堆转储的 Dominator Tree缓存 Map 占据最大保留大小
淘汰策略是否配 maximumSize/expireAfterWrite未配即无界增长
值对象大小平均条目字节数 × 条目数与老年代增量吻合即确认
引用泄漏监听器/ThreadLocal/静态集合是否持有另一类不释放来源
System.gc()是否有显式调用(NIO 直接内存会触发)也会造成全停

对症做法:

java
// 1. 容量与过期都显式设置,按权重而不是条目数限制(值大小差异大时更准)
Cache<String, Quote> cache = Caffeine.newBuilder()
        .maximumWeight(256L * 1024 * 1024)                 // 256MB 预算,需按堆容量校准
        .weigher((String k, Quote v) -> v.approxBytes())
        .expireAfterWrite(Duration.ofSeconds(30))
        .recordStats()
        .build();

// 2. 关键路径的“缓存命中/未命中 + 大小”要能观测,异常增长能被立刻发现
Gauge.builder("cache.size", cache, c -> c.estimatedDataSize()).register(registry);
Gauge.builder("cache.weight", cache, c -> c.estimatedWeight()).register(registry);
bash
# 3. 内存紧张时的驱逐与 dump 兜底
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump
# 容器场景确认 dump 路径有盘可写且容量足够,否则 OOM 时只留下截断文件

# 4. 验证是否存在“缓存外”的泄漏:
jcmd <pid> GC.heap_info
jmap -histo:live <pid> | head -20        # 注意 :live 会触发一次 Full GC,生产慎用
text
5. 缓存必须有“上限 + 过期”两条腿:
   只设 TTL 不设上限,突发写入仍会把老年代填满;
   只设上限不设 TTL,脏数据风险与内存驻留同时存在。
6. 需要跨实例一致的共享状态放 Redis,本地缓存只承担热点短期加速,
   容量预算要显式写进设计,而不是“看着加”。

复盘要点:判据是“老年代占用曲线的斜率可归因”:每个增长点能对应到一个有预算的组件;无归属的增长要当成缺陷。本地缓存的容量数字要与堆大小一起进容量表(例如缓存 ≤ 堆的 20%,经验值需校准)。这与 内存管理 是同一个道理:没有上限的内存占用迟早会以 GC 或 OOM 的形式还回来。

最小上手 ​

写一个持续分配对象的程序,用固定小堆逼出多次 Young GC:

java
public class Alloc {
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 2000; i++) {
            byte[] block = new byte[1 << 20]; // 1 MiB
            if (i % 100 == 0) Thread.sleep(20);
        }
        System.out.println("done");
    }
}
bash
javac Alloc.java
java -Xms64m -Xmx64m -Xlog:gc*:file=gc.log:time,uptime,level,tags Alloc
jcmd <pid> GC.heap_info        # 程序运行期间另开终端执行
jstat -gcutil <pid> 1000 5     # 每秒一次,共 5 次

GC 日志里会出现类似这样一行(JDK 17 G1):

text
[1.234s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 58M->12M(64M) 4.567ms

判读顺序:GC(3) 是第几次;Pause Young 是回收类型;58M->12M(64M) 是回收前、回收后和堆容量;4.567ms 是这次停顿。如果 Young GC 很频繁但每次回收后占用仍高,先看存活对象和分配速率;如果出现 Pause Full 且持续变长,再查老年代占用、元空间和内存泄漏。

生产动作 ​

  • 堆固定:-Xms 与 -Xmx 设相同,避免运行期扩缩堆;容器里用 -XX:MaxRAMPercentage=70.0 时仍要预留元空间、线程栈、直接内存和 OS 开销。
  • GC 日志:JDK 9+ 用 -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50m。旧的 -Xloggc、-XX:+PrintGCDetails 是 JDK 8 写法,不要混用。
  • OOM 现场:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump,事后用 MAT 看 Dominator Tree 和 GC Roots 路径;jcmd <pid> GC.heap_dump /data/dump.hprof 可手动导出,但会带来停顿。
  • 排障三段:现象是 Full GC 频繁或停顿超阈值;命令用 jstat -gcutil、jcmd GC.heap_info、GC 日志;判断看回收后老年代是否持续上涨、元空间是否只增不减、单次停顿是否随堆线性增长。
  • CPU 高:top -Hp <pid> 找线程,printf '%x\n' <tid> 转十六进制,再 jcmd <pid> Thread.print 定位栈;先排除 GC 线程和业务死循环,再谈调参。
  • 不要预防性调参:默认值通常适合大多数服务。先确认问题在 GC,再改一个参数并对比同一流量下的 GC 日志。

边界 ​

  • GC 不是越新越好:ZGC 的低停顿用吞吐和内存换来;CPU 已经打满的服务换 ZGC 可能更慢。G1 的停顿目标是软的,不能拿来承诺 SLA。
  • 堆不是越大越好:堆越大,单次 Full GC 和 dump 越慢,缓存命中带来的收益也可能被 GC 抵消。
  • 元空间不是无限安全:动态生成类、热部署、类加载器泄漏都会让 Metaspace 持续上涨;MaxMetaspaceSize 是兜底,不是根因修复。
  • System.gc() 不一定真执行:默认可能被忽略,-XX:+DisableExplicitGC 可显式禁用;不要依赖它做业务清理。
  • GC 日志里的时间不是业务延迟:它只说明停顿,端到端延迟还要看排队、锁、下游和线程池。

讲清楚 ​

JVM 把对象按存活时间分代,新对象在 Eden 分配,熬过多次 Young GC 才进老年代;年龄上限 15 来自对象头 4 bit 的物理限制。判断对象死活靠从 GC Roots 出发的可达性分析,不靠引用计数,所以循环引用不会阻止回收。

收集器的选择是在吞吐、停顿和内存之间取舍:Parallel 追求吞吐,G1 用 Region 和停顿目标折中,ZGC 把标记和移动并发化来压低停顿,但吞吐和内存开销更高。生产上先固定堆、打开 GC 日志,用 jstat 和 jcmd 看回收后占用和停顿,再决定是否调参;不要用 GC 日志之外的单点数字下结论。

把一门技术讲透到既懂也会用