单次gc释放内存低于预期,关键在分析对象存活率、晋升行为和空间结构异常:eden未清空可能因对象短命性失真或survivor溢出被迫晋升;heap降幅远小于eden降幅说明大量对象进入老年代;full gc后老年代不降反升是内存泄漏信号。

单次 GC 释放内存低于预期,不是看“释放了多少”,而是看“为什么没释放”——关键在对象存活率、晋升行为和空间结构是否异常。真正的问题往往藏在数字变化的逻辑断层里。
先确认“预期”是否合理
很多人默认“一次 Minor GC 应该清空 Eden 区”,但这是错觉。Eden 清零只是理想情况,实际取决于:
- 对象是否真的短命:若大量对象在第一次 GC 时就已满足晋升年龄(如 -XX:MaxTenuringThreshold=1),它们会直接进入老年代,Eden 回收后剩余仍可能很高
- Survivor 空间是否被撑满:日志中出现 Survivor: 128M->128M,说明 Survivor 已无余量容纳存活对象,被迫晋升,导致 Eden 回收后“看似清了,实则全挪走了”
- 分配速率是否远超回收能力:比如 Eden 4GB,每 200ms 填满,而 GC 耗时 10ms,但新对象还在持续涌进——GC 完那一瞬间,Eden 可能又占了 500MB,日志显示“4000M→3500M”,不是没清,是边清边填
重点盯三类低释放信号
以下情形中,即使 real 时间很短、GC 成功返回,也代表释放效率严重打折:
- Eden 降幅极小:如 Eden: 2048M→1980M(仅释放 68M),说明绝大多数对象都“活下来了”。这时要立刻查 -XX:+PrintTenuringDistribution 日志,看 age 1 存活对象是否占比超 70%——大概率是业务代码在循环中反复 new 同类对象且被强引用持有
- Heap 总体降幅远小于 Eden 降幅:例如 Eden: 2048M→0B,但 Heap: 3200M→3150M(只减 50M)。差额 1998M 去哪了?基本都进了老年代。说明年轻代对象“假死亡”,GC 没解决根本问题
- Full GC 后老年代不降反升:如 G1OldGen: 3800M→3850M 或 ParOldGen: 4200K→4210K。这不是 GC 失败,而是泄漏信号——对象被静态容器、未关闭的连接、线程局部变量等长期持有,GC 扫不到
结合 jstat 快速交叉验证
光看日志容易误判,用 jstat -gc
- 如果 EC(Eden 当前使用) 长期 >95%,且 EU(Eden 已用) 在 GC 后回落幅度逐次收窄(如上次回落 1800M,这次只回落 1200M),说明 Eden “越收越难清”
- 观察 OC(老年代容量) 不变前提下,OU(老年代已用) 持续爬升,哪怕每次只+10MB,连续 10 分钟,就是典型缓慢泄漏
- 对比 YGCT(Young GC 总耗时) 和 FGCT(Full GC 总耗时):若 YGCT 占比突然从 20% 升到 65%,说明年轻代压力已传导至全局,GC 策略可能失衡
别忽略元数据与大对象干扰
有些“释放不足”根本不在堆里:
- Metaspace 稳定 ≠ 安全:日志显示 Metaspace: 20599K→20599K 很安心?错。自定义 ClassLoader 加载的类实例(如 com.example.CacheHolder)占的是堆内存,不是 Metaspace。ClassLoader 实例本身不释放,它加载的所有对象就永远无法回收
- Humongous Object 占坑不释放:G1 日志中若频繁出现 Humongous Allocation 或 to-space exhausted,说明超大数组/ByteBuffer 占用了整块 Region,且长期存活。它们不走常规晋升路径,也不参与 Survivor 年龄计算,却实实在在吃掉堆空间










