finalize 方法重写对 GC 的影响分析

3,066 阅读15分钟

这是我参与11月更文挑战的第 2 天,活动详情查看:2021最后一次更文挑战

关于 Object 的 finalize 方法,在日常开发中可能有超过 99% 的人都没有关注过,因为业务开发很少有重写 finalize 方法的场景;开发者对于 finalize 的认知大多在是“面试八股文”中,而且也不乏见到将 finalize、finally 以及 final 放在一块比较的 case,面试官可能是出于对初学者 java 基本语言知识的考量,但是这真的有意义吗?

本文将用一个非常简单的 case 来直观的看下,finalize 方法重写带来的影响。

finalize 方法是什么

下面我们直接来看下 finalize 的代码注释。

* Called by the garbage collector on an object when garbage collection
* determines that there are no more references to the object.
* A subclass overrides the {@code finalize} method to dispose of
* system resources or to perform other cleanup.

finalize 是 java 的顶级父类 Object 中的一些方法,默认情况下 finalize 方法是空实现;其调时机是:当前对象没有任何引用时,执行 GC 时被调用。子类可以重写了 finalize 方法去释放系统资源或执行其他清理。

* The general contract of {@code finalize} is that it is invoked
* if and when the Java™ virtual
* machine has determined that there is no longer any
* means by which this object can be accessed by any thread that has
* not yet died, except as a result of an action taken by the
* finalization of some other object or class which is ready to be
* finalized. The {@code finalize} method may take any action, including
* making this object available again to other threads; the usual purpose
* of {@code finalize}, however, is to perform cleanup actions before
* the object is irrevocably discarded. For example, the finalize method
* for an object that represents an input/output connection might perform
* explicit I/O transactions to break the connection before the object is
* permanently discarded.

