深入理解Java中的7种JVM垃圾收集器原理【一万字】1

「这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战」。

虽然常见的垃圾收集算法都是固定的,但是内存回收如何具体进行是由虚拟机所采用的GC收集器决定的,而通常虚拟机中往往不止有一种GC收集器,他们也采用的不同的垃圾收集算法,下面来看看HotSpot中的7种GC收集器。如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

关于常见垃圾收集算法,可以看这篇文章:Java中的常见JVM垃圾收集算法

@[toc]

1 垃圾收集器概述

HotSpot虚拟机的常见垃圾收集器如下:

在这里插入图片描述

上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。下面看看7种垃圾收集器的简图。

在这里插入图片描述

1.1 默认垃圾回收器

查看默认设置命令:

java -XX:+PrintCommandLineFlags -version

查看默认垃圾收集器详细信息命令,通过新生代、老年代名字确定垃圾收集器

java -XX:+PrintGCDetails -version

更加准确的定位使用了哪些垃圾收集器

java -XX:+PrintFlagsFinal

本人JDK1.8的信息如下

在这里插入图片描述

PSYoungGen 表示的是由Parallel Scavenge垃圾收集器管理的新生代,ParOldGen表示由Parallel Old管理的老年代。

java -XX:+PrintFlagsFinal查找的结果,印证了咱们的想法:

在这里插入图片描述

默认垃圾收集器:

JDK1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
JDK1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
JDK1.9 默认垃圾收集器G1

1.2 垃圾收集器中的并发与并行

并发和并行。这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下。

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

在这里插入图片描述

并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

在这里插入图片描述

2 垃圾收集器介绍

2.1 Serial收集器

Serial(直译为连续)是最基本垃圾收集器,使用复制算法,曾经是JDK1.3.1之前年轻代唯一的垃圾收集器。串行垃圾收集器,是指Serial是一个单线程的收集器,它不但只会使用一个CPU或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。这种现象称之为STW(Stop-The-World)。

STW是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。

serial垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个CPU环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java虚拟机运行在Client模式下默认的年轻代垃圾收集器

对于交互性较强的应用而言,这种垃圾收集器是不能够接受的。一般在Javaweb应用中是不会采用该收集器的。

年轻代均采用复制算法,老年代用标记-整理算法(Serial Old)。

Serial和Serial Old配合示意图:

在这里插入图片描述

2.1.1相关参数

-XX:+UseSerialGC 参数指定新生代和老年代都是用串行垃圾收集器,即Serial+Serial Old。

2.2 Serial Old收集器

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

2.2.1相关参数

-XX:+UseSerialGC 参数指定新生代和老年代都是用串行垃圾收集器,即Serial+Serial Old。

-XX:+UseParNewGC 新生代使用ParNew,老年代默认使用Serial Old。

-XX:+UseParallelGC 新生代使用Parallel Scavenge,老年代默认使用Serial Old。

2.3 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,是一款并行垃圾收集器。除了使用多条线程进行垃圾收集之外,其余行为与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。ParNew收集器的工作过程如下图所示:

在这里插入图片描述

ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有ParNew能与CMS收集器配合工作(因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架,而另外独立实现;而其余几种收集器则共用了部分的框架代码)。

2.3.1 相关参数

由于单CPU存在多线程交互的开销,ParNew收集器在单CPU的环境中可能不会有比Serial收集器更好的效果,默认开启的收集线程数与CPU的数量相同,可以使用 -XX:ParallelGCThreads 来限制垃圾收集的线程数。

-XX:+UseConcMarkSweepGC 新生代使用ParNew,老年代使用CMS。

-XX:+UseParNewGC 新生代使用ParNew,老年代默认使用Serial Old。

2.4 Parallel Scavenge收集器

Parallel Scavenge收集器是新生代收集器,它也是使用复制算法的收集器,是并行的多线程收集器,Java1.8默认的新生代垃圾收集器。

它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU用于运行用户代码的时间/CPU总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),即吞吐量优先。高吞吐量可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多与用户交互的任务,就是说在任务完后允许进行一次长时间的GC(良好的响应速度能提升用户的体验,此种场景需要更短的GC时间,更短的SWT,此时使用CMS效果更好)。

自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别,它也经常称为“吞吐量优先”收集器。

2.4.1 相关参数

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

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

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

Parallel Scavenge收集器还有一个参数 -XX:+UseAdaptiveSizePolicy ,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。

