[翻译]Java垃圾收集精粹(Java Garbage Collection Distilled)

时间:2022-11-18 13:43:08
source
URL: http://www.infoq.com/articles/Java_Garbage_Collection_Distilled
Name: Java Garbage Collection Distilled
Author: Martin Thompson
PostDate: 2013-06-17
翻译前言
作者还出了本关于Java性能tuning的书。
翻译这篇文章的目的是缓解上次在支付宝面试时仅回答出堆中有Young和Perm代这样的尴尬。
为自己的人生信条代言:What I see, and what I hear about, I conquer.
具体收集器的介绍没有翻译,原因先消化一下minor/major gc的概念。
Version
翻译计划 - 2014-02-23
翻译初稿 - 2014-02-24
Java垃圾收集(GC)精粹
0 符号约定
GC: garbage collection
gc: garbage collector
generation:代
space:区
文章内容
1 权衡
2 对象生命周期(lifetime)
3 Stop-The-World事件
4 Hotspot中的堆结构
5 对象分配(allocation)
6 Minor收集
7 Major收集
8 Serial Collector
9 Parallel Collector
10 Concurrent Mark Sweep(CMS) Collector
11 Gargage First(G1) Collector
12 其他Concurrent Collectors
13 GC监控和调整
关于作者
 
JVM启动标志有Serial, Parallel, Connurrent, CMS, G1, Young Gen, New Gen, Old Gen, Perm Gen, Eden, Tenured, Survivor Spaces, Safepoints等几百个。这些都是你在调整GC策略以获得符合需要的Java应用的吞吐量(throughput)和延迟(latency)时困扰你的原因?如果是这样,不要担心,你不是一个人在战斗。描述GC的文档跟*NIX man手册页一样太过简略。所有的旋钮和仪表盘都被详细的记录和解释了,但找不到一个如何使用的指南。本文章尝试解释在特定工作负载(workload)下选择和调整GC算法的权衡。
这里主要考虑较为常用的Oracle Hotspot和OpenJDK收集器,其他商用JVM也会有所涉及。
1 权衡
人们总说不能不劳而获。当我们获得什么时,必须要放弃其他一些东西。对应到GC中,我们主要处理影响收集器目标的三个主要因素:
(1)吞吐量(throughput)
应用执行时间与GC消耗的时间的比值。-XX:GCTimeRatio=99,99是默认值,表示1%的GC时间。
(2)延迟(lantency)
系统因GC导致的暂停对事件的响应时间。其配置参数是-XX:MAXGCPauseMillis=。
(3)内存
系统用于存储状态的内存大小,状态经常被拷贝和移动。应用中任何时间活动对象(active objects)的集合称为活动集(Live Set)。可以通过-Xmx设置应用的最大堆大小。
注:Hotpot经常无法实现这些目标,经常在没有警告信息的情况下继续运行。
延迟是事件间的分布。增加平均延迟以减少最坏的延迟,或者让其更少发生可能是可以接受的。我们不应该将实时(real-time)解读为最差的延迟,它应该被理解为在不考虑吞吐量的情况下有可预期的延迟。
对于一些工作负载来说,吞吐量是最重要的目标。一个例子是长时间运行的批处理作业,这种情况下一个批作业因垃圾收集而偶然的停顿没有关系,只要总体的作业能够尽快结束。
其他的一些工作负载,从人机交互应用到经济交易系统,如果系统无响应超过哪怕几秒钟,都会被视为灾难。在经济交易应用系统中,常需要为保证一致的延迟而降低系统吞吐量指标。当应用受限于可用的物理内存数量且需要维护footprint时,我们就必须放弃延迟和吞吐量两个指标。
下面是一些常见的权衡:
-在很多情况下,像偿还分期债务一样,GC成本可以通过给GC算法增加内存降低。
-可以观察到的因gc导致的最坏延迟,可以通过包含活动集和保持堆较小来降低。
-小停顿的频率可以通过配置对和代区大小、控制应用的对象分配律来控制。
-大停顿的频率可以通过与应用并发运行GC降低,这可能会影响吞吐量指标。
2 对象生命周期(lifetime)
GC算法通常根据大多数对象存活时间较短、少量对象存活时间较长这样的预期来优化。在大多数应用中,存活时间较长的对象只占应用中分配的对象很少的一部分。在垃圾收集理论中,这种行为常被称为in夭折(fant mortality)或弱代假设(weak generational hypothesis)。例如,循环迭代器通常是存活较短时间,而静态字符串则能存活很长一段时间。
已有实验表明,基于代(generation)的gc的性能通常比不基于代的gc好一个数量级,因此基于代的gc是服务器JVM的首选。通过区分对象的代,我们知道新分配对象区(region)是非常稀疏的。故采用gc清理该新区的新生活动对象,并将它们拷贝到存放较老代的区中是非常有效的。Hotspot gc用GC周期中逃生的次数来记录对象的年龄(age)。
注:如果你的应用总是产生长时间存活的大量对象,那么你的应用需要消耗大量的时间用于GC,你也需要花费些时间调整Hotspot gc。这是因为无效的代过滤影响了GC的效率,导致频繁的收集长时间存活代中对象的消耗。较旧代区不是稀疏的,因此旧代收集算法效率不是很高。基于代的gc倾向于在两个不同的收集周期中执行收集操作:minor收集(收集短时间存活对象)和major收集(频率较minor收集低,旧代区被收集)。
3 Stop-The-World事件
在GC期间应用的停顿原因是stop-the-world事件。从实践工程角度,这对gc来说是必需的,周期性的停止运行中的应用,这样才可以管理内存。依赖于其算法,不同的gc会在应用执行的特定时间点触发stop-the-world事件,gc执行的时间也有所不同。为将应用完全停止,需要暂停所有运行中的进程。gc在他们达到安全点(safepoint)时向这些进程发送停止信号,安全点是程序执行中的一个时间点,此时所有GC root都是可知的、所有堆中对象的状态是一致的【是gc到达安全点、还是程序到达安全点?】。依赖于需停止的线程,gc可能需花一些时间达到安全点。安全点检查通常在方法返回或循环结束时执行,但也可以在其他地方执行。例如,一个线程在拷贝大数组、克隆大对象或执行单调计数的有限循环,在达到安全点之前还有一段时间。到达安全点的时间是低延迟应用需要慎重考虑的。这个时间可以启动GC参数-XX:+PrintGCApplicationStoppedTime观察。
注:对于运行着大量线程的应用,当stop-the-world事件发生时,系统面临线程恢复时的调度压力。所以较少依赖于stop-the-world事件的算法本质上更高效。
4 Hotspot中的堆结构
为理解不同的gc如何执行的,最好先看看Java堆是如何组织以支持基于代的收集器的。
Eden(伊甸园)是大多数对象初始分配的区域。Survivor(幸存者)区是存储Eden区中逃过一次GC的对象的临时存储空间。Survivor区的使用将在monir收集中讨论。Eden和Survivor区又分别称为Young和New代。
长时间存活的对象最终进入Tenured(终身)区。
Perm(选定)代是运行时存储它知道的总是存活的对象的区域,如Class对象和静态字符串对象。不幸的是应用中采用类加载导致Perm代背后的假设是错误的。在Java 7中字符串已被从Perm代移至Tenured区,从Java 8开始Pern代不再重要了,不会在本文中讨论。大多数其他商用收集器不使用单独的Perm区,而倾向于将所有长时间存活对象放到Tenured区中去。
注:收集器借助这些虚拟空间调整区域的大小以满足吞吐量和延迟指标。收集器持有每次收集阶段的统计信息,在尝试中根据这些信息调整区域大小以满足指标。
5 对象分配(allocation)
为避免竞争,每个线程分配一个线程本地分配缓冲区(Thread Local Allocation Buffer, TLAB),线程将对象分配到该缓冲区中。使用TLAB,通过避免在单个内存资源上的竞争,允许多个进程同时分配对象。通过TLAB分配对象是非常廉价的操作;它简单的移动指针(距离跟对象大小相关),这在大多数平台上都可以用10条左右指令完成。java中堆内存的分配甚至比用C运行时的malloc还廉价。
[翻译]Java垃圾收集精粹(Java Garbage Collection Distilled)
注:因单个对象的分配很廉价,minor收集的频率必须跟对象分配的频率成正比。
当一个线程中的TLAB耗尽时,它简单的从Eden区请求一个新的TLAB。当Eden耗尽时,minor收集开始了。
大对象(-XX:PretenureSizeThreadhold=n)可能在Young代中无法分配,需要在Old代中分配,例如一个大数组。如果该阈值小于TLAB的大小,可以在TLAB中分配的对象就不会在Old代中分配。G1收集器采用不同的方法处理大对象,将会在下面讨论。
6 Minor收集
当Eden区满时触发minor收集。minor收集拷贝New代中所有活动对象到Survivor区或Tenured区。拷贝到Tenured区被称为提升(promition)或永久任命(tenuring)。提升发生在对象足够老(配置-XX:MaxTenuringThreshold参数),或者Survivor区满时。
活动对象时应用中可达对象;其他对象不可达被视为已死亡(dead)。在minor收集中,在GC Roots上迭代拷贝可达对象到Suvivor区中。GC Roots一般包含应用和JVM内部静态字段的引用,在线程栈帧中,这些引用只想应用的可达对象图。
在基于代的收集中,New代可达对象图的GC Roots同时包含从Old代到New代的引用。必须处理这些引用以保证New代中所有可达对象在minior收集中存活。记录这些跨代引用是通过卡片表(card table)完成的。Hotspot的卡片表是一个byte数组,每个byte记录相关的Old代中512byte的区域中存在的跨代引用。因引用存储于堆中,存储边界(store barrier)代码会标记卡片,以表示从Old代到New代的引用存在于相关的512byte的堆区域中。在收集时,扫描卡片表获得这些跨代引用,这些引用在New代中表示额外的GC Roots。因此,minior收集一个明显的固定消费是与Old代大小成比例的。
HotSpot的New代中有两个Survivor区,分别扮演to-space和from-space角色。在一次minior收集的开始阶段,to-space Survivor区总是空的,作为minior收集的目标拷贝副本。上一次minior收集的目标Survivor区是现在from-space的一部分,from-space同时包含了Eden代,可以再from-space中找到需要拷贝的活动对象。
minor GC的成本由拷贝对象到Survivor和Tenured区的成本支配。对象若在minor收集中未存活,则立即被释放(free)。minor收集阶段的工作与找到的活动对象数量成比例,而不是与New代大小成比例。如果Eden区大小增长一倍,则用于minor收集的时间几乎会减半。因此,为了达到吞吐量指标可以牺牲些内存。Eden区大小增加一倍会导致每个收集周期中收集时间的增加,但如果提升的对象数量和Old代的大小是常量的话,这点时间还是很短的。
注:在Hotspot的minor收集是stop-the-world事件。这将随堆因包含了更多的活动对象而成为一个问题。我们已经开始看到需要Yound代的concurrent收集以减少停顿时间。
7 Major收集
Major收集会收集Old代,所以对象可以从Young代中获得提升。在大多数应用中,程序状态会在Old代中结束。存在大量Old代GC算法。一些会在Old代满时进行压缩,另一些会同应用并发运行避免该代被填满。
Old代收集器尝试预测收集的时机,以避免从Young代提升失败。收集器记录Old代的一个填充阈值,当超过阈值时开始进行收集。如果该阈值无法满足提升的需求,则触发FullGC。FullGC在Old代收集和压缩后,从Young代提升所有活动对象。提升失败是很耗时的操作,因为该周期的状态和已提升的对象必须回退(unwound),这样FullGC事件才会发生。
注:为避免提升失败,需要调整Old代允许的填充(padding)以适应提升(用-XX:PromotedPadding=)。
注:当堆需要增长时触发FullGC。堆增长导致的FullGC可以通过设置-Xms、-Xmx同样的值避免。
除FullGC外,Old代的压缩可能是应用中最大的stop-the-world停顿。压缩的时间与Tenured区中活动对象的数量成比例。
Tenured区被填满的频率有时可以通过增加Survior区的大小和对象被提升到Tenured代的年龄降低。然而,增加Survior区大小和提升前对象在minor收集中的年龄(-XX:MaxTenuringThreshold),同时也会增加minor收集的成本和停顿时间,这是因为在minor收集中增加的Survivor区间拷贝的成本。
8 Serial Collector
9 Parallel Collector
10 Concurrent Mark Sweep(CMS) Collector
11 Gargage First(G1) Collector
12 其他Concurrent Collectors
13 GC监控和调整
为理解你的应用和gc的行为,用下面的设置启动JVM:
-verbose:gc
-Xloggc:
-XX:+printGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationConcurrentTime
-XX:+PrintGCApplicationStoppedTime
将日志加载入类似于Chewiebug(https://github.com/chewiebug/GCViewer)的工具中进行分析。
为观察GC的动态特征,加载JVisualVM,安装Visual GC插件。下面是应用GC活动的一个示例图。
[翻译]Java垃圾收集精粹(Java Garbage Collection Distilled)
要理解应用GC的需求,需要加载可以重复执行的测试。一旦掌握了收集器是如何工作的,实验性的调整测试运行参数,直到收集器能够满足吞吐量和延迟指标。从终端用户的角度度量延迟是重要的。这可以用统计每次测试请求的时间直方图来表示,详细信息见https://github.com/giltene/HdrHistogram。如果在测试中你的延迟指标没有达到,尝试借助GC日志以确定是不是GC是产生问题的关键。有可能是其他问题导致了延迟指标没有满足。另一个有用的工具是jHiccup(http://www.jhiccup.com/),它可以跟踪JVM停顿、支持跨系统跟踪。
如果确实是GC问题导致的延迟,深入和调整CMS或G1参数,看看能否解决问题。有时,在高分配率、高提升率以及非常低的延迟需求的情况下,延迟指标很难满足。GC调整就变成了需要高度技巧的实践,长需要改变应用以降低对象分配率或减少对象存活时间。如果这样,就需要权衡是花时间和资源在GC调整和应用变更上,还是购买商业解决方案,如JRockit Real Time或Azul Zing等。
关于作者
[翻译]Java垃圾收集精粹(Java Garbage Collection Distilled)
Martin Thompson is a high-performance and low-latency specialist, with experience gained over two decades working on large scale transactional and big-data systems. He believes in Mechanical Sympathy, i.e. applying an understanding of the hardware to the creation of software as being fundamental to delivering elegant high-performance solutions. The Disruptor framework is just one example of what his mechanical sympathy has created. Martin was the co-founder and CTO of LMAX. He blogs here(http://mechanical-sympathy.blogspot.com/) , and can be found giving training courses on performance and concurrency, or hacking code to make systems better.