JVM(3)-垃圾收集

本文引用自文献:1)《深入理解Java虚拟机》,作者:周志明;

垃圾收集就是我们常说的GC(Garbege Collect),对于垃圾回收,我们需要弄清楚几个问题:1)哪些内存需要回收?2)怎么回收?

怎么判断对象是否存活

引用记数法

引用记数法是很多具有垃圾回收功能的语言所用来判断对象是否存活的一种方法,它的算法逻辑是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1;计数器值为0的对象则是已死亡的,它可以被垃圾收集器回收。

引用记数法(Reference Counting)实现起来简单,判断效率也很高,但主流的 Java 虚拟机并不是采用的这个方法,主要的原因是它很难解决对象之间相互循环引用的问题。如下是一个测试代码,其中testGC方法:对象objA和objB都有字段instance,赋值objA.instance = objobjB.instance = objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,那这两个对象会被回收吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class ReferenceCountingGC {

public Object instance = null;

private static final int SIZE_1MB = 1024 * 1024;

/*
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
*/
private byte[] bigSize = new byte[2 * SIZE_1MB];


/**
* testGC方法执行后,objA和objB会不会被GC呢?
*/
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

//假设在这行发生GC,objA和objB是否能被回收
System.gc();
}


public static void main(String[] args) {
testGC();
}


}

运行main方法后打印的结果(加上jvm参数:-XX:+PrintGCDetails):

1
2
3
4
5
6
7
8
9
10
11
[GC (System.gc()) [PSYoungGen: 7381K->872K(37888K)] 7381K->880K(123904K), 0.0026002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 872K->0K(37888K)] [ParOldGen: 8K->690K(86016K)] 880K->690K(123904K), [Metaspace: 3359K->3359K(1056768K)], 0.0068761 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 37888K, used 983K [0x00000000d6100000, 0x00000000d8b00000, 0x0000000100000000)
eden space 32768K, 3% used [0x00000000d6100000,0x00000000d61f5db8,0x00000000d8100000)
from space 5120K, 0% used [0x00000000d8100000,0x00000000d8100000,0x00000000d8600000)
to space 5120K, 0% used [0x00000000d8600000,0x00000000d8600000,0x00000000d8b00000)
ParOldGen total 86016K, used 690K [0x0000000082200000, 0x0000000087600000, 0x00000000d6100000)
object space 86016K, 0% used [0x0000000082200000,0x00000000822ac8a8,0x0000000087600000)
Metaspace used 3366K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 373K, capacity 388K, committed 512K, reserved 1048576K

从运行结果中可以清楚看到,GC日志中包含“872K->0K”,意味着虚拟机并没有因为这两个对象互相引用就不回收它们,这也从侧面说明Java虚拟机并不是通过引用记数法来判断对象是否存活的。

可达性分析算法

Java虚拟机实际上是通过可达性分析(Reachability Analysis)来判断对象是否存活的。这个算法的基本思想就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到“GC Roots”没有任何引用链相连时(用图论的话来说,就是从GC Roots到这个对象不可达),则此对象是可回收的。如下图所示,对象object5、Object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会判定为是可回收的对象。

可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    方法执行的时候都会创建一个相应的栈帧,所以栈帧中引用的对象就是方法中使用的对象。
  • 方法区中类静态属性引用的对象。
    例如private static User user = new User();这种对象。
  • 方法区中常量引用的对象。
    例如private final User user2 = new User();这种对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

finalize()

在可达性分析算法中不可达的对象,也并非一定会被回收,它们暂时只是出于一个“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两个过程:
1)如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
2)如果“没有必要执行”finalize()方法,那对象被宣告死亡。
3)如果有必要执行finalize()方法,那么这个对象将会放置到一个叫做F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢或者发生死循环,将很可能会导致F-Queue队列中的其它对象永久出于等待,甚至导致整个内存回收系统崩溃。如果对象在finalize()方法中重新与GC Roots建立了引用链,那它将会被移除出“即将回收”的集合,它又活过来了,如果没有则被宣告死亡。

注意:日常开发中不建议使用finalize()方法。

Minor GC 与 Full GC

Minor GC 是指发生在新生代的GC,因为Java对象大多都是朝生夕灭的,所以 Minor GC 非常频繁,一般回收速度也比较快。
Full GC 是指发生在老年代的GC,也叫做 Major GC。出现 Full GC 时经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scanvege 收集器的收集策略里就有直接进行 Full GC 的策略选择过程)。Full GC的速度一般会比 Minor GC 慢10倍以上。

垃圾收集算法

