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

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

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

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

阅读本文前请确保本身的JVM的GC原理有足够理解,不然看起来会很是艰难。看过本文后若对finalize()仍有疑惑,或有不一样意见,欢迎提出和指正。web

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

为了帮助你们理解,举个例子,有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(),测试环境会很快发现问题,及时更改便可。

相关文章
相关标签/搜索