Java的GC类型介绍

介绍Java的常见的垃圾收集器的类型

Posted by qin4zhang on September 7, 2019

注意

想法及时记录,实现可以待做。

GC Roots

GC的时候,怎么识别谁应该被回收,谁不应该被回收呢?啥哦知道,JVM提供的自动管理内存的功能,这属于Java的一大特色,不用主动管理内存,专注于业务。

如果回收内存不准,那GC便失去了意义,所以要准确的回收内存垃圾才行。针对垃圾识别,一种是引用计数(Reference Counting),另一种是根搜索算法(GC Roots Tracing)。

引用计数,也就是在一个对象被引用时加一,去除引用时减一,这样我们就可以通过判断引用计数是否为零来判断一个对象是否为垃圾。这种方法我们一般称之为「引用计数法」。

这种方法虽然简单,但是其存在一个致命的问题,那就是循环引用。那第二种也就是可达性跟踪法:从 GC Root 出发,所有可达的对象都是存活的对象,而所有不可达的对象都是垃圾。

那哪些是GC Root呢?有以下:

  • System Class:由bootstrap/system 类加载器装入的类。例如,rt.jar中的所有内容都像java.util。*。
  • JNI Local:native代码中的本地变量,例如用户定义的JNI代码或JVM内部代码。
  • JNI Global:native代码中的全局变量,如用户定义的JNI代码或JVM内部代码。
  • Thread Block:来自当前活动的线程块中引用的对象。
  • Thread:开始,但没有停止的线程。
  • Busy Monitor:所有调用了wait()或notify()的对象,或者是同步的对象。例如,通过调用synchronized(Object)或输入一个同步方法。静态方法是类,非静态方法是对象。
  • Java Local:局部变量。例如,仍然在线程堆栈中的输入参数或本地创建的方法对象。
  • Native Stack:native代码中的输入或输出参数,例如用户定义的JNI代码或JVM内部代码。这种情况很常见,因为许多方法都有native部分,而当方法参数变成GC根时处理的对象。例如,用于文件/网络I/O方法或反射的参数。
  • Finalizable:一个在队列中等待其终结器运行的对象。
  • Unfinalized:一个具有finalize方法,但尚未结束且尚未在终结器队列上的对象。
  • Unreachable:一个无法从任何其他根访问的对象,但MAT已将其标记为根,以保留其他情况下不会包含在分析中的对象。
  • Java Stack Frame:保存局部变量的Java堆栈帧。仅在使用首选项集解析转储以将Java堆栈框架视为对象时生成。
  • Unknown:根类型未知的对象。一些转储文件,如IBM Portable Heap Dump文件,没有根信息。对于这些转储,MAT解析器将没有入站引用或无法从任何其他根访问的对象标记为这种类型的根。这确保MAT保留转储中的所有对象。

以上内容来自Garbage Collection Roots

GC 算法

  1. 标记-清除(Mark and sweep)算法

标记-清除算法,分两阶段:

  • 标记的过程就是前面的可达性分析法执行的过程。首先遍历所有 GC Roots 对象,对从 GC Roots 对象可达的对象都打上一个可达标识。这个可达标识一般记录在对象 header 中(一个对象一般包括对象头、实例数据、对齐填充三个部分),表示该对象可以被 GC Roots 访问。
  • 清除阶段是对堆内存进行遍历,通过读取这些对象的 header 信息来获取对象是否标记可达。如果未标记则表示这些对象没有引用,就可以进行回收。

标记 - 清除算法主要不足有两个:

  • 效率问题:标记和清除都需要遍历,效率不高;
  • 空间问题:标记清除后会产生大量不连续的内存水平,空间碎片太多会导致大内存对象无法生成而频繁进行 GC。

标记-清除

  1. 标记-复制算法

标记-复制

  1. 标记-整理算法

标记-整理

强/软/弱/虚引用

从JDK1.2开始,对象的引用被分为四个级别,从而使JVM可以更好地控制对象的生命周期。这四个引用级别就是:强引用、软引用、弱引用和虚引用。

强引用(Strong Reference)

强引用可以直接访问目标对象。如果一个对象具有强引用,那垃圾回收器绝不会回收它。JVM宁愿抛出OOM异常也不会回收这一类对象。

显示地将对象引用置为null,或者超出对象引用的使用范围(比如方法出栈,方法内部的强引用,是保存在Java栈中的)。

软引用(Soft Reference)

对于软引用对象,如果JVM内存空间充足,JVM就不会GC它;如果内存空间不足,就会酌情回收这些对象。软引用的这一特性很适合用来做内存缓存。

弱引用(Weak Reference)

弱引用和软引用区别在于,弱引用的对象拥有更短暂的生命周期,当JVM在GC时扫描到只具有弱引用的对象时,无论当前内存空间是否足够,都会回收它。弱引用对应的类为 WeakReference。

常见的使用WeakReference的有:ThreadLocal,WeakHashMap。

在ThreadLocal中,内部使用了ThreadLocalMap来保存key-value,ThreadLocalMap维护 ThreadLocal 变量与具体value的映射,由于key是WeakReference的,所以Entry中的key可以被回收,是null的。

这样一来,就无法访问对应的value了。但是只要当前的线程没有退出,就存在一条Thread Ref->Thread->ThreadLocalMap->Entry->value的强引用链,所以value无法被GC。

只有当线程退出,Current Thread Ref不在栈中,value的强引用断开,value才会被正确GC。

虚引用(Phantom Reference)

虚引用并不会影响对象的生命周期,如果一个对象仅持有虚引用,那么和没有任何引用一样,随时可以被GC。对应 PhantomReference 类。

内存池

内存的划分

伊甸(Eden)

Eden是内存中的区域,对象通常在创建时被分配到该区域。由于通常有多个线程同时创建许多对象,Eden被进一步划分为驻留在Eden空间中的一个或多个Thread Local Allocation Buffer (简称TLAB)。这些缓冲区允许JVM在相应的TLAB中直接在一个线程中分配大多数对象,从而避免与其他线程进行昂贵的同步。

当TLAB内部的分配是不可能的(通常是因为那里没有足够的空间),分配转移到共享的Eden空间。如果其中也没有足够的空间,则会触发Young Generation中的垃圾收集进程来释放更多的空间。如果垃圾收集也没有导致Eden内部有足够的空闲内存,那么对象将在老一代中分配。

在收集Eden时,GC从根遍历所有可访问的对象,并将它们标记为活动对象。

我们之前已经注意到对象可以有跨代链接,所以一个简单的方法是检查其他代对Eden的所有引用。不幸的是,这样做会首先破坏繁衍后代的意义。JVM有一个锦囊妙计:卡片标记(card-marking)。从本质上说,JVM只是在Eden中标记出“脏”对象的大致位置,这些对象可能有来自老一代的链接。

TLAB-in-Eden-memory

标记阶段完成后,伊甸园中的所有活动对象都被复制到Survivor空间中的一个。整个伊甸园现在被认为是空的,可以重用来分配更多的对象。这种方法称为“标记和复制”:标记活动对象,然后将其复制(而不是移动)到幸存者空间。

Survivor Spaces

Eden空间旁边是两个Survivor空间,分别为from和to。需要注意的是,两个Survivor空间中的一个总是空的。

