本文是成为Java GC专家的案例介绍GC调优相关的内容。
本文的目的是以通俗的方式为你介绍GC概念。我希望本文会对你有所帮助。事实上,我的同事们已经发表了一些在Twitter上非常受关注的[优秀文章](),你同样也可以拿来参考。
回到垃圾回收上,在开始学习GC之前你应该知道一个词:stop-the-world。不管选择哪种GC算法,stop-the-world都是不可避免的。Stop-the-world意味着从应用中停下来并进入到GC执行过程中去。一旦Stop-the-world发生,除了GC所需的线程外,其他线程都将停止工作,中断了的线程直到GC任务结束才继续它们的任务。GC调优通常就是为了改善stop-the-world的时间。
在Java程序里不需要显式的分配和释放内存。有些人通过给对象赋值为null或调用System.gc()以期望显式的释放内存空间。给对象设置null虽没什么用,但问题不会太大;如果调用了System.gc()却可能会为系统性能带来严重的波动,即便调用System.gc()系统也未必立即响应去执行垃圾回收。(所幸的是,在NHN未曾看到有工程师这么做。)
在使用Java时,程序员不需要在程序代码中显式的释放内存空间,垃圾回收器会帮你找到不再需要的(垃圾)对象并把他们移出。垃圾回收器的创建基于以下两个假设(也许称之为推论或前提更合适):
这两条假设被称为”弱分代假设“。为了证明此假设,在HotSpot VM中物理内存空间被划分为两部分:新生代(young generate)和老年代(old generation)。
新生代:大部分的新创建对象分配在新生代。因为大部分对象很快就会变得不可达,所以它们被分配在新生代,然后消失不再。当对象从新生代移除时,我们称之为”minor GC“。
老年代:存活在新生代中但未变为不可达的对象会被复制到老年代。一般来说老年代的内存空间比新生代大,所以在老年代GC发生的频率较新生代低一些。当对象从老年代被移除时,我们称之为”major GC“(或者full GC)。
看一下下图的示意:
图1:GC区域和数据流向
图中的permanent generation称为方法区,其中存储着类和接口的元信息以及interned的字符串信息。所以这一区域并不是为老年代中存活下来的对象所定义的持久区。方法区中也会发生GC,这里的GC同样也被称为major GC。
有些人可能认为:
如果老年代的对象需要持有新生代对象的引用怎么办?
为了处理这种场景,在老年代中设计了”索引表(card table)“,是一个512字节的数据块。不管何时老年代需要持有新生代对象的引用时,都会记录到此表中。当新生代中需要执行GC时,通过搜索此表决定新生代的对象是否为GC的目标对象,从而降低遍历所有老年代对象进行检查的代价。该索引表使用写栅栏(write barrier)进行管理。wite barrier是一个允许高性能执行minor GC的设备。尽管它会引入一个数据位的开销,却能带来总体GC时间的大幅降低。
图2:索引表结构
为了深入理解GC,我们先从新生代开始学起。所有的对象在初始创建时都会被分配在新生代中。新生代又可分为三个部分:
在三个区域中有两个是Survivor区。对象在三个区域中的存活过程如下:
如上所述,两个Survivor区域在任何时候必定有一个保持空白。如果同时有数据存在于两个Survivor区或者两个区域的的使用量都是0,则意味着你的系统可能出现了运行错误。
下图向你展示了经过minor GC把数据迁移到老年代的过程:
图3: GC前后
在HotSpot VM中,使用了两项技术来实现更快的内存分配:”指针碰撞(bump-the-pointer)“和”TLABs(Thread-Local Allocation Buffers)“。
Bump-the-pointer技术会跟踪在Eden上新创建的对象。由于新对象被分配在Eden空间的最上面,所以后续如果有新对象创建,只需要判断新创建对象的大小是否满足剩余的Eden空间。如果新对象满足要求,则其会被分配到Eden空间,同样位于Eden的最上面。所以当有新对象创建时,只需要判断此新对象的大小即可,因此具有更快的内存分配速度。然而,在多线程环境下,将会有别样的状况。为了满足多个线程在Eden空间上创建对象时的线程安全,不可避免的会引入锁,因此随着锁竞争的开销,创建对象的性能也大打折扣。在HotSpot中正是通过TLABs解决了多线程问题。TLABs允许每个线程在Eden上有自己的小片空间,线程只能访问其自己的TLAB区域,因此bump-the-pointer能通过TLAB在不加锁的情况下完成快速的内存分配。
本小节快速浏览了新生代上的GC知识。上面讲的两项技术无需刻意记忆,只需要明白对象开始是创建在Eden区,然后经过在Survivor区域上的数次转移而存活下来的长寿对象最后会被移到老年代。
当老年代数据满时,便会执行老年代垃圾回收。根据GC算法的不同其执行过程也会有所区别,所以当你了解了每种GC的特点后再来理解老年代的垃圾回收就会容易很多。
在JDK 7中,内置了5种GC类型:
其中Serial GC务必不要在生产环境的服务器上使用,这种GC是为单核CPU上的桌面应用设计的。使用Serial GC会明显的损耗应用的性能。
下面分别介绍每种GC的特性。
在前面介绍的年轻代垃圾回收中使用了这种类型的GC。在老年代,则使用了一种称之为”mark-sweep-compact“的算法。
Serial GC适用于CPU核数较少且使用的内存空间较小的场景。
图4:Serial GC与Parallel GC的区别
图中可以容易的看出serial GC与parallel GC的区别。Serial GC使用单一线程执行GC,而parallel GC则使用多个线程并发执行,因此parallel GC 较serial GC具有更快的速度。Parallel GC适用于多核CPU且使用了较大内存空间的场景。Parallel GC又被称为”高吞吐GC(throughput GC)”
Parallel Old GC在JDK 5中被引入,与Parallel GC相比唯一的区别在于Parallel的GC算法是为老年代设计的。它的执行过程分为三步:标记(mark)–总结(summary)–压缩(compaction)。其中summary步骤会会分别为存活的对象在已执行过GC的空间上标出位置,因此与mark-sweep-compact算法中的sweep步骤有所区别,并需要一些复杂步骤才能完成。
图5:Serial GC与CMS GC
从图上可看出并发标记-清理(Concurrent Mark-Sweep) GC比以后上其他GC都要复杂。开始时的初始标记(initial mark)比较简单,只有靠近类加载器的存活对象会被标记,因此停顿时间(stop-the-world)比较短暂。在并发标记(concurrent mark)阶段,由刚被确认和标记过的存活对象所关联的对象将被会跟踪和检测存活状态。此步骤的不同之处在于有多个线程并行处理此过程。在重标记(remark)阶段,由并发标记所关联的新增或中止的对象瘵被会检测。在最后的并发清理(concurrent sweep)阶段,垃圾回收过程被真正执行。在垃圾回收执行过程中,其他线程依然在执行。得益于CMS GC的执行方式,在GC期间系统中断时间非常短暂。CMS GC也被称为低延迟GC,适用于所有应用对响应时间要求比较严格的场景。
CMS GC虽然具有中断时间断的优势,其缺点也比较明显:
使用CMS GC之前需要对系统做全面的分析。另外为了避免过多的内存碎片而需要执行压缩任务时,CMS GC会比任何其他GC带来更多的stop-the-world时间,所以你需要分析和判断压缩任务执行的频率及其耗时情况。
最后我们学习有关G1垃圾回收的介绍。
图6:G1 GC的布局
如果你想清晰的理解GC,请先忘记上面介绍的有关新生代和老年代的知识。如上图所示,每个对象在创建时会分析到一个格子中,后续的GC也是在格子中完成的。每当一个区域分配满对象后,新创建的对象就会分配到另外一个区域,并开始执行GC。在这种GC中不会出现其他GC中的对象在新生代和老生代三区域中移动的现象。G1是为了取代在长期使用中暴露出大量问题且饱受抱怨的CMS GC。
G1最大的改进在于其性能表现,它比以上任何一种GC都更快速。它在JDK6中以早期版本的形式释放出来以用于测试,它真正的发布是在JDK7中。我个人认为在NHN真正在生产环境使用JDK7至少还需要1年的测试时间,所以还需要等待一段时间。并且我听说在JDK6中使用G1偶尔会出现JVM崩溃现象。所以稳定版尚需时日。
接下来的文章中会讲解GC调优,但我想先提一个问题。如果应用中所有对象的类型和大小都是一样的,WAS上使用的GC可以设置相同的GC选项。如果在WAS上创建的对象的大小和生命周期各不相同的对象,配置的GC选项也各不相同。换名话说,不能因为一个服务使用了GC选项”A”,其他的不同服务使用相同的选项”A”也能获取最好的表现。所以为了找到WAS线程的最佳值,每个WAS实例需要通过持续的调优和监控以便找到最优的配置和GC优项。这不只是来自我的个人经验,而是来自于JavaOne 2010上工程师们对于Oracle JVM讨论后的一致看法。
本节我们只简单介绍Java中的GC基础。下一章节,我将会讨论关于如何监控GC状态以及如何做性能调优。
本文参考了2011年12月出版的《Java 性能》和Oracle网站上提供的白皮书《Java HotspotTM 虚拟机内存管理》。
作者:Sangmin Lee, 性能实验室高级工程师,NHN公司