-XX:+UseParallelGC 新生代使用Parallel Scavenge,老年代默认使用Serial Old。

-XX:+UseParallelOldGC 新生代使用Parallel Scavenge,老年代使用Parallel Old。

2.5 Parallel Old收集器

Parallel Scavenge的老年代版本,一般它们搭配使用,追求CPU吞吐量。这个收集器是在JDK 1.6中才开始提供的,它们在垃圾收集时都是由多条GC线程并行执行,并暂停一切用户线程,使用"标记-整理"算法。因此,由于在GC过程中没有使垃圾收集和用户线程并行执行,因此它们是追求吞吐量的垃圾收集器。

在这里插入图片描述

2.5.1 相关参数

-XX:+UseParallelOldGC 新生代使用Parallel Scavenge,老年代使用Parallel Old。

2.6 CMS收集器

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

2.6.1 CMS收集器的工作步骤

CMS收集器总体是基于“标记—清除”算法实现的,它的具体运作过程相对于前面几种收集器来说更复杂一些,包括:

  1. 初始化标记(CMS-initial-mark) ,标记所有root,会导致stw
  2. 并发标记(CMS-concurrent-mark),与用户线程同时运行;
  3. 预清理(CMS-concurrent-preclean),与用户线程同时运行;
  4. 可中断预清理(CMS-concurrent-abortable-preclean) 清理前准备以及控制停顿时间,与用户线程同时运行;
  5. 重新标记(CMS-remark) ,修正并发标记的数据,会导致stw
  6. 并发清除(CMS-concurrent-sweep),清理垃圾,与用户线程同时运行;
  7. 并发重置(Concurrent reset):等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行。

主要步骤是: 初始标记(CMS initial mark)、并发标记(CMS concurrent mark)、重新标记(CMS remark)、并发清除(CMS concurrent sweep)。

由于采用标记清除算法,初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots对象,速度很快,并发标记阶段就是进行GC RootsTracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的,可以看作是并发垃圾收集器。

在这里插入图片描述

2.6.1.1 初始标记

这是CMS中两次stop-the-world事件中的一次。这一步的作用是标记存活的对象,有两部分:

  1. 标记老年代中所有的GC Roots对象,如下图节点1;

  2. 标记年轻代中活着的对象引用到的老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象)如下图节点2、3;

    在这里插入图片描述

为了加快此阶段处理速度,减少停顿时间,可以开启初始标记并行化 -XX:+CMSParallelInitialMarkEnabled ,同时调大并行标记的线程数,线程数不要超过cpu的核数。

2.6.1.2 并发标记

并发标记阶段是与应用程序一起执行的,这个阶段主要做两件事:

  1. 从“初始标记”阶段标记的GC Roots对象开始,遍历所有能被引用的对象,从而找出所有存活的对象;
  2. 将在并发阶段新生代晋升到老年代的对象、直接在老年代分配的对象以及老年代引用关系发生变化的对象所在的card标记为dirty,后续只需扫描这些Dirty Card的对象,避免在重新标记阶段扫描整个老年代。

并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理;由于这个阶段是和用户线程并发的,可能会导致concurrent mode failure。
在这里插入图片描述

2.6.1.3 预清理

这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为Dirty的Card. 如下图所示,在并发清理阶段,节点3的引用指向了6;则会把节点3的card标记为Dirty;

在这里插入图片描述

最后将6标记为存活,Dirty区域消失,如下图所示:

在这里插入图片描述

2.6.1.4 可中断预清理

该阶段存在的目的是减轻重新标记的工作量,减少暂停时间,该阶段可以被取消,使用 -XX:-CMSPrecleaningEnabled 参数,表示不进行预消理。该阶段主要做两件事情(重复上个阶段操作),可被终止:

  1. 扫描处理DirtyCard中的对象
  2. 处理新生代引用到的老年代的对象

退出条件为:

  1. CMSMaxAbortablePrecleanTime参数控制的5秒退出
  2. Eden区达到CMSScheduleRemarkEdenPenetration参数配置的值(默认50%)
  3. CMSMaxAbortablePrecleanLoops控制的扫描次数(默认是0,不退出)

