在垃圾回收器(上篇)中介绍了垃圾回收器分类、性能指标,以及其中的几种垃圾回收器,如Serial回收器、Serial Old回收器、ParNew回收器、Parallel Scavenge回收器、Parallel Old回收器和CMS回收器。中篇介绍上图剩下的最后一个G1回收器,下篇再介绍另外两种低延迟回收器Shenandosh回收器和ZGC回收器。
G1(Garbage First)回收器是一种面向服务端应用的垃圾回收器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足停顿时间的同时,还兼具高吞吐量的性能特征。它也是JDK9之后Java虚拟机默认的垃圾回收器。
G1回收器的全称为Garbage First GC,那==如何理解名称中的first一词呢?==前面说到,G1使用了分区和分代算法来划分堆内存空间,此时堆内存不止拥有固定的一个Eden区、两个Survivor区、一个Old区,而是变成可多个变化的Eden区、Survivor区和Old区,以及多个Humongous区。当执行垃圾回收时,虚拟机可有计划的避免在整个堆内存上进行垃圾回收。G1可以根据各个region的垃圾堆积的价值大小,即回收所获得的空间大小以及回收所需时间的经验值,在后台维护一个相应的优先列表。在具体执行垃圾回收时,优先回收价值最大的region,即所谓的垃圾优先(Garbage First)。
G1回收器具有如下的特点
并发是指在垃圾回收期间,Java虚拟机可以使用多个线程同时执行回收操作,这样有效的利用了设备的多核能力,提升了垃圾回收的效率。但在并发回收阶段,用户线程会触发STW。
并行是指G1拥有和应用程序交替执行的能力,部分工作可以和应用程序同时执行。因此,一般不会在整个回收阶段发生完全阻塞应用程序的情况。
G1仍然遵循了分代收集理论,它将堆内存划分为很多物理上不连续的region,使用不同的region来表示Eden区、Survivor区、Old区。但此时Eden区、Survivor区和Old区的数量不再和之前使用分代收集的并发回收器一致,它们的数量不再是固定的,同时不要求内存空间都是连续的。
由于它将堆内存空间划分为多个新生代和老年代,同时兼顾了两代空间的垃圾回收。因此,G1不再像之前的回收器只能单独执行某一代的回收操作,而是可以同时工作在新生代和老年代。
它将堆内存划分为2048个物理上不连续的独立的region,使用不同的region来表示Eden区、Survivor区、Old区。不同的region的大小可根据对空间的实际大小确定,通常空间在1M~32M,且为2的次幂。一旦确定了每个region的大小,它们在JVM的整个生命周期中就不再变化,而且每个region在具体的某次分配中只能但当一个角色。
不同的region不仅要求在物理存储上不必连续,而且它们的分配也是动态的,某个region具体作为哪一种区间是随着垃圾回收动作不断变化的,这样动态分的特点实现了逻辑上的连续。
另外还增加了一个Humongous区,它用来存放创建的短期使用的大对象。当需保存的对象大小超过了0.5个region时,JVM会将其直接放到Humongous区中。如果一个Humongous区存放不下,则会找连续的Humongous区。如果连续的Humongous区不存在,则会出发Full GC。
G1将内存划分为多个region,内存回收便以region为基础单位进行。region之间使用的是复制算法,但整体上实际可看做是标记-整理算法,因此,回收操作可以避免内存碎片的产生。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发Full GC。尤其是在Java对空间非常大时,G1相对于其他的垃圾回收器优势更加明显。
G1除了追求低停顿时间外,还可建立可预测的停顿时间模型,它允许程序通过参数指定在一个长度为M毫秒的时间片段内内执行回收操作,这样就要求收集器消耗在垃圾回收上的时间不得超过N毫秒。
由于G1将堆内存划分为了多个region,使得回收器在执行垃圾回收操作的时间,不仅可以只针对于部分区域进行回收外,还可以根据维护的优先列表优先回收价值较高的区域。因此,这样的操作方式可以预测停顿时间的范围,同时保证了G1在有限的时间内获取尽可能高的收集效率。
如上所示,G1垃圾回收过程主要包括如下的三个环节:
应用程序分配内存时,当新生代的Eden区拥有近视就开始执行Young GC。它是一个并行的独占式收集过程,此时G1回收器会暂停所有的用户线程,即STW,启动多线程执行垃圾回收操作。然后从新生代区间移动存活对象到Survivor区间或者老年代区间,可能两个区间都有涉及,取决于对象的年龄计数值。
具体的执行过程可分为五个阶段:
细节部分看了可能也模模糊糊,看完了可能也就忘了。故详细的执行过程可自行查阅相关的资料,或是浏览宋红康老师 - JVM教程 - G1回收器回收过程。
当堆内存使用达到一定值(默认45%)时,开始执行老年代的并发标记操作。从GC Roots开始对堆中的对象进行可达性分析,递归扫描整个堆中的对象图,找出要回收的对象,标记操作与用户线程并发执行。并发标记操作分为如下的六个阶段执行:
当越来越多的对象晋升到老年代,为了避免堆内存被耗尽,JVM会执行混合回收(Mixed GC)操作。Mixed GG除了回收整个新生代region外,还会回收一部分的老年代region。对于每一个回收期来说,G1从老年代区间移动存活对象到空闲区间,这些空闲区间也就成为了整个老年代的一部分。此时老年代的回收操作一次只会回收一小部分的region,同时老年代region是和新生代一起被回收的。
如果某个区域中的所有对象都是垃圾,则会在并发标记阶段回收。如果部分对象为垃圾,则会将老年代分为8次进行回收。此时混合回收的回收集(Collection Set)包括八分之一的老年代内存分段、Eden区内存段和Survivor内存段。由于老年代中的内存分段默认为8次回收,因此G1会优先回收垃圾多的内存分段。
但混合回收不一定就是要分8次回收,以及对于区域中对象不完全是垃圾的分段也不一定会进行回收,JVM提供了相应的参数来进行设置,后续会提到相关的参数。
当某些参数设置的不合理时,G1回收的速度赶不上对象分配的速度,就会导致没有足够的空间来分配对象。此时,G1就不得不触发Full GC,暂停应用程序的执行,使用单线程的内存回收算法进行垃圾回收。当执行Full GC时,应用的程序停顿时间会很长,所以应使用合理的内存设置来尽量避免Full GC的出现。
-XX:+UseG1GC
:手动指定使用G1回收器
-XX:G1HeapRegionSize
:设置每个region的大小,取值为2的次幂,范围是1M~32M
-XX:MaxGCPauseMillis
:设置期望达到的最大停顿时间指标,默认为200ms。该值并不是越小越好,设置的太小会影响吞吐量
-XX:ParallelGCThread
:设置STW工作线程数的值,最多设置为8
-XX:ConcGCThreads
:设置并发标记的线程数,取值一般为上一个参数的1/4左右
-XX:InitiatingHeapOccupancyPercent
:设置触发并发GC周期的堆占用率阈值,超过则触发GC,默认为45
不管使用的是哪个垃圾回收器,执行的是哪一个回收算法,在垃圾回收操作之前都要首先判断对象是否存活。前面讲到判断对象是否存活的两种方式:
引用计数法虽然实现简单,但是它无法解决循环引用问题,所以Java虚拟机实现中并没有采用它,而是采用了可达性分析法。而可达性分许首要的就是确定哪些可以作为GC Roots,即对象遍历图的根节点。GC Roots是一组必须活跃的引用集合,Java中GC Roots包括以下几类元素:
除了上面所提高的元素外,在广泛遵循的分代收集理论和局部回收的垃圾回收器中,处理判断存活对象时一个 很重要的问题就是如何处理跨代引用问题。如果对新生代执行垃圾回收操作时,新生代中的对象被老年代中的某些对象所引用,那么在回收时就需要遍历老年代在找到引用了新生代对象的对象,从而最终决定回收新生代中的哪些对象。而如果再每次新生代的垃圾回收中都要遍历一次老年代,这显然是不太好的,而且导致的开销也很大。因此,就需要一种机制来告诉垃圾回收器,新生代中的对象是否被老年代中的对象所引用。
为了解决这个广泛存在的跨代引用问题,Java使用了记忆集(Remember Set)来巧妙的避免对老年代的全局扫描。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,每个region都维护一个自己的记忆集,根据记忆集就知道自己是否被其他对象所引用,如果被引用,又是被哪个对象所引用。
记忆集并不是一个具体的数据结构,而是提供了一种实现的理念,具体采用什么样的形式实现,以及实现的精度是什么,都没有具体要求。Java中采用了卡精度来记录引用信息,它可以将记录精确到一块内存区域,该区域内存在对象的字段含有跨代指针。而卡精度的记忆集的实现形式称为卡表(Card Table),HotSpot中卡表的实现形式就是一个字节数组。字节数组中的每个元素都对应着称为卡页的一块内存区域,只要卡页中有一个或更多的对象存在跨代指针,就将卡页中对应位置的元素标识为1,称该元素变脏(Dirty);没有则标识0。
如果在垃圾回收前,记忆集已经构建完毕,那么执行垃圾回收操作只需要筛选出卡表中变脏额元素,就能轻易的得出哪些卡页内存块中包含跨代指针,最后将它们加入到GC Roots中一起扫描。
在介绍剩下的两款主打低延迟的回收器前,首先总结下前面提到的所有垃圾回收器,如下所示:
GC | 分类 | 作用位置 | 算法 | 特点 | 使用场景 |
---|---|---|---|---|---|
Serial | 串行运行 | 新生代 | 复制算法 | 响应速度优先 | 单CPU的Client模式 |
ParNew | 并行运行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU的Server模式 |
Parallel | 并行运行 | 新生代 | 复制算法 | 吞吐量优先 | 与后台运算不需太多交互的场景 |
Serial Old | 串行运行 | 老年代 | 标记-整理算法 | 响应速度优先 | 单CPU的Client模式 |
Parallel Old | 并行运行 | 老年代 | 标记-整理算法 | 吞吐量优先 | 与后台运算不需太多交互的场景 |
CMS | 并发运行 | 老年代 | 标记-整理算法 | 响应速度优先 | 互联网或B/S业务 |
G1 | 并发、并行运行 | 整堆 | 标记-整理算法、复制算法 | 响应速度优先 | 面向服务端应用 |