JVM--JVM finalize实现原理与由此引发的血案

大城市里の小女人 提交于 2020-01-08 14:58:27

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

原创内容,转载请注明出处

本文由一桩因为使用了JAVA finalize()而引发的血案入手,讲解了JVM中finalize()的实现原理和它的陷阱所在,希望能够对广大JAVA开发者起到一点警示作用。除此之外,本文从实际问题出发,描述了解决问题的过程和方法。如写模拟程序来重现问题,使用jmap工具进行分析等,希望对大家提供借鉴。

本文分三个章节,先介绍实际项目中遇到的问题,随后介绍了问题重现和分析方法,最后对问题的元凶,override finalize()的实现原理和陷阱进行了讲解和介绍。篇幅较长,可以分开独立阅读。

阅读本文前请确保自己的JVM的GC原理有足够理解,否则看起来会非常艰难。看过本文后若对finalize()仍有疑惑,或有不同意见,欢迎提出和指正。

DDB Proxy的一桩血案

入职没多久,接手一个分布式数据库(分库分表MySQL)的SQL Proxy(以下简称Proxy),在对它进行测试的过程张发现一些带limit的语句跑着跑着会引发整个代理服务器的TPS骤降。当时遇到这个问题毫无头绪,跟着一些人的建议检查了GC日志,结果发现TPS骤降的时候,Proxy进程正好开始频繁的full gc,full gc将近每3秒一次,一次维持2秒左右,TPS从2000直接降到100,考虑到GC基本把JVM进程的资源吃光了,这种现象也可以理解(Proxy的GC参数见下面的血案再现,除了Proxy新生代为1G,老生代也为1G外,其他都一样)。

之后开始追查频繁full gc的原因,经过团队长时间奋战,最后把问题定位在Proxy Server对大数据结果集的处理对策上。

为了帮助大家理解,举个例子,有SQL如下:

select titile, content from blog where user_id = 1001 limit 10;
显示用户1001第一页博客列表

因为Proxy Server后端是一个分库分表的MySQL集群,它在接到这个SQL请求时,会先把这个SQL下发给所有数据节点,假如集群中有10个MySQL数据节点,那么会从所有数据节点中返回最多100行数据,然而应用SQL需要的仅仅是10行数据,因此100行数据中90行数据是要丢弃的。虽然在这条SQL中,90行数据看起来没什么,但如果集群中有100个节点,limit改为100呢?就需要丢弃9900条数据,而且在常规做法中,要先将10000行数据载入内存里。倘若limit 100后面再加个offset 1000,如:

select sender_id, msg from message where user_id = 1001 limit 100 offset 1000
显示用户1001第10页消息列表,每页100条消息

对Proxy Server的内存来说将是一场灾难(对分布式执行计划,offet的数值是要累加到limit中下发给数据节点的)。

为了避免OOM,Proxy采用了MySQL提供的流结果集机制(详情谷歌MySQL stream resultset),在这种机制下,MySQL结果集是在调用ResultSet.next()方法是一行行(流水一样)载入内存的,一般情况下在后面几行被载入时前面的数据行就可以被GC了,由此避免了OOM。但是这种机制下还存在另一个问题:拿之前的SQL来说,在得到最终的10行数据后,Proxy需要丢弃多余的90行数据,而这个丢弃的前提是先把它们读进内存,因为流数据没读完的连接是不可用的(MySQL实现流结果集的机制是一边获得结果集一边向Client传输,因此在流数据没有读完前,MySQL对应的连接线程可能还处于忙碌状态),也就是说,如果不把剩余的90行数据读进内存,而直接把连接放回物理连接池,当这些连接被再利用时会向Proxy抛出“stream resultset is still alive”的异常。但是从设计层面讲,“读完”连接中多余的数据是毫无意义的,如果多余的数据有上百万行,那将是件极其痛苦的事情。为此,Proxy的设计先驱们想了一个办法:当一个到数据节点的物理连接中含有多余流数据时,直接关掉。下个SQL请求向连接池申请连接时可以通过创建新连接来弥补不足。