由于重新标记是独占CPU的,如果新生代GC发生后, 立即触发一次重新标记,那么一次停顿时间可能很长。而可中断预清理阶段最大持续时间为5秒,之所以可以持续5秒,另外一个原因也是为了期待这5秒内能够发生一次Young GC,并且预清理年轻代的引用(预清理是并发的),使得下个阶段的重新标记阶段,扫描年轻带指向老年代的引用的时间减少;从最大程度上避免新生代GC和重新标记重合, 尽可能减少重新标记停顿时间。

2.6.1.5 最终重新标记

最终标记又叫重新标记,这个阶段会导致第二次stop the word,该阶段的任务是完成标记整个年老代的所有的存活对象。重新标记的内存范围是整个堆,包含Young Gen和Old Gen。

为什么要扫描新生代呢,因为对于老年代中的对象,如果被新生代中的对象引用,那么就会被视为存活对象,即使新生代的对象已经不可达了,也会使用这些不可达的对象当做cms的“GC root”,来扫描老年代; 因此对于老年代来说,引用了老年代中对象的新生代的对象,也会被老年代视作“GC ROOTS”。

当此阶段耗时较长的时候,可以加入参数**-XX:+CMSScavengeBeforeRemark**,在重新标记之前,先执行一次Young GC,回收掉年轻带的对象无用的对象,并将对象放入幸存带或晋升到老年代,这样再进行年轻带扫描时,只需要扫描幸存区的对象即可,一般幸存带非常小,这大大减少了扫描时间。

由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻带的对象对老年代的引用已经发生了很多改变,这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled

2.6.1.6 并发清理

这个阶段主要是清除那些没有标记的对象并且回收空间。

由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为 “浮动垃圾”

2.6.1.6 并发重置

该阶段是最后一个阶段,重置CMS的数据的结构,以准备下一次GC。

2.6.2 使用CMS需要注意的几点

2.6.2.1 吞吐量低

由于CMS在GC过程用户线程和GC线程并行,从而有线程切换的额外开销,因此CPU吞吐量就不如在GC过程中停止一切用户线程的方式来的高

2.6.2.2 减少remark阶段停顿

一般CMS的GC耗时80%都在remark阶段,如果发现remark阶段停顿时间很长,可以尝试添加该参数:
-XX:+CMSScavengeBeforeRemark 在执行remark操作之前先做一次Young GC,目的在于减少年轻代对老年代的无效引用,降低remark时的开销。

2.6.2.3 内存碎片问题

CMS是基于标记-清除算法的,CMS只会删除无用对象,不会对内存做压缩,会造成内存碎片,空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前再触发一次Full GC,此处(JDK 8场景下)的Full GC会采用Serial GC,即串行GC,效率很低。

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

这时候我们需要用到这个参数:-XX:CMSFullGCsBeforeCompaction=n.意思是说在上一次CMS并发GC执行过后,到底还要再执行多少次Full GC才会做压缩。默认是0,这意味着每次Full GC(标记清除)后,都会压缩。 如果把CMSFullGCsBeforeCompaction配置为10,就会让上面说的第一个条件变成每隔10次真正的Full GC才做一次压缩。

2.6.2.3 concurrent mode failure

这个异常发生在cms正在回收的时候。执行CMS GC的过程中,同时业务线程也在运行,当年轻带空间满了,执行Young GC时,需要将存活的对象放入到老年代,而此时老年代空间不足,这时CMS还没有机会回收老年带产生的垃圾,或者在做Young GC的时候,新生代空间放不下,需要放入老年代,而老年代也放不下而产生的。

设置cms触发时机有两个参数:

  1. -XX:+UseCMSInitiatingOccupancyOnly
  2. -XX:CMSInitiatingOccupancyFraction=70

-XX:CMSInitiatingOccupancyFraction=70 是指设定CMS在对内存占用率达到70%的时候开始GC。。在JDK 1.5的默认设置下,CMS收集器默认为68%,在JDK 1.6中,CMS收集器的启动阈值已经提升至92%。

-XX:+UseCMSInitiatingOccupancyOnly如果不指定,只是用设定的回收阈值CMSInitiatingOccupancyFraction,则JVM仅在第一次使用设定值,后续则自动调整会导致上面的那个参数不起作用。

为什么要有这两个参数?

由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

CMS前五个阶段都是标记存活对象的,除了”初始标记”和”重新标记”阶段会SWT ,其它三个阶段都是与用户线程一起跑的,就会出现这样的情况GC线程正在标记存活对象,用户线程同时向老年代提升新的对象,清理工作还没有开始,Old Gen已经没有空间容纳更多对象了,这时候就会导致concurrent mode failure, 然后就会退化执行Full GC--使用串行收集器回收整个堆的垃圾,导致停顿的时间非常长。

