MAT 里那个 12 万的 java.lang.ref.Finalizer
8 月初排查一个服务的老年代增长,dump 下来用 MAT 打开,Dominator Tree 里第三名是 java.lang.ref.Finalizer,12.4 万个实例,占了 210 MB。我当时看不懂这是个什么类,点开 Merge Shortest Paths to GC Roots,发现它引用着一堆 java.io.FileInputStream。
师兄过来看了一眼说:你们是不是有类实现了 finalize 方法?一查,还真有——同事写的一个文件工具类,为了防止忘记关闭流,在 finalize() 里做了兜底清理。
finalize 是怎么被执行的
一个对象被判定不可达之后,并不会立刻被回收。它要经历这套流程:
- 第一次 GC 时,JVM 发现这个对象不可达,但它的类重写了
finalize()且从未被调用过。 - JVM 把这个对象包成一个
Finalizer对象,放进Finalizer.ReferenceQueue链表里。因为有了这个引用,对象在这次 GC 中活下来了。 - JVM 启动时创建的
Finalizer守护线程(优先级 8,比普通线程高)不断从队列里取对象,调用它的finalize()方法。 - 调用完之后,
Finalizer把这个对象从链表摘掉,下次 GC 时它才是真的不可达,被回收。
所以一个实现了 finalize() 的对象,至少要两次 GC 才能被回收。而且从它被判定不可达到 finalize() 被调用,中间隔了多久完全不可控,取决于队列长度和 Finalizer 线程的调度。
看一眼 JDK 8 里的源码就明白了:
final class Finalizer extends FinalReference<Object> {
private static ReferenceQueue<Object> queue = new ReferenceQueue<>();
private static Finalizer unfinalized = null; // 静态链表头
private Finalizer next = null, prev = null;
private Finalizer(Object finalizee) {
super(finalizee, queue);
add(); // 挂到 unfinalized 链表上
}
private void runFinalizer(JavaLangAccess jla) {
synchronized (this) {
if (hasBeenFinalized()) return;
remove(); // 从链表摘掉
}
Object finalizee = this.get();
if (finalizee != null && !(finalizee instanceof java.lang.Enum)) {
jla.invokeFinalize(finalizee); // 反射调用 finalize()
finalizee = null; // 断开引用,下次 GC 才能回收
}
super.clear();
}
}
那个 unfinalized 是个静态链表,所有待 finalize 的对象都挂在上面。它有 next 和 prev 指针,硬生生把对象串成了一条强引用的链(FinalReference 的 referent 字段指向原始对象)。MAT 里看到的 12.4 万个 Finalizer,就是这条链表上的节点。
同事那个类长这样:
public class FileHolder {
private FileInputStream fis;
public FileHolder(String path) throws FileNotFoundException {
this.fis = new FileInputStream(path);
}
public byte[] read() { ... }
@Override
protected void finalize() throws Throwable {
try {
if (fis != null) {
fis.close(); // 想做兜底清理
}
} finally {
super.finalize();
}
}
}
这个类每天创建约 40 万个实例,全部进入 finalize 队列。Finalizer 线程只有一个,处理速度跟不上创建速度,队列越积越多,老年代就这么涨上去了。更麻烦的是 FileInputStream 本身也实现了 finalize(),它又挂了一层。
jstack 里能看到这个线程的真实状态:
"Finalizer" #3 daemon prio=8 os_prio=31 tid=0x00007f8a1b02a000 nid=0x3f07 in Object.wait()
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:144)
- locked <0x00000006c0a1b3e0> (a java.lang.ref.ReferenceQueue$Lock)
at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:165)
at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:216)
它一直阻塞在 ReferenceQueue.remove() 上,队列里有东西才醒来。处理一个对象的耗时包括一次反射调用(invokeFinalize 走的是 Method.invoke),实测平均 3.2 微秒,比一次普通方法调用慢 30 倍左右。
四种引用类型
顺着这个问题我把 JDK 的引用体系看了一遍。Java 里除了我们平时用的强引用,还有三个在 java.lang.ref 包下的类,回收时机各不相同:
| 类型 | 类 | 回收时机 | 典型用途 |
|---|---|---|---|
| 强引用 | (普通赋值) | 永不回收,宁可 OOM | 日常代码 |
| 软引用 | SoftReference | 内存不足时(OOM 之前) | 缓存 |
| 弱引用 | WeakReference | 下次 GC 时,不管内存够不够 | WeakHashMap、ThreadLocal key |
| 虚引用 | PhantomReference | 随时,get() 永远返回 null | 对象回收的回调通知 |
软引用适合做缓存。它有个有意思的特性:GC 后会根据剩余内存和 -XX:SoftRefLRUPolicyMSPerMB(默认 1000,即每 MB 空闲内存让软引用多活 1 秒)决定要不要回收。我试过:
// -Xmx100m -XX:SoftRefLRUPolicyMSPerMB=1000
public class SoftRefTest {
static class BigObj {
byte[] data = new byte[1024 * 1024]; // 1MB
}
public static void main(String[] args) throws Exception {
List<SoftReference<BigObj>> cache = new ArrayList<>();
for (int i = 0; i < 60; i++) {
cache.add(new SoftReference<>(new BigObj()));
Thread.sleep(50);
}
long alive = cache.stream().filter(r -> r.get() != null).count();
System.out.println("alive = " + alive);
}
}
堆 100 MB,塞 60 个 1MB 的对象。跑到第 40 个左右开始触发 GC,软引用被回收。最终 alive = 37,也就是回收了 23 个。把 SoftRefLRUPolicyMSPerMB 调成 0,同样的代码 alive = 12,回收得更狠。
弱引用比软引用激进得多,只要发生 GC 就没了,不管内存够不够:
public class WeakRefTest {
public static void main(String[] args) {
WeakReference<byte[]> ref = new WeakReference<>(new byte[1024 * 1024]);
System.out.println("before gc: " + ref.get()); // [B@1b6d3586
System.gc();
System.out.println("after gc: " + ref.get()); // null
}
}
WeakHashMap 就是基于它实现的,key 是弱引用,key 被回收后对应的 Entry 会在下次访问时自动清理。所以别用 WeakHashMap 做长期缓存,它撑不过一次 GC。
虚引用最特殊,它的 get() 方法被硬编码成返回 null:
public class PhantomReference<T> extends Reference<T> {
public T get() {
return null; // 永远拿不到引用对象
}
}
它的唯一用途是:配合 ReferenceQueue,在对象真正被回收时收到一个通知。JDK 里 java.nio.DirectByteBuffer 的堆外内存回收就用了这套机制——Cleaner 继承自 PhantomReference,DirectByteBuffer 被 GC 时,Cleaner 被放进队列,ReferenceHandler 线程取出后调用 Unsafe.freeMemory() 释放堆外内存。
ReferenceQueue 怎么用
后三种引用都可以在构造时传一个 ReferenceQueue。被引用的对象被回收后,引用对象本身(不是被引用的对象)会被 JVM 放进这个队列。轮询它就能知道"哪些引用已经失效了"。
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
Map<PhantomReference<byte[]>, String> refMap = new ConcurrentHashMap<>();
PhantomReference<byte[]> ref = new PhantomReference<>(new byte[1024], queue);
refMap.put(ref, "resource-1");
// 单独起一个线程处理回收通知
new Thread(() -> {
while (true) {
try {
Reference<? extends byte[]> r = queue.remove(); // 阻塞
String name = refMap.remove(r);
System.out.println("reclaimed: " + name);
r.clear();
} catch (InterruptedException e) {
break;
}
}
}).start();
注意那个 refMap:虚引用对象自己也是个普通对象,如果没人持有它,它自己先被回收了,就没机会进队列。所以必须有一个强引用持有 Reference 对象本身。这也是很多示例代码容易漏的一点。
怎么改掉 finalize
回到 FileHolder 那件事。我们的修法很简单:删掉 finalize(),让类实现 AutoCloseable,强制调用方用 try-with-resources。
public class FileHolder implements AutoCloseable {
private final FileInputStream fis;
public FileHolder(String path) throws FileNotFoundException {
this.fis = new FileInputStream(path);
}
public byte[] read() { ... }
@Override
public void close() throws IOException {
fis.close();
}
}
// 调用方
try (FileHolder holder = new FileHolder("/data/x.txt")) {
return holder.read();
}
改完之后老年代从 3.8 GB 降到 1.1 GB,GC 次数从每分钟 4.2 次降到 0.7 次。MAT 里 Finalizer 的数量降到 63 个(都是 JDK 内部的,比如 FileInputStream、FileOutputStream,正常范围)。
JDK 9 已经把 finalize() 标记为 deprecated 了,替代方案是 java.lang.ref.Cleaner。它把清理逻辑和对象本身解耦,清理动作在一个独立的线程里跑,不会拖慢 GC,也不会让对象多活一个周期。我们项目还在 JDK 8,暂时用不上,但方向是对的。
小结
- 实现了
finalize()的对象要经历"标记 → 进 Finalizer 队列 → 被单线程反射调用 → 下次 GC 才回收",至少两次 GC,而且中间的时间窗口不可控。 - Finalizer 线程只有一个,优先级 8。创建速度超过处理速度时队列会无限堆积,最终 OOM。我遇到的 case 是 12.4 万个对象堆了 210 MB。
- 强引用永不回收;软引用在内存不足时回收(受
-XX:SoftRefLRUPolicyMSPerMB影响);弱引用下次 GC 就没;虚引用的get()恒为 null,只用于回收通知。 - 用
ReferenceQueue时必须用强引用持有 Reference 对象本身,否则它自己先被回收了。 - 资源清理用 try-with-resources 加
AutoCloseable,别用 finalize 兜底。JDK 9+ 可以用Cleaner。
顺便说一句,ThreadLocal 的 key 也是弱引用,这是为了防止 ThreadLocal 对象本身泄漏。但 value 是强引用,线程池里的线程不销毁,value 就一直挂着。所以用完一定要 remove(),这个我另外写一篇记。