自动内存管理主要作用在堆(heap)上,而虚拟机规范并没有限定自动内存管理的实现方式,本文以时下流行的HotSpot虚拟机为例,介绍其中的自动内存管理(主要是垃圾回收)机制,以及它提供的几款垃圾回收器(Garbage Collector,简称GC)。

1 什么是GC(垃圾回收器)

垃圾回收器就是虚拟机中实现自动内存管理的部分,它的作用如下:

  • 从操作系统中获取内存,以及把内存交还给系统
  • 当应用程序请求内存时,分配内存给它
  • 判断哪部分内存仍然被应用程序使用
  • 回收那些不再被使用的内存

自动内存管理是Java区别于C++的一个重要特征(C++中虽然也有智能指针,但是使用上比较繁锁,只能算“半自动”),它使得程序员不再需要编写销毁对象或者释放内存之类的代码,一切都由GC处理。虚拟机会根据场景自动选择合适的GC,同时也提供了一些参数,让我们可以手动的选择指定的GC,并且调整相应的参数,使其回收方式跟我们的软件更匹配。

2 GC的实现方式和性能指标

垃圾判断

首先,怎么判断一个对象已经属于垃圾?

判断一个对象是否是垃圾,有两种主流的方法,引用计数法和追踪式垃圾回收:

  • 引用计数法(reference counting,wiki链接):通过在对象本身中添加一个变量,表示该对象被引用的次数,如果这个值变为0,则这个对象可以被回收。

    • 优点:

      • 当引用计数变为0时,立马回收内存,不用像追踪式回收法一样暂停来集中回收,对于小内存来说,比较好
      • 且当可用内存减少时,不会导致回收操作更频繁。如果内存中存放的大部分都是有用对象,则追踪式回收法就会随着可用内存的减少而更加频繁的触发垃圾回收(因为使用内存不断的达到堆上限)。
    • 缺点:

      • 每个对象需要额外的空间来保存引用计数。
      • 即时触发的垃圾回收如果频率太高会拖慢性能,并且一个对象的回收可能会触发整条对象链的回收(A引用B,B引用C。。。A被回收,B的引用计数变为0,B变回收,C的引用计数变为0。。。。)
      • 对于对象的循环引用,单纯的引用计数法无法正确的回收,需要借助其它手段,如弱引用(weak reference)或者结合追踪式垃圾回收。看一下这个循环引用的例子:
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      
      public class Test {
          byte[] b=new byte[1000000]; // 1MB
          Object obj=null;
      
          public static void main(String[] args) {
              Test ta=new Test();
              Test tb=new Test();
              ta.obj=tb;
              tb.obj=ta;
      
              ta=null; // 释放ta对内存区域中对象的引用
              tb=null; // 释放tb对内存区域中对象的引用
          }
      }
      

      例子中,虽然释放了tatb两个变量对对象的引用,但是两个对象之间仍然存在互相引用的情况(这里的对象指的是在内存中实际存在的对象,而不是tatb这两个“引用对象”),所以引用计数无法正确的回收。

      启动程序,添加-Xlog:gc观察gc日志(如下),发现内存被回收了,如果把byte[]这行代码注释掉,则回收的内存变为3MB,差值正好是2MB(每个对象1MB)。因为JVM使用的不是引用计数法

      1
      2
      
      [0.011s][info][gc] Using G1
      [0.135s][info][gc] GC(0) Pause Full (System.gc()) 5M->0M(10M) 4.303ms
      
  • 追踪式垃圾回收(tracing garbage collection,wiki链接):很多地方也叫可达性分析算法。当触发垃圾回收时(比如指定内存区域的使用率达到限定值时),从一系列的“根节点对象(root object)”出发,对所能到达的对象进行标记,剩余没有被标记的对象就是垃圾。
    哪些节点可以作为根节点?

    • 栈帧中的本地变量、参数等,因为这些是虚拟机线程执行需要的对象
    • 虚拟机内部管理的对象,如基本类型对应的class对象,OutOfMemoryError异常对象等
    • system class loader(application class loader)加载的class对象,也就是应用程序路径下加载的class对象
    • 当前激活的线程(Thread)对象
    • 作为monitor使用的对象,也就是synchroized(obj)里的obj
    • 跟JNI方法调用相关的一些对象

