这是我参与11月更文挑战的第 2 天,活动详情查看:2021最后一次更文挑战
关于 Object 的 finalize 方法,在日常开发中可能有超过 99% 的人都没有关注过,因为业务开发很少有重写 finalize 方法的场景;开发者对于 finalize 的认知大多在是“面试八股文”中,而且也不乏见到将 finalize、finally 以及 final 放在一块比较的 case,面试官可能是出于对初学者 java 基本语言知识的考量,但是这真的有意义吗?
本文将用一个非常简单的 case 来直观的看下,finalize 方法重写带来的影响。
finalize 方法是什么
下面我们直接来看下 finalize 的代码注释。
1 | vbnet复制代码* Called by the garbage collector on an object when garbage collection |
finalize 是 java 的顶级父类 Object 中的一些方法,默认情况下 finalize 方法是空实现;其调时机是:当前对象没有任何引用时,执行 GC 时被调用。子类可以重写了 finalize 方法去释放系统资源或执行其他清理。
1 | kotlin复制代码* The general contract of {@code finalize} is that it is invoked |
finalizer 方法的调用时机由 JavaTM 开发商决定:简单说就是要确定对象的任何方法都不(再)会被调用时,再调用其 finalize 方法。除非一些其他的已经准备好被终止的对象或类将调用 finalize 方法,包括在其终止动作之中(即调用对象的 finalize 方法,此时该对象的 finalize 方法将是最后被调用的方法,在这之后,对象的任何方法都不(再)会被调用。finalize 方法中可以执行任何操作,包括再次使该对象可用于其它线程(重新初始化);但是 finalize 的通常目的是在对象(一定)不再被需要时(对象将被丢弃)之前执行清除操作。例如,表示input/output 连接的对象的 finalize 方法可能会在对象被永久丢弃之前执行显式 I/O 事务来中断连接。
1 | kotlin复制代码* The Java programming language does not guarantee which thread will |
java 语言不对任何对象的 finalize 方法调用发生的线程做限制,即任何线程都可以调用对象的 finalize 方法,然而,调用 finalize 方法的线程将不能持有任何用户可见的线程同步锁。当 finalize 方法被调用时,如果 finalize 方法抛出异常,且异常未被捕获时,异常将被忽略,finalize 方法将中止。
1 | kotlin复制代码* After the {@code finalize} method has been invoked for an object, no |
当对象的 finalize 方法被调用后,不会再有基于该对象的方法调用,直到 JVM 再次进行回收动作时该对象将被释放,占用的内存将被回收。
另外,任何对象的 finalize 方法只会被 JVM 调用一次。finalize()方法引发的任何异常都会导致该对象的终止被暂停,否则被忽略。
finalize 方法重写对 GC 的影响
这里丢一个简单的例子,TestMain 类重写了 finalize 方法,并且在 finalize 方法中已创建的对象总数 COUNT 做减操作,并且没隔 100000 次输出下当前 COUNT。
1 | java复制代码public class TestMain { |
运行环境:MacOS 10.14.6
JVM 参数:-XX:+PrintGCDetails -Xms200M -Xmx200M -Xmn100M
执行这段代码,可以在控制台观察,会出现以下几个阶段:
阶段一:第一次执行 GC 的时候
1 | sql复制代码creating 0 objects, current 1 are alive. |
看下 GC 之后,COUNT 中统计的存活的对象数还是有很多。
阶段二:频繁 fgc
1 | scss复制代码creating 3200000 objects, current 3132405 are alive. |
差不多在 330 万次时,就开始持续有 fgc 的情况了;可以看到各个数据区都被占满了。
阶段三:OOM
1 | scss复制代码[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] |
在创建了 330 万个对象后就抛出 java.lang.OutOfMemoryError: GC overhead limitt exceeded 异常退出了。从我工程测试日志中看到,基本全都都是 fgc(只有一次 ygc),从代码看,这些对象并没有什么特殊,代码层面也没有引用,但是 JVM 就直接使用代价更高的 Full GC 来清理老生代和持久代的空间了。所以 why ?
那作为对比,我们把代码中重写 finalize 的代码逻辑去掉再跑一次:
1 | scss复制代码creating 111000000 objects |
可以看到,在创建 13600 万对象时仍然可以继续跑,并且通过 GC 日志看,也只有 ygc , 没有一次 fgc。所以这个和重写 finalize 时的差距还是非常大的。那么下面就来分析下具体原因。
GC 影响分析
这里思路很简单,首先我们要知道是什么对象导致了 OOM,要找出来。
找到占用空间的元凶
在启动参数中加上 -XX:+HeapDumpOnOutOfMemoryError 参数,重新执行一次,在出现 OOM 时会执行一次 heap dump
1 | bash复制代码java.lang.OutOfMemoryError: GC overhead limit exceeded |
通过分析工具(我使用的是 Jprofile 2),看到都是 Finalizer 这个类的对象实例
在我的测试代码中,非常明确没有创建 Finalizer 对象的逻辑,那为什么会有这么多 Finalizer 对象实例呢?其实从上面 OOM 堆栈那里已经可以看出些端倪了:
1 | kotlin复制代码class space used 404K, capacity 434K, committed 512K, reserved 1048576K |
在堆栈中看到了 Finalizer.register 这样一个方法执行,把断点打在这里:
对于重写 finalize 方法的类,在创建其实例时,会同时创建一个 Finalizer 实例,这些所有的 Finalizer 实例又会为 Finalizer 类所引用,由于存在这么一个引用链关系存在,所以整个的这些对象都是存活的;所以当 Eden 区满了之后,此时所有的对象还是存活的,所以并不会被回收掉,继而只能将他们进一步放到 Suvivor 区去,但是由于这些对象不会被释放,引用一直存在,所以 Suvivor 区也很快被占满,既然这些对象被放到老年代,直到存入元数据空间,最后 OOM;所以前面提到的,不是 JVM 不使用 ygc ,而是基于既定规则下,ygc 并不能将这些存活的对象回收掉。关于引用链通过 Jprofile 也可以直观的得到结论
如何被回收的?
那是不是就一直没法被回收呢?其实也不是,我们看到在执行了 fgc 之后,还是有一些对象被回收掉的。那就是说,这些被引用的对象,还是有可能被释放的;那其实就看这个对象什么时候从下面这个队列中被弹出。
1 | arduino复制代码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() 方法的情况,如果不是使用常规的方式来清理对象的话,最好是多考虑一下。
本文转载自: 掘金