译者注:上个月写了一遍博文,介绍一种高效的Java缓存实现http://maoyidao.iteye.com/blog/1559420。其本质是模仿Memcached的Slab,通过分配连续定长的byte[]减少大规模使用Java Heap作为缓存时不可避免的GC问题。虽然当时构思和实现这一思路时并没有参照其他开源产品,但这一思路在很多著名的开源产品上也有类似的实现。随着内存使用成本越来越低,高并发海量数据应用开发者逐渐倾向于这样一种思路:“内存就是新的硬盘,硬盘就是新的磁带”。即通过尽量多的使用内存,和尽量多的顺序读写磁盘实现高吞吐。因此大规模使用内存引起的Java GC问题就成为了一个普遍问题。翻译这篇文章的初衷是:
(1)该系列文章以Hadoop为例介绍了GC停顿带来的问题,比较生动。
(2)比较详细的介绍了GC原理,特别是CMS错误产生的原因,并且有实验例子。
(3)介绍了一系列GC参数及GC profile思路,比较有借鉴价值。
(4)HBase实现方式和我的实现思路类似,可以作为对照。
原文地址:http://www.cloudera.com/blog/2011/02/avoiding-full-gcs-in-hbase-with-memstore-local-allocation-buffers-part-1/
作者:Todd Lipcon, software engineer for Cloudera. Todd在2012年Hadoop峰会上介绍了:Optimizing MapReduce Job Performance,对性能调优有丰富经验。
今天我想分享一些Cloudera Hadoop中的工程细节。在这篇文章中,我将解释一个新的Apache HBase组件:MemStore-Local Allocation Buffer,它显著的降低了Java GC导致的线程暂停频率。本篇文章将是这个三部分系列文章的第一部分。
廉价商用服务器上的内存数量在过去的几年中不断的增长。当Apache的HBase的项目于2007年开始时,运行Hadoop的典型配置有4到8GB内存。今天,大多数Hadoop客户以至少24G内存运行Hadoop,使用48G甚至72G内存的客户也变得越来越普遍,而内存使用成本则继续回落。表面上,这对像HBase的数据库这样对延迟敏感的软件来说,这似乎是一个伟大的胜利,大量的内存可以容纳更多的数据缓存,在刷新到磁盘前作为写入缓,避免昂贵的磁盘寻址和读。然而在实践中,HBase使用的内存不断增长,但JDK可用的垃圾收集算法仍然相同。这导致了HBase的许多用户的一个主要问题:随着Java使用堆大小继续增长,垃圾回收导致的“stop-the-world”时间变得越来越长。这在实践中意味着什么?
在垃圾回收导致的“stop-the-world”期间,任何到HBase客户端请求都不会被处理,造成用户可见的延迟,甚至超时。如果因为暂停导致请求超过一分钟响应,HBase本身也可能会停止 - 仅仅因为垃圾回收导致这样的延迟显得很不值。
HBase依赖Apache Zookeeper的管理群集成员和生命周期。如果服务器暂停的时间过长,它将无法发送心跳ping消息到Zookeeper quorum(译者注:Zookeeper的分布式算法),其余的服务器将假定该服务器已经死亡。这将导致主服务器启动特定的恢复程序,替换被认为死亡的服务器。当这个服务器从暂停中恢复时,会发现所有它拥有的租约都被撤销,进而只好自杀。HBase的开发团队已经亲切地称这种情况为朱丽叶暂停,此情况下 - 主服务器(罗密欧)假定边缘服务器(朱丽叶)死亡的(其实它真没死,只是睡觉),因此需要一些激烈的行动(恢复)。当边缘服务器从GC暂停中醒过来,它发现了一个巨大的错误已经铸成,只好结束自己的生命。这是一个非常可怕的故障!
(译者注:由于Java GC导致的心跳包没有及时响应问题,在对延时要求敏感的场景非常普遍。曾经有一个业务场景,集群中的服务器每500ms发送一次心跳包到mater服务器,而master服务器由于GC导致没有及时响应心跳包,进而认为服务器死亡,导致故障)
做过大负载HBase集群负载测试的人应该对上述问题很熟悉,在典型的硬件环境上,每GB的堆可以导致Hadoop暂停8-10秒。则分配8G堆的Hadoop会因GC暂停一分钟以上。
(译者注:Hadoop这个回收时间比较恐怖,我在线上服务分配36G Heap,平均FullGC暂停时间是6-8秒,因此万恶不是“大堆”为首,而是“内存使用方式不当”为首。这也是后文中调优的原因所在)
不管人们如何调整,事实证明这个问题是完全不可避免的。由于这是一个共同的问题,而且每况愈下。因此在今年年初,它成为一个Cloudera的优先项目。在这篇文的其余部分,我将介绍我们开发的方法,该解决方案在很大程度上消除了这个问题。
为了彻底了解GC暂停的问题,有必要了解有一些在Java GC的技术背景。这里只作出一些简单描述,所以我非常鼓励你做进一步的研究。如果你已经在GC的专家,随时跳过这一节。
Generational GC(译者注:即分“代”回收算法)
Java的垃圾回收器通常工作在Generational GC模式,该模式基于一个假设:假设大多数对象英年早逝,还是坚持相当长的时间?(译者注:事实上两种情况同时存在)例如,在RPC请求缓冲区中对象将只存活几毫秒,而在HBase的MemStore数据的数据可能会存活许多分钟。很显然用两种不同的垃圾收集算法处理两种不同的生命周期的对象更好些。因此,JVM把对象分成两代:年轻代(New)和老年代(Old)。分配对象时,JVM在年轻代里分配对象。如果一个对象经过几次GC在还存活在年轻代,垃圾回收程序就把这个对象搬迁到老年代,在这里我们假设数据是会存活很长时间。
在大多数对延迟敏感的场景,比如HBase,我们建议使用JVM参数:-XX:+ UseParNewGC和-XX:+ UseConcMarkSweepGC。
Parallel New Collector是“stop-the-world”复制收集。每当它运行时,它首先暂停所有的Java线程。然后,它追踪的对象引用来确定些对象是活的(仍然有程序引用这些对象)。最后它移动活动对象到堆里的空闲空间,并更新到这些对象的指针指向新的地址。
重点是:
(译者注:似乎很少见到有配置这么小得Yong区,一般是New:Old=1:8,比如:-Xms8g -Xmx8g -Xmn1g;不过作者的说法也可以试一试。原则是,不管你把JVM Heap设置多大,Yong区都不要设置的过大)
每次Parallel New collector复制一个对象,该对象的计数器递增。当对象在年轻代多次复制后仍存活,决定了它属于长寿命的对象,将移动到老年代。在对象被移入老年代之前,在年轻代内被拷贝的最大次数被称为tenuring threshold。
每一次新的并行收集器运行时,它将移动一些对象到老年代,老年代最终将被填满。因此我们需要一个回收老年代的策略。Concurrent-Mark-Sweep collector(CMS)是负责清除老年代里的死对象。
CMS的收集工作包括一系列阶段。某些阶段stop-the-world,某些阶段则和其他Java应用程序同时运行。主要阶段是:
这里要注意的重要事情是:
正如我所描述的,CMS看起来真的很棒 - 只停留很短的时间,而繁重的工作都可以和Java线程同时进行。那么当我们在重负载下运行分配了大Heap HBase时,GC是如何造成我们看到的多分钟暂停的呢?事实证明,CMS有两种故障模式。
第一种失败的模式,是简单的并发模式失败。最好的一个例子:假设有一个8GB堆,已经使用了7GB。当CMS的收集开始第一阶段,它欢快的隆隆的做着并发标记。与此同时,有更多的数据被分配到老年代。如果老年代增长的速度太快,在CMS完成第一阶段标记工作之前就填满了全部老年代。这时候因为没有自由空间,CMS就无法工作!CMS必须放弃并行工作,并回落到停止世界(stop-the-world)单线程复制收集算法。此算法开始搬迁堆,检查所有活动对象,并释放了所有死角。长时间的停顿后,该程序可能会继续。
但我们可以很容易的通过调整JVM参数避免并发模式失败:我们只需要鼓励CMS提前开始工作!设置-XX:CMSInitiatingOccupancyFraction = N,其中N是堆在开始收集过程中的百分比。HBase仔细的计算了内存使用,以保持其只使用60%的堆空间,所以我们通常将此值设置为大约70。(译者注:同时也可以考虑Old区的30%要比Young区大,这样即使Young区在CMS之前全部搬迁到Old区也不会把Old区填满)
这种故障模式是多一点复杂。回想一下,CMS收集不搬迁对象,而是简单地跟踪所有堆的自由空间,而且自有空间是分开的。作为一个思想实验,想象我拨出1亿个对象,每个1KB,这正是1GB堆的总用量为1GB。然后,我释放所有奇数对象,所以我有500MB的自有空间,然而自由空间都是1KB的块。如果我需要分配一个2KB的对象,尽管我表面上有500MB免费空间,依然会无处可放。这就是所谓的内存碎片。因为CMS不搬迁对象,不管如何让CMS提前启动,都不可以解决这个问题!发生此问题时,CM再次回落到复制收集器,该方法能够压缩所有的对象并释放空间。
让我们回来并使用我们关于Java GC的知识思考HBase:
通过设置的CMSInitiatingOccupancyFraction,一些用户能够避免GC的问题。但对于其他的场景,GC会经常发生,无论CMSInitiatingOccupancyFraction设置的多么低。我们则经常看到在这些GC停顿时,堆还有几个GB的自由空间!鉴于这些情况,我们推测,我们的问题应该是由碎片引起的,而不是一些内存泄漏或不当调整。
一个实验:测量碎片
我们将运行一个实验证实这一假设。第一步是收集一些有关堆的碎片信息。在探查OpenJDK源代码后,我发现鲜为人知的参数的-XX:PrintFLSStatistics = 1(译者注:JDK1.6也支持该选项),结合其他详细GC日志记录选项时,会导致CMS之前和之后每打印有关其自由空间的统计信息。特别是,我们关心的指标是:
我启用了这个选项,启动了一个集群,然后运行了Yahoo Cloud Serving Benchmark(YCSB)的三个独立的压力测试:
只写:每行10列,每列100个字节,1亿个row key。
只读(有缓存替换):随机读取1亿不同的行键的数据,使数据不能完全存储在适LRU缓存。
只读(无缓存替换):随机读取1万不同的行键的数据,使数据完全符合LRU缓存。
每个压力测试将运行至少一个小时,这样我们可以收集GC行为数据。这个实验的目标是首先要验证我们的假说,暂停是由碎片引起的,第二,以确定造成这些问题的主要原因是读取路径(包括LRU缓存)还是写路径(包
括每个地区的MemStores )。
将要继续...
在本系列的下一篇文章将显示HBase的这个实验和挖掘内部结果,了解不同的工作负载如何影响内存布局。
同时,如果您想了解更多有关Java的垃圾收集器,我推荐以下几个环节:
Jon “the collector” Masamitsu has a good post describing the various collectors in Java 6.
To learn more about CMS, you can read the original paper: A Generational Mostly-concurrent Garbage Collector [Printezis/Detlefs, ISMM2000]