垃圾收集器(二)垃圾收集算法

垃圾收集器(二)垃圾收集算法

思维导图

2019091201_垃圾收集算法

2019091201_垃圾收集器


第二节 垃圾收集算法

2.1 标记-清除算法

  最基础的收集算法是“标记-清除”算法(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经介绍过了。最基础是因为后续的收集算法都是基于此思路改进而来。主要有两点不足:一是效率问题,标记和清除过程都不高效另一个是空间问题,标记和清除之后会产生大量不连续的内存碎片,会导致以后在程序需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

“标记-清除”算法示意图

2.2 复制算法

  复制算法是为了解决效率问题而设计,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活的对象复制到另一块上,然后将已使用过的内存空间一次清理掉。这样的做法使每次都是对整个半区进行内存回收,内存分配时就不用考虑到内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但此算法的代价是会将内存空间缩小一半,代价实在太高。

复制算法示意图

  当前的商业虚拟机都采用此算法来回收新生代,IBM研究表示新生代中对象98%都是“朝生夕死”,所以不需要对半划分内存空间,而是划分为一块大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性的复制到另外一块Survivor空间上,最后清理掉Eden和用过的Survivor空间。(HotSpot默认Eden和Survivor空间大小比例为8:1,即每次新生代可用内存空间为整体的90%)。当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。

  分配担保类似于银行的担保人,如果一块Survivor空间没有足够的空间存放上次新生代收集下的存活对象,可以通过分配担保机制进入老年代,只要新生代有很好的“信誉”。具体实现此处不作详细描述。

2.3 标记-整理算法

  复制算法在对象存活率较高时需要进行较多的复制操作,会降低其运行效率。还有就是如果预留空间较小就需要额外的空间进行分配担保,以应对对象存活率极高的情况,所以老年代不会选用复制算法。

  根据老年代的特点,有了标记-整理算法,标记过程等同于标记-清除算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

“标记-整理”算法示意图

2.4 分代收集算法

  当前的商业虚拟机的垃圾收集都采用分代收集算法,就是根据对象的存活周期的不同将内存划分为几块。一般把Java堆划分为新生代和老年代,可以根据各个代的特点采用最适合的收集算法。新生代中,每次垃圾收集会有大量对象被清理,仅有少量存活,就采用复制算法老年代中对象存活率较高,没有额外的内存空间作分配担保,就采用标记-清除算法或标记-整理算法


第三节 HotSpot算法实现

3.1 枚举根节点

  可达性分析中从GC Roots节点找引用链这个操作为例,可以作为GC Roots的节点主要在全局性的引用(如常量或类静态属性)与执行上下文(如栈帧中的本地变量表)中,有些应用仅方法区就有数百兆,逐个检查这里面的引用是不现实的。

  可达性分析中的GC停顿用来确保分析工作在一个保证一致性的“快照”中进行。在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不能在分析过程中对象关系还在不断地变化,不能满足这一点就无法保证最终分析结果的准确性。这就是GC进行时必须停顿所有Java执行线程的其中一个原因,即使CMS收集器号称不会发生停顿,枚举根节点时也必须停顿。

  主流的Java虚拟机都是准确式GC(虚拟机可以知道内存中某个位置的数据类型具体是什么类型,如内存中有一个32位整数123456,虚拟机可以得知其是一个reference类型指向123456内存地址或者是一个数值为123456的整数,这样虚拟机才能在GC时判断堆上的数据是否还能被使用)。所以当执行系统停顿下来,并不需要一个不漏的检查完所有执行上下文全局的引用设置,虚拟机有办法知道哪些地方存放着对象引用。在HotSpot的实现中,是通过一组被称为OopMap的数据结构来达到此目的,在类加载完成时,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。所以GC在扫描时就可以直接得知这些信息了。

  下列代码是HotSpot Client VM生成的一段String.hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针的引用,有效范围从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
[Verified Entry Point]
0x026eb730: mov %eax,-0x8000(%esp)
......
;; ImplicitNullCheckStub slow case
0x026eb7a9: call 0x026e83e0 ; OopMap{ebx=Oop [16]=Oop off=142}
;*caload
; - java.lang.String::hashCode@48 (line 1489)
; {runtime_call}
0x026eb7ae: push $0x83c5c18 ; {external_word}
0x026eb7b3: call 0x026eb7b8
0x026eb7b8: pusha
0x026eb7b9: call 0x0822bec0 ; {runtime_call}
0x026eb7be: hlt

3.2 安全点

  在OopMap的帮助下,HotSpot可以快速且准确地完成GC Roots枚举,但随之而来的一个现实的问题:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样会导致GC的空间成本变得很高。

  HotSpot实际上并没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只会在安全点暂停。Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令的执行时间都非常短暂,程序不会因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。

  关于Safepoint,另外一个需要考虑的问题是如何在GC发生时让所有线程(不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。有两种实现方案:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

  抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点,就恢复线程,让它“跑”到安全点上。但如今几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。

  主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,而只是简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

  下面代码中的test指令是HotSpot生成的轮询指令,当需要暂停线程时,虚拟机把0x160100的内存页设置为不可读,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待,这样一条汇编指令便完成安全点轮询和触发线程中断。

1
2
3
4
5
6
7
8
9
10
0x01b6d627: call   0x01b2b210         ; OopMap{[60]=Oop off=460}
;*invokeinterface size
; - Client::main@113 (line 23)
; {virtual_call}
0x01b6d62c: nop ; OopMap{[60]=Oop off=461}
;*if_icmplt
; - Client::main@118 (line 23)
0x01b6d62d: test %eax,0x160100 ; {poll}
0x01b6d633: mov 0x50(%esp),%esi
0x01b6d637: cmp %eax,%esi

3.3 安全区域

  Safepoint并没有完美地解决了如何进入GC的问题。Safepoint机制保证了程序执行时,在不久的时间内就会遇到可进入GC的Safepoint,但程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。

  安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。也可以把Safe Region看做是被扩展了的Safepoint。

  在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了,在线程需要离开Safe Region时,它就要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

  到此,简单介绍了HotSpot虚拟机如何去发起内存回收的问题,但是虚拟机如何具体地进行内存回收动作仍然未涉及,因为内存回收如何进行是由虚拟机所采用的GC收集器决定的,而通常虚拟机中往往不止有一种GC收集器,之后来看HotSpot中有哪些GC收集器。


第四节 垃圾收集器

  如何说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。这里讨论的收集器基于JDK 1.7 Update 14之后的HotSpot虚拟机(在这个版本中正式提供了商用的G1收集器,之前G1仍处于实验状态),虚拟机包含的所有收集器如下图所示。

HotSpot虚拟机的垃圾收集器

  上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明他们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。接下来各节将逐一介绍这些收集器的特性、基本原理和使用场景,并重点分析CMS和G1这两款相对复杂的收集器,了解它们的部分运作细节。

4.1 Serial收集器

  Serial收集器是最基本、发展历史最悠久的收集器,曾经(在JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。此收集器是一个单线程的收集器,但其“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop The World” 这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。下图示意了Serial / Serial Old 收集器运行过程。

Serial / Serial Old 收集器运行示意图

  对于“Stop The World”带给用户的不良体验,虚拟机设计者的苦衷是“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?”这确实是一个合情合理的矛盾,但垃圾收集是比打扫房间要复杂的多。

  从JDK 1.3 开始,一直到现在HotSpot开发团队仍在致力于消除或减少工作线程因内存回收而导致停顿的努力一直在进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)乃至GC收集器的最前沿成果Garbage First(G1)收集器,一个个越来越优秀也越来越复杂的收集器的不断出现,用户线程的停顿时间在不断缩短,但是仍无法完全消除。

  Serial收集器到现在仍是虚拟机运行在Client模式下的默认新生代收集器,它还有相比于其他收集器的优点:简单而高效(单线程下)。对于限定单线程环境下,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代,停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以对于运行在Client模式下的虚拟机来说Serial收集器是很好的选择。

4.2 ParNew收集器

  ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器一样,在实现上,这两种收集器也共用了很多相当多的代码。ParNew收集器的工作过程如下图所示。

ParNew / Serial Old 收集器运行示意图

  ParNew收集器除了多线程收集以外并没有其他创新之处,但它是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。在JDK 1.5时期,HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器——CMS收集器(Concurrent Mark Sweep,稍后详细介绍),这款收集器是HotSpot虚拟机中第一款真正意义上的并发(Concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程基本上同时工作。

  不幸的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制执行它。

  ParNew收集器在单CPU的环境中不会比Serial收集器好,甚至因为存在线程交互的开销,此收集器在通过超线程技术实现的两个CPU的环境中都不能百分百的超过Serial收集器。随着可使用CPU的数量增加,其对于GC时系统资源的有效利用还是很有好处的,它默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

并发编程的两个概念名词在垃圾收集器的上下文语境中的解释:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。

4.3 Parallel Scavenge收集器

  Parallel Scavenge收集器是一个新生代收集器,使用复制算法,还是并行的多线程收集器,似乎和ParNew收集器没什么区别。Parallel Scavenge收集器的特点是它的关注点和其他收集器不同,CMS等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),如果虚拟机运行了100分钟,其中垃圾收集花费了1分钟,吞吐量就是99%。

  停顿时间短意味着有较快的响应速度可以提升用户体验,适合与用户交互的程序;高吞吐量则可以高效率的利用CPU时间,尽快的完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

  Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,-XX:MaxGCPauseMills,-XX:GCTimeRatio。

  Parallel Scavenge收集器也经常被叫做“吞吐量优先”收集器,除了以上两个参数还有一个-XX:UseAdaptiveSizePolicy值得关注。

  • -XX:MaxGCPauseMills 此参数控制最大垃圾收集停顿时间。MaxGCPauseMills参数的值是大于0的毫秒数,收集器会尽可能地保证内存回收花费时间不超过设定时间。但并不是设置的小就会使垃圾收集速度变快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统会把新生代调小,也就导致垃圾收集会更频繁,所以吞吐量就会降低。
  • -XX:GCTimeRatio 此参数直接设置吞吐量大小。GCTimeRatio参数的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。比如参数值为19,允许的最大GC时间就为1 / (1 + 19) = 5%的最大时间比率,如果为99,结果就为1%。
  • -XX:UseAdaptiveSizePolicy 它是一个开关参数,开启后就不需要手动指定新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。这种调节方式叫GC自适应的调节策略(GC Ergonomics)。

  如果开发者能力不足以进行优化工作,使用Parallel Scavenge收集器搭配自适应调节策略,把优化任务交给虚拟机来完成会是一个不错的选择。只需要把基本的内存数据设置好(-Xmx设置最大堆等),然后MaxGCPauseMills(关注最大停顿时间)或GCTimeRatio(关注最大吞吐量)参数给虚拟机设置一个优化目标,具体调节就由虚拟机去完成了。

  自适应调节策略是Parallel Scavenge收集器和ParNew收集器的一个重要区别。

4.4 Serial Old收集器

  Serial Old收集器是Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。此收集器的主要意义同样是在于给Client模式下的虚拟机使用。但在Server模式下,它还有两大用途:一种用途是在JDK 1.5 以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

  Serial Old收集器工作过程如下图。

Serial / Serial Old 收集器运行示意图

4.5 Parallel Old收集器

  Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。此收集器从JDK 1.6开始提供,在此之前新生代的Parallel Scavenge收集器只能搭配老年代的Serial Old收集器,因为它无法与CMS收集器配合。Serial Old收集器在服务端应用性能上并不可靠,使用了Parallel Scavenge收集器也未必能够在整体应用上获得最大吞吐量的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年大很大而且硬件比较高级的环境中,这种组合甚至不一定比ParNew+CMS的组合更好。

  Parallel Old收集器出现完善了“吞吐量优先”收集器有了合适的组合,在注重吞吐量以及CPU资源敏感的场合,可以优先考虑Parallel Scavenge收集器+Parallel Old收集器的搭配。

Parallel Scavenge / Parallel Old 收集器运行示意图

4.6 CMS收集器

  CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,从而给用户带来较好的体验,CMS收集器非常适合这种需求。

  从名字Mark Sweep便可以得知CMS收集器是基于“标记-清除”算法实现的,其运作过程相比前几个收集器相对复杂。

CMS收集器运作过程:

  • 初始标记(CMS initial mark):仅仅是标记一下GC Roots能直接关联到的对象,速度很快。初始标记和重新标记两个步骤仍需要“Stop The World”。
  • 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程。
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,此阶段的停顿时间一般会比初始标记阶段稍长,但总耗时比并发标记阶段短。
  • 并发清除(CMS concurrent sweep):并发清除和并发标记是耗时最长的阶段,这两个阶段收集器线程都可以与用户线程一起工作。

  总体来说CMS收集器的内存回收过程是与用户线程一起并发执行的,下图可以看到CMS收集器的运作步骤中并发和需要停顿的时间。

Concurrent Mark Sweep 收集器运行示意图

  CMS是一款优秀的收集器,优点就是并发收集低停顿,所以Sun公司文档中称其为并发低停顿收集器(Concurrent Low Pause Collector),但CMS也有明显的缺点。

CMS的三个缺点

  1. 对CPU资源十分敏感,这是并发设计程序的通病。在并发阶段,虽然不会导致用户线程停顿,但会因为占用了一部分线程或者说CPU资源而导致应用程序变慢,总吞吐量会降低。

  CMS默认启动的回收线程数是 (CPU数量 + 3) / 4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时,CMS对用户程序的影响就可能变得很大,如果CPU本身负载就比较大,还要分出一半的运算能力去执行收集器线程,就会导致用户程序的执行速度突然下降50%。

  为了应对这种情况,虚拟机提供了一种称为增量式并发收集器(Incremental Concurrent Mark Sweep / i-CMS)的CMS收集器变种,所做的工作和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思路一样,就是在并发标记、清理的时候让GC线程、用户线程交替运行,尽量减少线程独占资源的时间,这样整个垃圾收集的过程会更长,但会降低对用户程序的影响。但实际使用中i-CMS效果非常一般,所以后续版本已经弃用(deprecated)。

  2. 无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。因为CMS并发清理阶段用户线程还在运行,所以自然会不断地产生垃圾,这部分垃圾产生在标记过程之后,所以CMS无法在此次收集中处理它们,只能留到下次GC时再进行清理。这部分垃圾就被称为浮动垃圾(Floating Garbage)。因为垃圾收集阶段用户线程还在运行,还意味着需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎被填满了再去收集,而是需要预留一部分空间提供并发收集时的程序运作使用。

  使用JDK 1.5默认设置时,CMS收集器当老年代使用了68%就会被激活,这个数值比较保守,可以通过提高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,从而降低内存回收次数获取更好的性能。JDK 1.6时,CMS收集器的启动阈值已经提升至92%。如果CMS运行期间预留的内存无法满足程序需要,会出现一次“Concurrent Mode Failure”失败,此时虚拟机将启动后背预案:临时启动Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以CMSInitiatingOccupancyFraction参数谨慎处理,设置太高反而可能导致性能降低。

  3. 生成大量的空间碎片。因为CMS收集器基于“标记-清除”算法,所以自然会在收集结束后产生大量的空间碎片,给大对象的分配造成困难,经常老年代仍有大量空间未使用,却无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

  为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启),用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程,此过程无法并发,解决了空间碎片问题,但代价是延长了停顿时间。

  虚拟机还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,此参数用来设置执行多少次不压缩Full GC后,跟着来一次带压缩的(默认为0,表示每次进入Full GC时都进行碎片整理)

4.7 G1收集器

  G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,早在JDK 1.7时就有了进化特征,在JDK 6u14时有了Early Access版本提供实验、使用。直到JDK 7u4时达到成熟阶段。G1是一款面向服务端应用的垃圾收集器,其使命是替换掉JDK 1.5发布的CMS收集器。

G1相比其它收集器的特点:

  • 并行与并发 :G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java线程继续执行。
  • 分代收集 :和其他收集器一样,G1仍然保留了分代概念。虽然G1能独立管理整个GC堆,不需要其他收集器配合,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合 :与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器。从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。这个特性有利于程序长时间运行,分配大对象不会因为找不到连续空间而提前触发下次GC。
  • 可预测的停顿 :这个特点是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

  在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1收集器的Java堆内存布局会有很大变化,它将整个Java堆分为对个大小相等的独立区域(Region),虽然还保留了新生代和老年代的概念,但两者之间不再物理隔离,都是一部分Region(不需要连续)的集合。

  G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(G1由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

  G1把内存“化整为零”的思路,实际实现并不简单。一个细节为例:把Java堆分为多个Region后,垃圾收集是否就真的能以Region为单位进行了?似乎顺理成章,但仔细想想会发现问题所在:Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被此Region中的对象引用,而是可以和整个Java堆中任意的对象发生引用关系。那么在做可达性分析判定确定对象是否存活时,岂不是还要扫描整个Java堆才能保证准确性?这个问题并非G1独有,但G1尤其明显突出。在以前的分代收集中,新生代一般都比老年代要小许多,收集也要频繁许多,那么回收新生代对象时也面临着同样的问题,如果回收新生代对象时不能不同时扫描老年代的话,Minor GC的效率要下降不少。

  G1收集器中Region间的对象引用,其他收集器中新生代和老年代之间的对象引用,都是通过 Remembered Set 来避免全堆扫描。在G1中每个 Region 都有一个对应的 Remembered Set ,当虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代的对象引用了新生代的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在GC根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

运行步骤

  G1收集器的前几个步骤和CMS收集器类似。

不考虑维护 Remembered Set 的操作,G1收集器的运作步骤:

  • 初始标记(Initial Marking):仅仅是标记一下GC Roots能直接关联到的对象,并修改TAMS(Next To at Mark Start)的值,使下一阶段用户程序并发运行时,能在正确可用的Region中创建对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking):从GC Roots开始对堆中对象进行可达性分析,找出存活的对象,此阶段耗时较长,但可与用户程序并发执行
  • 最终标记(Final Marking):此阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但可以并发执行
  • 筛选回收(Live Data Counting and Evacuation):此阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,此阶段可以做到与用户程序一起并发执行,但因为只回收一部分 Region ,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

  通过下图可以比较清楚的看到G1收集器的运作步骤中并发和需要停顿的阶段。

G1收集器运行示意图

性能测试

  通过简单的Java代码写个Microbenchmark程序来创建、移除Java对象,用-XX:+PrintGCDetails等参数来查看GC日志是很难去准确衡量其性能的。下面引用Sun实验室论文数据。

  • 硬件:Sun V880 服务器(8×750MHz UltraSPARC Ⅲ CPU、32G内存、Solaris 10 OS)
  • 软件:SPECjbb(模拟商业数据库应用,堆中存活对象约为165MB,结果反映吐量和最长事物处理时间)、telco(模拟电话应答服务应用,堆中存活对象约为100MB,结果反映系统能支持的最大吞吐量)。
  • 补充:另外收集了一组用ParNew+CMS收集器的测试数据方便对比,所有测试都配置为与CPU数量相同的8条GC线程

  在反应停顿时间的软实时目标(Soft Real-Time Goal)测试中,横向是两个测试软件的时间片段配置,单位是毫秒,以(X/Y)形式表示,代表在Y毫秒内最大运行GC时间为X毫秒(对于CMS收集器,无法直接指定这个目标,通过调整分代大小的方式大致模拟)。纵向是两个软件在对应配置和不同的Java堆容量下的测试结果,V%、avgV%和wV%分别表示以下含义:

  • V%:表示测试过程中软实时目标失败的概率,软实时目标失败即某个时间片段中实际GC时间超过了允许的最大GC时间。
  • avgV%:表示在所有实际GC时间超标的时间片段里,实际GC时间超过最大GC时间的平均百分比(实际GC时间减去允许最大GC时间,再除以总时间片段)。
  • wV%:表示在测试结果最差的时间片段里,实际GC时间占用执行时间的百分比。

测试结果

  从上图测试结果可见,对于telco,软实时目标失败的概率控制在0.5% ~ 0.7%之间,SPECjbb就要差一些,但也在2% ~ 5%之间。概率随着(X/Y)比值减小而增加。另一方面,失败时超出允许GC时间的比值随着总时间片段增加而变小,在(100/200)+512MB配置下,G1收集器出现了某些时间片段下100%时间在进行GC的最坏情况。相比之下CMS收集器的测试结果要差很多,3种Java堆容量下都出现100%GC情况。

  吞吐量测试中,测试数据取3次SPECjbb和15次telco的平均结果,如下图所示。在SPECjbb应用下,各种配置下的G1表现了一致的行为,吞吐量看起来只与允许最大GC时间成正比关系,而telco应用下,不同配置对于吞吐量的影响则显得很微弱。与CMS收集器的吞吐量对比可以看到,在SPECjbb测试中,在堆容量超过768MB时,CMS收集器有5% ~ 10%的优势,而在telco测试中,优势要小一些,只有3% ~ 4%左右。

吞吐量测试结果

  如果你的应用追求低停顿,且对现有的收集器不够满意,可以尝试使用G1收集器,否则没有必要。

4.8 理解GC日志

  阅读GC日志是处理Java内存问题的基础技能,每种收集器的日志的形式都由其自身实现决定,虚拟机设计者为了方便用户阅读,是有对收集器维护一些共性。如下图所示两段GC日志,“33.125”和“100.667”表示GC发生时间,数字来源于虚拟机启动后经过的秒数。“[GC”和“[Full GC”表示了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC(比如新生代收集器ParNew的日志也会出现Full GC)。如果有Full表示这次GC发生了Stop-The-World。如果是调用System.gc()方法触发的收集,会显示“[Full GC(System)”。

GC日志

  “[DefNew”,“[Tenured”,“[Perm”表示GC发生的区域,区域名和收集器密切相关。如上述是Serial收集器,新生代名为“Def New Generation”,所以显示DefNew。如果是ParNew收集器,新生代名为“Parallel New Generation”,就显示“[ParNew”。如果是Parallel Scavenge收集器,新生代叫“PSYoungGen”。老年代和新生代一样也由收集器起名决定。

  “3324K->152K(3712K)”含义是**GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)。方括号外的“3324K->152K(11904K)”含义是GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)**。

  “0.0025925 secs”表示该内存区域GC所占用时间,单位为秒。有些收集器会给出更具体的时间数据“[Time : user=0.01 sys=0.00, real=0.02 secs]”
三个参数分别代表用户态消耗的CPU时间,内核态消耗的CPU时间和操作从开始到结束经过的墙钟时间(Wall Clock Time)。当系统有多CPU或多核时,会叠加这些CPU时间,所以user或sys超过real是正常的。

CPU时间和墙钟时间的区别是:墙钟时间包括各种非运算的等待耗时,如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时。

4.9 垃圾收集器参数总结

  整理了一下垃圾收集相关的常用参数。

垃圾收集相关的常用参数

垃圾收集相关的常用参数


参考博客和文章书籍等:

《深入理解Java虚拟机》

因博客主等未标明不可引用,若部分内容涉及侵权请及时告知,我会尽快修改和删除相关内容