本文只介绍几种垃圾收集算法的大致思想,不涉及算法的具体实现。

标记-清除算法

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。这里的标记过程在上一节讲述对象存活判定时已经提到过,之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

它的主要缺点有两个:

  1. 一个是效率问题,标记和清除过程的效率都不高,因为要遍历整个堆内存区域,而不需要回收的对象只占很小部分,大部分都需要被回收;
  2. 另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,未免太高了一点。复制算法的执行过程如下图所示。

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM的专门研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间。而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor 。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被“浪费”的。当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

标记-整理算法

对于老年代来说,对象的存活率比较高,如果采用复制收集算法就会进行较多的复制操作,效率比较低。根据老年代的特点,所以提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块,根据情况合理使用前面的几个算法。

一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  1. 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法。
  2. 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。这里讨论的收集器基于JDK1.7 update 14之后的 HotSpot 虚拟机。该虚拟机包含的所有收集器如图所示:

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

Serial收集器

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

Serial/Serial Old收集器的运行过程如下(新生代使用Serial收集器,老年代使用Serial Old收集器):

从JDK 1.3开始,HotSpot虚拟机开发团队为消除或减少工作线程因内存回收而导致停顿的努力一直在进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)现在还未正式发布的Garbage First(G1)收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除(这里暂不包括RTSJ中的收集器)。

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

ParNew收集器

ParNew收集器收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The world、对象分配规则、回收策略等都与Serial收集器完全一样,实现上这两种收集器也共用了相当多的代码。ParNew/Serial Old收集器的工作过程如下图所示(新生代使用ParNew收集器,老年代使用Serial Old收集器)。

ParNew收集器除了多线程收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。在JDK1.5时期,HotSpot推出了一款在强交互应用中几乎可认为有划时代意思的垃圾收集器————CMS收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发(concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程同时工作,用前面那个例子的话来说,就是做到来在你妈妈打扫房间的时候你还能同时往地上扔纸屑。

不幸的是,CMS作为老年代的收集器,却无法与JDK 1.4中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在JDK 1.5 中使用CMS来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

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

注意:从 ParNew 收集器开始,后面还会解除到几款并发和并行的收集器,有必要先解释两个名词:并发和并行。这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

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

Parallel Scavenge收集器

Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,似乎看上去和ParNew一样,那它有什么特别之处吗?

Parallel Scavenge 收集器的特点是它的关注点与其它收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

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

Parallel Scavenge收集器提供了两个参数用于精准控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

MaxGCPauseMillis 参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不过大家不要认为把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿10毫秒。停顿时间的确在下降,但吞吐量也降下来了。

GCTimeRatio 参数的值应当是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1 / (1 + 19))。默认值为99,就是允许最大1%(即1 / (1 + 99))的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为“吞吐量优先”收集器。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurviorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。如果开发人员对于收集器运作不太了解,手工优化存在困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆内存),然后使用MaxGCPauseMillis参数(更关注最大停顿时间)或GCTimeRatio参数(更关注吞吐量)给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

Serial Old收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK1.5以及之前的版本中与Parallel Scanvege收集器搭配使用,另一种用途就是作为CMS收集器的后背预案,在并发收集发生 Concurrent Mode Failure 时使用。这两点都将在后面的内容中详细讲解。

Parallel Old收集器

Parallel Old 是 Parallel Scanvege 收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在1.6中才开始提供的,在此之前,新生代的Parallel Scanvege收集器一直处于比较尴尬的状态。原因是,如果新生代选择了Parallel Scanvege收集器,老年代除了Serial Old收集器外别无选择(前面提到过Parallel Scanvege收集器无法与CMS收集器配合工作)。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scanvege收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件条件比较好的情况下,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。

知道Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑 Parallel Scanvege 加 Parallel Old 收集器。Parallel Scanvege 加 Parallel Old 收集器的工作过程入下图所示。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很多Java应用主要用于web服务端,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤。

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

从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的,CMS收集器的运作过程示意图如下。

CMS是一款优秀的收集器,它具有并发收集、低停顿的优点。但是它还远达不到完美的程度,它有一下3个明显的缺点:

  • CMS收集器对CPU资源非常敏感。其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个(比如2个)时,CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还分出一半的运算能力去执行收集器线程,就可能你导致用户程序的执行速度忽然降低了,这是CMS的一个缺点。

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

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

  • 最后一个缺点就是CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束后会有大量的空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行Full GC的时候,开启内存碎片的合并整理过程,这个整理过程是无法并发进行的,这样虽然解决了空间碎片的问题,但程序停顿的时间不得不变长。

G1收集器

