资讯动态

finalize如何拖垮G1垃圾回收?一次内存排查与原理深挖

发布时间:2026/9/24 20:39:01 来源:尧图企业网站定制
1. 写在前面一次老年代持续增长的线上排查几个月前我们一个核心服务遇到一个怪问题老年代内存曲线稳步爬升每次触顶触发Full GC后只回落一点点然后继续涨。G1垃圾回收器的年轻代回收日志看上去也正常没有大量Humongous分配也没有明显的锁竞争。当时团队里一个小伙子查了两天最后在堆栈里偶然发现了一批对象它们的类重写了finalize()方法而且逻辑里还带着I/O操作。Java里的finalize长期以来是个著名的坑。但我发现很多资料把它讲成了“用了会内存泄漏”的玄学没有说清楚它到底怎么影响垃圾回收器、影响在哪个阶段、为什么在G1这种现代垃圾回收器上影响被放大了。借这个机会我把当时排查的思路、原理层面的推导、以及验证结果完整梳理一遍。这篇东西适合正在排查内存问题的人也适合想真正搞懂JVM垃圾回收机制的人。不管你是背过八股文还是写过几年代码我希望你看完之后能对finalize和垃圾回收器的交互关系建立起一幅完整的图景。2. 先拆开看finalize到底在垃圾回收流程里做了什么2.1 JVM给“临终对象”的特殊通道要理解finalize的影响首先要清楚JVM为它单独设计了一套机制。在HotSpot虚拟机里一个重写了finalize()的普通对象被判定为垃圾后不会像普通对象那样直接被回收。JVM会先判断这个类是否重写了finalize()如果重写了这个对象会被打上一个特殊标记然后放进一个叫F-Queue的队列。F-Queue并不是一个普通的任务队列。JVM里有一个专门的Finalizer线程它负责从这个队列里取出对象执行它们的finalize()方法。这里最关键的地方在于线程在执行finalize()期间这个对象虽然已经被判定为“逻辑上的垃圾”但它仍然留在堆内存里没有被回收而且在线程执行完finalize()之前这个对象甚至有机会“复活”——也就是在finalize()里把自己的引用交给一个静态变量或者别的地方让GC不再认为它是垃圾。这个过程用一张图来形容就是JVM给每个待回收的对象建了个“停尸房”。普通的垃圾对象直接火化而重写了finalize的对象先进停尸房躺好等Finalizer线程来“做最后的告别”。这一等回收的时间点就完全不可控了。2.2 注意finalize的执行时机根本不受你控制很多开发者以为finally{}和finalize()差不多或者以为对象变成垃圾之后会立即执行finalize()。这是一个非常危险的误解。finalize()的执行时机取决于三个因素对象什么时候被判定为不可达、Finalizer线程什么时候捞到这个对象、以及finalize()方法本身执行多久。前两个因素通常不由业务代码决定而是由垃圾回收器的运行规律决定的。比如G1分代的垃圾回收策略里年轻代回收频率高、间隔短但老年代回收就慢得多如果一个对象已经进入老年代才变成垃圾那它进F-Queue的时间点就完全要看老年代什么时候触发回收。更麻烦的是当F-Queue里的对象越来越多Finalizer线程处理不过来堆积效应就出现了。这些“等待执行finalize”的对象既不能被回收又占着堆内存逐渐形成一种事实上的泄漏。这就是很多人所说的“finalize导致内存泄漏”的本质——不是垃圾回收器过错了而是finalize的执行模型本身就给了对象一个“延迟死亡”的缓冲期。3. 深入GC内部finalize对垃圾回收器各环节的影响3.1 对可达性分析的多一次遍历现代服务端JVM基本都用G1垃圾回收器所以这里重点看G1。G1的基础回收单位是Region回收过程依赖可达性分析——从GC Roots出发沿着引用链把存活对象标记出来没被标记到的就是垃圾。当一个对象重写了finalize且不可达时G1在标记阶段会额外判断这个对象是否已经执行过finalize()如果没有且对象被判定为不可达G1不会立刻把它标成垃圾而是会把它放到一个java.lang.ref.Finalizer引用对象后面这个过程涉及特定的“F-Reference”处理逻辑。从标记流程上讲这意味着垃圾回收器对这类对象要额外做一次“是否进入过F-Queue”的状态判断。这个判断在对象数量少的时候没感觉但一旦数量上去了就会直接影响标记阶段的耗时。我见过一个极端案例同事为了做一个统一资源释放的基类把所有资源的释放逻辑都放进了finalize()结果F-Queue里积压了上万个对象G1在一次并发标记阶段明显变慢——不是标记算法本身慢而是处理这些F-Reference时频繁地访问引用表并且这些对象还要多保留一个周期才能被回收。3.2 存活对象判定对分代回收的干扰G1是一个分代垃圾回收器它维护了年轻代和老年代的Region划分。正常情况下一个对象被创建后先分配在年轻代Eden区经历几次年轻代回收后会晋升到老年代。这个过程基于对象的年龄经历过Minor GC的次数。有了finalize之后这个晋升逻辑会被打乱。因为一个对象进入F-Queue后即使它逻辑上已经死亡但在垃圾回收器的视角里它仍有“存活”的可能如果finalize()中把它复活了。所以这个对象至少要再经历一次“复活判定”过程也就是从F-Queue里出来之后垃圾回收器还要再次标记检查它是否真的不可达了。这里就出现一个微妙的现象一个本来早该被回收的年轻代对象因为进入F-Queue相当于被迫多活了一个周期。如果它的finalize()迟迟没执行完对象年龄会增长甚至可能被晋升到老年代——然后才在老年代被回收掉。本来一个年轻代对象就能解决的回收问题最后变成了老年代的大扫除代价完全不同。3.3 对G1的SATBSnapshot-at-the-Beginning的影响G1的并发标记阶段使用SATB算法在并发标记开始时为所有引用关系打一个快照并发标记期间变化的对象会被记录保证标记的完整性。SATB的核心代价在于写屏障和记忆集。finalize和SATB怎么扯上关系关键在于F-Queue里的对象被Finalizer线程取出来时执行finalize()的过程是用户线程运行的这个执行过程中一旦有对象引用发生变化比如复活动作给某个存活对象赋了一个本来要死的引用就触发写屏障记录到SATB队列里。G1会认为这是并发标记期间改变的引用从而对相关对象保持存活状态甚至重新标记。所以一个简单的finalize()里如果给静态变量赋值哪怕只是把一个对象的引用存起来在G1的并发标记周期结束之前这个引用链上的所有对象都会被当成存活。G1要等下一个标记周期才能重新判断这个对象是不是真的没用。这个时间窗口少则是几百毫秒多则是一个完整的并发标记周期。换句话说finalize里的任何“多余动作”都会直接污染G1的SATB快照迫使垃圾回收器保守地保留更多对象。3.4 反思为什么说java.lang.ref.Cleaner是更好的替代既然finalize问题这么多Java 9开始官方就把它标记为Deprecated推荐使用java.lang.ref.Cleaner。Cleaner的设计思路完全不同它基于PhantomReference也就是幻象引用结合一个清洁线程来执行清理逻辑。关键区别在于执行模型PhantomReference在垃圾回收器判定对象不可达之后会被放入引用队列而Cleaner所引用的Cleanable对象会在幻象引用入队时自动触发清理动作。这个过程中对象不会“复活”也不会延迟到老年代更不会干扰SATB的引用快照。我后来给那个团队的建议就是把finalize改成Cleaner改造后老年代曲线立刻平稳了。具体怎么改后面实操环节会详细说。4. 实操复盘一次基于G1的finalize影响压测4.1 复现实验的JVM参数设计为了把finalize对垃圾回收器的影响量化出来我本地专门搭了一个复现环境。JDK版本用的是11垃圾回收器指定为G1。以下是我当时用的核心启动参数java -Xms2g -Xmx2g -XX:UseG1GC -XX:MaxGCPauseMillis200 -Xlog:gc*:filegc.log:time,uptime,level,tags:filecount5,filesize50m -jar finalize-demo.jar这里重点解释几个关键参数-XX:UseG1GC显式指定G1垃圾回收器。-XX:MaxGCPauseMillis200G1会尽量控制垃圾回收导致的停顿在200毫秒以内。这个参数直接约束了G1的回收节奏。-Xlog:gc*打印详细的GC日志包含标记、清理、停顿等各阶段耗时。排查垃圾回收问题GC日志是绝对的第一手资料。压测模型很简单每秒钟创建10万个重写了finalize()的临时对象这些对象内部保存一个1KB的字节数组模拟实际业务中对象携带数据的情况。finalize()方法里做了一个空循环耗时约0.5毫秒模拟真实业务场景里不能马上执行完的情况。4.2 观察一年轻代回收的老年代晋升率压测运行了30分钟我截取了两段GC日志做对比。第一段是运行前5分钟此时F-Queue的空闲线程还能勉强处理积压对象第二段是20分钟后Finalizer线程已经明显跟不上了。核心日志片段长这样[10.235s][info][gc,young] GC(7) Pause Young (Normal) (G1 Evacuation Pause) 1024M-850M(2048M) 150.231ms [20.478s][info][gc,young] GC(23) Pause Young (Normal) (G1 Evacuation Pause) 1024M-976M(2048M) 182.059ms注意看1024M-850M和1024M-976M的对比。前者的回收效果明显更好回收了174M后者只回收了48M大量本应死掉的对象因为F-Queue积压被判定为“仍存活”只能被晋升或者继续留在原Region。20分钟后年轻代回收基本回收不动了因为Eden区达到阈值后Survivor放不下的对象直接晋升老年代。这组数据直观地说明finalize堆积会直接削弱垃圾回收器的可达性分析效果让G1“误以为”很多对象还活着。4.3 观察二Finalizer线程CPU占用与F-Queue堆积再看线程状态。我在压测过程中用jstack抓了两次线程栈第一次10分钟时Finalizer线程状态是WAITING说明它在等待新的清理任务空闲比很高。第二次25分钟时Finalizer线程状态变成了RUNNABLE而且卡在自定义的finalize()方法里Finalizer daemon prio8 tid0x00007f6bc00ae000 nid0x4f50 runnable [0x00007f6bb55f7000] java.lang.Thread.State: RUNNABLE at demo.FinalizeDemo$DemoObject.finalize(FinalizeDemo.java:25) at java.lang.ref.Finalizer.invokeFinalizeMethod(Native Method) at java.lang.ref.Finalizer.runFinalizer(Finalizer.java:216) at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:233)单看线程栈还不足以说明问题。我进一步通过jstat -gcutil观察F-Queue的长度影响发现老年代使用率从压测开始时的40%一路涨到80%以上。这个过程中F-Queue里堆积的对象数量也在同步上涨。为了更直观地观察F-Queue堆积数量我写了一段附加诊断代码通过反射获取java.lang.ref.Finalizer类的unfinalized链表长度每10秒打印一次。这个字段表示尚未执行finalize的对象数量。Field unfinalizedField Finalizer.class.getDeclaredField(unfinalized); unfinalizedField.setAccessible(true); ReferenceQueueObject queue (ReferenceQueueObject) unfinalizedField.get(null);注意这个字段的实际类型和一些内部结构在不同JDK版本里可能有差异我是在JDK 11上测的。打印出来的数量从最初的几百个一路涨到几万Finalizer线程的处理速度远跟不上生产速度。4.4 观察三GC停顿时间的变化趋势停顿时间是对用户影响最直接的一个指标。我把GC日志里Pause YoungG1 Evacuation Pause的耗时数据拉出来做了个汇总运行时间区间平均停顿ms最大停顿ms停顿次数老年代占用0-5分钟1351684241%5-10分钟1581953752%10-15分钟1722143163%15-20分钟1892432876%20-25分钟2012872484%这里有一个反直觉的现象停顿次数在减少每次停顿的时间反而在拉长。原因是F-Queue积压越严重每个对象在做可达性分析时消耗的额外时间就越多同时老年代占用率上升导致G1回收时需要处理更多Region。G1虽然会自动调整年轻代大小来迎合MaxGCPauseMillis的约束但当老年代容量逼近阈值时年轻代被压缩到很小的程度对象晋升更频繁停顿目标根本守不住。4.5 反思实验如果去掉finalize会怎样为了排除其他干扰我用同样的压测代码跑了一组对照组唯一的区别是去掉finalize重写改成一个普通方法。结果是老年代占用率始终稳定在35%左右GC平均停顿时间降到85毫秒最大停顿118毫秒完全没有F-Queue堆积的问题。两组实验的差异足以说明finalize在现代G1垃圾回收器上的影响是显著且可量化的。这套实验环境不复杂如果你也想复现核心就是保持创建速率和对象大小一致对照组只改变finalize这一变量。5. finalize与G1之外的垃圾回收器对比5.1 不同垃圾回收器下的表现差异finalize对不同垃圾回收器的影响程度不一样因为不同回收器认知、标记、整理内存的方式不同。以常见的几个为例垃圾回收器与finalize的交互特点受影响程度Serial/Parallel回收时STWF-Queue清理统一走Finalizer线程但堆结构简单影响主要体现为STW变长中等CMS初始标记和重新标记会遍历F-Reference并发清理期间停顿较长中等偏上G1SATB快照机制会把finalize中的引用变化记录到写屏障影响并发标记阶段Region晋升逻辑被打乱高ZGC着色指针和读屏障并发标记与finalize交互机制不同ZGC在后续版本对finalize支持仍有特殊处理路径较高在G1上finalize的影响之所以被放大核心原因在于G1对引用处理的精细程度。G1做并发标记时为了保证SATB的正确性会非常保守地对待所有引用变化。而finalize恰恰是引用变化的高发区。5.2 G1的Remembered Set与finalize的隐藏冲突G1中还有一个容易被忽略的机制Remembered SetRSet它记录了Region之间的引用关系。G1回收某个Region时通过RSet知道哪些Region引用它从而确定是否需要扫描这些引用。finalize执行的时机和位置非常尴尬——Finalizer线程会运行在一个独立的线程里它可能在任意时刻触发写屏障、修改对象引用。G1维护RSet时每次写操作都要记录到对应的RSet中。如果finalize里产生了大量的引用变更RSet的维护开销就会上升垃圾回收器需要处理更多的跨Region引用。我之前在做压测时用-XX:PrintGCDetails看过RSet的处理发现F-Queue积压严重的时候G1的Update RS阶段耗时明显增加。这说明finalize不仅仅影响标记阶段连记忆集的维护也跟着遭殃。5.3 处理finalize对象时的停顿风险点G1的回收过程分成多个阶段。finalize对象的处理最容易在两个阶段制造停顿第一个是并发标记的初始标记阶段。这个阶段需要停止用户线程快速标记GC Roots直接引用的对象。如果F-Queue里有大量对象初始标记时也要处理它们的引用状态STW时间会被拉长。第二个是重新标记阶段。重新标记是G1处理SATB缓冲区和引用对象的关键阶段也是STW的。finalize执行后的对象引用变更在重新标记阶段会被统一检查。如果这次finalize执行期间引用变化很多重新标记的耗时就会显著提升。我们在压测中的GC日志也验证了这个猜想重新标记阶段的耗时从正常情况下的十几毫秒涨到了几十甚至上百毫秒。6. 实操建议如何安全替换掉finalize6.1 使用Cleaner的正确姿势如果你正在维护老代码里面还有finalize我的建议是尽快迁到Cleaner。它的用法其实不复杂下面是标准的改造模板import java.lang.ref.Cleaner; public class ResourceHolder implements AutoCloseable { private static final Cleaner CLEANER Cleaner.create(); private final Cleaner.Cleanable cleanable; private final State state; public ResourceHolder() { this.state new State(); this.cleanable CLEANER.register(this, new CleanupAction(state)); } Override public void close() { cleanable.clean(); } // 需要清理的共享状态不能持有对外部对象的强引用 private static final class State implements Runnable { Override public void run() { System.out.println(release native resources here); } } // 清理动作持有State的引用在对象不可达时被Cleaner线程调用 private static final class CleanupAction implements Runnable { private final State state; CleanupAction(State state) { this.state state; } Override public void run() { state.run(); } } }这个模式里有几个要点全是踩坑换来的经验第一CleanupAction持有的State必须是静态内部类不能持有外部类的引用。否则会形成外部对象 - 内部对象 - 外部对象的引用环Cleaner可能永远等不到清理时机跟finalize一样变成泄漏。第二State里的状态对象必须和外部对象解耦。Cleaner注册时java.lang.ref.Cleaner.Cleanable内部是通过PhantomReference关联对象生命周期的所以不能让State直接引用外部资源否则幻象引用永远无法入队。第三尽量调用close()主动释放。Cleaner是兜底机制不是推荐路径。能显式关掉就显式关掉别老想着靠回收器来打扫卫生。6.2 用try-with-resources兜底自动清理如果资源持有者有close()的语义最好实现AutoCloseable然后用try-with-resources管理生命周期。改造方式很直接try (ResourceHolder holder new ResourceHolder()) { // 业务逻辑 } catch (Exception e) { log.error(business failed, e); }这样close()方法会主动调用cleanable.clean()资源释放变成确定性行为垃圾回收器不需要参与。Cleaner只处理那些代码路径异常、来不及手动关闭的兜底场景。6.3 如何快速排查线上是否存在finalize滥用如果线上服务已经出现内存问题但不确定是否和finalize有关我建议按下面这套流程排查速度最快第一步直接抓堆转储然后分析一下哪个类重写了finalize。可以在代码里全局搜索finalize关键字但更可靠的是用MAT或者JProfiler打开堆转储直接看java.lang.ref.Finalizer实例数量。如果这个数量级很大上千以上基本可以断定有大量对象在排队等待执行finalize。第二步观察Finalizer线程CPU占用。线上用top -Hp找JVM进程下名为Finalizer的线程如果长时间跑高多半是finalize执行逻辑过于耗时。第三步看GC日志中对象晋升异常。如果年轻代回收时老年代占用率不降反升或者晋升对象数量异常多配合前两步基本能锁定finalize是元凶。6.4 遗留代码改造的渐进思路对于历史遗留代码不建议一次性全部替换finalize。风险太大。渐进改造的思路可以这样先挑出高频创建和销毁的对象优先改造。一般这种对象对垃圾回收影响最大。改的时候先加close()方法再引入Cleaner保持finalize暂时不动双轨运行一段时间。观察GC指标稳定后再逐步删掉finalize。有一个关键点不要试图在finalize里加统计日志然后再慢慢排查。finalize的执行时机本来就不稳定加了日志反而干扰GC节奏还把日志文件撑爆。7. 一个容易忽略的隐形坑finalize与内存模型7.1 finalize方法内的内存屏障开销看到这里你可能会问finalize只是一个普通方法除了执行时机不同还有什么特殊之处JVM层面其实还给finalize安排了一个隐藏的Memory Barrier。这是为了满足Java语言规范里对finalize的一个特殊要求“在finalize方法执行期间及执行之后该对象不能被任何线程重新读取引用。”这个语义要求JVM在finalize开始和结束时插入内存屏障确保对象引用的可见性。内存屏障属于CPU指令级别开销虽然不高但在F-Queue积压了成千上万个对象时积少成多对垃圾回收器的停顿时间是一个不可忽视的增量。这也是为什么finalize执行得越频繁停顿越长。7.2 finalize的异常吞噬问题还有一个细节很多人写finalize时完全没意识到如果在finalize方法里抛出了未捕获的异常这个异常会被JVM静默吞噬不会打印任何日志也不会中断Finalizer线程。这就会导致两个问题一是你根本不知道清理逻辑是否执行成功二是异常导致清理逻辑走到一半资源没释放干净对象还是会被回收。我在压测中故意在finalize里构造了一个RuntimeException结果GC日志完全没有任何异常输出用jstack看Finalizer线程也一切正常。对象的finalize就是神秘地“失败”了外部无从感知。这种行为在线上环境极其危险因为它让所有资源清理的可靠性完全不可控。8. 结合G1调优如果暂时留用finalize怎么办8.1 能不能用参数缓解finalize的影响如果代码一时半会改不完比如第三方依赖里还在用finalize有没有办法通过JVM参数降低影响坦白讲效果有限。JVM没有专门针对F-Queue长度的参数。-XX:ParallelRefProcEnabled可以开启并行引用处理在一定程度上减少引用处理阶段的停顿但G1在很多场景下默认并不开启这个选项需要手动加。开启后引用处理包括Finalizer的引用会利用多线程做停顿能明显下降一些。还有一个思路是调整Finalizer线程的优先级。Finalizer线程是daemon线程默认优先级在HotSpot里对应的是NORM_PRIORITY。理论上可以通过Thread.setPriority()改它的优先级但Finalizer线程由JVM内部创建用户代码无法直接拿它的实例所以这条路基本走不通。8.2 一个大方向在应用层面控制finalize对象数量如果一定要用finalize最现实的做法是控制同时存在的、带有finalize的对象总量。比如对象池复用、避免每次创建新对象。finalize对象越少F-Queue堆积的风险就越低。再一个做法是把finalize里的逻辑放到独立的清理线程池里自行调度不要在finalize里做重活。finalize只负责提交一个清理任务然后立刻返回。这样Finalizer线程不会阻塞在耗时操作上F-Queue的消费速度能跟上生产速度。不过这些都是缓兵之计。说句实在话跟finalize打了这么多年交道我的结论就是它能不用就不用。为了一个看起来很“优雅”的自动清理机制搭上一个可能的内存泄漏隐患和GC停顿风险不值得。9. 个人踩坑经验总结最后不写什么总结性的大话只分享几个我在实际项目中踩过的坑希望对你有用。第一个坑finalize里头做耗时操作。之前一个老项目的缓存清理逻辑放在finalize里清理时要写文件、调接口每次执行几十毫秒。Finalizer线程被拖死F-Queue持续堆积最后线上频频Full GC。把耗时操作从finalize挪出去后问题立刻缓解。第二个坑finalize里用了未初始化的外部状态。因为finalize执行时机完全不固定可能在类加载后的任意时刻也可能在JVM快要退出的时候。如果你的finalize依赖了某个静态变量或Spring容器里的Bean可能拿到null也可能抛异常。而且这个异常被吞掉查都没地方查。第三个坑以为System.gc()能马上触发finalize。System.gc()只是“建议”JVM执行垃圾回收不保证执行就算执行了finalize也不保证在本次GC里跑完。这里没有半点确定性可言。第四个坑用finalize释放堆外内存。堆外内存的回收不受JVM堆管理控制finalize执行太晚的话堆外内存早就爆了。堆外内存一定要用Cleaner或者自己的引用队列机制去管理。如果你正在做GC调优或者排查内存问题建议在代码库全局搜索一下finalize看看未清理的存量有多少。说不定你排查了一周的老年代增长问题源头就在这一行不起眼的重写方法里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