首页 > 代码库 > JVM——成为Java GC专家(1)

JVM——成为Java GC专家(1)

原文: 

Understanding Java Garbage Collection



JVM——成为Java GC专家(1)


 

    理解Java垃圾回收机制(GarbageCollection,简称GC)是如何工作的有什么好处?做为一名软件工程师,为了满足自己的好奇心去了解他是其中的一个原因,并且理解GC工作原理更能让我们写出性能更好、更健壮的Java应用程序。

    这仅仅是我个人观念,但是我相信、精通GC是做为一个优秀的Java工程师的必要条件。如果你对GC工作原理感兴趣、那么就意味着你已经具有了开发一定规模的Java应用程序的项目经验。如果你已经在为你的Java应用程序到底该使用哪种GC算法而小心翼翼的做出选择的时候,说明你已经完全的了解你所开发的项目的特点与细节。当然、这也许不能成为衡量一个好的Java工程师的标准。但是很少人会反对我前面提到的一个观点:理解并掌握GC工作原理是成为一个伟大Java工程师的必要条件。

    这是"Become a Java GC Expert"系列的第一篇文章,我会对GC做一些简介。在下面的文章中我会讨论一些NHN上关于分析GC状态和GC调优的示例。

    这篇文件主要的目的是深入简出的介绍一下GC,用于帮助我们理解他到底是什么。我希望他能为你提供一些帮助。实际上,我的学生们已经在Twitter上发表了一些非常优秀的论文: a few great articles on Java Internals。如果你有兴趣的话、可以浏览一下。

    现在回到GC这个问题上来,在学习GC之前、你应该知道一个术语“Stop-the-world”。无论你使用那种垃圾回收算法,当应用程序在执行垃圾回收的时候,Stop-the-world都会发生。当Stop-the-world发生的时候,除了执行GC的线程以外、所有的线程都会停止他们正在执行的任务。这些被迫停止的任务只能在GC回收任务完成之后才能继续执行。GC调优通常情况下就是尽可能的减少stop-the-world发生的时间。

 

分代垃圾回收

 

    Java程序代码中不会显示的声明和释放一块内存空间。有些程序员可能会将相关的Object对象引用设置为null或者手动调用System.gc()方法去显示的释放一块内存空间。将引用设置为null不是什么大问题、但是手动调用System.gc()方法则会严重的影响系统的执行效率,这种方式是严禁使用的。(谢天谢地,直到现在NHN的程序员还没有哪个会这样做)

    在Java中、既然开发者不能在代码中手动的调用System.gc()方法去释放内存,那么这些就应该交给Java垃圾回收器去做、它会去收集那些不再需要的对象实例并且移除他们、释放内存。垃圾回收器的创建是建立在下面两个假设(hypotheses)成立的情况下。(将他们称作前提(suppositions)或者先决条件(preconditions)会比假设更合适点。)

    a)大量实例对象短时间内变得不可达(unreachable)

    b)只有少量老的实例对象的引用指向新的对象

这些假设被成为“弱代假设”——weakgenerational hypothesis。所以为了呈现这种假设、HotSpot VM物理上被划分为两块区域——“年轻代”和“年老代”(younggeneration and old generation)。

    年轻代:大多数新建的对象都会被先存放在这里。当大量的对象很快变的不可达的时候,一些在年轻代中创建的对象就会消失(被回收)。当年轻代中的对象消失的时候、“minor GC”执行了。

    年老代:那些没有变得不可达并且一直存货在年轻代的实例对象会被移动到这里(也就是年老代中)。一般情况下年老代内存空间会比年轻代大。因此在年老代发生的垃圾回收频率会比年轻代低。当年老代中的对象消失(被回收)的时候、“major GC”执行了(或者称为“full GC”)。





上图中的持久代(permanent generation)也被称为方法域(method areas),被用来存放classes和方法的元数据。所以很明显这块区域不是用来持久化那些年老代中存活的实例对象的。这块区域也有可能发生垃圾回收、并且这里发生的垃圾回收也会被归类为“major GC”也称作“full GC”。

