上周帮同事排查一个线上服务,老年代使用率不到一半却频繁触发Full GC,GC日志里H区(Humongous区)总有大段大段的连续空间被占用,另一批老代码里用finalize写的资源释放逻辑也让一堆对象赖在老年代不走。这个案例几乎把G1下“对象进入老年代”的所有关键路径串起来了:正常年龄晋升、动态年龄判定、Survivor空间不足提前晋升、大对象直达老年代,以及finalize带来的隐形滞留。这篇就把这几条路径从原理到排查工具完整拆一遍,既适合准备JVM面试的人,也适合手头正在调G1的开发者。
1. 先从G1的分区模型说起:对象“变老”的完整路径
1.1 Region是什么,为什么G1要放弃物理连续分代
很多人在理解G1时最容易犯的错,是用串行GC、CMS那套“物理连续内存分代”的思维去看它。G1把整个堆划分成大小相等的Region,默认情况下每个Region从1MB到32MB不等,具体值由JVM根据堆大小自动决定,也可以通过-XX:G1HeapRegionSize显式指定,且必须是2的幂。
这套设计的核心目的有两个:一是让年轻代、老年代不再占据物理上的连续区域,而是由一堆Region动态“拼”出来;二是让GC可以只回收其中一部分Region,真正做到“可预测停顿”。比如年轻代在G1里就是一批Eden Region和Survivor Region的集合,这次GC回收哪些Region、哪些Region保留,完全由回收集合决定。
对象的一生在这个模型下变得更清晰了:新对象分配在Eden Region里,经历一次Young GC(G1里叫Evacuation Pause)后,存活对象被复制到Survivor Region,年龄加1;年龄达到阈值后,再被复制到老年代的某个Region。这里要特别注意,G1的“老年代”不是一个连续区域,而是逻辑上一个Region集合。
1.2 真正决定“什么时候老”的,不只有年龄
普通对象的晋升条件,教科书上常说是-XX:MaxTenuringThreshold,默认值是15,对象熬过15次Young GC才进入老年代。但G1实际运行中并不是死板地按这个值来,它还引入了-XX:InitialTenuringThreshold,默认7,作为动态晋升的起点。G1会根据Survivor区的实际占用情况,在这个上下界之间动态调整阈值。
动态年龄判定是另一个容易被忽略的晋升路径。每次Young GC后,JVM会统计Survivor里各年龄段的存活对象总大小,当某个年龄段的对象总大小超过Survivor空间的一定比例(-XX:TargetSurvivorRatio,默认50%)时,从该年龄开始的所有对象都会直接晋升到老年代。这样做的目的是防止Survivor区被打爆,属于一种“提前认老”的保护机制。
还有一条更直接的路径:Survivor区在复制过程中不够用了。Young GC把Eden存活对象往Survivor里复制时,如果to-space空间不足,多余对象只能直接进老年代,这就是常见的“晋升失败”(Promotion Failure)。这种场景下,对象可能才一两岁就被迫变老,在GC日志上表现为老年代使用率突兀上涨。
1.3 Mixed GC:G1处理老年代的节奏控制器
理解了晋升路径,还得理解G1回收老年代的节奏。G1不是等老年代满了才一次性Full GC,而是走一套周期性的流程:平时靠Young GC清理年轻代;当老年代占用达到-XX:InitiatingHeapOccupancyPercent(默认45%)时,进入并发标记周期,标记完成后进入Mixed GC阶段。
Mixed GC在执行后续若干次年轻代回收时,会顺带回收一部分“存活率低”的老年代Region,具体能不能被选进回收集合,受-XX:G1MixedGCLiveThresholdPercent(默认85%)控制,只有存活对象占比低于这个值的老年代Region才值得回收。整个回收过程分散到多次GC里完成,-XX:G1MixedGCCountTarget(默认8)就是用来控制分散次数的。
这套机制本来很优雅,但一旦混入大量大对象或finalize滞留对象,节奏就会被打乱,最终退化成Full GC(G1 Compaction Pause),STW时间动辄几秒。下面两章就讲这两个“节奏破坏者”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 大对象直达老年代:Humongous Region的边界、代价与对策
2.1 “大对象”的判定门槛是Region的一半
G1里对“大对象”的定义非常具体:对象总大小达到或超过Region大小的一半,就属于Humongous对象,会直接分配在老年代的Humongous Region(简称H区),完全不经过年轻代。
举个例子:如果Region大小是默认的1MB,那512KB以上的对象就直接进H区。这里的“对象大小”是整体大小,不是单纯的数据长度,数组要算上对象头和数组长度,一个byte[512 * 1024 - 1]的数组加上对象头,很可能就触发了Humongous分配。
如果是超过Region大小的对象,比如Region是1MB时分配一个3MB的数组,会占用连续多个Region。这就有个比较麻烦的后果:这些Region被当成一个整体,不能各自独立回收。更麻烦的是,Humongous对象不参与G1的复制式回收,没办法移动,分配时会占用连续的Region集合,时间一长,堆里就会出现“空闲Region很多,但凑不出一段连续区域”的碎片局面。
2.2 Humongous对象为什么容易拖垮Whole GC节奏
大对象对G1的影响不只是占空间那么简单。第一个问题出在分配竞争上:大对象不走线程本地分配缓冲(TLAB),直接进全局分配,高并发下大对象分配路径的竞争会比普通对象激烈得多。第二个问题出在回收上:Humongous Region主要靠并发标记后确认对象不可达才能回收,这类Region在年轻代回收里基本是“钉子户”,如果对象本身活得久,相关Region就一直被占着。
第三个问题最反直觉:大对象会干扰Mixed GC对老年代Region的选择。G1MixedGCLiveThresholdPercent针对的是“存活对象占Region的比例”,但一个Region被超大数组占了70%空间,即使数组已经不被引用了,在并发标记完成前它依然被算作“高存活率Region”,不会被选入Mixed GC。于是老年代使用率看起来不低,却迟迟回收不掉,最终触发Full GC。这也是我同事那个案例里老年代使用率不高却频繁FGC的原因之一。
2.3 实战对策:调Region、看日志、改业务
面对大对象问题,有几步可以依次排查。
第一步是确认到底哪些对象触发了Humongous分配。JDK 9以上可以开-Xlog:gc+humongous=debug,直接看到大对象的分配详情,配合JDK Flight Recorder里的“G1 Humongous Allocation”事件,能定位到大对象类型和分配栈。JDK 8以下用-XX:+PrintGCDetails也能在GC日志里看到H区的痕迹。
第二步是评估是否调整Region大小。把-XX:G1HeapRegionSize从1MB调到4MB甚至8MB,原先512KB到2MB的对象就不再命中Humongous路径。但这不是免费的:Region变大后,Young GC复制的粒度变大,单次停顿时间可能上升。我一般建议在压测环境先把Region调大一档,对比停顿目标再决定。
第三步是业务侧优化。大数组/大缓存能否分片、复用、压缩,能否用对象池规避频繁创建,这些比单纯调参更有效。如果大对象都是短生命周期对象,还要关注并发标记周期结束后H区是否被及时清理,必要时把InitiatingHeapOccupancyPercent调低一点,让标记周期提前触发。
提示:G1里Region大小必须是2的幂,且范围是1MB到32MB。调大Region对“恰好卡在阈值边缘的对象”收益最明显,对超大对象(比如几十MB)意义不大。
3. finalize:晋升链路上最容易被忽略的“隐形炸弹”
3.1 finalize的两阶段机制:为什么回收它至少要两次GC
finalize()是Java早期提供对象销毁前回调的机制,但它对GC的影响被大部分人低估了。当一个类重写了finalize(),JVM在创建对象时会额外生成一个java.lang.ref.Finalizer引用对象,这个Finalizer引用本身被一个全局链表(unfinalized链表)强引用着。
这意味着什么?业务代码里的对象不可达了,GC发现它“应该被回收”,但因为Finalizer引用还挂在链上,对象实际上处于一种特殊状态,既不是可达对象,也不能被立即回收。GC会把Finalizer引用放入一个队列,由专门的“Finalizer”守护线程从队列里取出引用,执行对象的finalize()方法,执行完并把Finalizer引用从链表移除后,对象才真正变为完全不可达,要等下一轮GC才能把内存释放掉。
所以一个重写了finalize的对象,正常情况下至少要经历两次GC才能真正释放内存。第一次GC把它从“不可达”变成“待执行finalize”,第二次GC才能回收对象本体。这个机制本身已经很耽误事了,更麻烦的是执行时机完全不可控。
3.2 为什么finalize对象会占着老年代迟迟不走
Finalizer线程虽然是独立的守护线程,但它的处理速度完全取决于finalize()方法本身有多快。如果finalize里有网络调用、锁等待、耗时IO,队列里的对象就会越积越多。这些对象从年轻代进入队列后,因为被Finalizer链挂着,生命周期被拉长,很容易在后续GC中晋升进老年代。
finalize还有一个更隐蔽的坑——对象复活。在finalize()里把this赋值给某个静态变量或全局容器,对象就会重新变成可达状态。JVM规定finalize方法对同一个对象最多执行一次,复活后的对象之后不会再被调用finalize,它会带着一堆本应释放的资源继续活着,而且老年代里这样的对象越多,GC越难清理。
这也是为什么现在绝大多数线上调优指南劝大家别用finalize:它的非确定性让“兜底释放”变成了“兜底泄漏”,尤其是配合老年代晋升路径时,会让老年代使用率长期维持在一个下不来的水位。
3.3 替代方案:从try-with-resources到Cleaner的设计取舍
新代码里肯定不该再写finalize了。JDK 9已经把它标记为Deprecated,JDK 18进一步标记为Deprecated for removal。替代方案按优先级排列:
第一选择是AutoCloseable配合try-with-resources。一切资源类(流、连接、Channel)都走显式关闭路径,这是最可控的。第二选择是JDK 9引入的java.lang.ref.Cleaner,它基于PhantomReference实现,在对象不可达后由专用线程执行清理动作,比finalize更可控:
java复制public class NativeBuffer implements AutoCloseable {
private final Cleaner.Cleanable cleanable;
public NativeBuffer() {
cleanable = Cleaner.create().register(this, () -> releaseNativeMemory());
}
@Override
public void close() {
cleanable.clean();
}
private void releaseNativeMemory() {
// 释放堆外内存或系统资源
}
}
注意Cleaner的定位是“资源生命周期的最后防线”,不是主路径。设计上仍然要以显式close为主,Cleaner兜底为辅。如果是老代码里已有的finalize,短期内不好重构的话,至少保证finalize方法极其轻量,不碰远程调用、不加锁、不做任何可能阻塞Finalizer线程的事。
4. 从GC日志和参数坐标验证晋升链路是否健康
4.1 关键参数一览:该调的、别乱动的
晋升链路相关的参数不难记,但容易混。列出我在排查时最常用的一组:
| 参数 | 默认值 | 作用 |
|---|---|---|
-XX:G1HeapRegionSize |
随堆大小自动1MB~32MB | Region大小,决定Humongous判定阈值 |
-XX:InitialTenuringThreshold |
7 | G1初始晋升年龄阈值 |
-XX:MaxTenuringThreshold |
15 | 晋升年龄上限 |
-XX:TargetSurvivorRatio |
50 | 触发动态年龄判定的Survivor占用比例 |
-XX:SurvivorRatio |
8 | Eden与Survivor的大小比例 |
-XX:InitiatingHeapOccupancyPercent |
45 | 老年代占用达到该比例时启动并发标记 |
-XX:G1MixedGCLiveThresholdPercent |
85 | 存活率低于该值的老年代Region才参与Mixed GC |
-XX:G1MixedGCCountTarget |
8 | Mixed GC分散执行的次数 |
这里特别提醒一下:JDK 9开始-XX:+PrintGCDetails已经废弃,改用统一日志系统-Xlog。我常用的启动参数长这样:
code复制-Xms4g -Xmx4g -XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=4m
-XX:MaxTenuringThreshold=8
-XX:InitiatingHeapOccupancyPercent=40
-Xlog:gc*:file=gc.log:time,uptime,level,tags
-Xlog:gc+humongous=debug
4.2 GC日志里的关键信号与解读思路
拿到GC日志后,按这个顺序看:
第一,看Young GC中是否有Promotion Failure或明显的“to-space exhausted”信号。出现这类字眼说明Survivor设计偏小,对象在被迫提前晋升。第二,看老年代Region的回收效率。Mixed GC阶段多次执行后老年代如果一直降不下来,优先怀疑Humongous和finalize滞留对象。第三,看Full GC触发前的IHOP状态。老年代占用还没到阈值就FGC,通常不是参数问题,而是堆里有大量不可回收的H区对象。
这里举个实际日志片段感受一下:
code复制[2024-xx-xxT10:15:23.456+0800] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
[2024-xx-xxT10:15:23.456+0800] GC(42) Humongous regions: 12
[2024-xx-xxT10:15:23.456+0800] GC(42) Metaspace: 120.0M used
Humongous regions出现在Young GC阶段本身就值得警惕:说明Eden区里有大对象在持续产生,而且它们已经占用了12个Region。这种日志配合gc+humongous=debug,基本能把大对象的路数摸清楚。
4.3 三个常见调优场景的实操建议
场景一:大对象密集导致H区膨胀。操作路径是-Xlog:gc+humongous=debug确认对象类型,-XX:G1HeapRegionSize调大一档,业务侧做分片或对象池。别一上来把IHOP调很低,那只会让并发标记更频繁,大对象本身回收效率低,标记了也白搭。
场景二:Survivor频繁被打爆,晋升量异常高。优先考虑-XX:+UseG1GC下年轻代动态调整策略,可以配合-XX:G1NewSizePercent(默认5)和-XX:G1MaxNewSizePercent(默认60)适当扩大年轻代下限和上限,同时把-XX:TargetSurvivorRatio调低一点,让动态年龄判定提前触发,避免极端晋升。
场景三:老年代使用率居高不下,Mixed GC收效甚微。先用jcmd或JFR确认有没有大量Humongous Region存活,再用jcmd GC.heap_info观察H区分布。如果H区还有余量,配合-XX:InitiatingHeapOccupancyPercent调低到35~40,让并发标记和Mixed GC更早介入。如果发现Finalizer线程堆积(jcmd Thread.print里能看到Finalizer线程栈),优先排查重写finalize的老代码。
5. 高频追问:面试官真正想听到的G1、老年代与finalize回答
5.1 问题一:G1是如何处理老年代的,和CMS本质区别在哪
面试里这题的正确打开方式,不是上来背“分区、可预测停顿”,而是先说G1的回收节奏:老年代不会等满了一次性处理,而是通过IHOP触发并发标记,标记完用多次Mixed GC分散回收。提到CMS的CMS GC和Full GC分离设计,对比G1在碎片控制上的优势,同时点出G1退化成Full GC的代价。能提到G1MixedGCLiveThresholdPercent、G1MixedGCCountTarget这些参数,说明你是真的在项目里调过。
5.2 问题二:对象进入老年代有哪几种方式
这类题建议按“正常路径”和“异常路径”分类回答。正常路径是年龄晋升和动态年龄判定;异常路径包括:Survivor空间不足被迫晋升、大对象直达Humongous Region、以及finalize导致对象生命周期拉长后被动晋升。把G1的Humongous路径单独拎出来讲,能明显拉开和其他候选人的差距,因为很多人背了“大对象直接进老年代”这句话,却说不出Region大小一半这个具体判定标准。
5.3 问题三:finalize为什么被弃用,替代方案是什么
回答的核心是“两次GC、执行时机不可控、可能复活”。先说机制:Finalizer引用链导致对象至少延迟一轮GC,Finalizer线程执行效率不确定,finalize里可以复活对象且只会执行一次。再说替代方案:try-with-resources作为第一选择,Cleaner作为GC兜底,设计上永远不依赖“兜底”来保证资源释放。能补一句“JDK 9开始Deprecated,JDK 18标记Deprecated for removal”会让回答更完整。
我个人排查这类问题养成的一个习惯是:线上出问题先拉GC日志和JFR,先看H区和Promotion相关信号,别一头扎进参数调优里。很多时候对象进老年代路径异常,根源都在业务代码——要么大数组频繁创建,要么老代码里藏着finalize,参数只是帮我们验证了问题出在哪一层。把这几条晋升链路在脑子里固化成一张地图,无论是面试还是真调优,都能少走一大段弯路。