JVM采用的就是追踪式垃圾回收

上面我们提到,在回收垃圾前,需要先用标记区分哪些对象是垃圾,哪些对象是存活的,这个标记过程也是有说法的。

最朴素的标记方法就是,先挂起所有工作线程,然后从root对象开始进行遍历,对遍历到的对象进行标记,结束后清理所有没有标记的对象。为什么要暂停所有线程呢?因为如果你刚分析并标记完一个对象A及其引用链相关的对象,然后在接下来的标记过程中,程序又新增了一个从A到A1的引用,那么在标记过程结束后,A1就会被当作垃圾处理。这种方法实现简单,缺点也很明显,就是要中断程序,当程序涉及的对象越来越多的时候,需要中断程序的时间也相应增长,这显然不能满足很多交互性应用程序的要求。

三色标记法(Tri-color marking)

假如我们的标记过程可以和程序线程并发运行,那么就可以大大减少程序中断的时间,优化用户体验。三色标记法把所有对象分为三类(三种颜色),每个对象在同一时间只会属于其中的一种:

  • 白色:对象尚未被分析。当标记过程开始时,所有的对象都是白色。
  • 灰色:对象正在被分析。只要标记还没完成,这个区域就不为空。
  • 黑色:对象已经被完全分析,不需要被再次分析。这里并不是说我们已经分析完对象A及其引用链,而是我们把A引用到的所有对象标记为灰色,然后就可以把A标记为黑色了。如果从另一个root对象分析,又引用到了A,发现A是黑色,就可以直接跳过分析,因为所有A引用到的应该被分析的对象都已经安排分析了,至于那些对象是否已经分析完成并不重要。

所以三色标的过程就是,起初所有对象都是白色,然后把root引用的对象标记为灰色,再分析这些对象,比如分析到对象A,把A引用的所有对象都由白色标记为灰色,此时就可以把A标记为黑色。重复这个过程,直到灰色对象数量为0,那么依然是白色的对象就是垃圾对象,黑色对象就是存活对象。

三色标记法有以下几个性质:

  • 对象总是先从白色变成灰色,再从灰色变成黑色,而不会直接从白色变为黑色。
  • 当灰色对象数量为0时,标记就完成了。
  • 标记完成后,所有的对象要么是黑色(存活对象),要么是白色(垃圾对象)。

三色标记也可能在并发的情况下遇到“对象消失”的问题,但需要同时满足两个条件:1.添加黑->白的引用 2.删除所有灰->白的引用。

看下图:

三色标记的"对象消失问题"

图中,从GC root出发,对象引用链:A->B->C、B->D,正在分析对象B,A对象已经是黑色,B准备把C和D标记为灰色,如果此时正好B取消了对D的引用,并且添加了A->D的引用,那么D依然会在标记结束后被回收,怎么避免这种情况呢?

有两种应对方法:

  1. 增量更新:每当对黑色对象(例子中是A)添加一个引用时,记录下这个节点,等扫描结束后再以这个对象(A)为起点,进行追踪分析,增量更新是在新的引用关系添加后进行分析。
  2. 原始快照:每当从灰色对象(例子中是B)删除一个引用时,记录下这个引用,等扫描结束后以这些对象(B)为起点,进行追踪分析,原始快照是按照引用删除前的引用状态进行分析。

也许看上去增量更新的方法更加的“正确”(因为它使用的是事实上的正确的引用关系,而原始快照则是借助删除前的引用关系),但是这两种方法都能让我们正确的标记对象D,避免"对象消失",结果是一样的。

在上图中,假如有一个孤立的白色对象E,然后在分析B的过程中添加了A->E的引用,为什么这种情况不会产生对象消失问题呢?因为我们无法让A引用一个在内存中的垃圾对象。

垃圾回收

