ImportNew注:本文是JVM性能优化 系列-第4篇。前3篇文章请参考文章结尾处的JVM优化系列文章。做为Eva Andreasson的JVM性能优化系列的第4篇,本文将对C4垃圾回收器进行介绍。使用C4垃圾回收器能够有效提高对低延迟有要求的企业级Java应用程序的伸缩性。html
到目前为止,本系列的文章将stop-the-world式的垃圾回收视为影响Java应用程序伸缩性的一大障碍,而伸缩性又是现代企业级Java应用程序开发的基础要求,所以这一问题亟待改善。幸运的是,针对此问题,JVM中已经出现了一些新特性,所使用的方式或是对stop-the-world式的垃圾回收作微调,或是消除冗长的暂停(这样更好些)。在一些多核系统中,内存再也不是稀缺资源,所以,JVM的一些新特性就充分利用多核系统的潜在优点来加强Java应用程序的伸缩性。算法
在本文中,我将着重介绍C4算法,该算法是Azul System公司中无暂停垃圾回收算法的新成果,目前只在Zing JVM上获得实现。此外,本文还将对Oracle公司的G1垃圾回收算法和IBM公司的Balanced Garbage Collection Policy算法作简单介绍。但愿经过对这些垃圾回收算法的学习能够扩展你对Java内存管理模型和Java应用程序伸缩性的理解,并激发你对这方面内容的兴趣以便更深刻的学习相关知识。至少,你能够学习到在选择JVM时有哪些须要关注的方面,以及在不一样应用程序场景下要注意的事项。缓存
C4算法中的并发性安全
Azul System公司的C4(Concurrent Continuously Compacting Collector,译者注,Azul官网给出的名字是Continuously Concurrent Compacting Collector)算法使用独一无二而又很是有趣的方法来实现低延迟的分代式垃圾回收。相比于大多数分代式垃圾回收器,C4的不一样之处在于它认为垃圾回收并非什么坏事(即应用程序产生垃圾很正常),而压缩是不可避免的。在设计之初,C4就是要牺牲各类动态内存管理的需求,以知足须要长时间运行的服务器端应用程序的需求。性能优化
C4算法将释放内存的过程从应用程序行为和内存分配速率中分离出来,并加以区分。这样就实现了并发运行,即应用程序能够持续运行,而没必要等待垃圾回收的完成。其中的并发性是关键所在,正是因为并发性的存在才可使暂停时间不受垃圾回收周期内堆上活动数据数量和须要跟踪与更新的引用数量的影响,将暂停时间保持在较低的水平。正如我在本系列第3篇中介绍的同样,大多数垃圾回收器在工做周期内都包含了stop-the-world式的压缩过程,这就是说应用程序的暂停时间会随活动数据总量和堆中对象间引用的复杂度的上升而增长。使用C4算法的垃圾回收器能够并发的执行压缩操做,即压缩与应用程序线程同时工做,从而解决了影响JVM伸缩性的最大难题。服务器
实际上,为了实现并发性,C4算法改变了现代Java企业级架构和部署模型的基本假设。想象一下拥有数百GB内存的JVM会是什么样的:数据结构
部署Java应用程序时,对伸缩性的要求无须要多个JVM配合,在单一JVM实例中便可完成。这时的部署是什么样呢?架构
有哪些以往因GC限制而没法在内存存储的对象?并发
那些分布式集群(如缓存服务器、区域服务器,或其余类型的服务器节点)会有什么变化?当能够增长JVM内存而不会对应用程序响应时间形成负面影响时,传统的节点数量、节点死亡和缓存丢失的计算会有什么变化呢?app
C4算法的3的阶段
C4算法的一个基本假设是“垃圾回收不是坏事”和“压缩不可避免”。C4算法的设计目标是实现垃圾回收的并发与协做,剔除stop-the-world式的垃圾回收。C4垃圾回收算法包含一下3个阶段:
标记(Marking) — 找到活动对象
重定位(Relocation) — 将存活对象移动到一块儿,以即可以释放较大的连续空间,这个阶段也可称为“压缩(compaction)”
重映射(Remapping) — 更新被移动的对象的引用。
下面的内容将对每一个阶段作详细介绍。
C4算法中的标记阶段
在C4算法中,标记阶段(marking phase)使用了并发标记(concurrent marking)和引用跟踪(reference-tracing)的方法来标记活动对象,这方面内容已经在本系列的第3篇中介绍过。
在标记阶段中,GC线程会从线程栈和寄存器中的活动对象开始,遍历全部的引用,标记找到的对象,这些GC线程会遍历堆上全部的可达(reachable)对象。在这个阶段,C4算法与其余并发标记器的工做方式很是类似。
C4算法的标记器与其余并发标记器的区别也是始于并发标记阶段的。在并发标记阶段中,若是应用程序线程修改未标记的对象,那么该对象会被放到一个队列中,以备遍历。这就保证了该对象最终会被标记,也由于如此,C4垃圾回收器或另外一个应用程序线程不会重复遍历该对象。这样就节省了标记时间,消除了递归重标记(recursive remark)的风险。(注意,长时间的递归重标记有可能会使应用程序因没法得到足够的内存而抛出OOM错误,这也是大部分垃圾回收场景中的广泛问题。)
Figure 1. Application threads traverse the heap just once during marking
若是C4算法的实现是基于脏卡表(dirty-card tables)或其余对已经遍历过的堆区域的读写操做进行记录的方法,那垃圾回收线程就须要从新访问这些区域作重标记。在极端条件下,垃圾回收线程会陷入到永无止境的重标记中 —— 至少这个过程可能会长到使应用程序因没法分配到新的内存而抛出OOM错误。但C4算法是基于LVB(load value barrier)实现的,LVB具备自愈能力,可使应用程序线程迅速查明某个引用是否已经被标记过了。若是这个引用没有被标记过,那么应用程序会将其添加到GC队列中。一旦该引用被放入到队列中,它就不会再被重标记了。应用程序线程能够继续作它本身的事。
脏对象(dirty object)和卡表(card table)
因为某些缘由(例如在一个并发垃圾回收周期中,对象被修改了),垃圾回收器须要从新访问某些对象,那么这些对象脏对象(dirty object)。这这些脏对象,或堆中脏区域的引用,经过会记录在一个专门的数据结构中,这就是卡表。
在C4算法中,并无重标记(re-marking)这个阶段,在第一次便利整个堆时就会将全部可达对象作标记。由于运行时不须要作重标记,也就不会陷入无限循环的重标记陷阱中,由此而下降了应用程序因没法分配到内存而抛出OOM错误的风险。
C4算法中的重定位 —— 应用程序线程与GC的协做
C4算法中,*重定位阶段(reloacation phase)*是由GC线程和应用程序线程以协做的方式,并发完成的。这是由于GC线程和应用程序线程会同时工做,并且不管哪一个线程先访问将被移动的对象,都会以协做的方式帮助完成该对象的移动任务。所以,应用程序线程能够继续执行本身的任务,而没必要等待整个垃圾回收周期的完成。
正如Figure 2所示,碎片内存页中的活动对象会被重定位。在这个例子中,应用程序线程先访问了要被移动的对象,那么应用程序线程也会帮助完成移动该对象的工做的初始部分,这样,它就能够很快的继续作本身的任务。虚拟地址(指相关引用)能够指向新的正确位置,内存也能够快速回收。
Figure 2. A page selected for relocation and the empty new page that it will be moved to
若是是GC线程先访问到了将被移动的对象,那事情就简单多了,GC线程会执行移动操做的。若是在重映射阶段(re-mapping phase,后续会提到)也访问这个对象,那么它必须检查该对象是不是要被移动的。若是是,那么应用程序线程会从新定位这个对象的位置,以即可以继续完成本身任务。(对大对象的移动是经过将该对象打碎再移动完成的。若是你对这部份内容感兴趣的话,推荐你阅读一下相关资源中的这篇白皮书“C4: The Continuously Concurrent Compacting Collector”)
当全部的活动对象都从某个内存也中移出后,剩下的就都是垃圾数据了,这个内存页也就能够被总体回收了。正如Figure 2中所示。
关于清理
在C4算法中并无清理阶段(sweep phase),所以也就不须要这个在大多数垃圾回收算法中比较经常使用的操做。在指向被移动的对象的引用都更新为指向新的位置以前,from页中的虚拟地址空间必须被完整保留。因此C4算法的实现保证了,在全部指向这个页的引用处于稳定状态前,全部的虚拟地址空间都会被锁定。而后,算法会当即回收物理内存页。
很明显,无需执行stop-the-world式的移动对象是有很大好处的。因为在重定位阶段,全部活动对象都是并发移动的,所以它们能够被更有效率的放入到相邻的地址中,而且能够充分的压缩。经过并发执行重定位操做,堆被压缩为连续空间,也无需挂起全部的应用程序线程。这种方式消除了Java应用程序访问内存的传统限制(更多关于Java应用程序内存模型的内容参见ImportNew编译整理的第一篇《JVM性能优化, Part 1 ―― JVM简介》)。
通过上述的过程后,如何更新引用呢?如何实现一个非stop-the-world式的操做呢?
C4算法中的重映射
在重定位阶段,某些指向被移动的对象的引用会自动更新。可是,在重定位阶段,那些指向了被移动的对象的引用并无更新,仍然指向原处,因此它们须要在后续完成更新操做。C4算法中的重映射阶段(re-mapping phase)负责完成对那些活动对象已经移出,但仍指向那些的引用进行更新。固然,重映射也是一个协做式的并发操做。
Figure 3中,在重定位阶段,活动对象已经被移动到了一个新的内存页中。在重定位以后,GC线程当即开始更新那些仍然指向以前的虚拟地址空间的引用,将它们指向那些被移动的对象的新地址。垃圾回收器会一直执行此项任务,直到全部的引用都被更新,这样原先虚拟内存空间就能够被总体回收了。
Figure 3. Whatever thread finds an invalid address enables an update to the correct new address
但若是在GC完成对全部引用的更新以前,应用程序线程想要访问这些引用的话,会出现什么状况呢?在C4算法中,应用程序线程能够很方便的帮助完成对引用进行更新的工做。若是在重映射阶段,应用程序线程访问了处于非稳定状态的引用,它会找到该引用的正确指向。若是应用程序线程找到了正确的引用,它会更新该引用的指向。当完成更新后,应用程序线程会继续本身的工做。
协做式的重映射保证了引用只会被更新一次,该引用下的子引用也均可以指向正确的新地址。此外,在大多数其余GC实现中,引用指向的地址不会被存储在该对象被移动以前的位置;相反,这些地址被存储在一个堆外结构(off-heap structure)中。这样,无需在对全部引用的更新完成以前,再花费精力保持整个内存页无缺无损,这个内存页能够被总体回收。
C4算法真的是无暂停的么?
在C4算法的重映射阶段,正在跟踪引用的线程仅会被中断一次,而此次中断仅仅会持续到对该引用的检索和更新完成,在此次中断后,线程会继续运行。相比于其余并发算法来讲,这种实现会带来巨大的性能提高,由于其余的并发当即回收算法须要等到每一个线程都运行到一个安全点(safe point),而后同时挂起全部线程,再开始对全部的引用进行更新,完成后再恢复全部线程的运行。
对于并发压缩垃圾回收器来讲,因为垃圾回收所引发的暂停历来都不是问题。在C4算法的重定位阶段中,也不会有再出现更糟的碎片化场景了。实现了C4算法的垃圾回收器也不会出现背靠背(back-to-back)式的垃圾回收周期,或者是因垃圾回收而使应用程序暂停数秒甚至数分钟。若是你曾经体验过这种stop-the-world式的垃圾回收,那么颇有多是你给应用程序设置的内存过小了。你能够试用一下实现了C4算法的垃圾回收器,并为其分配足够多的内存,而彻底没必要担忧暂停时间过长的问题。
评估C4算法和其余可选方案
像往常同样,你须要针对应用程序的需求选择一款JVM和垃圾回收器。C4算法在设计之初就是不管堆中活动数据有多少,只要应用程序还有足够的内存可用,暂停时间都始终保持在较低的水平。正因如此,对于那些有大量内存可用,而对响应时间比较敏感的应用程来讲,选择实现了C4算法的垃圾回收器正是不二之选。
而对于那些要求快速启动,内存有限的客户端应用程序来讲,C4就不是那么适用。而对于那些对吞吐量有较高要求的应用程序来讲,C4也并不适用。真正可以发挥C4威力的是那些为了提高应用程序工做负载而在每台服务器上部署了4到16个JVM实例的场景。此外,若是你常常要对垃圾回收器作调优的话,那么不妨考虑一下使用C4算法。综上所述,当响应时间比吞吐量占有更高的优先级时,C4是个不错的选择。而对那些不能接受长时间暂停的应用程序来讲,C4是个理想的选择。
若是你正考虑在生产环境中使用C4,那么你可能还须要从新考虑一下如何部署应用程序。例如,没必要为每一个服务器配置16个具备2GB堆的JVM实例,而是使用一个64GB的JVM实例(或者增长一个做为热备份)。C4须要尽量大的内存来保证始终有一个空闲内存页来为新建立的对象分配内存。(记住,内存再也不是昂贵的资源了!)
若是你没有64GB,128GB,或1TB(或更多)内存可用,那么分布式的多JVM部署多是一个更好的选择。在这种场景中,你能够考虑使用Oracle HotSpot JVM的G1垃圾回收器,或者IBM JVM的平衡垃圾回收策略(Balanced Garbage Collection Policy)。下面将对这两种垃圾回收器作简单介绍。
Gargabe-First (G1) 垃圾回收器
G1垃圾回收器是新近才出现的垃圾回收器,是Oracle HotSpot JVM的一部分,在最近的JDK1.6版本中首次出现(译者注,该文章写于2012-07-11)。在启动Oracle JDK时附加命令行选项-XX:+UseG1GC,能够启动G1垃圾回收器。
与C4相似,这款标记-清理(mark-and-sweep)垃圾回收器也可做为对低延迟有要求的应用程序的备选方案。G1算法将堆分为固定大小区域,垃圾回收会做用于其中的某些区域。在应用程序线程运行的同时,启用后台线程,并发的完成标记工做。这点与其余并发标记算法类似。
G1增量方法可使暂停时间更短,但更频繁,而这对一些力求避免长时间暂停的应用程序来讲已经足够了。另外一方面,正如在本系列的[Part 3][4]中介绍的,使用G1垃圾回收器须要针对应用程序的实际需求作长时间的调优,而其GC中断又是stop-the-world式的。因此对那些对低延迟有很高要求的应用程序来讲,G1并非一个好的选择。进一步说,从暂停时间总长来看,G1长于CMS(Oracle JVM中广为人知的并发垃圾回收器)。
G1使用拷贝算法(在Part 3中介绍过)完成部分垃圾回收任务。这样,每次垃圾回收器后,都会产生彻底可用的空闲空间。G1垃圾回收器定义了一些区域的集合做为年轻代,剩下的做为老年代。
G1已经吸引了足够多的注意,引发了不小的轰动,可是它真正的挑战在于如何应对现实世界的需求。正确的调优就是其中一个挑战 —— 回忆一下,对于动态应用程序负载来讲,没有永远“正确的调优”。一个问题是如何处理与分区大小相近的大对象,由于剩余的空间会成为碎片而没法使用。还有一个性能问题始终困扰着低延迟垃圾回收器,那就是垃圾回收器必须管理额外的数据结构。就我来讲,使用G1的关键问题在于如何解决stop-the-world式垃圾回收器引发的暂停。Stop-the-world式的垃圾回收引发的暂停使任何垃圾回收器的能力都受制于堆大小和活动数据数量的增加,对企业级Java应用程序的伸缩性来讲是一大困扰。
IBM JVM的平衡垃圾回收策略(Balanced Garbage Collection Policy)
IBM JVM的平衡垃圾回收(Balanced Garbage Collection BGC)策略经过在启动IBM JDK时指定命令行选项-Xgcpolicy:balanced来启用。乍一看,BGC很像G1,它也是将Java堆划分红相同大小的空间,称为区间(region),执行垃圾回收时会对每一个区间单独回收。为了达到最佳性能,在选择要执行垃圾回收的区间时使用了一些启发性算法。BGC中关于代的划分也与G1类似。
IBM的平衡垃圾回收策略仅在64位平台获得实现,是一种NUMA架构(Non-Uniform Memory Architecture),设计之初是为了用于具备4GB以上堆的应用程序。因为拷贝算法或压缩算法的须要,BGC的部分垃圾回收工做是stop-the-world式的,并不是彻底并发完成。因此,归根结底,BGC也会遇到与G1和其余没有实现并发压缩选法的垃圾回收器类似的问题。
结论:回顾
C4是基于引用跟踪的、分代式的、并发的、协做式垃圾回收算法,目前只在Azul System公司的Zing JVM获得实现。C4算法的真正价值在于:
消除了重标记可能引发的重标记无限循环,也就消除了在标记阶段出现OOM错误的风险。
压缩,以自动、且不断重定位的方式消除了固有限制:堆中活动数据越多,压缩所引发的暂停越长。
垃圾回收再也不是stop-the-world式的,大大下降垃圾回收对应用程序响应时间形成的影响。
没有了清理阶段,下降了在完成GC以前就由于空闲内存不足而出现OOM错误的风险。
内存能够以页为单位当即回收,使那些须要使用较多内存的Java应用程序有足够的内存可用。
并发压缩是C4独一无二的优点。使应用程序线程GC线程协做运行,保证了应用程序不会因GC而被阻塞。C4将内存分配和提供足够连续空闲内存的能力彻底区分开。C4使你能够为JVM实例分配尽量大的内存,而无需为应用程序暂停而烦恼。使用得当的话,这将是JVM技术的一项革新,它能够借助于当今的多核、TB级内存的硬件优点,大大提高低延迟Java应用程序的运行速度。
若是你不介意一遍又一遍的调优,以及频繁的重启的话,若是你的应用程序适用于水平部署模型的话(即部署几百个小堆JVM实例而不是几个大堆JVM实例),G1也是个不错的选择。
对于动态低延迟启发性自适应(dynamic low-latency heuristic adaption)算法而言,BGC是一项革新,JVM研究者对此算法已经研究了几十年。该算法能够应用于较大的堆。而动态自调优算法( dynamic self-tuning algorithm)的缺陷是,它没法跟上忽然出现的负载高峰。那时,你将不得不面对最糟糕的场景,并根据实际状况再分配相关资源。
最后,为你的应用程序选择最适合的JVM和垃圾回收器时,最重要的考虑因素是应用程序中吞吐量和暂停时间的优先级次序。你想把时间和金钱花在哪?从纯粹的技术角度说,基于我十年来对垃圾回收的经验,我一直在寻找更多关于并发压缩的革新性技术,或其余能够以较小代价完成移动对象或重定位的方法。我想影响企业级Java应用程序伸缩性的关键就在于并发性。