finalizer 方法的调用时机由 JavaTM 开发商决定:简单说就是要确定对象的任何方法都不(再)会被调用时,再调用其 finalize 方法。除非一些其他的已经准备好被终止的对象或类将调用 finalize 方法,包括在其终止动作之中(即调用对象的 finalize 方法,此时该对象的 finalize 方法将是最后被调用的方法,在这之后,对象的任何方法都不(再)会被调用。finalize 方法中可以执行任何操作,包括再次使该对象可用于其它线程(重新初始化);但是 finalize 的通常目的是在对象(一定)不再被需要时(对象将被丢弃)之前执行清除操作。例如,表示input/output 连接的对象的 finalize 方法可能会在对象被永久丢弃之前执行显式 I/O 事务来中断连接。

* The Java programming language does not guarantee which thread will
* invoke the {@code finalize} method for any given object. It is
* guaranteed, however, that the thread that invokes finalize will not
* be holding any user-visible synchronization locks when finalize is
* invoked. If an uncaught exception is thrown by the finalize method,
* the exception is ignored and finalization of that object terminates.

java 语言不对任何对象的 finalize 方法调用发生的线程做限制,即任何线程都可以调用对象的 finalize 方法,然而,调用 finalize 方法的线程将不能持有任何用户可见的线程同步锁。当 finalize 方法被调用时,如果 finalize 方法抛出异常,且异常未被捕获时,异常将被忽略,finalize 方法将中止。

* After the {@code finalize} method has been invoked for an object, no
* further action is taken until the Java virtual machine has again
* determined that there is no longer any means by which this object can
* be accessed by any thread that has not yet died, including possible
* actions by other objects or classes which are ready to be finalized,
* at which point the object may be discarded.

当对象的 finalize 方法被调用后,不会再有基于该对象的方法调用,直到 JVM 再次进行回收动作时该对象将被释放,占用的内存将被回收。

另外,任何对象的 finalize 方法只会被 JVM 调用一次。finalize()方法引发的任何异常都会导致该对象的终止被暂停,否则被忽略。

finalize 方法重写对 GC 的影响

这里丢一个简单的例子,TestMain 类重写了 finalize 方法,并且在 finalize 方法中已创建的对象总数 COUNT 做减操作,并且没隔 100000 次输出下当前 COUNT。

public class TestMain {

    private static AtomicInteger COUNT = new AtomicInteger(0);

    public TestMain() {
        COUNT.incrementAndGet();
    }

    /**
     * 重写 finalize 方法测试
     * @throws Throwable
     */
    @Override
    protected void finalize() throws Throwable {
        COUNT.decrementAndGet();
    }

    public static void main(String args[]) {
        for (int i = 0 ;; i++) {
            TestMain item = new TestMain();
            if ((i % 100000) == 0) {
                System.out.format("creating %d objects, current %d are alive.%n", new Object[] {i, COUNT.get() });
            }
        }
    }
}

运行环境:MacOS 10.14.6

JVM 参数:-XX:+PrintGCDetails -Xms200M -Xmx200M -Xmn100M

执行这段代码,可以在控制台观察,会出现以下几个阶段:

阶段一:第一次执行 GC 的时候

creating 0 objects, current 1 are alive.
creating 100000 objects, current 100001 are alive.
creating 200000 objects, current 200001 are alive.
creating 300000 objects, current 300001 are alive.
creating 400000 objects, current 400001 are alive.
creating 500000 objects, current 500001 are alive.
creating 600000 objects, current 600001 are alive.
creating 700000 objects, current 700001 are alive.
creating 800000 objects, current 800001 are alive.
creating 900000 objects, current 900001 are alive.
creating 1000000 objects, current 1000001 are alive.
creating 1100000 objects, current 1100001 are alive.
creating 1200000 objects, current 1200001 are alive.
// ygc 失败,下面直接进行 fgc 了
[GC (Allocation Failure) [PSYoungGen: 76800K->12800K(89600K)] 76800K->71066K(192000K), 0.3839994 secs] [Times: user=1.87 sys=0.07, real=0.38 secs] 
// 执行 fgc
[Full GC (Ergonomics) [PSYoungGen: 12800K->0K(89600K)] [ParOldGen: 58266K->70801K(102400K)] 71066K->70801K(192000K), [Metaspace: 3696K->3696K(1056768K)], 1.3266229 secs] [Times: user=4.89 sys=0.17, real=1.33 secs]
creating 1300000 objects, current 1296221 are alive.

看下 GC 之后,COUNT 中统计的存活的对象数还是有很多。

阶段二:频繁 fgc

creating 3200000 objects, current 3132405 are alive.
creating 3300000 objects, current 3230808 are alive.
[Full GC (Ergonomics) [PSYoungGen: 76800K->75818K(89600K)] [ParOldGen: 102171K->102028K(102400K)] 178971K->177846K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.8383374 secs] [Times: user=4.93 sys=0.09, real=0.84 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76768K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178797K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.3276008 secs] [Times: user=2.24 sys=0.01, real=0.33 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76784K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178813K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2876044 secs] [Times: user=1.74 sys=0.03, real=0.29 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76775K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178803K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2761930 secs] [Times: user=1.74 sys=0.02, real=0.28 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76778K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178806K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.3522859 secs] [Times: user=1.67 sys=0.02, real=0.35 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76786K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178814K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2609472 secs] [Times: user=1.36 sys=0.02, real=0.26 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76793K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178821K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2691448 secs] [Times: user=1.28 sys=0.01, real=0.27 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76787K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178816K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2254074 secs] [Times: user=1.37 sys=0.01, real=0.22 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76779K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178808K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2670959 secs] [Times: user=1.77 sys=0.02, real=0.27 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76778K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178807K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2704234 secs] [Times: user=1.71 sys=0.02, real=0.27 secs] 
[Full GC (Ergonomics) [PSYoungGen: 76800K->76785K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178813K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.3302090 secs] [Times: user=1.84 sys=0.02, real=0.33 secs] 

差不多在 330 万次时,就开始持续有 fgc 的情况了;可以看到各个数据区都被占满了。

阶段三:OOM

[Full GC (Ergonomics) [PSYoungGen: 76800K->76784K(89600K)] [ParOldGen: 102028K->101994K(102400K)] 178828K->178778K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.4323213 secs] [Times: user=2.13 sys=0.04, real=0.43 secs] 
[Full GC (Ergonomics) Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded[PSYoungGen: 76799K->76697K(89600K)] [ParOldGen: 101994K->101994K(102400K)] 178793K->178691K(192000K), [Metaspace: 3720K->3720K(1056768K)], 0.3325707 secs] [Times: user=1.70 sys=0.02, real=0.33 secs] 
Heap
 PSYoungGen      total 89600K, used 76800K [0x00000007b9c00000, 0x00000007c0000000, 0x00000007c0000000)  eden space 76800K, 100% used [0x00000007b9c00000,0x00000007be700000,0x00000007be700000)  from space 12800K, 0% used [0x00000007be700000,0x00000007be700000,0x00000007bf380000)  to   space 12800K, 0% used [0x00000007bf380000,0x00000007bf380000,0x00000007c0000000) ParOldGen       total 102400K, used 101994K [0x00000007b3800000, 0x00000007b9c00000, 0x00000007b9c00000)  object space 102400K, 99% used [0x00000007b3800000,0x00000007b9b9a8e0,0x00000007b9c00000) Metaspace       used 3751K, capacity 4670K, committed 4864K, reserved 1056768K  class space    used 404K, capacity 434K, committed 512K, reserved 1048576K	at java.lang.ref.Finalizer.register(Finalizer.java:87)	at java.lang.Object.<init>(Object.java:37)	at com.glmapper.bridge.boot.finalize.TestMain.<init>(TestMain.java:13)	at com.glmapper.bridge.boot.finalize.TestMain.main(TestMain.java:28)

在创建了 330 万个对象后就抛出 java.lang.OutOfMemoryError: GC overhead limitt exceeded 异常退出了。从我工程测试日志中看到,基本全都都是 fgc(只有一次 ygc),从代码看,这些对象并没有什么特殊,代码层面也没有引用,但是 JVM 就直接使用代价更高的 Full GC 来清理老生代和持久代的空间了。所以 why ?

那作为对比,我们把代码中重写 finalize 的代码逻辑去掉再跑一次:

creating 111000000 objects 
[GC (Allocation Failure) [PSYoungGen: 99328K->0K(100864K)] 100161K->833K(203264K), 0.0008235 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
creating 112000000 objects 
creating 113000000 objects 
creating 114000000 objects 
creating 115000000 objects 
creating 116000000 objects 
creating 117000000 objects 
[GC (Allocation Failure) [PSYoungGen: 99328K->0K(100864K)] 100161K->833K(203264K), 0.0005181 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
creating 118000000 objects 
creating 119000000 objects 
creating 120000000 objects 
creating 121000000 objects 
creating 122000000 objects 
creating 123000000 objects 
creating 124000000 objects 
[GC (Allocation Failure) [PSYoungGen: 99328K->0K(100864K)] 100161K->833K(203264K), 0.0004161 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
creating 125000000 objects 
creating 126000000 objects 
creating 127000000 objects 
creating 128000000 objects 
creating 129000000 objects 
creating 130000000 objects 
[GC (Allocation Failure) [PSYoungGen: 99328K->32K(101376K)] 100161K->865K(203776K), 0.0004908 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
creating 131000000 objects 
creating 132000000 objects 
creating 133000000 objects 
creating 134000000 objects 
creating 135000000 objects 
creating 136000000 objects

可以看到,在创建 13600 万对象时仍然可以继续跑,并且通过 GC 日志看,也只有 ygc , 没有一次 fgc。所以这个和重写 finalize 时的差距还是非常大的。那么下面就来分析下具体原因。

GC 影响分析

这里思路很简单,首先我们要知道是什么对象导致了 OOM,要找出来。

找到占用空间的元凶

在启动参数中加上 -XX:+HeapDumpOnOutOfMemoryError 参数,重新执行一次,在出现 OOM 时会执行一次 heap dump

java.lang.OutOfMemoryError: GC overhead limit exceeded
Dumping heap to java_pid88685.hprof ...
Heap dump file created [320504328 bytes in 3.050 secs]

通过分析工具(我使用的是 Jprofile 2),看到都是 Finalizer 这个类的对象实例

在我的测试代码中,非常明确没有创建 Finalizer 对象的逻辑,那为什么会有这么多 Finalizer 对象实例呢?其实从上面 OOM 堆栈那里已经可以看出些端倪了:

class space    used 404K, capacity 434K, committed 512K, reserved 1048576K
	at java.lang.ref.Finalizer.register(Finalizer.java:87)
    at java.lang.Object.<init>(Object.java:37)
    at com.glmapper.bridge.boot.finalize.TestMain.<init>(TestMain.java:13)
    at com.glmapper.bridge.boot.finalize.TestMain.main(TestMain.java:28)

在堆栈中看到了 Finalizer.register 这样一个方法执行,把断点打在这里:

对于重写 finalize 方法的类,在创建其实例时,会同时创建一个 Finalizer 实例,这些所有的 Finalizer 实例又会为 Finalizer 类所引用,由于存在这么一个引用链关系存在,所以整个的这些对象都是存活的;所以当 Eden 区满了之后,此时所有的对象还是存活的,所以并不会被回收掉,继而只能将他们进一步放到 Suvivor 区去,但是由于这些对象不会被释放,引用一直存在,所以 Suvivor 区也很快被占满,既然这些对象被放到老年代,直到存入元数据空间,最后 OOM;所以前面提到的,不是 JVM 不使用 ygc ,而是基于既定规则下,ygc 并不能将这些存活的对象回收掉。关于引用链通过 Jprofile 也可以直观的得到结论

如何被回收的?

那是不是就一直没法被回收呢?其实也不是,我们看到在执行了 fgc 之后,还是有一些对象被回收掉的。那就是说,这些被引用的对象,还是有可能被释放的;那其实就看这个对象什么时候从下面这个队列中被弹出。

private static ReferenceQueue<Object> queue = new ReferenceQueue<>();

被弹出的对象在下一次 GC 的时候就会被认为已经没有任何引用从而被回收掉。

Finalizer 线程: FinalizerThread

FinalizerThread 的职责非常简单,就是不停的循环等待 ReferenceQueue 中的新增对象,然后弹出这个对象,调用它的 finalize() 方法,将该引用从 Finalizer 类中移除,因此下次 GC 再执行的时候,这个 Finalizer 实例以及它引用的那个对象就可以回垃圾回收掉了。

finalize() 方法的调用会比你创建新对象要早得多,因此大多数时候,Finalizer 线程能够赶在下次 GC 带来更多的 Finalizer 对象前清空这个队列。

既然如此,那为什么会出现 OOM 呢?因为 Finalizer 线程和主线程相比它的优先级要低。这意味着分配给它的CPU 时间更少,因此它的处理速度没法赶上新对象创建的速度。这就是问题的根源——对象创建的速度要比Finalizer 线程调用 finalize() 结束它们的速度要快,这导致最后堆中所有可用的空间都被耗尽了,结果就出现了 OOM 这种情况。(PS: 案例代码在一直循环创建新的对象)

总结

通过上面的 case 和分析,可以知道,对于重写了 finalize 的类,其对象的生命周期和普通对象的生命周期是完全不一样的。对于重写了 finalize 的类,其生命周期大致如下:

  • JVM 创建 TestMain 对象
  • JVM 创建一个 Finalizer 对象,指向 TestMain 对象
  • Finalizer 类持有新创建的 Finalizer 的实例,使得下一次新生代 GC 无法回收这些对象
  • 新生代 GC 无法清空 Eden 区(引用被持有了),因此会将这些对象移到 Survivor 区或者老生代
  • 垃圾回收器发现这些对象实现了finalize() 方法,因为会把它们添加到 ReferenceQueue 队列中
  • FinalizerThread 处理 ReferenceQueue 队列,将里面的对象逐个弹出,并调用它们的 finalize() 方法
  • finalize() 方法调用完后,FinalizerThread 会将引用从 Finalizer 类中去掉,因此在下一轮 GC 中,这些对象就可以被回收了

所以,如果你有使用 finalize() 方法的情况,如果不是使用常规的方式来清理对象的话,最好是多考虑一下。