并行标记清除(CMS)回收器
CMS垃圾回收器是第一个广泛使用的低延迟回收器。虽然在Java 1.4.2中就可以使用了,但刚开始还不是很稳定。这些问题直到Java 5才得以解决。
从CMS回收器的名字就可以看出它使用并行方式:大部分回收工作由一个GC线程完成,与处理用户请求的工作线程并行执行。老年代原来单一的stop-the-world回收过程被划分为两个更短的stop-the-world暂停加上5个并行阶段。在这些并行阶段中,原来的工作线程照常运行(不会被暂停)。关于CMS更详细的介绍可以参考这篇文章《Java SE 6 HotSpot Virtual Machine Garbage Collection Tuning》。
使用下面的参数可以激活CMS回收器:
-XX:+UseConcMarkSweepGC
再次应用到上面的测试程序(并提高负载)可以得到以下结果:
图4 优化堆大小并使用CMS的JVM在50小时内的GC行为(-Xms1200m -Xmx1200m -XX:NewSize=400m -XX:MaxNewSize=400m -XX:SurvivorRatio=6 -XX:+UseConcMarkSweepGC))
可以看到,老年代GC的8s左右暂停已经消失了。现在,老年代回收过程只出现两次暂停(前一次的结果50小时内有5次),并且所有暂停都在1s内。
默认情况下,CMS回收器使用ParNew(GC算法)处理新生代回收。如果ParNew和CMS一起运行,它的暂停会比没有CMS时长一点,因为他们之间需要额外的协同工作。与上次的测试结果相比,可以从新生代的平均暂停时间略有上升发现这个问题。新生代暂停时间中离群值频繁出现,从这里也可以发现这个问题。离群值可以达到0.5s左右。但是这些暂停对很多应用来说已经足够短了,所以CMS/ParNew组合可以作为一个很好的低延迟优化选择。
CMS回收器的一个严重缺陷就是,当老年代空间都被占满时CMS无法启动。一旦老年代被占满了,启动CMS就太晚了;虚拟机必须使用通常的“stop-the-world”策略(在GC日志中会出现“concurrent mode failure”的记录)。为了实现低延迟目标,当老年代空间占用量达到一定门限值时,就应该启动CMS回收器,通过以下设置来实现:
-XX:CMSInitiatingOccupancyFraction=80
这表示一旦老年代空间被占用80%时,CMS回收器就会运行。对于我们的应用,使用这个值(也就是默认值)就可以。但如果把门限值设太高的话,就会产生“concurrent mode failure”,导致长时间的老年代GC暂停。反过来,如果设的太低(低于活跃空间大小),CMS可能一直并行运行,导致某个CPU核心完全用在GC上。如果一个应用的对象创建和堆使用行为变化很快,比如通过交互的方式或者计时器启动专门的任务,很难设置一个合适的门限值同时避免上述两种问题。
碎片的阴影
然而,CMS最大的一个问题是它不会整理老年代堆空间。这样会产生堆碎片,随着时间运行,会导致服务严重恶化。有两个因素会导致这种情况:紧缺的老年代空间大小,以及频繁的CMS回收。第一个因素可以通过增大老年代堆空间来改善,要大于ParallelGC回收器所需要的空间(我从1024M增加到1200M,从前几幅图可以看到)。第二个问题可以通过合适地划分各代空间来优化,前面讲过。我们可以实际看一下这样可以把老年代GC的频率降低多少。
为了证明使用CMS前合理地调整各代堆大小很重要,我们先看看如果不遵守上述的原则,在图1(几乎不对堆做优化)的基础上直接使用CMS回收器会怎么样:
图5 未优化堆大小的GC行为,以及使用CMS后内存碎片导致的性能恶化(从第14小时开始)
很明显,JVM在这样设置的负载测试下可以稳定地工作将近14个小时(在生产环境以及更小的负载条件下,这个不稳定的良性阶段可能会持续更久)。接下来,突然间会出现多次很长的GC暂停,暂停时间几乎占剩余时间的一半。不仅老年代的暂停时间会达到10s以上,而且新生代的暂停时间也会达到数秒。因为回收器为了将新生代的对象移到老年代,需要耗费很长的时间搜索老年代空间。
CMS低延迟优点的代价就是内存碎片。这个问题可以最小化,但是不会彻底消失。你永远不知道它什么时候会被触发。然而,通过合理的优化与监控可以控制它的风险。
G1(Garbage First)回收器的希望
G1回收器设计的目的就是保证低延迟的同时而没有堆碎片风险。因此,Oracle把它作为CMS的一个长期取代。G1可以避免碎片风险是因为它会整理堆空间。对于GC暂停来说,G1的目标并不是使暂停时间最小化,而是设置一个时间上限,使GC暂停尽量满足这一上限值。读者可以从G1的重要教程《Getting Started with the G1 Garbage Collector》中了解到更详细的内容。德国的读者可以也阅读Angelika Langer的文章《Der Garbage-First Garbage Collector (G1) – Übersicht über die Funktionalität》。
在将G1回收器用于测试程序中并与上述其他经典回收器做对比之前,先总结两点关于G1的重要信息。
Oracle在Java 7u4中开始支持G1。为了使用G1你应该将Java 7更新到最新。Oracle的GC团队一直致力于G1的研发,在最新的Java更新中(本文编写时最新版本是7u7到7u9),G1的改进很显著。另一方面,G1无法在任何Java 6版本中使用,而且到目前更优越的Java 7不可能向后移植到Java 6中。
前面关于调节各代空间大小的优化对G1来说已经淘汰了。设置各代空间大小与设置暂停目标时间相冲突会使G1回收器偏离原本的设计目标。使用G1时,可以使用“-Xms”和“-Xmx”设置整体的内存大小,也可以设置GC暂停目标时间(可选),对G1来说不用设置其他选项。与ParallelGC回收器的AdapativeSizingPolicy类似,它自适应地调整各代空间大小来满足暂停目标时间。
遵循这些原则后,G1回收器在默认配置下的结果如下:
图6 最小配置(-Xms1024m -Xmx1024 -XX:+UseG1GC)的JVM在G1下26小时内的GC性能
在这个例子中,我们使用了默认的GC暂停目标时间200ms。从图中可以看到,平均时间与这个目标比较吻合,最长GC暂停时间与使用CMS回收器差不多(图4)。G1明显可以很好地控制GC暂停,与平均时长相比,离群值也相当少。
另一方面,平均GC暂停时间要比CMS回收器长很多(270 vs 100ms),而且更频繁。这意味着GC累积暂停时间(也就是GC本身所占总时间)是使用CMS的4倍以上(6.96% vs 1.66%)。
与CMS一样,G1也分为GC暂停阶段和并行回收阶段(不暂停任务)。同样与CMS类似,当堆占用比达到一定门限后,它才启动并行回收阶段。从图6可以看到,1GB的可用内存到目前为止并没有完全使用。这是因为G1的默认占用比门限值要比CMS低很多。也有人指出,一般来说较小的堆空间就可以满足G1的需求。
垃圾回收器的定量比较
下面的表格总结了Oracle Java 7中4种最重要的垃圾回收器在测试中的关键性能指标。在同样的应用程序上,进行相同的负载测试,但是负载的级别不同(由第2列的垃圾创建速率体现)。
表 几种垃圾回收器的比较
所有的回收器都运行在1GB的堆空间上。传统的回收器(ParallelGC、ParNewGC和CMS)另外使用下面的堆设置:
-XX:NewSize=400m -XX:MaxNewSize=400m -XX:SurvivorRatio=6
而G1回收器没有额外的堆大小设置,并且使用默认的暂停目标时间200ms,也可以显示设置:
-XX:MaxGCPauseMillis=200
从表中可以看到,传统回收器在新生代回收上(第3列)时间差不多。对ParallelGC和ParNewGC来说是差不多的,而CMS实际上也是使用ParNewGC去回收新生代。然而,在新生代GC暂停中,将新生代存活对象移入老年代需要ParNewGC和CMS的协同。这样的协同引入额外的代价,也就导致CMS的新生代GC暂停时间要略长。
第7列是GC暂停所耗费的时间占总时间的百分比,这个值可以很好地反映GC的总时间代价。因为并行GC总时间(最后一列)以及引入的CPU占用代价可以忽略。按前文所述,优化堆大小后老年代GC次数会变得很少,这样第7列的值主要由新生代GC暂停总时间所决定。新生代暂停总时间是新生代暂停(连续)时长(第3列)与暂停次数的乘积。新生代暂停频率与新生代空间大小有关,对传统回收器来说,这个大小是相同的(400MB)。因此,对传统回收器来说,第7列的值或多或少地反映着第3列的值(负载差不多的情况)。
CMS的优点可以从第6列明显看出:它用稍长的总时间代价换来了更短(低一个量级)的老年代GC暂停。对很多真实环境的应用来说,这是一个不错的折衷。
那么,对于我们的应用,G1回收器表现怎么样呢?第6列(以及第5列)可以看出,在减少老年代GC暂停时长上,G1回收器要比CMS回收器做的好。但是从第7列也可以看到,它付出相当高的代价:同样的负载下,GC总时间代价占7%,而CMS只占1.6%。
我会在后续的文章中检查在什么条件下会导致G1产生更高的GC时间代价,同样也会分析G1与其他回收器(尤其是CMS回收器)相比的优缺点。这是一个庞大而且有价值的主题。
总结与展望
对所有的经典Java GC算法(SerialGC、ParallelGC、ParNewGC和CMS)来说,优化各代堆空间大小是很重要的,然而实际中很多应用程序并没有做足够合理的优化。导致的结果就是应用性能不够优化,以及操作退化(造成性能损失,如果没有很好地监控甚至会出现一段时间内程序暂停的情况)。
优化各代堆空间大小可以显著提高应用性能,并将GC长暂停次数减到最小。然后,消除GC长暂停需要使用低延迟回收器。CMS一直(直到现在)是首选且有效的低延迟回收器。在很多情况下,CMS就可以满足需求。通过合理的优化,它还是可以保证长期稳定,只不过存在堆碎片的风险。
作为替代,G1回收器目前(Java 7u9)是一个被支持且可用的选择,但仍有改进的余地。对很多应用来说,它的结果可以接受,但与CMS回收器相比还不是很好。其优缺点的细节值得仔细地研究。