空的Survivor空间将在下次年轻一代被收集时开始有”居民”。所有来自整个Young代的活动对象(包括Eden空间和非空的Survivor空间)都被复制到Survivor空间。在这个过程完成后,’ to ‘现在包含对象,而’ from ‘不包含对象。这时他们的角色互换了。

Survivor Spaces

在两个Survivor空间之间复制活动对象的过程会重复几次,直到一些对象被认为已经成熟并且“足够老了”。记住,根据代际假说,那些已经存在了一段时间的物品应该会继续使用很长时间。

这样的“终身”目标就可以推广到老一代。当这种情况发生时,对象不会从一个幸存者空间移动到另一个,而是移动到Old空间,它们将驻留在那里,直到它们变得不可到达。

为了确定对象是否“足够老”,可以考虑将其传播到old空间,GC跟踪特定对象存活下来的收集数量。在每一代对象以GC结束后,那些仍然存活的对象的年龄增加。当年龄超过一定的保留期阈值时,对象将被提升到Old空间。

实际的期限阈值由JVM动态调整,但是指定-XX:+MaxTenuringThreshold将设置它的上限。设置-XX:+MaxTenuringThreshold=0会立即提升,而不会在Survivor空间之间复制。默认情况下,现代jvm上的这个阈值被设置为15个GC周期。这也是HotSpot中的最大值。

如果Survivor空间的大小不足以容纳Young代中的所有活动对象,升级也可能过早发生。

老年代(Old Generation)

老年代内存空间的实现要复杂得多。Old Generation通常要大得多,被不太可能成为垃圾的对象所占据。

老年代的GC比年轻一代的GC发生得少。此外,由于大多数对象都预期在老年代中是活的,因此没有发生标记和复制。相反,对象会四处移动以最小化碎片。清理旧空间的算法通常建立在不同的基础上。原则上,所采取的步骤如下:

  • 通过在所有可以通过GC根访问的对象旁边设置标记位来标记可访问对象
  • 删除所有不可达的对象
  • 通过将活动对象连续复制到旧空间的开头,压缩旧空间的内容

正如上述描述中可以看到的,Old Generation中的GC必须处理显式压缩以避免过度碎片化。

永久代(PermGen)

在Java 8之前,有一个特殊的空间叫做“永久代”。这是诸如类之类的元数据的去处。此外,一些额外的东西,如内部化字符串保存在Permgen中。

它实际上给Java开发人员带来了很多麻烦,因为很难预测所有这些需要多少空间。这些失败预测的结果以java.lang.OutOfMemoryError: Permgen space的形式出现。

除非这种OutOfMemoryError的原因是一个实际的内存泄漏,解决这个问题的方法是简单地增加permgen的大小,类似于下面的例子设置最大允许的permgen大小为256MB:

java -XX:MaxPermSize=256m com.mycompany.MyApplication

元数据区(Metaspace)

由于预测元数据的需求是一项复杂且不方便的工作,因此在Java 8中为了支持metspace而删除了永久代。从那时起,大多数杂项都被移到了普通的Java堆中。

但是,类定义现在被加载到称为metspace的东西中。它位于本机内存中,不会干扰常规堆对象。默认情况下,元空间大小仅受Java进程可用的本机内存数量的限制。

这使开发人员避免了在应用程序中只添加一个类就会导致java.lang.OutOfMemoryError: Permgen空间的情况。请注意,拥有这种看似无限的空间并不是没有成本的——让metspace不受控制地增长,反而会导致大量的交换和/或本地分配失败。

如果你仍然希望保护自己在这种情况下,你可以限制metspace的增长类似于以下,限制metspace的大小为256MB:

java -XX:MaxMetaspaceSize=256m com.mycompany.MyApplication

Minor GC vs Major GC vs Full GC

清理堆内存中不同部分的垃圾收集事件通常称为Minor GC事件、Major GC事件和Full GC事件。

Minor GC

从Young空间收集垃圾称为Minor GC。这个定义既清晰又统一。但在处理小垃圾收集事件时,仍然有一些有趣的要点需要注意:

  1. Minor GC总是在JVM无法为新对象分配空间时触发,例如Eden已经满了。因此,分配率越高,Minor GC发生的频率就越高。
  2. 在Minor GC事件期间,Tenured Generation实际上被忽略。从年老代到年轻代的引用被认为是GC根。在标记阶段,从Young Generation到Tenured Generation的引用被简单地忽略。
  3. 与一般的看法相反,Minor GC确实会触发暂停,使应用程序线程挂起。对于大多数应用程序来说,如果Eden中的大多数对象都被认为是垃圾并且永远不会复制到Survivor/Old空间中,那么暂停时间的长度在延迟方面可以忽略不计。如果相反的情况是正确的,并且大多数新生成的对象都不适合收集,那么Minor GC暂停将花费相当多的时间。

所以定义Minor GC很容易——Minor GC收集清除年轻代。

Major GC vs Full GC

Major GC正在清理老年代。

Full GC是清理整个堆——包括年轻代和老年代的空间。

不要担心GC是被称为Major GC还是Full GC,而应该关注手边的GC是停止了所有应用程序线程,还是能够与应用程序线程并发进行。

比较使用Concurrent Mark Sweep收集器运行的JVM上跟踪GC的两种不同工具的输出(-XX:+UseConcMarkSweepGC)

my-precious: me$ jstat -gc -t 4235 1s
Time S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT   
 5.7 34048.0 34048.0  0.0   34048.0 272640.0 194699.7 1756416.0   181419.9  18304.0 17865.1 2688.0 2497.6      3    0.275   0      0.000    0.275
 6.7 34048.0 34048.0 34048.0  0.0   272640.0 247555.4 1756416.0   263447.9  18816.0 18123.3 2688.0 2523.1      4    0.359   0      0.000    0.359
 7.7 34048.0 34048.0  0.0   34048.0 272640.0 257729.3 1756416.0   345109.8  19072.0 18396.6 2688.0 2550.3      5    0.451   0      0.000    0.451
 8.7 34048.0 34048.0 34048.0 34048.0 272640.0 272640.0 1756416.0  444982.5  19456.0 18681.3 2816.0 2575.8      7    0.550   0      0.000    0.550
 9.7 34048.0 34048.0 34046.7  0.0   272640.0 16777.0  1756416.0   587906.3  20096.0 19235.1 2944.0 2631.8      8    0.720   0      0.000    0.720
10.7 34048.0 34048.0  0.0   34046.2 272640.0 80171.6  1756416.0   664913.4  20352.0 19495.9 2944.0 2657.4      9    0.810   0      0.000    0.810
11.7 34048.0 34048.0 34048.0  0.0   272640.0 129480.8 1756416.0   745100.2  20608.0 19704.5 2944.0 2678.4     10    0.896   0      0.000    0.896
12.7 34048.0 34048.0  0.0   34046.6 272640.0 164070.7 1756416.0   822073.7  20992.0 19937.1 3072.0 2702.8     11    0.978   0      0.000    0.978
13.7 34048.0 34048.0 34048.0  0.0   272640.0 211949.9 1756416.0   897364.4  21248.0 20179.6 3072.0 2728.1     12    1.087   1      0.004    1.091
14.7 34048.0 34048.0  0.0   34047.1 272640.0 245801.5 1756416.0   597362.6  21504.0 20390.6 3072.0 2750.3     13    1.183   2      0.050    1.233
15.7 34048.0 34048.0  0.0   34048.0 272640.0 21474.1  1756416.0   757347.0  22012.0 20792.0 3200.0 2791.0     15    1.336   2      0.050    1.386
16.7 34048.0 34048.0 34047.0  0.0   272640.0 48378.0  1756416.0   838594.4  22268.0 21003.5 3200.0 2813.2     16    1.433   2      0.050    1.484
java -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC eu.plumbr.demo.GarbageProducer


