译者注:上个月写了一遍博文,介绍一种高效的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实现方式和我的实现思路类似,可以作为对照。
在HBase中应用MemStore-Local Allocation Buffers解决Full GC问题:第一部分
今天我想分享一些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的优先项目。在这篇文的其余部分,我将介绍我们开发的方法,该解决方案在很大程度上消除了这个问题。
Java的GC背景
为了彻底了解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
Parallel New Collector是“stop-the-world”复制收集。每当它运行时,它首先暂停所有的Java线程。然后,它追踪的对象引用来确定些对象是活的(仍然有程序引用这些对象)。最后它移动活动对象到堆里的空闲空间,并更新到这些对象的指针指向新的地址。
重点是:
- 它stop-the-world,但不是很长。因为年轻代通常是相当小的,它可以非常迅速地完成其工作。在生产环境中,我们通常建议不超过512MB ,这导致即使在最坏的情况下,年轻代GC暂停的时间也不到几百毫秒。
(译者注:似乎很少见到有配置这么小得Yong区,一般是New:Old=1:8,比如:-Xms8g -Xmx8g -Xmn1g;不过作者的说法也可以试一试。原则是,不管你把JVM Heap设置多大,Yong区都不要设置的过大)
- 它复制活动对象到一个自由的堆区,同时在每次回收后压缩(compating)自由空间。因此每次GC后,free space在年轻代都是一个连续的块,这意味着再次分配可以是非常有效的。
每次Parallel New collector复制一个对象,该对象的计数器递增。当对象在年轻代多次复制后仍存活,决定了它属于长寿命的对象,将移动到老年代。在对象被移入老年代之前,在年轻代内被拷贝的最大次数被称为tenuring threshold。
Concurrent-Mark-Sweep collector
每一次新的并行收集器运行时,它将移动一些对象到老年代,老年代最终将被填满。因此我们需要一个回收老年代的策略。Concurrent-Mark-Sweep collector(CMS)是负责清除老年代里的死对象。
CMS的收集工作包括一系列阶段。某些阶段stop-the-world,某些阶段则和其他Java应用程序同时运行。主要阶段是:
- 初始标志(停止世界)。在这个阶段CMS对根对象打标记。根对象是线程直接引用的对象 - 例如,该线程使用的局部变量。这个阶段是的暂停是短暂的,因为根对象数量很少。
- 并发标记(并行)。现在CMS从根对象开始标记被根对象引用的所有对象,直到它标记完系统中的所有活动对象。
- 重标记(停止世界)。由于对象可能有引用改变,新的对象可能已经在并发标记创建的,我们需要回去考虑那些在这个阶段被创建的对象。这也是个短期的停顿,因为有一个特殊的数据结构,让我们只需要检查那些在上一个阶段被修改的对象。
- 并发扫描(并行)。现在,我们继续梳理在堆中的所有对象。任何没有被标记的对象将会被回收并视为空闲空间。在此期间分配的新对象也会被打标记,以免被回收。
这里要注意的重要事情是:
- stop-the-world的阶段很短。而扫描整个堆和清除死对象这种长期工作可以和Java线程并行发生。
- CMS不搬迁的活动对象,所以自由空间可以分布在整个堆的不同区块。我们将稍后回来!(译者注:这个是优化的重点,稍后的系列文章会继续介绍)
CMS的失效模式(CMS Failure)
正如我所描述的,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失败
这种故障模式是多一点复杂。回想一下,CMS收集不搬迁对象,而是简单地跟踪所有堆的自由空间,而且自有空间是分开的。作为一个思想实验,想象我拨出1亿个对象,每个1KB,这正是1GB堆的总用量为1GB。然后,我释放所有奇数对象,所以我有500MB的自有空间,然而自由空间都是1KB的块。如果我需要分配一个2KB的对象,尽管我表面上有500MB免费空间,依然会无处可放。这就是所谓的内存碎片。因为CMS不搬迁对象,不管如何让CMS提前启动,都不可以解决这个问题!发生此问题时,CM再次回落到复制收集器,该方法能够压缩所有的对象并释放空间。
GC知识已经足够了!回到HBase来!
让我们回来并使用我们关于Java GC的知识思考HBase:
通过设置的CMSInitiatingOccupancyFraction,一些用户能够避免GC的问题。但对于其他的场景,GC会经常发生,无论CMSInitiatingOccupancyFraction设置的多么低。我们则经常看到在这些GC停顿时,堆还有几个GB的自由空间!鉴于这些情况,我们推测,我们的问题应该是由碎片引起的,而不是一些内存泄漏或不当调整。
一个实验:测量碎片
我们将运行一个实验证实这一假设。第一步是收集一些有关堆的碎片信息。在探查OpenJDK源代码后,我发现鲜为人知的参数的-XX:PrintFLSStatistics = 1(译者注:JDK1.6也支持该选项),结合其他详细GC日志记录选项时,会导致CMS之前和之后每打印有关其自由空间的统计信息。特别是,我们关心的指标是:
- free space - 老年代的空闲内存的总数
- NUM块 - 非连续的内存空闲块总数
- 最大块大小 - 空闲块的最大大小
我启用了这个选项,启动了一个集群,然后运行了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]
------------------------------------------------
maoyidao注:上个月写了一遍博文,介绍一种高效的Java缓存实现,http://maoyidao.iteye.com/admin/blogs/1559420。其本质是模仿Memcached的Slab,通过分配连续定长的byte[]减少大规模使用Java Heap作为缓存时不可避免的GC问题。虽然当时构思和实现这一思路时并没有参照其他开源产品,但这一思路在很多著名的开源产品上也有类似的实现。
随着内存使用成本越来越低,高并发海量数据应用开发者逐渐倾向于这样一种思路:“内存就是新的硬盘,硬盘就是新的磁带”。即通过尽量多的使用内存,和尽量多的顺序读写磁盘实现高吞吐。因此大规模使用内存引起的Java GC问题就成为了一个普遍问题。翻译这篇文章的初衷是:(1)该系列文章以Hadoop为例介绍了GC停顿带来的问题,比较生动。(2)比较详细的介绍了GC原理,特别是CMS错误产生的原因,并且有实验例子。(3)介绍了一系列GC参数及GC profile思路,比较有借鉴价值。(4)最终实现方式几乎和我自己的实现思路一致,可以作为对照。
在HBase中应用MemStore-Local Allocation Buffers解决Full GC问题:第二部分
在上周的文章中,我们注意到HBase有垃圾收集导致的长时间停顿问题,总结了Java 6 VM使用的不同垃圾收集算法。然后我们推测长时间的垃圾收集暂停是由内存碎片引起的,并设计了一个实验证实这一假说,并探讨哪种场景(workload)最最容易产生这种问题。
实验结果
概述
正如以前的描述,我在HBase服务器上跑了三个不同的场景,同时打开-XX:PrintFLSStatistics= 1收集详细GC日志。结果如下:
图的上半部分显示free_space,即堆的总自由空间。图底部显示max_chunk,即最大连续可用块空间的大小。X轴是时间(以秒为单位),Y轴是堆word大小,一个堆word是8个字节,因为我在运行64位JVM。
从图中很容易看出,这三个不同的场景有非常不同的内存使用特性。我们将仔细看看每个部分。
只写场景
我们可以看到两个有趣的情况:free_space在2.8GB和3.8GB之间波动。每次free_space到2.8G时,就开始执行CMS
并释放了1GB左右的空间。这表明,CMS开始收集的时间(maoyidao注:由-XX:CMSInitiatingOccupancyFraction = N参数指定)已调整到足够低 - 即CMS开始运行时堆中有足够的自由空间。我们也可以看到没有内存泄漏 - 堆使用状况相当一致,随着时间的推移,没有偏向任何方向的趋势。
然而CMS工作时,从图中看到最大连续可用块空间的大小(max_chunk)急剧下降近下降到0。每次达到0(如在时间102800秒),我们看到max_trunk陡然回升到了最大值。同时考虑GC日志,长时间的GC停顿正好和max_chunk陡然回升的时间吻合 - 即每次FGC后整个堆碎片被整理,使所有的自由空间成为一个完整的大型块。
因此我们认识到当堆中没有大的连续空闲块时,确实造成堆碎片并导致写场景中的GC长暂停。
有缓存逐出情况的只读场景
(maoyidao注:有缓存逐出的意思是缓存大小不足以缓存所有数据,因此有缓存更替的情况)
在第二个场景中,客户端仅执行读取,要读取的记录集是远远大于LRU缓存的大小。所以,我们看到大量的对象从缓存中被驱逐。
只读场景的free_space图反映了这一点 - 它显示了比只写场景更加频繁的GC。然而,我们注意到max_chunk图
显示max_chunk值几乎保持常数。这表明只读的工作量不造成堆碎片,即使缓存交互比只写要高得多。
没有缓存逐出情况的只读场景
第三种场景(overview图中的绿色部分),因为有没有缓存逐出的情况,内存中分配是为每个RPC请求提供服务的短生命周期对象。因此,他们从未被晋升为老年代,free_space和max_chunk时间序列保持完全不变。
试验总结
总结这个实验的结果:
我们想消除的FGC是由于碎片,而不是并发模式失败。
只写的场景导致比只读多很多的碎片。
HBase的写路径
为了在分布式集群存储一个非常大的数据集,Apache HBase把每个表分割成段,叫做Region(区)。每个Region都有一个指定的“开始键”和“停止键”,包含每个落在两者之间的行key。这个方案可以和基于RDBMS中的主键分区相比较(尽管HBase透明的管理分区)。每个Region通常是小于1GB,所以每一个HBase的集群中的服务器负责几百个Region,因此有大量读取和写入请求被发送到服务器。
一旦一个写请求已经达到了正确的服务器,新的数据首先被添加到一个内存中的结构称为:MemStore。这本质上是一个包含所有最近写入的数据的有序映Map。当然,内存是一种有限的资源,因此该地区的服务器仔细估算内存的使用情况,并在内存使用达到阈值时触发刷新,把数据写入到磁盘并释放内存。
MemStore碎片
让我们想象一下,一个Region服务器托管5个Region - 粉色,蓝色,绿色,红色和黄色。
随机写入均匀地分散在整个地区,并没有特定的顺序到达。新写入到达时,为每一行数据分配新的缓冲区,然后这些缓冲数据被提升到老年代,并在MemStore中等待几分钟,然后被刷新到硬盘。由于没有特定的顺序写入到达,来自不同地区的数据混杂在老一代。当其中一个region被刷新时,意味着我们不能释放任何大的连续块,因此一定会得到碎片:
这种行为的结果,正是我们的实验结果表明:随着时间的推移,导致一个FGC。
未完继续...
在这篇文章中,我们回顾我们的实验结果,明白了为什么在HBase内存碎片的原因。在本系列的下一个,即最后一篇文章中,我们将看看MemStore避免碎片的缓冲区设计。