这个方案看起来极其美好,既不会内存溢出,也不会因为读多余的流数据而影响QPS。

然后我就在性能测试中发现了这个严重的full gc问题。

这个问题我是从结论开始讲的,认真看下来的朋友应该已经猜到了各种缘由,没错,正是因为Proxy采用了当物理连接中含有多余流数据时选择关连接,而放弃重用,导致了内存资源被快速耗尽,并引发了频繁full gc。

虽然现在可以很轻松的说出这个结论,但当时往这个方向想却费了我很大的周折,试想测试中Proxy的QPS也就2000不到,测试的客户端并发线程不过10个,JVM的GC时间和效率取决于GC那STW的一会内存中垃圾所占比重,从原理上讲,10个客户端线程顶多也就10个Connection对象是活跃的,其他Connection对象都可以被回收,而且每秒2000个对象也不能称之为多,所以GC时首先触发的minor gc效率应该很高,因为它仅仅是将活跃的对象拷贝出去,把剩余的整块内存重利用而已。然而测试中我们发现minor gc时所拷贝的活跃对象远远超出了预期:1G的新生代,Survivor区域设置为100m,因此每次最多往Survivor拷贝100m活跃对象,多余的活跃对象会直接晋升老年代。在我们的测试中,每次minor gc除了拷贝100m活跃对象外,还会有几十m的对象往老年代晋升,这样每次minor gc都要花秒级时间,而且过不了多久就会因为老年代撑满触发full gc,而full gc时能够回收的对象又很少,以至于进入一个恶性循环。

现有原理上说不通的事情,最好的办法就是先用小程序模拟场景,再做细致分析。于是我用一个简单的JDBC小程序模拟了不断关连接,申请新连接的操作,结果真的复现了频繁full gc问题。

无论如何还是要先解决问题,在把“当物理连接中含有多余流数据时选择关连接,而放弃重用”的机制改为“读完多余流数据后,放回连接池重用”,Proxy的QPS终于稳定下来,查看GC日志,每次minor gc仅拷贝7-10m数据,耗时个位数ms,连续跑2天没有发生full gc。

于是最后解决方案就是“读完多余流数据后,放回连接池重用”,当然读完流数据是有开销的,在测试程序中都是limit 10到100的SELECT用例,没有offset,所以影响甚微。我们也在Proxy的开发者白皮书中建议用户不要写过大的limit,尽量不要使用offset。

到此虽然问题解决,但是究竟什么原因导致了频繁full gc和gc时间过长,还一头雾水,接下来我们通过一个小JDBC小程序来再现,并分析一下这个问题场景。

血案再现与分析

写了个不能再简单的程序来复现上述问题,代码如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

public static void main(String[] args)

            throws ClassNotFoundException, InterruptedException {

    Class.forName("com.mysql.jdbc.Driver");

    final String url = "jdbc:mysql://127.0.0.1:3306";

    final String user = "majin";

    final String pwd = "123456";

    for (int i = 0; i < 5; i++) {

        new Thread() {

            public void run() {

                java.sql.Connection con = null;

                while (true) {

                    try {

                        con = DriverManager.getConnection(url, user, pwd);

                        con.createStatement().executeQuery("select 1");

                        Thread.sleep(200);

                    } catch (Exception e) {

                        e.printStackTrace();

                    } finally {

                        if (con != null)

                            try {

                                con.close();

                            } catch (SQLException e) {

                                e.printStackTrace();

                            }

                    }

                }

            }

        }.start();

    }

}

程序中有10个线程,每个线程循环进行建立连接,执行select 1,释放连接的操作,为了防止socket被快速耗尽,在释放连接后sleep 200ms。GC算法与Proxy保持一致采用CMS,设置新生代100m,老生代100m,survivor大小为默认的新生代1/8。另外JDBC Connector/J采用了5.0.8版本(因为之前Proxy使用的是这个老版本,用的还是JDK1.5):