有些人会疑惑:

    如果年老代中的实例对象持有年轻代中实例对象的引用怎么办?(也就是说、当年轻代发生GC时、如何检查其内的实例对象是否被年老代引用,是遍历整个年老代所有实例对象是否满值条件还是如何处理?)。

   对于这种情况,在年老代中有一个大小为512字节的数据块,被称为“cardtable”,当年老代中有对象引用年轻代对象的时候、年老代中的这个对象就会被记录在这个数据块中。当年轻代发生垃圾回收时(“minor GC”),年老代中只有“card table”中的内容会被搜索,进而决定年轻代中此对象是否被引用、而不是检查年老代中所有对象是否持有此对象引用。这个“card table”是被“write barrier”管理的、“writer barrier”是一种提高“minor GC”执行效率的设备。虽然因为“card table”的存在使得设计有点复杂、但是也因为它的存在大大减少了垃圾回收器执行的整体时间。




年轻代组成结构


为了理解GC、首先要了解用于存放所有新建的实例对象的年轻代,年轻代被划分为三块:

    a)一个Eden space(伊甸园,觉得还是不要直译的好、怪怪的)

    b)两个Survivor spaces(幸存者空间,依然不再直译了)

总共三个空间:其中两个是Survivor spaces,每个空间中事件的执行步骤如下:

    1. 绝大部分新建对象都会被分配到Eden space。

    2. Edenspace执行第一次GC之后,继续存在的实例对象会被转移到Survivor space.

    3. Edenspace再执行一次GC之后,继续存在的实例对象会被转移到那个已经有实例对象的Survivor space中。

    4. 一旦Survivor space满了之后、继续存在的对象会被转移到另一个Survivor space中。然后前面的Survivor space就会被清空。

    5. 上述步骤重复到一定次数之后仍然存活的实例对象就会被转移到年老代中。

 

    注:目的就是在对象被转移到年老代之前、增加被回收几率。

从上述步骤可以看出、其中一个Survivor space一定是空的。如果你的两个Survivor space都有数据、或者都没有数据、那么很有可能意味着你的程序出了什么问题。

    下图展示了数据是如何经过minor GC 转移到年老代中的:





值得注意的是:在HotSpot VM中、有两种技术被应用与加快内存分配:

    1.Bump-the-pointer

    2.TLABs(Thread-Local Allocation Buffers).

Bump-the-pointer(指针)技术追踪最后一个添加到Eden space的实例对象的位置。此对象会被放置与Edenspace顶部。当有再新对象被创建时、只需要检查最后一个存放在Eden space中的对象的信息就可以知道新建对象是否能够存放在Eden space。如果可以则会被存放在Edenspace顶部。所以当一个新的对象被创建之后、之后最后一个被存放的对象会被检测,这种机制大大提升了内存分配效率。然而这种方式却不适合多线程的环境下、多线程操作时会因为锁而导致某些线程不能正常执行、这就导致了效率大大降低。

TLABs则是针对这种情况设计的。TLABs允许每个线程把Eden space的一小部分做为自己的操作对象、这样一来、每个线程就可以使用Bump-top-pointer技术来加快内存分配,从而避免锁带来的线程问题。

到现在为止是对年轻代的概述。读者不必一定要记住我上面提到的两种加快分配内存的技术,即使不知道他们也不影响后面内容的学习。但是要记住的是:新建的对象是存放在Eden space中的、经过各种处理依然存活的对象将会被转移到年老代中。



年老代的垃圾回收



    当年老代中存放满实例对象时会触发“full GC”执行。执行的情况与GC的类型有关、所以理解GC的类型有助于我们理解执行的情况。根据JDK7、有5中GC类型:

    1. SerialGC 

    2. ParallelGC

    3. ParallelOld GC

    4. ConcurrentMark & Sweep GC(or “CMS”)

    5. GarbageFirst(G1) GC

上面提到的这些、服务器上一定不要使用Serial GC,这种类型的GC只适合使用在单线程的客户机上。使用这种类型的GC会大大降低程序的执行效率。

 

Serial GC (-XX:+UseSerialGC)

    年轻代使用的GC算法(bump-top-pointer,TLABs)我们已经在前面提到过了、年老代使用的GC算法被称为“mark-sweep-compact”(标记-擦除-压缩)。

    1. 算法第一步是标记出年老代中继续存活的对象

    2. 从前到后检查堆中所有对象、回收所有未被标记的对象、只留下被标记的对象

    3. 将所有对象在堆中从前到后连续排放、并且把堆空间划分成两部分、一部分存放对象、另一部分未存放对象(即压缩部分)