在判断哪些对象是垃圾后,就要对它们进行回收,有几种算法适用于不同的场景:

  1. 标记-清除算法(mark and sweep):这是最简单的回收方法,在知道哪些对象是垃圾后,我们直接清除对象所占用的内存。它有两个缺点:a.会在内存中产生很多碎片,因为内存的分配通常不是连续的 b.如果要回收的对象占所有对象的大多数,那么清除内存的操作开销就会偏大
  2. 标记-复制算法:把内存分成两部分(比如A区域和B区域),同一时间只使用其中的一部分(例如当前正在使用A区域),在进行垃圾回收时,把A中存活的对象复制到B中,然后对A进行整个区域的清理。它解决了标记-清除算法的两个缺点。但它也有缺点:a.内存利用率太低(只能用一半)b 如果存活对象较多,那么复制对象的开销也是很大的,我们复制对象的时候还需要修改指向它的引用
  3. 标记-整理算法:跟标记-清除算法不同的是,它的标记后并不直接清除垃圾对象,而是先把存活对象移动到内存区域的一侧,然后再对无用区域进行统一清理。这种方法跟标记-复制算法思路上有点类似,但是可以利用整个内存区域。它的缺点:a.移动对象的开销可能很大(整理后再进行回收的效率并没有直接回收来的高,但是整理后的内存分配和访问效率会变高,而且整体的吞吐量变高)。移动对象需要暂停所有线程,如果存活对象很多,这个时间可能会造成明显的卡顿,所以对于响应要求较高的场景,就不适合,而对于吞吐量要求较高的场景(比如一些非交互型的应用),就可以用标记-整理算法。还有,上面提到的标记-清除算法产生的碎片问题,也可以通过定期执行标记-整理算法来解决。

这些回收算法都是分代收集理论的基础。

分代垃圾收集(Generational Garbage Collection)

分代垃圾收集基于一个假定:大部分对象都是朝生夕灭的,而存活的越久的对象(经历多次垃圾回收)则越难以被回收。

我们可以在逻辑上把内存区域分成两块:年轻代(young generation)内存区域和老年代内存区域(old generation)。一开始对象都分配在年轻代,经过一定次数的回收后仍然存活的对象就转移到老年代。

年轻代:在年轻代上通常执行标记-复制算法,但它并不是把年轻代分成两半,而是分为1个初始区域(Eden)和2个幸存区域(survivor),对象都分配在eden区域。两个survivor区域至少有一个是空的,每次在年轻代上执行回收时,对eden和其中一个survivor区(有对象的那个)执行垃圾回收,存活下来的对象转到空的这个survivor区上,并清空eden和survivor,下一次回收时,两个survivor的角色互换。通常eden:survivor:survivor=8:1:1,可以通过-XX:SurvivorRatio=n来调整,这里n表示的eden/survivor的比例,而不是survivor/eden,所以n是正整数,默认8。

老年代:在老年代上通常使用标记-整理算法,虽然老年代上有很多存活对象,移动的开销比较大,但是老年代的回收频率低,标记-整理算法一步到位可以接受。

年轻代和老年代的默认比例通常是1:2,可以通过-XX:NewRatio=n来调整,这里n是正整数,但是表示的老年代/年轻代的值,虽然名字是NewRatio。

性能指标

垃圾收集器有几个性能指标:停顿时间、吞吐量、内存占用,这三者是不能同时做到最优的,所以jdk通常都提供了多款垃圾收集器,可以让我们在不同的场景手动选择合适的收集器来达成最重要的指标。对于同一款收集器,我们也可以通过虚拟机参数来调节这几个指标,使其尽量符合要求。

停顿时间(pause-time)

停顿时间指的是暂停用户程序进行垃圾回收的时间。比如标记-整理算法,在移动对象时就必须暂停用户程序。暂停时间的长短在交互型的应用程序中非常重要,如果这个时间过长,就会让用户觉得应用程序“卡顿”。

垃圾收集器会记录一个历次停顿时间的加权平均值(越接近当前时间发生的几次权重越高)和方差,如果这个平均值+方差大于最大停顿时间限值,就认为停顿时间这个指标没有满足。

虚拟机参数-XX:MaxGCPauseMillis=n可以设置最大停顿时间(单位:毫秒),但这只是对虚拟机的一个建议,虚拟机会尽量满足这个要求,但不保证。

吞吐量(throughtput)

吞吐量指的是程序本身运行时间占总时间的比例(总时间=程序本身运行时间+GC消耗时间)。吞吐量跟堆内存大小成正比,同时也跟垃圾回收的频率有关(回收的越频繁,回收总时间就越长)。

虚拟机参数-XX:GCTimeRatio=n(n是正整数)可以设置程序本身运行时间GC消耗时间的比率,比如-XX:GCTimeRatio=19表示垃圾回收占总时间的比例为5%(1/20),程序本身运行时间占总时间的比例为95%(19/20)。

