JVM Finalize实现原理与代价

相信有很大一部分JAVA程序员是从C/C++开始的(在我印象里,本科必修课程没有JAVA),而JAVA在基本语义与C++保持一致的基础上,其更加面向对象,类型安全,RTTI等特性使大部分用惯了CC++的程序员对JAVA爱不释手,然而习惯于C++的程序员不可避免地会在JAVA中寻找C++的影子,其中最典型的就是析构函数问题。

我们说JAVA在基本语义与C++保持一致,并不是说C++的所有特性JAVA都会具有,相反,对于一些繁琐的、有风险的动作,JAVA会把他们隐藏在JVM的实现细节中,指针的事情大家都是知道的,OK,这里我们就谈谈C++的析构函数与JAVA的finalize()。

首先在JAVA看来,析构函数本身是不应该存在的,或者说其存在本身就带来了一定的风险,因为机器永远比程序员清楚一个对象什么时候该析构,为什么这么说呢?假设在程序员A的代码中构造了个对象O1,程序员A往往无法保证这个对象O1会在自己的代码片段中析构,那么他能做的就是写各种各样的manual或者与接口开发者沟通,告诉他们哪些对象必须及时析构才不会造成内存泄露,即便程序员A的代码能够覆盖对象O1的所有生命周期,也不能保证他不会在各种各样的析构场景下犯错误,那我们换个角度考虑,对象O1什么时候需要被析构?当前仅当O1不被任何其他对象需要的情况下,也就是不被任何其他对象引用的时候,而对象之间的引用关系,程序本身是再清楚不过的了。

基于上述的考虑,JAVA不为开发者提供析构函数,对象的析构由JVM中的GC线程根据对象间的引用关系决定,但是聪明人会发现,刚才我们仅仅讨论的是析构的时机问题,对于一些对象,在业务层面存在析构的需求,如一些文件描述符,数据库连接资源,需要在对象被回收之前被释放,C++的话会把这些逻辑果断放入析构函数中,但是JAVA是没有析构函数的,那我们要怎样确保对象回收前一些业务逻辑一定执行呢?这就是JAVA finalize()方法能够解决的问题了。

对finalize()的一句话概括: JVM 能够保证一个对象在回收以前一定会调用一次它的 finalize() 方法 。这句话中两个陷阱:回收以前一定和一次,这里先请大家记住这句话,后面会结合JVM的实现来解释。

OK,相信了解过finalize()的人或多或少有个印象: finalize() 就是 JAVA 中的析构函数,或者说 finalize() 是对析构函数的一种妥协。这其实是个危险的误会 ,因为析构函数是构造函数的逆向过程,当程序员调用析构函数时,析构过程是同步透明的,然而对finalize(),你永远不知道它什么时候被调用甚至会不会调用(因为有些对象是永远不会被回收的,或者被回收以前程序就结束了),其次,finalize()是非必要的,看完这篇文章,你甚至会发现它是不被建议的,而对需要析构函数的语言,程序没了它寸步难行。

所以如果一定要给finalize()一个定位,应该说 它是 JAVA 给懒惰的开发者的一个小福利 : ) 。而且请大家牢牢记住一点, JAVA 中的福利往往伴随着风险和性能开销, finalize() 尤其如此 

废话说了这么多,现在来看看SUN JVM是怎么实现finalize()机制的。在看以下内容前,请确保自己对JVM GC机制足够了解。

先看没有自定义finalize()的对象是怎么被GC回收的:

JVM Finalize实现原理与代价_第1张图片

没有自定义finalize()的对象的minor gc

如上图所示:对象在新生代eden区域创建,在eden满了之后会发生一次minor gc,minor gc会将新生代中所有活跃对象(被其他对象引用)从eden+s0/s1区域拷贝到s1/s0,这里我们不考虑GC线程是怎样遍历heap数据以将新生代中活跃的数据找出来的(实际上就是root tracing,通过card table加速),因为这样讲起来会成为另外一个故事,我们这里需要知道的就是minor gc非常快,因为它只会把新生代中非常少量的数据(一般<1%)拷贝到另外一个地方罢了。

我们现在来看一下自定义了(override)finalize()的对象(或是某个父类override finalize())是怎样被GC回收的,首先需要注意的是,含有override finalize()的对象A创建要经历以下3个步骤:

创建对象A实例

创建java.lang.ref.Finalizer对象实例F1,F1指向A和一个reference queue 
(引用关系,F1—>A,F1—>ReferenceQueue,ReferenceQueue的作用先卖个关子)

使java.lang.ref.Finalizer的类对象引用F1 
(这样可以保持F1永远不会被回收,除非解除Finalizer的类对象对F1的引用)

经过上述三个步骤,我们建立了这样的一个引用关系:

java.lang.ref.Finalizer–>F1–>A,F1–>ReferenceQueue。GC过程如下所示:

JVM Finalize实现原理与代价_第2张图片

有override finalize()对象的minor gc

