Administrator
发布于 2019-08-17 / 1406 阅读
23

对象真的不可达才被回收吗?finalize 与引用类型

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 是怎么被执行的

一个对象被判定不可达之后,并不会立刻被回收。它要经历这套流程:

  1. 第一次 GC 时,JVM 发现这个对象不可达,但它的类重写了 finalize() 且从未被调用过。
  2. JVM 把这个对象包成一个 Finalizer 对象,放进 Finalizer.ReferenceQueue 链表里。因为有了这个引用,对象在这次 GC 中活下来了
  3. JVM 启动时创建的 Finalizer 守护线程(优先级 8,比普通线程高)不断从队列里取对象,调用它的 finalize() 方法。
  4. 调用完之后,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 的对象都挂在上面。它有 nextprev 指针,硬生生把对象串成了一条强引用的链(FinalReferencereferent 字段指向原始对象)。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 内部的,比如 FileInputStreamFileOutputStream,正常范围)。

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(),这个我另外写一篇记。

参考