注:分配内存的时间也属于程序本身运行时间。

堆内存大小(footprint)

堆内存大小通常根据其它两个指标动态的变化,很少去指定上下限。比如,吞吐量太小,没有满足要求,那么垃圾收集器就会增加堆内存大小。

虚拟机参数-Xms<n>-Xmx<n>可以指定堆内存大小的上下限(<n>表示设置的值,中间不用加=或者:),单位可以是k,m,g等(大小写都行),比如-Xmx256m,表示最大堆内存256MB。

-Xms<n>的设置有时也称为设置堆内存初始值,这区别不大,因为虚拟机保证堆内存不会小于这个值。

这三个指标,吞吐量和其它两个是冲突的,如果要增加吞吐量,通常就是增加堆内存大小,而堆内存变大,回收导致的停顿时间也会变长。

3 不同的垃圾收集器

每款垃圾收集器都有其特性,我们可以在不同的场景下选择合适的收集器。

3.1 Serial Collector

这是最简单的垃圾收集器。它采用单线程,没有线程间的开销,所以自身效率非常高。

适用场景:单核处理器的机器或者涉及的内存较小的多核处理器(上限大约100MB)。

虚拟机参数:-XX:+UseSerialGC可以手动启用。

3.2 Parallel Collector

Parallel CollectorSerial Collector很像,但是它在收集垃圾时可以调用多个线程提高效率,在单核处理器中的性能不如Serial Collector,而在双核处理器并且处理的数据量较大时就会反超。

注:parallel指的是“并行”,也就是同一时间多条垃圾回收线程一起工作,但是用户线程仍然需要被暂停。后面提到的concurrent(“并发”)指的是用户线程和垃圾回收线程可以一起工作。

适用场景:多核处理器的机器,并且涉及到中量或大量的数据。

虚拟机参数:-XX:UseParallelGC可以手动启用。

多线程调用

Parallel Collector调用的线程数量跟机器线程数n有关,当n<=8,全部线程都会被调用,而当n较大时,大概5/8的线程会被调用(有的平台可能只有5/16)。

虚拟机参数:-XX:ParallelGCThreads=n可以设置线程数量。

Parallel Collector默认在minor collection和major collection,都使用多线程的方式工作,可以用虚拟机参数-XX:-UseParallelOldGC来取消major collection的并行操作,使其用单线程的方式工作。