注意CMSInitiatingOccupancyFraction参数要设置一个合理的值,设置大了,会增加concurrent mode failure发生的频率,设置的小了,又会增加CMS频率,所以要根据应用的运行情况来选取一个合理的值。如果发现这两个参数设置大了会导致Full GC,设置小了会导致频繁的CMS GC,说明你的老年代空间过小,应该增加老年代空间的大小了。

2.6.3 CMS失败处理

在运行CMS收集器的时候,可能会出现两种类型的失败:

2.6.3.1 并发模式失败(concurrent mode failure)

当老年代无法容纳新生代GC晋升的对象时发生并发模式失败,并发模式失败意味着CMS退化成完全STW的Full GC,也就是Serial GC。
处理(其实上面有讲):

  1. CMS根据内存的使用比例来决定是否启动CMS垃圾收集,可以调小这个比例. -XX:CMSInitiatingOccupancyFraction=N和-XX:+UseCMSInitiatingOccupancyOnly。如果同时设置了这两个参数就可以让CMS只根据老年代的使用比例来决定是否启动CMS垃圾收集。
  2. 更多的线程来运行CMS.之所以出现并发模式失败,是因为CMS的速度跑不赢对象晋升到老年代的速度了。可以通过-XX:ConGCThreads=N来设置后台线程的数量。默认情况下线程数ConcGCThreads=(3+ParallelGCThreads)/4,是根据ParallelGCThreads来计算的,

2.6.3.2 晋升失败(promoration failure)

老年代有足够的空间,但是由于碎片化严重,无法容纳新生代中晋升的对象,发生晋升失败。

晋升失败的原因是碎片化严重,所以这个问题的解决方案就是如何减少碎片化的问题。CMS提供了两个参数来对碎片进行整理和压缩: -XX:+UseCMSCompactAtFullCollection这个设置的作用是在进行FullGC的时候对碎片进行整理和压缩。-XX:CMSFullGCsBeforeCompaction=N这个参数是设置在进行多少次FullGC的时候对老年代的内存进行一次碎片整理压缩。通过设置这两个参数可以有效的对碎片问题进行优化。同样需要注意的是对碎片进行整理压缩是一个比较耗时的操作,所以也需要谨慎设置。

2.6.4 CMS相关参数

结合上面已经讲过的参数配置,下面给出CMS的一些参数说明:

-XX:+UseConcMarkSweepGC 激活CMS收集器,默认false

-XX:ConcGCThreads=N 设置CMS线程的数量

-XX:+UseCMSInitiatingOccupancyOnly 只根据老年代使用比例来决定是否进行CMS

-XX:CMSInitiatingOccupancyFraction 设置触发CMS老年代回收的内存使用率占比

-XX:+CMSParallelRemarkEnabled 并行运行最终标记阶段,加快最终标记的速度

-XX:+UseCMSCompactAtFullCollection 每次触发CMS Full GC的时候都整理一次碎片,默认false

-XX:CMSFullGCsBeforeCompaction=N经过几次 Full GC的时候整理一次碎片,默认0,即在默认配置下每次Full GC之后都会做压缩。

-XX:+CMSClassUnloadingEnabled 让CMS可以收集永久带,默认不会收集,收集之后会触发一次Full GC。

-XX:+CMSScavengeBeforeRemark 最终标记之前强制进行一个Minor GC,默认false。

2.7 G1收集器

G1垃圾收集器是在JDK1.7中正式使用的全新的垃圾收集器,oracle官方计划在JDK9中将G1变成默认的垃圾收集器,以替代CMS。G1特点如下:

  1. 和CMS收集器一样,GC操作与应用的线程一起并发执行,并且是多线程GC。
  2. G1收集器可以兼顾收集年轻代和老年代的垃圾。
  3. 采用分区算法,取消了传统的堆空间连续分代机制,而是将堆空间划分为一片片小的分区,但是每一个分区是一个分代。
  4. G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,在回收过程中,会进行适量的对象的移动,相比于CMS,有效的减少了内存碎片。
  5. 由于小分区的原因,每次GC时选择对部分分区进行回收,而不是像以前那样回收整个代,这样可以更加准确的控制GC停顿时间,以少量时间有优先回收垃圾最多的分区,在不牺牲吞吐量前提下,实现低停顿垃圾回收。