-Xmn100m -Xmx200m -Xms200m -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=85 -XX:+CMSParallelRemarkEnabled -XX:+UseConcMarkSweepGC

从gc日志看出,full gc平均30s一次,截取minor gc日志如下:

1

2

3

81.049: [GC 81.049: [ParNew: 92159K->10239K(92160K), 0.0878585 secs] 113662K->48935K(194560K), 0.0879499 secs]

89.204: [GC 89.204: [ParNew: 92159K->10239K(92160K), 0.0963608 secs] 130855K->66922K(194560K), 0.0964569 secs]

97.368: [GC 97.368: [ParNew: 92159K->10240K(92160K), 0.0977226 secs] 148842K->85149K(194560K), 0.0978146 secs]

可以看出minor gc时间几乎在百ms级别(如果是1G新生代可能就是秒级别了),很不理想,30s一次full gc也无法令人接受。问题既然已经复现,现在就要寻找分析问题的手段,首先想到的是用jmap命令打印出程序中大概的对象分布。但是发现jmap不支持JDK1.5。于是将程序的依赖包改为Connector/j 5.1.27和JDK1.6。再测发现full gc的平均间隔从30s延长到了50s,另外minor gc的时间也降到50ms左右,看来1.6的JVM在GC算法上有非常明显的进步。

用jmap -histo pid得到内存活跃对象列表后,有两类对象引起了我的注意:

1695 1979760 com.mysql.jdbc.JDBC4Connection
3963 158520 java.lang.ref.Finalizer

第一个JDBC4Connection是Connection/J中的Connection实例,在jmap的那一瞬间,内存中有1695个Connection,做个简答的计算:minor gc的间隔平均8s,8s内10个线程最多产生的Connection数目为1000*8*10/200(假设建立连接,select 1,释放连接的时间为0,仅除以200ms的间隔),400个。而jmap的结果却有1695个,而且这个还不是峰值。这个现象足以说明一部分Connection对象在被清除引用后,没有在第一次minor gc被回收

第二个Finalizer对象让我想到了JAVA中的finalize()方法,我知道override finalize()的对象在被回收以前一定会被调用finalize()以做一些清理工作,但这个实现机制不了解。于是做了一些调研,然后就有了这篇博客。没错,override finalize()就是罪魁祸首,让我们看看JDBC Connector/J中这个万恶的存在:

1

2

3

protected void finalize() throws Throwable {

    cleanup(null);

}

可以看到finalize()中仅仅调用了cleanup(null),而cleanup()也是close()方法中的主要逻辑,也就是说finalize()这里做的工作仅仅是确保Connection对象在被回收前释放它占有的资源,如果程序中已经调用了Connection.close(),这个确保可谓是没有意义的。

尝试把Connection/J中的finalize()源码注释掉,再运行测试程序,结果出乎意料地好,full gc消失了(在有限的测试时间内),截取部分minor gc日志如下:

1

2

3

74.150: [GC 74.150: [ParNew: 83047K->1223K(92160K), 0.0017912 secs] 83805K->1981K(194560K), 0.0018810 secs]

82.372: [GC 82.372: [ParNew: 83143K->1072K(92160K), 0.0038011 secs] 83901K->1830K(194560K), 0.0038968 secs]

90.455: [GC 90.455: [ParNew: 82992K->1097K(92160K), 0.0024273 secs] 83750K->1855K(194560K), 0.0025451 secs]

可以看到minor gc的代价有了质的下降,修改源码前每次minor gc需要拷贝20m的数据,其中10m是直接晋升老年代的。而去掉finalize()方法后,每次minor gc仅拷贝1m数据,且gc时间从百ms级别降到了5ms以下。可见finalize()的影响之大。

接下来我们看看override finalize()到底是怎样把GC搞的一塌糊涂的。

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回收的:

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

没有自定义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过程如下所示:

有override finalize()对象的minor gc

有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(),测试环境会很快发现问题,及时更改即可。


易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!