如上图所示,在发生minor gc时,即便一个对象A不被任何其他对象引用,只要它含有override finalize(),就会最终被java.lang.ref.Finalizer类的一个对象F1引用,等等,如果新生代的对象都含有override finalize(),那岂不是无法GC?没错,这就是finalize()的第一个风险所在,对于刚才说的情况,minor gc会把所有活跃对象以及被java.lang.ref.Finalizer类对象引用的(实际)垃圾对象拷贝到下一个survivor区域,如果拷贝溢出,就将溢出的数据晋升到老年代,极端情况下,老年代的容量会被迅速填满,于是让人头痛的full gc就离我们不远了。

那么含有override finalize()的对象什么时候被GC呢?例如对象A,当第一次minor gc中发现一个对象只被java.lang.ref.Finalizer类对象引用时,GC线程会把指向对象A的Finalizer对象F1塞入F1所引用的ReferenceQueue中,java.lang.ref.Finalizer类对象中包含了一个运行级别很低的deamon线程finalizer来异步地调用这些对象的finalize()方法,调用完之后,java.lang.ref.Finalizer类对象会清除自己对F1的引用。这样GC线程就可以在下一次minor gc时将对象A回收掉。

也就是说一次minor gc中实际至少包含两个操作:

将活跃对象拷贝到survivor区域中

以Finalizer类对象为根,遍历所有Finalizer对象,将只被Finalizer对象引用的对象(对应的Finalizer对象)塞入Finalizer的ReferenceQueue中

可见Finalizer对象的多少也会直接影响minor gc的快慢。

包含有自定义finalizer方法的对象回收过程总结下来,有以下三个风险:

如果随便一个finalize()抛出一个异常,finallize线程会终止,很快地会由于f queue的不断增长导致OOM

finalizer线程运行级别很低,有可能出现finalize速度跟不上对象创建速度,最终可能还是会OOM,实际应用中一般会有富裕的CPU时间,所以这种OOM情况可能不太常出现

含有override finalize()的对象至少要经历两次GC才能被回收,严重拖慢GC速度,运气不好的话直接晋升到老年代,可能会造成频繁的full gc,进而影响这个系统的性能和吞吐率。

以上的三点还没有考虑minor gc时为了分辨哪些对象只被java.lang.ref.Finalizer类对象引用的开销,讲完了finalize()原理,我们回头看看最初的那句话: JVM 能够保证一个对象在回收以前一定会调用一次它的 finalize() 方法。

含有override finalize()的对象在会收前必然会进入F QUEUE,但是JVM本身无法保证一个对象什么时候被回收,因为GC的触发条件是需要GC,所以JVM方法不保证finalize()的调用点,如果对象一直不被回收,就一直不调用,而调用了finalize(),也不代表对象就被回收了,只有到了下一次GC时该对象才能真正被回收。另外一个关键点是一次,在调用过一次对象A的finalize()之后,就解除了Finalizer类对象和对象F1之间的引用关系,如果在finalize()中又将对象本身重新赋给另外一个引用(对象拯救),那这个对象在真正被GC前是不会再次调用finalize()的。

总结一下finalize()的两个个问题:

没有析构函数那样明确的语义,调用时间由JVM确定,一个对象的生命周期中只会调用一次

拉长了对象生命周期,拖慢GC速度,增加了OOM风险

回到最初的问题,对于那些需要释放资源的操作,我们应该怎么办?effective java告诉我们,最好的做法是提供close()方法,并且告知上层应用在不需要该对象时一掉要调用这类接口,可以简单的理解这类接口充当了析构函数。当然,在某些特定场景下,finalize()还是非常有用的,例如实现一个native对象的伙伴对象,这种伙伴对象提供一个类似close()接口可能不太方便,或者语义上不够友好,可以在finalize()中去做native对象的析构。不过还是那句话,fianlize()永远不是必须的,千万不要把它当做析构函数,对于一个对性能有相当要求的应用或服务,从一开始就杜绝使用finalize()是最好的选择。

 总结

override finalize()的主要风险在于Finalizer的Deamon线程运行的是否够快,它本身是个级别较低的线程,若应用程序中CPU资源吃紧,很可能出现Finalizer线程速度赶不上新对象产生的速度,如果出现这种情况,那程序很快会朝着“GC搞死你”的方向发展。当然,如果能确保CPU的性能足够好,以及应用程序的逻辑足够简单,是不用担心这个问题的。例如那个再现问题的小程序,在我自己i7的笔记本上跑,就没有任何GC问题,CPU占用率从未超过25%(硬件上的东西不太懂,为什么差距会这么多?),出现问题的是在我的办公机上,CPU使用率维持在90%左右。

当然,互联网应用,谁能保障自己的服务器在高峰期不会资源吃紧?无论如何,我们都需要慎重使用override finalize()。至于JDBC Connector/J中应不应该override finalize(),出于保险考虑,我认为是应该的,但若是公司内部服务,例如网易DDB实现的JDBC DBI(分布式JDBC),Connection完全没必要做这层考虑,如果应用程序忘了调close(),测试环境会很快发现问题,及时更改即可。

你可能感兴趣的:(JVM)