G1垃圾收集算法主要应用在多CPU、大内存的服务中,在满足低暂停时间同时,尽可能提高吞吐量,同时处理内存碎片。

2.7.1 G1收集器的堆内存GC模型

以往的垃圾收集器如CMS,使用的堆内存GC模型如下:
在这里插入图片描述

年轻代:Eden space + 2个Survivor

老年代:old space

永久代:1.8之前的perm space

元空间:1.8之后的metaspace

这些内存空间的特点就是必须是地址连续的空间。G1垃圾收集器相对比其他收集器而言,最大的区别在于它取消了年轻代、老年代的物理划分,取而代之的是将堆划分为若干个区域(Region),G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;这些区域中包含了有逻辑上的年轻代、老年代。某个区可能是Eden,可能是Survivor,也可能是Old。这样我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

堆内存中一个Region的大小可以通过-XX:G1HeapRegionSize参数指定,大小区间只能是1M、2M、4M、8M、16M和32M,总之是2的幂次方,如果G1HeapRegionSize为默认值,则在堆初始化时计算Region的实践大小,具体实现如下:

默认把堆内存按照2048份均分,最后得到一个合理的大小。

这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有CMS内存碎片问题的存在了。

G1收集器的堆内存GC模型如下:

在这里插入图片描述

每个Region被标记了E、S、O和H,说明每个Region在运行时都充当了一种角色。在G1中,还有一种特殊的区域,叫Humongous区域,表示Region存储的是巨型对象。

如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。

这些巨型对象,默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。

为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

2.7.2 G1收集器的工作过程

G1收集器的工作过程主要包括4个阶段:Young GC、并发标记周期、Mixed GC、Full GC。

2.7.2.1 Young GC

Young GC主要是对Eden区和Survivor区进行GC,它在Eden区空间耗尽时会被触发。年轻代的垃圾收集依然采用暂停所有应用线程的方式。

Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。

Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。

最终Eden空间的数据为空,Survivor区数据增多,Old区数据增多,GC停止工作,应用线程继续执行。

Yong GC前后对比图:

在这里插入图片描述

Young GC还负责维护对象年龄,存活对象经历过年轻代收集总次数等信息。G1将晋升对象的尺寸总和和它们的年龄信息维护到年龄表中,结合年龄表、survivor占比(-XX:TargetSurvivorRatio 缺省50%)、最大任期阈值(-XX:MaxTenuringThreshold 缺省为15)来计算出一个合适的任期阈值。

2.7.2.1.1 Remembered Set(已记忆集合)

在GC年轻代的对象时,我们如何找到年轻代中对象的根对象呢?根对象可能是在年轻代中,也可以在老年代中,那么老年代中的所有对象都是根么?

如果全量扫描老年代,那么这样扫描下来会耗费大量的时间。于是,G1引进了RSet的概念。它的全称是Remembered Set,其作用是跟踪指向某个堆内的对象引用。在这里插入图片描述

每个Region初始化时,会初始化一个RSet,该集合用来记录并跟踪其它Region指向该Region中对象的引用,每个Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是 XX Region的 XX Card。

2.7.2.2 并发标记周期

当老年代占比达到一定参数之后,G1首先会触发并发标记周期并发标记周期,该周期和CMS类似,都是为了降低一次停顿时间,并发标记之后之后进入Mixed GC阶段。并发周期分为六个阶段:

  1. 初始标记:收集所有GC Root对象,STW,这个过程会执行一次Yong GC。
  2. 根区域扫描:Yong GC之后Eden区被清空,该阶段则扫描由survivor区直接可达的老年代区域,并标记这些直接可达的对象引用。这个过程是可以和应用程序并发执行的。但是根区域扫描不能和新生代GC同时执行(因为根区域扫描依赖survivor区的对象, 而新生代GC会修改这个区域), 因此如果恰巧在此时需要进行新生代GC, GC就需要等待根区域扫描结束后才能进行, 如果发生这种情况, 这次新生代GC的时间就会延长。
  3. 并发标记:标记存活对象,这个过程是可以和应用程序并发执行的,并且可以被一次Yong GC打断。
  4. 重新标记:SWT,是最后一个标记阶段,时间很短。由于在并发标记过程中,应用程序依然在运行,因此标记结果可能需要进行修正, 所以在此对上一次的标记结果进行补充。
  5. 独占清除:SWT,它将计算各个区域的存活对象和GC回收比例并进行排序,识别可供混合回收的区域。在这个阶段,还会更新记忆集(RemeberedSet)。该阶段给出了需要被混合回收的区域并进行了标记, 在混合回收阶段, 需要这些信息。
  6. 井发清理阶段: 这里会识别并清理完全空闲的区域。它是并发的消理, 不会引起停顿。