Serial GC适合内存小、单核机器。

 

Parallel GC(-XX:+UseParallelGC)





从图片中你可以很容易的发现两者的不同之处。Serial GC使用单线程、而Parallel使用多线程并且更快。Parallel GC的效率在内存较大多核CPU上更高。也常被称为“throughputGC”。



Parallel Old GC(-XX:UseParallelOldGC)

Parallel Old GC是从JDK1.5之后才支持的。相对于ParallelGC、唯一不同的地方就是GC算法不同。ParallelOld GC算法分三步:标记-摘要-压缩。摘要是识别出前面已经执行过GC的区域中存活的对象、这就是不同于Parallel GC中sweep步骤的地方、执行的细节更复杂了一点。



CMS GC(-XX:UseConcMarkSweepGC)





如图所示、Concurrent Mark-Sweep GC比其他几个GC更加复杂。

    前期的初始化标(initial mark)记这一步比较简单、距离ClassLoader最近的继续存活的对象会首先被搜索到。所以暂停的时候非常短。

    在并行标记(concurrent mark)这一步、那些被存活的对象引用的对象仅仅被确认是否已经被追踪和检验过。这一步的不同之处在于当其他的线程被执行的同时、他也在执行。

    在重新标记(remark)这一步、那些最新添加的和在并发标记步骤被停止被引用的对象开始被检验。

    最后、在并行交换(concurrent sweep)这一步、垃圾回收开始执行。从这可以看出、垃圾回收执行的同时、其他的线程仍然在执行。所以这种GC暂停的时间非常短暂。CMCGC又称为low latency GC、用于那些对响应时间要求特别苛刻的程序。

    事有利弊、CMS也有一些缺陷:

    1. 比其他类型GC需要更多的内存、更强的CPU

    2. 默认不会自动执行压缩

 

所以你要经过深思熟虑之后才能决定是否需要使用这种GC。同时如果是因为内存碎片太多而需要执行压缩任务时、stop-the-world的时间要比其他任何GC都要长、所以你要确定这种压缩发生的频率和耗时。


G1 GC


如果你想要理解G1 GC、那就要抛开年轻代、年老代的概念。正如图片显示的一样、每个实例对象会被分配到每个网格中、随后GC执行。一旦一个区域被数据填充满之后、实例对象会被分配到另一个区域中、GC再次执行。在这里没有从年轻代转移到年老代这样的步骤。这种GC类型存在的意义是用来替换CMSGC的、因为CMS GC在很长一段时间内引发了很多问题和抱怨。

    G1 GC最大的优点就是他的执行效率比任何类型的GC都要高。但是在JDK1.6中、这仅仅是预览版、用于测试。在JDK7中才被正式的加入。在我看来、我们至少还需要一个很长的测试阶段才能将其应用与实际的服务中去、所以你应该可能要等很长一段时间。并且我也不止一次的听到关于使用JDK6的G1而导致的JVM宏机。我们要做的就是等到他更加的稳定的时候才去使用。

    我将会在下一章讨论关于GC调优的问题、但是在这之前我想向读者明确一件事情:假如应用中所有的实例对象的类型、大小都是相同的,那么公司WAS的所有的GC参数也都是相同的。但是WAS创建的对象的大小和寿命会因为WAS所依赖的设备、运行环境的变化而变化。换句话说、在一个运行环境中运行效果良好的GC参数“A”、并不一定适合另一个运行环境。我们需要找到适合每个WAS线程的参数,并且持续的监控和优化每个设备上的WAS实例。这并不是我从个人经验得到的结论,而是从负责Oracle Java虚拟机研发的工程师在JavaOne 2010上的讨论中得来的。

本文中我们简略的介绍了Java的GC机制,请继续关于我们的后续文章,我们将会讨论如何监控JavaGC状态以及GC调优。

另外,我特别推荐一本2011年12月发布的《Java性能》(Amazon,如果公司提供帐号的话、也可以使用safari在线阅读),还有在Oracle官网发布的白皮书《Java HotSpotTM虚拟机内存管理》(这本书与Java性能优化不是同一本) 

    作者SangminLee, NHN公司,性能工程师实验室高级工程师。













JVM——成为Java GC专家(1)