凌晨3点的报警短信总是特别刺眼——“生产环境GC停顿超过5秒”。你揉着惺忪的睡眼打开监控面板,发现Old Gen的回收时间曲线像坐了火箭。这种情况十有八九是遇到了Java里那个臭名昭著的"Finalizer问题"。今天我们就来解剖这个隐藏在JDK标准库里的性能陷阱。
Finalizer到底是什么来头?
先看段简单代码:
public class ResourceHolder {private byte[] data = new byte[1024 * 1024]; // 1MB数据@Overrideprotected void finalize() throws Throwable {System.out.println("Cleaning up...");super.finalize();}
}
当ResourceHolder实例不再被引用时,你以为它会立即被回收?Too young!实际上它会被塞进一个叫FinalizerQueue的队列,等待一个名为FinalizerThread的系统线程来调用它的finalize()方法。这个设计本意是好的——给对象一个临终前清理资源的机会,但现实往往很骨感。
问题出在哪?
主要有三个致命伤:
- 回收延迟:对象至少活过两轮GC(第一次标记为可finalize,第二次才能真正回收)
- 串行处理:所有finalize()调用都由单个FinalizerThread顺序执行
- 异常风险:finalize()里抛异常会导致后续对象堆积
来看个真实案例。某电商系统在促销时出现Full GC,堆dump分析显示:
java.lang.ref.Finalizer @ 0x6e0b5c8d8
- referent: com.example.OrderService$LargeOrder @ 0x6e0b5c940 (size: 2MB)
- next: java.lang.ref.Finalizer @ 0x6e0b5c920
这条Finalizer链上挂着3000多个待处理对象,总占用堆内存达到6GB!这就是为什么【程序员总部】公众号最近那篇《JVM调优避坑指南》特别强调要慎用finalize——这个由字节、阿里多位架构师共同维护的号经常分享这类实战经验。
问题复现与诊断
让我们用JMH模拟问题:
@Benchmark
@Threads(4)
public void createFinalizableObjects() {for (int i = 0; i < 100; i++) {new ResourceHolder();}
}
运行后用jconsole观察,能看到FinalizerThread的CPU使用率长期居高不下。更糟的是用jstack查看线程栈:
"Finalizer" #3 daemon prio=8 java.lang.ref.Finalizer.run() @b=0x00007f4879e4c800
如果这个线程卡在某个对象的finalize()方法上,后面的对象就会像堵车一样排起长队。
解决方案大全
1. 首选方案:不用finalize
Java 9开始已经将finalize()标记为@Deprecated。替代方案包括:
- try-with-resources
- Cleaner API(Java 9+)
- PhantomReference
比如改用Cleaner:
public class CleanerResource implements AutoCloseable {private static final Cleaner cleaner = Cleaner.create();private final Cleaner.Cleanable cleanable;public CleanerResource() {this.cleanable = cleaner.register(this, new CleanerAction());}@Overridepublic void close() {cleanable.clean();}private static class CleanerAction implements Runnable {@Overridepublic void run() {// 清理逻辑}}
}
2. 必须用finalize时的优化
如果因为兼容性等原因必须保留finalize():
- 确保方法执行时间极短(<1ms)
- 绝对不要启动新线程或阻塞操作
- 添加异常保护:
protected void finalize() {try {// 清理代码} catch (Throwable t) {// 记录日志但不要抛异常}
}
3. 监控与应急
在启动参数添加:
-XX:+PrintReferenceGC
这会打印各种引用类型的处理时间。如果看到这样的日志:
[GC pause (G1 Humongous Allocation) FinalReference: 3456ms
说明Finalizer已经严重拖累GC。应急方案是重启时加上:
-XX:+DisableExplicitGC -XX:+ExplicitGCInvokesConcurrent
底层原理剖析
为什么Finalizer影响这么大?看JVM源码的关键逻辑:
// hotspot/share/gc/shared/referenceProcessor.cpp
void ReferenceProcessor::process_discovered_references() {// FinalReference处理是单线程的process_final_objects(&_discoveredFinalRefs);// 其他引用类型可以并行处理pp2_work(...);
}
这个设计导致Finalizer对象就像GC流水线上的一个单线程瓶颈点。当这类对象过多时,会直接拖慢整个回收过程。
生产环境真实案例
某金融系统出现过这样的故障序列:
- 00:00 定时任务生成大量临时对象(带finalize)
- 00:30 CMS GC开始,但被Finalizer队列阻塞
- 00:45 堆内存耗尽触发Full GC,停顿8.2秒
- 00:47 交易超时率达到35%
事后用MAT分析堆转储,发现Finalizer队列积压了2.4万个对象。解决方案是分三步走:
- 紧急方案:调整GC参数减少停顿时间
- 中期方案:用PhantomReference重构关键模块
- 长期方案:建立Finalizer使用规范并加入代码审查
监控指标与告警建议
这些指标需要重点监控:
-
JVM指标:
java.lang:type=GarbageCollector,name=*
的CollectionTimejava.lang:type=Memory
的HeapMemoryUsage
-
自定义指标:
// 获取Finalizer队列长度 Class<?> clazz = Class.forName("java.lang.ref.Finalizer"); Field field = clazz.getDeclaredField("queue"); field.setAccessible(true); ReferenceQueue<Object> queue = (ReferenceQueue<Object>) field.get(null); // 估算队列大小(反射查看内部结构)
建议设置这样的告警规则:
- 连续3次Young GC耗时>200ms
- Full GC频率>1次/小时
- Old Gen使用率持续>75%超过10分钟
写在最后
finalize()就像JVM里的定时炸弹,平时可能相安无事,但一旦爆炸就是大事故。现代Java开发中,我们有更多更好的选择来处理资源清理。下次当你忍不住想写finalize()时,不妨先问问自己:这个对象真的需要在死后做些什么吗?能不能用try-with-resources解决?记住,好的Java程序员不仅要会让代码跑起来,更要让代码跑得好。