关于minor collcetion,major collection以及full collection:首先,minor collection指回收年轻代,full collection指回收年轻代+老年代,而major collection通常指回收老年代,在gc日志中发生major collection时报告为full collection。有一处让人困惑的地方,官方关于垃圾收集器调式的文章(链接:https://docs.oracle.com/en/java/javase/11/gctuning/garbage-collector-implementation.html#GUID-16166ED9-32C6-402D-BB22-FD85BCB04E57) 中的有一段“ Typically, some fraction of the surviving objects from the young generation are moved to the old generation during each minor collection. Eventually, the old generation fills up and must be collected, resulting in a major collection, in which the entire heap is collected”,从字面上看major collection的影响是整个堆被回收,而结合其它资料,这样理解比较合适:major collection依然指的是回收老年代,而major collection的回收是因为minor collection回收年轻代后移动对象到老年代导致老年代满了才被触发的,所以major colletion触发的时候整个堆都被回收过(但并不是所有垃圾收集器都是这样,有的收集器如G1可以做到局部回收)

多线程引起的内存碎片问题

当多个线程一起回收年轻代时,每个线程会各自保留老年代的一部分区域,作为把对象从年轻代提升到老年代的“缓冲区”,从老年代中划分这些“缓冲区”的过程可能会引起内存碎片。

减少线程数量或者增大老年代内存可以缓解这种情况。

性能参数调整及优先级

跟前面提到的一样,可以用-XX:MaxGCPauseMills=n设置最大停顿时间,-XX:GCTimeRatio设置吞吐量,-Xmx<n>最大堆内存上限,而堆内存下限虽然可以指定,但不必要,因为收集器在满足其它指标后会自行调整,让堆内存最小化。

如果同时设置了多个参数,收集器优先满足最大停顿时间的要求,在满足了停顿时间要求的基础上,考虑满足吞吐量的要求,最后,如果前面两个参数都满足了,才会去考虑堆内存大小的问题。

堆内存大小自动调整细节

收集器自身会在每一次收集操作结束后记录并统计停顿时间相关的信息(前面提到的“平均停顿时间”和“方差”),但是System.gc()等手动触发的回收不算。当停顿时间不满足时,通常会减少堆内存大小来减少停顿时间,而当停顿时间满足后,又可能会增大堆内存来增加吞吐量,但具体的调整方案是怎样的?

堆内存减少:当停顿时间不被满足时,会减少堆内存大小,但是一次只会减少年轻代或者老年代其中的一个区域,具体是哪个区域取决于两者的停顿时间,停顿时间更长的那个区域优先被减少。默认一次减少5%。这个比例可以通过-XX:AdaptiveSizeDecrementScaleFacotr=n调整,它是跟对应区域的堆内存增长率有关的调整因子,假如年轻代的堆内存增长率是x,则年轻代的堆内存缩减率是x/n%

堆内存增加:要增加吞吐量时会增大堆内存,堆内存的一次增长比例默认是20%,可以通过-XX:YoungGenerationSizeIncrement=y-XX:TenuredGenerationSizeIncrement=t来分别设置年轻代和老年代的增长比例(y,t是百分比对应整数,如20%就写20)。增长比例不代表每次堆内存就增长这么多,它只是其中的一个参数,另一个参数是相应区域的垃圾回收时间占总回收时间比例,比如,年轻代回收时间占20%,老年代回收时间占80%,两者的增长比例都是20%,那么最终年轻代会增长20%*20%=4%,老年代会增长:20%*80%=16%。

默认的堆内存大小:如果没有指定-Xms-Xmx参数的话,默认的堆内存上限是1/4物理内存,下限是1/64物理内存,年轻代占1/3,老年代占2/3。

OutOfMemoryErrorParallel Collector会在下面这个情况下抛出这个错误:超过98%的时间用来回收垃圾,但只回收了不到2%的内存,这个设定可以防止程序因为堆内存过小而长时间的做“无用功”。虚拟机参数-XX:-UseGCOverheadLimit可以在必要的时候关闭它。

3.3 Concurrent Mark Sweep(CMS) Collector

CMS收集器从jdk9以后被标记为deprecated,但是作为在历史中扮演重要角色的并发收集器,还是要介绍一下。

CMS收集器的特点是可以让用户线程和收集线程“并发”运行,但还是会有“stop the world”现象(暂停所有用户线程来执行垃圾回收)发生,也就是说,它只能算部分并发,它的优势是停顿时间小。CMS的并发收集主要是在major collection,minor collection则跟parallel collector类似,需要暂停用户线程,不过major collection和minor collection可以同时进行。

适用场景:在双核以上的机器并处理大量内存的时候。

虚拟机参数:-XX:+UseConcMarkSweepGC可以手动启用。

“并发”中的stop the world现象和浮动垃圾

stop the world现象

initial mark pause:在标记开始时,会暂停所有用户线程来标记那些从GC root直接可达的对象,这个停顿时间很短,称作初始标记暂停(initial mark pause)。这里必须要暂停来保证GC root枚举过程中不发生变化,否则也可能会出现对象消失问题。

remark pause:在前面的三色标记中,我们提到在并发标记的情况下可能会出现对象消失问题,CMS的解决办法就是增量更新,那么这个增量更新的过程就需要暂停所有用户线程,因为这样才能防止在这个过程中另一个对象消失问题的发生,否则在这次增量更新结束后我们又要执行一次增量更新(可能会多次循环下去浪费时间),这个暂停叫做“重新标记暂停”(remark pause),这个暂停的时间相对较长。

根据这两个暂停阶段,整个CMS收集器的过程可以大概描述为:初始标记阶段(暂停)->并发标记阶段(并发)->重新标记阶段(暂停)->并发回收阶段(并发)

浮动垃圾

在程序线程和回收线程并发运行的情况下,可能会发生下面这种情况:A对象刚被回收线程标记为“存活的”,紧接着却被用户线程删除了所有引用,变成了死对象,这部分对象就变成了浮动垃圾,需要到下一次回收时才会被处理。

浮动垃圾和并发标记中的对象消失问题并没有关系,增量更新也只解决了对象消失问题,而浮动垃圾相比之下属于可以容忍的问题。

浮动垃圾的多少跟并发收集的持续时间长短以及对象的引用变化(或者说突变)频率有关,官方建议把老年代内存增大20%来应对浮动垃圾问题。

触发垃圾回收的时机

在这之前先了解一下并发模式失败的概念: 假如收集器在并发收集的过程中,发生下列情况之一就称为并发模式失败(concurrent mode failure):

  1. 在并发回收结束前老年代满了
  2. 在并发回收结束前老年代虽然没满,但是没有足够的内存来分配对象

并发模式失败会导致所有用户线程被暂停(不暂停也无法正常工作了),所以CMS收集器不可能像Serial Collector一样等到老年代满了再回收,否则会因为并发模式失败而导致长时间停顿。

CMS收集器会动态维护两个估算值:距离老年代耗尽的时间以及一次并发垃圾回收需要的时间,而并发操作触发的时机就是保证一次并发垃圾回收结束后,老年代仍然没有被耗尽。因为并发模式失败的成本很高,所以两个值的估算会相对保守以确保并发模式失败的情况很少发生。

除了通过前面的估算老年代耗尽的时机来触发回收外,还可以通过老年代的使用比例(occupancy)来触发:在jdk11中,大约是92%的老年代内存被使用时会触发,这个值可以通过虚拟机参数:-XX:CMSInitiatingOccupancyFraction=n来设置,n是0-100的整数。这个值低了会导致回收太频繁,高了又可能会导致并发模式失败,需要根据实际情况调整。

3.4 Garbage-First(G1) Garbage Collector

G1是所有垃圾收集器中最重要也是最值得花时间学习的,官方用G1来替代CMS收集器。G1在大部分场景下都是默认的垃圾收集器,它的目标是在保证吞吐量的前提下有很高的概率满足停顿时间的要求(或者换个说法,G1可以在保证吞吐量的情况下还能把停顿时间控制在一个相对稳定的水平),在前面的性能指标一节中,我们得知要提高吞吐量通常意味着加大堆内存,进而导致停顿时间变长,那么G1为什么可以做到有很高的概率可以满足停顿时间要求呢?原因就在于分区,G1不再跟传统的垃圾收集器一样只把堆内存分成1个年轻代和1个老年代,而是分成很多相同小块,从而可以做到"部分回收",详情后面展开。

适用场景:拥有多核处理器和大内存的机器。

虚拟机参数:-XX:+UseG1GC是手动启用G1的参数,但G1本身就是默认的垃圾收集器。

堆内存分区(region)

G1把整个堆内存分为相同大小的regionregion是分配对象和回收内存的基本操作单位,region可以有两种状态:使用和未使用,而被使用的内存则可以根据其角色再分为年轻代(进一步分为eden和survivor)和老年代(老年代包含一些占用几个连续region的"大内存区域"用来分配大对象)。

虚拟机参数:-XX:G1HeapRegionSize=n可以设定region的大小,这个值必须是1-32MB之间,且是2的指数倍。通常不需要指定这个值,虚拟机会根据堆的上下限来计算region大小,使得堆的数量在2048个左右。

处理对象分配

当需要分配对象时,内存管理器(也就是垃圾收集器)会把对象分配到eden区域,如果空间不够,则获取一块未使用的region,并把这个region设为eden供程序使用,但是如果对象很大,大于等于region大小的一半,则直接分配为老年代(老年代中包含一些"大内存区域").

处理内存回收

当发生年轻代垃圾回收时,整个年轻代的对象被复制到survivor区(来自eden)或者老年代区(来自survivor),然后原来年轻代使用的region就变为未使用状态,这种类似标记-整理的方法称为evacuation。年轻代大小在每一次回收后都会动态调整,试图满足停顿时间的要求。

当发生老年代垃圾回收时,对象被复制到新的老年代region中,旧的region同样变为未使用状态。

G1之所以可以实现对停顿时间的相对可控,就在于它的垃圾回收操作并不是“一次性的”完成的,理论上来说,我们只要保证垃圾回收的速度比新对象分配的速度快(比较的是内存的量)就行了。G1会记录程序行为和垃圾回收操作等信息,让自己可以预判每个region对应的回收时间,当发生回收操作时,优先进行最有效的回收(垃圾最多的region,因为有效对象少,标记复制等操作的时间少,回收的快,且回收的内存多),这也是garbage-first名称的由来。

垃圾回收流程

G1在回收垃圾时会在两个不同的阶段之间变换,每个阶段中包含一些操作及必要的stop-the-world(stw)停顿:

  • young-only阶段:这个阶段的evacuation操作只涉及年轻代,并按顺序穿插如下操作:
    1. 常规年轻代回收:常规的年轻代evacuation操作,这个操作有stw停顿,随着提升到老年代的对象越来越多,老年代占比超过一定限值(可以用虚拟机参数-XX:InitiatingHeapOccupancyPercent=n设置,n是老年代的比例,相比于堆回收上限值而不是整个堆大小,前面提到过,这个上限应该保证在标记等操作结束前堆不会满)后触发后面的"并发标记回收流程",注意,“常规年轻代回收"是贯穿始终的操作(两个阶段都有)。
    2. 初始标记:跟CMS收集器类似,stw停顿。
    3. 并发标记:并发标记跟程序一起运行,且其间会发生“常规年轻代回收”的stw停顿。
    4. remark:对并发标记的修正,stw停顿,G1用的是"原始快照”(SATB,snapshot-at-the-beginning)。
    5. cleanup:这个阶段只回收那些未被标记到的region,stw停顿。在这一步,如果G1认为有必要对老年代执行evacuation操作,则进入space-reclamation阶段(MixedGC)。
  • space-reclamation阶段:在前面的cleanup阶段结束后,我们知道了老年代中对象存活的情况,这个阶段就会同时执行年轻代+老年代的evacuation操作,称为MixedGC。但是老年代的evacuation操作并不是针对整个老年代,而是优先选择最有回收价值的region,并且当G1认为剩下的region已经不值得进行evacuation操作时(比如说,要花费很长时间才能回收到一点点同存),就退出这个阶段。也正因为这个判断,所以在上面的cleanup阶段结束时,有可能不进入MixGC。如果这个阶段结束,则重新回到young-only阶段。

几点补充:

  1. 虚拟机参数-XX:InitiatingHeapOccupancyPercent=n:G1有一个功能叫做adaptiveIHOP,默认是开启的,在这种情况下,-XX:InitiatingHeapOccupancyPercent=n指定的值并不是绝对的,只有G1没有足够的信息去判断IHOP的值时,设定的值才会被使用。当然,如果关闭adaptiveIHOP(通过虚拟机参数:-XX:-G1UseAdaptiveIHOP),那么我们设定的值就是唯一指标。
  2. 在young-only阶段,每一次回收结束时,G1都会调整年轻代的大小,主要目的就是满足停顿时间的要求。年轻代大小占比可以通过虚拟机参数设置:-XX:G1NewSizePercent(下限),-XX:G1MaxNewSizePercent(上限)
  3. 在space-reclamation阶段,每一次stw pause对老年代的回收就更重要了,此时年轻代大小会设为最小值(-XX:G1NewSizePercent对应的堆大小),然后根据效率优先回收老年代中价值大的region,直到G1认为完成下一个region的回收会超过pause-time要求,那么就停止回收。
  4. 大对象导致的碎片问题:大对象区域需要占用连续的region,这些区域一般只有在并发标记的cleanup阶段会被回收(基本类型的数组对应的大对象会有例外),evacuation操作不会涉及大对象,所以这些对象在分配以后就不会移动(包括FullGC时)。当大对象数量很多的时候,可能会导致堆内存中出现很多碎片,虽然显示堆中可用内存足够,但是无法分配对象的问题,进而导致频繁的FullGC。G1会偶尔尝试去回收那些在年轻代或者老年代的回收操作中都没有被大量对象引用的大对象,这个特性也可以用虚拟机参数关闭:-XX:G1EagerReclaimHumongousObjects,这个特性无法解决碎片问题,当碎片问题严重时,我们只能选择增大region大小(可以让大对象数量变少)或者增大堆内存,否则如果FullGC后依然没有足够的空间来分配对象,虚拟机甚至会强制退出。

总的来说,G1相比于其它收集器,运行的开销的更大,可能会对吞吐量有一定的影响,但是正是这些开销才让它更智能,官方建议G1一般不用调整默认参数,最多根据需要设置puasetime或者堆上限。