主题
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 | 小堆、单核、客户端 | 单线程复制/标记整理,实现简单 | 全停 |
| Parallel | JDK 8 服务端默认 | 多线程吞吐优先 | 停顿随堆增长 |
| G1 | JDK 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.diffjava
// 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 GC | Pause 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:+ZGenerationaltext
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 日志之外的单点数字下结论。