G1垃圾收集器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。G1是面向服务端应用的,它主要是为了适配现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量。

Hotspot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK 1.5中发布的CMS收集器。实际上G1是JDK9以后默认的垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old组合。G1被Oracle官方称为”全功能的垃圾收集器“。在JDK7、JDK8中想使用G1,需要使用-XX:+UseG1GC来启用。与此同时,CMS已经在JDK9中被标记为废弃。在JDK14中被移除。

G1具备如下优点:

  • 并行与并发。
    并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力,缩短用户线程“Stop The World”停顿的时间。
    并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。

  • 分代收集
    分代概念在G1中依然得以保留,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
    G1可以不需要其它收集器配合就能独立管理整个GC堆,它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好地收集效果。

  • 空间整合
    与CMS的“标记-清理”算法不同,G1从整体上来看是基于“标记-整理”算法实现的收集器,从局部看是基于“复制”算法实现的(G1将内存划分为一个个的Region,内存的回收是以region作为基本单位的,Region之间是复制算法)。这两种算法都不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

  • 可预测的停顿
    降低停顿时间是G1和CMS共同的关注点,但G1除了追求停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集的时间上不超过N毫秒。

横跨整个堆内存
在G1之前的其它收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其它收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,他们都是一部分Region(不需要连续)的集合。

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

避免全堆扫描
G1把Java堆分为多个Region,就是“化整为零”。但是Region不可能是是孤立的,一个对象分配在某个Region中,它并非只能被本Region中其他对象引用,可以与整个Java堆任意对象发生引用关系。在做可达性判定对象是否存活的时候,需要扫描整个Java堆才能保证准确性。显示是对GC效率的极大伤害。

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

G1收集的运行过程
如果不计算维护Remember Set的操作,G1收集器的运作大致可划分为以下几个步骤:

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

各种收集器对比

收集器 线程 串行/并行/并发 新生代/老年代 算法 目标 Stop The World 使用场景
Serial 单线程 串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
ParNew 多线程 并行 新生代 复制算法 响应速度优先 不会 多CPU环境时在Server模式下与CMS配合使用
Parallel Scavenge 多线程 并行 新生代 复制算法 吞吐量优先 不会 在后台预算不需要太多交互的任务
Serial Old 单线程 串行 老年代 标记-整理算法 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
Parallel Old 多线程 并行 老年代 标记-整理算法 吞吐量优先 不会 在后台预算不需要太多交互的任务
CMS 多线程 并发 老年代 标记-清除算法 响应速度优先 集中在互联网网站或B/S系统服务端上的Java应用
G1 多线程 并发 新生代、老年代 分代收集算法(复制算法+标记-整理算法) 响应速度优先 不会 面向服务端应用,将来替换CMS

阅读GC日志

阅读GC日志是处理JVM内存问题的基础技能,它只是一些认为确定的规则,没有太多技术含量。

每一种收集器的日志形式都是由它们自身的实现所决定的,换言之,就是每个收集器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,例如一下两段典型的GC日志:

1
2
33.125: [GC  [DefNew: 4310K->872K(37888K),0.0025925 secs] 4310K->880K(123904K), 0.0009434 secs] 
100.667: [Full GC [Tenured: 4310K->872K(37888K),0.0149142 secs] 4310K->880K(123904K), 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]

  • 前面的数字“33.125:” 和 “100.667:”代表了GC发生的时间,这个数字的含义是从java虚拟机启动以来经过的秒数。
  • GC日志开头的“[GC”和“[Full GC” 说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC和老年代GC的,如果有“Full”,说明这次GC是发生了Stop The World的,如果调用System.gc()方法所触发的收集,那么在这里将显示“[Full GC (System.gc()”。
  • “[DefNew:”、“[Tenured:”、“[Perm:” 表示GC发生的区域,这里显示的区域名成与使用的GC收集器是密切相关的;如果使用Serial收集器中的新生代名为“Default New Generation”,所以显示“[DefNew:”;如果使用ParNew收集器,新生代名称会变为“[ParNew:”,意为“Parallel New Generation”;如果使用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”。
  • 后面的括号内部的“4310K->872K(37888K)”含义是“GC前该内存区域已使用容量–>GC后该内存区域已使用容量(该内存区域总容量)”,“0.0025925 secs” 表示该内存区域GC所占用的时间,单位秒。
  • 方括号之外的“4310K->880K(123904K)”表示“GC前java堆已使用容量”–>“GC后java堆的已使用容量(java堆总容量)”,“0.0009434 secs”表示该java堆中GC所占用的时间,单位秒。
------ 本文完 ------