并发标记周期前后对比图:

在这里插入图片描述

  1. Young区发生了变化、这意味着在G1并发阶段内至少发生了一次YGC(这点和CMS就有区别),Eden在标记之前已经被完全清空,因为在并发阶段应用线程同时在工作、所以可以看到Eden又有新的占用。
  2. 一些区域被G标记,但是这些区域仍然属于O区,此时仍然有数据存放、不同之处在G1已标记出这些区域包含的垃圾最多、也就是回收收益最高的区域。
  3. 在并发阶段完成之后实际上O区的容量变得更大了(O+G的方块)。这时因为这个过程中发生了Yong GC有新的对象进入所致。此外,这个阶段在O区没有回收任何对象:它的作用主要是标记出垃圾最多的区块出来。对象实际上是在后面的Mixed GC阶段真正开始被回收。

2.7.2.3 Mixed GC

接下来G1执行一系列的Mixed GC,也称混合GC。这个时期因为会同时进行Yong GC和清理上面已标记为G的老年代区域,所以称之为混合阶段,下面是一个混合GC执行的前后示意图:

在这里插入图片描述

Mixed GC会持续多次,直到回收了足够的内存,Mixed GC结束然后会单独触发一次Yong GC。此后依据老年代占比可能会进行并发标记,也可能不会。

在这里插入图片描述

Mixed GC什么时候触发? 由参数 -XX:InitiatingHeapOccupancyPercent=n 决定。默认:45%,该参数的意思是:当老年代大小占整个堆大小百分比达到该阀值时触发。

我们可以设置Mixed GC次数,-XX:G1MixedGCCountTarget:缺省值为8,意思是能启动混合收集的数目设定一个物理限制。G1根据将回收的老年分区除以该参数值得到每次混合收集的老年代CSet最小数量。

-XX:G1HeapWastePercent:缺省值为5%,每次混合收集暂停,G1算出废物百分比,根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。当暂停时间和运行时间呈现指数级增长,可以通过-XX:G1HeapWastePercent,调高该参数会有所帮助,但这也导致更多碎片化。

2.7.2.4 Full GC

如果分配巨型对象无法在老年代找到连续足够的分区,那么会触发一次Full GC。

如果对象内存分配速度过快,mixed GC空间不足或者Yong GC时,S区和O区都无法容纳幸存对象,同样会触发一次Full GC。

G1的Full GC算法和CMS一样,就是单线程执行的Serial GC,会导致长时间的SWT,因此需要进行不断的调优,尽可能的避免Full GC。

2.7.3 G1收集器其他相关参数

-XX:+UseG1GC:开启使用 G1 垃圾收集器

-XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标,默认值是 200 毫秒。如果任何一次停顿超过这个设置值时, Gl就会尝试调整新生代和老年代的比例、调整堆大小、调整晋升年龄等手段, 试图达到预设目标,但不是保证成功的。如果停顿时间缩短, 对于新生代来说, 这意味着很可能要增加新生代GC的次数, GC反而会变得更加频繁。对于老年代区域来说, 为了获得更短的停顿时间, 那么在混合GC收集时, 一次收集的区域数量也会变少, 这样无疑增加了进行Full GC的可能性。

-XX:G1HeapRegionSize=n:设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。默认是堆内存的1/2000。

-XX:ParallelGCThreads=n:设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。

-XX:ConcGCThreads=n:设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads)的 1/4 左右。

-XX:InitiatingHeapOccupancyPercent=n:设置触发并发标记周期的 Java 堆占用率阈值。默认值是45,表示占用率是整个 Java 堆的 45%时,将会触发并发标记周期。如果InitiatingHeapOccupancyPercent值设置偏大, 会导致并发周期迟迟得不到启动, 那么引起Full GC的可能性也大大增加, 反之,一个过小的InitiatingHeapOccupancyPercent 值, 会使得并发周期非常频繁, 大量GC线程抢占CPU, 会导致应用程序的性能有所下降。

相关文章:

  1. 《深入理解Java虚拟机》

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!