3.157: [GC (Allocation Failure) 3.157: [ParNew: 272640K->34048K(306688K), 0.0844702 secs] 272640K->69574K(2063104K), 0.0845560 secs] [Times: user=0.23 sys=0.03, real=0.09 secs] 
4.092: [GC (Allocation Failure) 4.092: [ParNew: 306688K->34048K(306688K), 0.1013723 secs] 342214K->136584K(2063104K), 0.1014307 secs] [Times: user=0.25 sys=0.05, real=0.10 secs] 
... cut for brevity ...
11.292: [GC (Allocation Failure) 11.292: [ParNew: 306686K->34048K(306688K), 0.0857219 secs] 971599K->779148K(2063104K), 0.0857875 secs] [Times: user=0.26 sys=0.04, real=0.09 secs] 
12.140: [GC (Allocation Failure) 12.140: [ParNew: 306688K->34046K(306688K), 0.0821774 secs] 1051788K->856120K(2063104K), 0.0822400 secs] [Times: user=0.25 sys=0.03, real=0.08 secs] 
12.989: [GC (Allocation Failure) 12.989: [ParNew: 306686K->34048K(306688K), 0.1086667 secs] 1128760K->931412K(2063104K), 0.1087416 secs] [Times: user=0.24 sys=0.04, real=0.11 secs] 
13.098: [GC (CMS Initial Mark) [1 CMS-initial-mark: 897364K(1756416K)] 936667K(2063104K), 0.0041705 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
13.102: [CMS-concurrent-mark-start]
13.341: [CMS-concurrent-mark: 0.238/0.238 secs] [Times: user=0.36 sys=0.01, real=0.24 secs] 
13.341: [CMS-concurrent-preclean-start]
13.350: [CMS-concurrent-preclean: 0.009/0.009 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
13.350: [CMS-concurrent-abortable-preclean-start]
13.878: [GC (Allocation Failure) 13.878: [ParNew: 306688K->34047K(306688K), 0.0960456 secs] 1204052K->1010638K(2063104K), 0.0961542 secs] [Times: user=0.29 sys=0.04, real=0.09 secs] 
14.366: [CMS-concurrent-abortable-preclean: 0.917/1.016 secs] [Times: user=2.22 sys=0.07, real=1.01 secs] 
14.366: [GC (CMS Final Remark) [YG occupancy: 182593 K (306688 K)]14.366: [Rescan (parallel) , 0.0291598 secs]14.395: [weak refs processing, 0.0000232 secs]14.395: [class unloading, 0.0117661 secs]14.407: [scrub symbol table, 0.0015323 secs]14.409: [scrub string table, 0.0003221 secs][1 CMS-remark: 976591K(1756416K)] 1159184K(2063104K), 0.0462010 secs] [Times: user=0.14 sys=0.00, real=0.05 secs] 
14.412: [CMS-concurrent-sweep-start]
14.633: [CMS-concurrent-sweep: 0.221/0.221 secs] [Times: user=0.37 sys=0.00, real=0.22 secs] 
14.633: [CMS-concurrent-reset-start]
14.636: [CMS-concurrent-reset: 0.002/0.002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

根据这些信息,我们可以看到在12次Minor GC运行后,“一些不同的事情”确实开始发生了。但不是两个完整的GC运行,这个“不同的东西”实际上只是一个在Old代中运行的GC,由不同的阶段组成:

  • 初始标记阶段:跨度为0.0041705秒或大约4ms。此阶段是一个stop-the-world事件,停止所有应用程序线程以进行初始标记。
  • 标记和预清理阶段:与应用程序线程并发执行。
  • 最后重标记阶段:跨度为0.0462010秒或大约46ms。这一阶段又是stop-the-world事件。
  • 清除阶段:清除操作并发执行,没有停止应用程序线程。

因此,我们从实际的垃圾收集日志中看到,实际执行的不是两个Full GC操作,而是一个Major GC cleanup Old空间。

JVM的分代收集

Java应用中,大部分的对象都是”朝生暮死”。不同阶段适合使用不同的GC算法进行GC回收。分代收集就是基于这种思想。

对象生命周期的典型分布

年轻代 老年代 JVM设置
Serial Serial -XX:+UseSerialGC
Parallel Scavenge Parallel Old -XX:+UseParallelGC -XX:+UseParallelOldGC
Parallel New CMS -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
G1 G1 -XX:+UseG1GC

Serial GC

此垃圾收集器的收集对年轻代使用标记-复制算法,对老年代使用标记-清除-整理算法。顾名思义—–这两个收集器都是单线程收集器,无法并行处理手头的任务。这两个收集器还会触发stop-the-world暂停,停止所有应用程序线程。

因此,这种GC算法不能利用现代硬件中常见的多个CPU核。与可用核数无关,JVM在垃圾收集期间只使用一个核。

通过在JVM启动脚本中指定一个参数,可以同时为年轻代和老年代启用该收集器:

java -XX:+UseSerialGC com.mypackages.MyExecutableClass

这个选项是有意义的,并且建议仅用于堆大小为几百兆字节、运行在只有一个CPU的环境中的JVM。

对于大多数服务器端部署来说,这是一种罕见的组合。大多数服务器端部署都是在多核平台上完成的,这意味着通过选择Serial GC,可以人为地限制系统资源的使用。这将导致空闲资源,否则这些资源可以用来减少延迟或增加吞吐量。

现在让我们回顾一下使用Serial GC时垃圾收集器日志是什么样子的,以及可以从那里获得什么有用的信息。为此,我们使用以下参数在JVM上打开了GC日志记录:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps

结果输出类似如下:

2015-05-26T14:45:37.987-0200: 151.126: [GC (Allocation Failure) 151.126: [DefNew: 629119K->69888K(629120K), 0.0584157 secs] 1619346K->1273247K(2027264K), 0.0585007 secs] [Times: user=0.06 sys=0.00, real=0.06 secs]
2015-05-26T14:45:59.690-0200: 172.829: [GC (Allocation Failure) 172.829: [DefNew: 629120K->629120K(629120K), 0.0000372 secs]172.829: [Tenured: 1203359K->755802K(1398144K), 0.1855567 secs] 1832479K->755802K(2027264K), [Metaspace: 6741K->6741K(1056768K)], 0.1856954 secs] [Times: user=0.18 sys=0.00, real=0.18 secs]

这种来自GC日志的简短片段公开了关于JVM中正在发生的事情的大量信息。事实上,在这个片段中发生了两个Garbage Collection事件,一个清除Young Generation,另一个处理整个堆。让我们从分析发生在年轻代的第一次收集开始。

Minor GC

下面的片段包含关于清除年轻代的GC事件的信息:

2015-05-26T14:45:37.987-0200: 151.126: [GC (Allocation Failure) 151.126: [DefNew: 629119K->69888K(629120K), 0.0584157 secs] 1619346K->1273247K(2027264K), 0.0585007 secs] [Times: user=0.06 sys=0.00, real=0.06 secs]```

下面来对上述的信息做一个描述:

  • 2015-05-26T14:45:37.987-0200:GC事件开始的时间
  • 151.126:GC事件启动的时间,相对于JVM启动时间。以秒为单位来衡量。
  • GC (Allocation Failure):GC发生的原因
  • DefNew: 629119K->69888K(629120K), 0.0584157 secs:清除年轻代的单线程标记-复制STW垃圾收集器,整个年轻代有629120K,GC前629119K,GC后69888K,释放了559231K,本次GC耗时0.0584157 secs
  • 1619346K->1273247K(2027264K), 0.0585007 secs:整个堆的变化,堆的总大小为2027264K,GC前1619346K,GC后1273247K,释放了346099K,GC耗时0.0584157 secs
  • Times: user=0.06 sys=0.00, real=0.06 secs:GC期间,系统的cpu资源耗时

从上面的代码片段中,我们可以确切地了解GC事件期间JVM内的内存消耗情况。在此收集之前,堆使用总数为1,619,346K。其中,年轻一代消费了629119k。由此我们可以计算出老一代的使用量等于990227k。

在下一批数字中隐藏着一个更重要的结论:在收集之后,年轻代的使用量减少了559,231K,但总堆使用量仅减少了346,099K。由此我们可以再次推导出213,132K个对象从年轻一代提升到老一代。

下面的示例还展示了这个GC事件,显示了GC开始之前和结束之后的内存使用情况: serial的年轻代收集

Full GC

在理解了第一个Minor GC事件之后,让我们看看日志中的第二个GC事件:

2015-05-26T14:45:59.690-0200: 172.829: [GC (Allocation Failure) 172.829: [DefNew: 629120K->629120K(629120K), 0.0000372 secs]172.829: [Tenured: 1203359K->755802K(1398144K), 0.1855567 secs] 1832479K->755802K(2027264K), [Metaspace: 6741K->6741K(1056768K)], 0.1856954 secs] [Times: user=0.18 sys=0.00, real=0.18 secs]

主要看区别的信息:

  • Tenured: 1203359K->755802K(1398144K), 0.1855567 secs:老年代的收集,老年代的堆大小为1398144K,GC前为1203359K,GC后为755802K,释放了447557K,耗时0.1855567 secs
  • 1832479K->755802K(2027264K):整个堆的变化,堆的总大小为2027264K,GC前1832479K,GC后755802K,释放了1076674K,GC耗时0.0584157 secs
  • Metaspace: 6741K->6741K(1056768K)], 0.1856954 secs:关于metspace收集的类似信息。可以看到,在事件期间metspace中没有收集任何垃圾。

与Minor GC的区别是显而易见的——除了年轻代之外,在此GC事件期间,老一代和metspace也被清理了。事件发生前后的记忆布局如下图所示: serial的FGC

Parallel GC

这种垃圾收集器组合在年轻代中使用标记-复制算法,在老一代中使用标记-清除-整理算法。Young和Old收集都会触发stop-the-world事件,停止所有应用程序线程来执行垃圾收集。这两个收集器都使用多个线程运行标记和复制/压缩阶段,因此称为“Parallel”。使用这种方法,可以大大减少收集时间。

垃圾收集期间使用的线程数可以通过命令行参数配置-XX:ParallelGCThreads=NNN。默认值等于机器中的内核数。

Parallel GC的选择是通过在JVM启动脚本中指定以下参数组合来完成的:

java -XX:+UseParallelGC com.mypackages.MyExecutableClass
java -XX:+UseParallelOldGC com.mypackages.MyExecutableClass
java -XX:+UseParallelGC -XX:+UseParallelOldGC com.mypackages.MyExecutableClass

当主要目标是增加吞吐量时,并行垃圾收集器适用于多核机器。更高的吞吐量是由于更有效地使用系统资源:

  • 在收集期间,所有内核都在并行地清理垃圾,从而缩短了暂停时间
  • 在垃圾收集周期之间,两个收集器都不消耗任何资源

另一方面,由于收集的所有阶段都必须在没有任何中断的情况下进行,因此这些收集器仍然容易受到长时间暂停的影响,在此期间应用程序线程将停止。因此,如果延迟是您的主要目标,你应该检查垃圾收集器的下一个组合。

在让我们回顾一下在使用Parallel GC时垃圾收集器日志是什么样子的,以及可以从那里获得什么有用的信息。为此,让我们再次查看垃圾收集器日志,它们再次公开了一个Minor GC暂停和一个Major GC暂停:

2015-05-26T14:27:40.915-0200: 116.115: [GC (Allocation Failure) [PSYoungGen: 2694440K->1305132K(2796544K)] 9556775K->8438926K(11185152K), 0.2406675 secs] [Times: user=1.77 sys=0.01, real=0.24 secs]
2015-05-26T14:27:41.155-0200: 116.356: [Full GC (Ergonomics) [PSYoungGen: 1305132K->0K(2796544K)] [ParOldGen: 7133794K->6597672K(8388608K)] 8438926K->6597672K(11185152K), [Metaspace: 6745K->6745K(1056768K)], 0.9158801 secs] [Times: user=4.49 sys=0.64, real=0.92 secs]

Minor GC

两个事件中的第一个事件表示发生在年轻代中的GC事件:

2015-05-26T14:27:40.915-0200: 116.115: [GC (Allocation Failure) [PSYoungGen: 2694440K->1305132K(2796544K)] 9556775K->8438926K(11185152K), 0.2406675 secs] [Times: user=1.77 sys=0.01, real=0.24 secs]
  • PSYoungGen: 2694440K->1305132K(2796544K):用于清除Young代的并行标记-复制算法STW垃圾收集器,年轻代的收集前后的堆变化

因此,简而言之,收集之前的总堆消耗是9,556,775K,年轻代有2694440k,这意味着使用老一代是6862335k。在收集之后,年轻代的使用量减少了1,389,308K,但总堆使用量仅减少了1,117,849K。也就是说,从”年轻代”晋升为”老年代”的堆大小为271459k。 ParallelGC的年轻代收集

Full GC

在理解了Parallel GC如何清理年轻代之后,我们准备通过分析GC日志的下一个片段来查看整个堆是如何被清理的:

2015-05-26T14:27:41.155-0200: 116.356: [Full GC (Ergonomics) [PSYoungGen: 1305132K->0K(2796544K)] [ParOldGen: 7133794K->6597672K(8388608K)] 8438926K->6597672K(11185152K), [Metaspace: 6745K->6745K(1056768K)], 0.9158801 secs] [Times: user=4.49 sys=0.64, real=0.92 secs]
  • Full GC (Ergonomics):GC是FGC,以及发生的原因
  • PSYoungGen: 1305132K->0K(2796544K):用于清除Young代的并行标记-复制算法STW垃圾收集器,年轻代的收集后堆大小为0
  • ParOldGen: 7133794K->6597672K(8388608K):老年代的收集前后堆大小

ParallelGC的FGC

Concurrent Mark and Sweep

这种垃圾收集器的正式名称是“主要并发标记和清除垃圾收集器”。它在年轻代中使用并行的STW标记复制算法,在老年代中使用并发标记清除算法。

这个收集器的设计是为了避免在老年代中收集时长时间的停顿。它通过两种方式实现这一点。首先,它不整理老年代,而是使用自由列表来管理回收的空间。

其次,它与应用程序同时完成标记和清除阶段的大部分工作。这意味着垃圾收集不会显式地停止应用程序线程来执行这些阶段。但是,应该注意的是,它仍然与应用程序线程竞争CPU时间。默认情况下,此GC算法使用的线程数等于机器物理核数的1/4。

可以通过在命令行中指定以下选项来选择此垃圾收集器:

java -XX:+UseConcMarkSweepGC com.mypackages.MyExecutableClass

如果你的主要目标是延迟,那么这种组合在多核机器上是一个不错的选择。减少单个GC暂停的持续时间将直接影响最终用户对应用程序的理解,让他们感觉应用程序响应更快。

由于在大多数情况下,至少有一些CPU资源会被GC消耗,而不会执行应用程序的代码,因此在CPU绑定的应用程序中,CMS的吞吐量通常比Parallel GC更低。

与前面的GC算法一样,现在让我们通过查看GC日志来看看该算法是如何在实践中应用的,这些日志再次暴露了一个minor GC暂停和一个major GC暂停:

2015-05-26T16:23:07.219-0200: 64.322: [GC (Allocation Failure) 64.322: [ParNew: 613404K->68068K(613440K), 0.1020465 secs] 10885349K->10880154K(12514816K), 0.1021309 secs] [Times: user=0.78 sys=0.01, real=0.11 secs]
2015-05-26T16:23:07.321-0200: 64.425: [GC (CMS Initial Mark) [1 CMS-initial-mark: 10812086K(11901376K)] 10887844K(12514816K), 0.0001997 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2015-05-26T16:23:07.321-0200: 64.425: [CMS-concurrent-mark-start]
2015-05-26T16:23:07.357-0200: 64.460: [CMS-concurrent-mark: 0.035/0.035 secs] [Times: user=0.07 sys=0.00, real=0.03 secs]
2015-05-26T16:23:07.357-0200: 64.460: [CMS-concurrent-preclean-start]
2015-05-26T16:23:07.373-0200: 64.476: [CMS-concurrent-preclean: 0.016/0.016 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
2015-05-26T16:23:07.373-0200: 64.476: [CMS-concurrent-abortable-preclean-start]
2015-05-26T16:23:08.446-0200: 65.550: [CMS-concurrent-abortable-preclean: 0.167/1.074 secs] [Times: user=0.20 sys=0.00, real=1.07 secs]
2015-05-26T16:23:08.447-0200: 65.550: [GC (CMS Final Remark) [YG occupancy: 387920 K (613440 K)]65.550: [Rescan (parallel) , 0.0085125 secs]65.559: [weak refs processing, 0.0000243 secs]65.559: [class unloading, 0.0013120 secs]65.560: [scrub symbol table, 0.0008345 secs]65.561: [scrub string table, 0.0001759 secs][1 CMS-remark: 10812086K(11901376K)] 11200006K(12514816K), 0.0110730 secs] [Times: user=0.06 sys=0.00, real=0.01 secs]
2015-05-26T16:23:08.458-0200: 65.561: [CMS-concurrent-sweep-start]
2015-05-26T16:23:08.485-0200: 65.588: [CMS-concurrent-sweep: 0.027/0.027 secs] [Times: user=0.03 sys=0.00, real=0.03 secs]
2015-05-26T16:23:08.485-0200: 65.589: [CMS-concurrent-reset-start]
2015-05-26T16:23:08.497-0200: 65.601: [CMS-concurrent-reset: 0.012/0.012 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

Minor GC

日志中的第一个GC事件表示清除Young空间的Minor GC。让我们分析一下这个收集器组合在这方面的行为:

2015-05-26T16:23:07.219-0200: 64.322: [GC (Allocation Failure) 64.322: [ParNew: 613404K->68068K(613440K), 0.1020465 secs] 10885349K->10880154K(12514816K), 0.1021309 secs] [Times: user=0.78 sys=0.01, real=0.11 secs]
  • ParNew: 613404K->68068K(613440K), 0.1020465 secs:在年轻代中使用的并行标记-复制STW垃圾收集器,设计为与老年代中的并发标记&清除垃圾收集器一起工作。

因此,从上面我们可以看到,在收集之前,使用的总堆是10885,349k,使用的年轻代共享是613,404K。这意味着老年代的份额是10271945k。在收集之后,年轻代的使用量减少了545,336K,但总堆使用量仅减少了5195k。这意味着从年轻代晋升为老年代的堆为540141k。

年轻代的收集

Full GC

除了年轻代的收集日志,其他的都是老年代日志,按照阶段逐步分析。

请记住—-在实际情况中,年轻代的小垃圾收集可以在并发收集老代的过程中随时发生。在这种情况下,下面看到的主要收集记录将与前一章中介绍的Minor GC事件交织在一起。

Phase 1: Initial Mark

这是CMS期间的两个STW的事件之一。此阶段的目标是标记老年代中的所有对象,这些对象要么是直接GC根,要么是从年轻代中的某个活动对象引用的。后者很重要,因为老年代是分开收集的。 CMS的初始化标记阶段

2015-05-26T16:23:07.321-0200: 64.425: [GC (CMS Initial Mark) [1 CMS-initial-mark: 10812086K(11901376K)] 10887844K(12514816K), 0.0001997 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  • GC (CMS Initial Mark):表示CMS收集的初始化标记阶段。
  • CMS-initial-mark: 10812086K(11901376K):老年代已使用的堆10812086K,整个老年代的堆11901376K
  • 10887844K(12514816K):整个堆的已使用10887844K,整体大小为12514816K
Phase 2: Concurrent Mark

在此阶段,垃圾收集器遍历老年代并标记所有活动对象,从上一阶段“初始标记”中找到的根开始。“并发标记”阶段,顾名思义,与应用程序并发运行,不停止应用程序线程。请注意,并非老年代中的所有活动对象都可以被标记,因为应用程序在标记期间会更改引用。 CMS并发标记阶段

2015-05-26T16:23:07.321-0200: 64.425: [CMS-concurrent-mark-start]
2015-05-26T16:23:07.357-0200: 64.460: [CMS-concurrent-mark: 0.035/0.035 secs] [Times: user=0.07 sys=0.00, real=0.03 secs]
  • CMS-concurrent-mark:GC的阶段—在这种情况下是”并发标记”—它遍历老年代并标记所有活动对象。
Phase 3: Concurrent Preclean

这也是一个并发阶段,与应用程序线程并行运行,而不是停止它们。当前一阶段与应用程序并发运行时,一些引用发生了更改。每当发生这种情况时,JVM就将包含突变对象的堆区域(称为“Card”)标记为“dirty”(称为Card Marking)。 CMS的并发预清理阶段

在预清理阶段,对这些脏对象进行统计,并标记从这些脏对象可达的对象。当这一步完成后,卡片就被清理干净了。 CMS的并发预清理阶段

此外,还执行了Final Remark阶段的一些必要的内务和准备工作。

2015-05-26T16:23:07.357-0200: 64.460: [CMS-concurrent-preclean-start]
2015-05-26T16:23:07.373-0200: 64.476: [CMS-concurrent-preclean: 0.016/0.016 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
  • CMS-concurrent-preclean:GC的阶段—在这种情况下是”并发预清理”—考虑在前一个标记阶段更改的引用。
Phase 4: Concurrent Abortable Preclean

同样,这是一个不停止应用程序线程的并发阶段。这句话试图尽可能地对于Final Remark减轻STW的负担。

2015-05-26T16:23:07.373-0200: 64.476: [CMS-concurrent-abortable-preclean-start]
2015-05-26T16:23:08.446-0200: 65.550: [CMS-concurrent-abortable-preclean: 0.167/1.074 secs] [Times: user=0.20 sys=0.00, real=1.07 secs]
  • CMS-concurrent-abortable-preclean:GC的”并发可中止预清理”阶段
  • 0.167/1.074 secs:阶段持续时间,分别显示实际时间和时钟时间。有趣的是,报告的用户时间比时钟时间小得多。通常我们看到,实时时间小于用户时间,这意味着一些工作是并行完成的,因此经过的时钟时间小于使用的CPU时间。这里我们有少量的工作—0.167秒的CPU时间,垃圾收集器线程做了大量的等待。从本质上说,他们试图在进行STW暂停之前尽可能地拖延。默认情况下,此阶段最多持续5秒。

这个阶段可能会显著影响即将到来的STW暂停的持续时间,并且有很多重要的配置选项和故障模式。

Phase 5: Final Remark

这是GC中的第二次也是最后一次STW。这个STW阶段的目标是在老年代中完成所有活动对象的标记。由于之前的预清理阶段是并发的,它们可能无法跟上应用程序不断变化的速度。为了结束这场考验,需要暂停一下。

通常CMS试图在年轻代尽可能空的时候运行Final Remark阶段,以消除几个连续发生的STW阶段的可能性。

2015-05-26T16:23:08.447-0200: 65.550: [GC (CMS Final Remark) [YG occupancy: 387920 K (613440 K)]65.550: [Rescan (parallel) , 0.0085125 secs]65.559: [weak refs processing, 0.0000243 secs]65.559: [class unloading, 0.0013120 secs]65.560: [scrub symbol table, 0.0008345 secs]65.561: [scrub string table, 0.0001759 secs][1 CMS-remark: 10812086K(11901376K)] 11200006K(12514816K), 0.0110730 secs] [Times: user=0.06 sys=0.00, real=0.01 secs]
  • GC (CMS Final Remark):GC的阶段(在本例中为”Final Remark”),该阶段标记老年代中的所有活动对象,包括在前一个并发标记阶段中创建/修改的引用。
  • YG occupancy: 387920 K (613440 K):年轻代目前的使用率和容量。
  • Rescan (parallel) , 0.0085125 secs:在停止应用程序时,Rescan完成活动对象的标记。在本例中,重新扫描是并行进行的,耗时0.0085125秒。
  • weak refs processing, 0.0000243 secs:第一个子阶段,它处理弱引用以及阶段的持续时间和时间戳。
  • class unloading, 0.0013120 secs:下一个子阶段将卸载未使用的类,并提供该阶段的持续时间和时间戳。
  • scrub symbol table, 0.0008345 secs:最后一阶段是清理分别保存类级元数据和内部化字符串的符号表和字符串表。还包括暂停的时钟时间。
  • scrub string table, 0.0001759 secs:最后一阶段是清理分别保存类级元数据和内部化字符串的符号表和字符串表。还包括暂停的时钟时间。
  • CMS-remark: 10812086K(11901376K):在这阶段完成后的老年代的使用和容量

在五个标记阶段之后,Old Generation中的所有活对象都被标记,现在垃圾收集器将通过清扫Old Generation回收所有未使用的对象:

Phase 6: Concurrent Sweep

与应用程序并发执行,不需要stop-the-world暂停。该阶段的目的是删除未使用的对象,并回收它们所占用的空间以供将来使用。 CMS并发清除阶段

2015-05-26T16:23:08.458-0200: 65.561: [CMS-concurrent-sweep-start]
2015-05-26T16:23:08.485-0200: 65.588: [CMS-concurrent-sweep: 0.027/0.027 secs] [Times: user=0.03 sys=0.00, real=0.03 secs]
  • CMS-concurrent-sweep:在这种情况下,在收集“并发扫描”阶段,扫描未标记因而未使用的对象以回收空间。
Phase 7: Concurrent Reset

并发执行阶段,重新设置CMS算法的内部数据结构,为下一个周期做好准备。

2015-05-26T16:23:08.485-0200: 65.589: [CMS-concurrent-reset-start]
2015-05-26T16:23:08.497-0200: 65.601: [CMS-concurrent-reset: 0.012/0.012 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
  • CMS-concurrent-reset:GC的阶段——在这种情况下是“并发重置”——这是重置CMS算法的内部数据结构,并为下一次收集做准备。

总之,CMS垃圾收集器通过将大量工作卸载给不需要应用程序停止的并发线程,在减少暂停持续时间方面做得很好。然而,它也有一些缺点,其中最显著的是老年代碎片和在某些情况下暂停持续时间缺乏可预测性,特别是在大型堆上。

G1 – Garbage First

G1的一个关键设计目标是使垃圾收集导致的STW的持续时间和分布是可预测和可配置的。实际上,G1是一个软实时垃圾收集器,这意味着您可以为它设置特定的性能目标。

您可以请求stop-the-world暂停在任何给定的y毫秒长时间范围内不超过x毫秒,例如在任何给定的秒内不超过5毫秒。Garbage-First GC将尽最大努力以高概率(但不确定,那将是硬实时)满足这个目标。

为了实现这一点,G1建立在许多新的见解的基础上。首先,堆不必分割为连续的Young和Old代。相反,堆被分成若干个(通常约为2048个)较小的堆区域,这些区域可以容纳对象。

每个地区可能是Eden,Survivor或Old。所有伊甸园和幸存者地区的逻辑结合是年轻代,所有老年代地区合在一起就是老年代: G1

这允许GC避免一次收集整个堆,而是增量地处理这个问题:每次只考虑被称为收集集的区域子集。每次暂停都会收集所有的Young区域,但也会包括一些Old区域: G1

G1的另一个新特性是,在并发阶段,它估计每个区域包含的活动数据量。这在构建收集集时使用:首先收集包含垃圾最多的区域。因此得名:垃圾优先收集。

要在启用了G1收集器的情况下运行JVM,将应用程序运行为:

java -XX:+UseG1GC com.mypackages.MyExecutableClass

Evacuation Pause: Fully Young

在应用程序生命周期的开始,G1没有任何来自尚未执行的并发阶段的额外信息,因此它最初是以完全年轻模式运行的。当Young Generation填满时,应用程序线程将停止,Young区域内的实时数据将复制到Survivor区域或任何因此成为Survivor的空闲区域。

复制这些的过程被称为“Evacuation”,它的工作方式和我们之前见过的其他年轻代GC几乎一样。

疏散暂停的完整日志相当大,因此,为了简单起见,我们将省去第一个完全年轻的疏散暂停中不相关的几个小比特。在更详细地解释并发阶段之后,我们将回到它们。此外,由于日志记录的大小,并行阶段细节和“其他”阶段细节被提取到单独的部分:

0.134: [GC pause (G1 Evacuation Pause) (young), 0.0144119 secs]
    [Parallel Time: 13.9 ms, GC Workers: 8]
        …
    [Code Root Fixup: 0.0 ms]
    [Code Root Purge: 0.0 ms]
    [Clear CT: 0.1 ms]
    [Other: 0.4 ms]
        …
    [Eden: 24.0M(24.0M)->0.0B(13.0M) Survivors: 0.0B->3072.0K Heap: 24.0M(256.0M)->21.9M(256.0M)]
    [Times: user=0.04 sys=0.04, real=0.02 secs] 
  • 0.134: [GC pause (G1 Evacuation Pause) (young), 0.0144119 secs]:G1的年轻代GC,耗时0.0144119秒
  • [Parallel Time: 13.9 ms, GC Workers: 8]:这表明,在13.9 ms(实时)时间内,由8个线程并行执行
  • [Code Root Fixup: 0.0 ms]:释放用于管理并行活动的数据结构。应该总是接近于零。这是按顺序完成的。
  • [Code Root Purge: 0.0 ms]:清理更多的数据结构,也应该非常快,但不一定几乎为零。这是按顺序完成的。
  • [Other: 0.4 ms]:其他杂项活动,其中许多也是并行的
  • [Eden: 24.0M(24.0M)->0.0B(13.0M) Survivors: 0.0B->3072.0K Heap: 24.0M(256.0M)->21.9M(256.0M)]:Eden、Survivors和整个堆的变化

大部分繁重的工作都是由多个专用GC工作线程完成的。日志的以下部分描述了他们的活动:

[Parallel Time: 13.9 ms, GC Workers: 8]
    [GC Worker Start (ms): Min: 134.0, Avg: 134.1, Max: 134.1, Diff: 0.1]
    [Ext Root Scanning (ms): Min: 0.1, Avg: 0.2, Max: 0.3, Diff: 0.2, Sum: 1.2]
    [Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
        [Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]
    [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
    [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.2, Diff: 0.2, Sum: 0.2]
    [Object Copy (ms): Min: 10.8, Avg: 12.1, Max: 12.6, Diff: 1.9, Sum: 96.5]
    [Termination (ms): Min: 0.8, Avg: 1.5, Max: 2.8, Diff: 1.9, Sum: 12.2]
        [Termination Attempts: Min: 173, Avg: 293.2, Max: 362, Diff: 189, Sum: 2346]
    [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
    GC Worker Total (ms): Min: 13.7, Avg: 13.8, Max: 13.8, Diff: 0.1, Sum: 110.2]
    [GC Worker End (ms): Min: 147.8, Avg: 147.8, Max: 147.8, Diff: 0.0]
  • [Parallel Time: 13.9 ms, GC Workers: 8]:这表明在13.9 ms(时钟时间)中,以下活动由8个线程并行执行
  • GC Worker Start (ms):工作开始活动的时刻,与暂停开始时的时间戳相匹配。如果Min和Max相差很大,那么这可能表明使用了太多的线程,或者机器上的其他进程正在从JVM内的垃圾收集进程中窃取CPU时间
  • Ext Root Scanning (ms):扫描外部(非堆)根(如类加载器、JNI引用、JVM系统根等)所花的时间。显示运行时间,“Sum”表示CPU时间
  • Code Root Scanning (ms):扫描来自实际代码的GC Root所花的时间:本地变量等。
  • Object Copy (ms):从收集区域复制活动对象所需的时间。
  • Termination (ms):工作线程需要多长时间来确保它们可以安全停止,没有更多的工作要做,然后实际终止
  • Termination Attempts:工作线程尝试和终止的次数。如果工作人员发现实际上还有更多的工作要做,而且终止还为时过早,那么尝试就失败了。
  • GC Worker Other (ms):其他不值得在日志中单独列出的杂项小活动。
  • GC Worker Total (ms):工作线程总共工作了多长时间
  • GC Worker End (ms):工人完成工作的时间戳。正常情况下,它们应该大致相等,否则可能表明有太多线程在周围徘徊,或者有一个嘈杂的邻居

Concurrent Marking

G1收集器建立在上一节中许多CMS概念的基础上,因此在继续之前确保对它有充分的了解是一个好主意。尽管它在许多方面有所不同,但并发标记的目标是非常相似的。

G1 Concurrent Marking使用Snapshot-At-The-Beginning方法标记所有在标记周期开始时处于活动状态的对象,即使它们同时已经变成了垃圾。关于哪些对象是活动对象的信息允许为每个区域构建活动状态统计信息,以便在之后可以有效地选择收集集。

然后使用此信息在Old区域执行垃圾收集。如果标记确定一个区域只包含垃圾,或者在Old区域同时包含垃圾和活对象的“停止世界”疏散暂停期间,它可以完全并发地发生。

并发标记在堆的总体占用足够大时开始。默认情况下,它是45%,但可以通过InitiatingHeapOccupancyPercent JVM选项更改。

像在CMS中一样,G1中的并发标记由许多阶段组成,其中一些阶段是完全并发的,而另一些阶段则需要停止应用程序线程。

Phase 1: Initial Mark

这个阶段标记了所有可以直接从GC根访问的对象。在CMS中,它需要一个单独的暂停—STW,但在G1中它通常附带一个疏散暂停,所以它的开销很小。通过在疏散暂停的第一行中添加“(初始标记)”,可以注意到GC日志中的这种暂停:

1.631: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0062656 secs]
Phase 2: Root Region Scan

这个阶段标记了从所谓的根区域可到达的所有活动对象,即非空的对象,我们可能不得不在标记周期的中间收集这些对象。

因为在并行标记的中间移动物品会造成麻烦,所以这个阶段必须在下一次疏散暂停开始之前完成。

如果它必须更早地开始,它将请求根区域扫描的早期中止,然后等待它完成。在当前的实现中,根区域是幸存者区域:它们是Young Generation的比特,在下一个疏散暂停中肯定会被收集。

1.362: [GC concurrent-root-region-scan-start]
1.364: [GC concurrent-root-region-scan-end, 0.0028513 secs]
Phase 3. Concurrent Mark

这个阶段与CMS非常相似:它只是遍历对象图,并在一个特殊的位图中标记访问的对象。为了确保满足开始时快照的语义,G1 GC要求应用程序线程对对象图进行的所有并发更新都保留之前的引用,以便进行标记。

这是通过使用预写屏障(不要与后面讨论的后写屏障以及与多线程编程相关的内存屏障相混淆)来实现的。它们的功能是,每当你在G1 Concurrent Marking活动时写入一个字段时,将之前的裁判存储在所谓的日志缓冲区中,以由并发标记线程处理。

1.364: [GC concurrent-mark-start]
1.645: [GC concurrent-mark-end, 0.2803470 secs]
Phase 4. Remark

这是一个STW,就像之前在CMS中看到的那样,它完成了标记过程。

对于G1,它会短暂地停止应用程序线程,以停止并发更新日志的流入,并处理少量剩余的线程,并标记在并发标记周期启动时处于活动状态的任何仍未标记的对象。此阶段还执行一些额外的清理,例如引用处理(参见疏散暂停日志)或类卸载。

1.645: [GC remark 1.645: [Finalize Marking, 0.0009461 secs] 1.646: [GC ref-proc, 0.0000417 secs] 1.646: [Unloading, 0.0011301 secs], 0.0074056 secs]
[Times: user=0.01 sys=0.00, real=0.01 secs]
Phase 5. Cleanup

最后一个阶段为即将到来的疏散阶段做好准备,计算堆区域中的所有活动对象,并根据预期的GC效率对这些区域进行排序。它还执行为下一个并发标记迭代维护内部状态所需的所有内部维护活动。

最后但并非最不重要的是,完全不包含活动对象的区域将在此阶段被回收。这个阶段的一些部分是并发的,比如空区域回收和大多数活动计算,但它也需要在应用程序线程不干扰的情况下进行短暂的暂停以完成图像。这种stop-the-world暂停的日志类似于:

1.652: [GC cleanup 1213M->1213M(1885M), 0.0030492 secs]
[Times: user=0.01 sys=0.00, real=0.00 secs]

当发现一些只包含垃圾的堆区域时,暂停格式看起来有点不同,类似于:

1.872: [GC cleanup 1357M->173M(1996M), 0.0015664 secs]
[Times: user=0.01 sys=0.00, real=0.01 secs]
1.874: [GC concurrent-cleanup-start]
1.876: [GC concurrent-cleanup-end, 0.0014846 secs]

Evacuation Pause: Mixed

当并发清理可以在Old Generation中释放整个区域时,这是一种令人愉快的情况,但情况可能并不总是如此。在并发标记成功完成后,G1将调度一个混合收集,它不仅将年轻区域的垃圾清除掉,而且还将一堆旧区域扔到收集集中。

混合疏散暂停并不总是紧跟在并发标记阶段结束之后。有许多规则和启发会影响这一点。例如,如果可以同时释放Old区域的很大一部分,那么就不需要这样做。

因此,在并发标记结束和混合疏散暂停之间很容易出现许多完全年轻的疏散暂停。

要添加到集合集中的Old区域的确切数量,以及添加它们的顺序,也会根据一些规则进行选择。这包括为应用程序指定的软实时性能目标、并发标记期间收集的活性和gc效率数据,以及许多可配置的JVM选项。混合收集的过程与我们之前讨论过的完全年轻gc的过程大致相同,但这次我们还将讨论remembered sets

Remembered sets允许不同堆区域的独立集合。例如,在收集区域A、B和C时,我们必须知道是否有来自区域D和E的引用,以确定它们的活跃度。

但遍历整个堆图将花费相当长的时间,破坏整个增量收集点,因此采用了优化。就像我们在其他GC算法中有用于独立收集Young区域的Card Table一样,我们在G1中有Remembered Sets

如下图所示,每个区域都有一个记忆集,其中列出了从外部指向该区域的引用。这些引用将被视为额外的GC根。注意,Old区域中在并发标记期间被确定为垃圾的对象将被忽略,即使有外部对它们的引用:在这种情况下,这些引用也是垃圾。

G1 Remembered sets

接下来发生的事情和其他收集器做的一样:多个并行GC线程找出哪些对象是活的,哪些对象是垃圾: G1 GC Roots

最后,活动对象被移动到幸存者区域,必要时创建新的。现在空的区域被释放,可以再次用于存储对象。 G1 Evacuation

了维护remembered sets,在应用程序的运行期间,每当执行对字段的写操作时,都会发出Post-Write Barrier。

如果结果引用是跨区域的,即从一个区域指向另一个区域,相应的条目将出现在目标区域的Remembered Set中。

为了减少Write Barrier带来的开销,将卡片放入Remembered Set的过程是异步的,并进行了大量的优化。但基本上它归结为Write Barrier将脏卡信息放入本地缓冲区,并由专门的GC线程拾取它并将信息传播到引用区域的remembered sets

在混合模式下,与完全年轻模式相比,日志会发布一些新的有趣的方面:

[Update RS (ms): Min: 0.7, Avg: 0.8, Max: 0.9, Diff: 0.2, Sum: 6.1]
[Processed Buffers: Min: 0, Avg: 2.2, Max: 5, Diff: 5, Sum: 18]
[Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 0.8]
[Clear CT: 0.2 ms]
[Redirty Cards: 0.1 ms]
  • Update RS (ms):由于Remembered set是并发处理的,我们必须确保在实际收集开始之前处理仍然缓存的卡片。如果这个数字很高,则并发GC线程无法处理负载。例如,可能是因为传入的字段修改太多,或者CPU资源不足。
  • Processed Buffers:每个工作线程处理了多少个本地缓冲区。
  • Scan RS (ms):从remembered sets中扫描引用需要多长时间。
  • Clear CT: 0.2 ms:是时候清理card tablecards了。清理只是删除了“dirty”状态,该状态表示某个字段已更新,用于Remembered Sets
  • Redirty Cards: 0.1 ms:将card table 中的适当位置标记为脏位置所花费的时间。适当的位置是由GC自己对堆进行的突变定义的,例如,在对引用进行排队时。

小结

这将使人们对G1的功能有一个充分的基本了解。当然,为了简洁起见,我们仍然忽略了相当多的实现细节,比如处理巨大的对象。总的来说,G1是HotSpot中技术最先进的生产就绪收集器。最重要的是,HotSpot工程师不断地改进它,在新的java版本中加入了新的优化或特性。

正如我们所看到的,G1解决了CMS存在的广泛问题,从暂停可预测性开始,到堆碎片结束。如果应用程序不受CPU利用率的限制,但对单个操作的延迟非常敏感,那么G1很可能是HotSpot用户的最佳选择,特别是在运行最新版本的Java时。

然而,这些延迟改进并不是免费的:由于额外的写屏障和更多的活跃后台线程,G1的吞吐量开销更大。因此,如果应用程序有吞吐量限制或者占用100%的CPU,并且不太关心单个暂停时间,那么CMS甚至Parallel可能是更好的选择。

选择正确的GC算法和设置的唯一可行方法是通过试验和错误。注意G1可能是Java 9的默认GC。

我们已经概述了HotSpot中所有可以直接使用的生产就绪算法。还有另一个正在开发中,即所谓的超低暂停时间垃圾收集器。

它的目标是具有大堆的大型多核机器,目标是管理100GB以上、暂停时间为10ms或更短的堆。这与吞吐量是相权衡的:实现者的目标是在没有GC暂停的情况下,应用程序的性能损失不超过10%。

参考

  1. 官方文档
  2. Garbage Collection Roots
  3. JVM 垃圾收集与 GC 算法
  4. Java Garbage Collection Algorithms
  5. JVM常见的几种GC算法
  6. What Is Garbage Collection?