【JVM专题】垃圾收集器Serial&Parallel&ParNew&CMS&G1&ZGC与底层三色标记算法详解

前置知识

分代收集理论

当前虚拟机的垃圾收集大多都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。比如:
在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
(PS,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上)

课程内容

垃圾收集算法

垃圾收集算法,是指在回收堆内存中的垃圾时,打扫、整理内存的策略。当前我们比较常见的策略,有如下几种:
垃圾收集算法

标记-复制算法(复制算法)

为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。(典型的空间换时间)
标记复制算法

  • 优点:清除垃圾效率高,直接将整片空间清除;没有内存碎片
  • 缺点:内存利用率不高(因为总有一块内存是不用的,用来复制的时候做中间缓冲区的)
  • 典型应用:年轻代的GC,存活对象从eden区、survivor-from到survivor-to的时候

标记-清除算法

算法分为【标记】和【清除】阶段。

  1. 标记阶段:标记存活的对象(一般选择这种标记策略);
  2. 清除阶段:统一回收所有未被标记的对象。

当然也可以反过来,标记出所有需要回收的垃圾对象,在标记完成后统一回收所有被标记的对象 。它是最基础的收集算法,比较简单。
标记清除算法

  • 优点:内存利用率高,没有闲置内存
  • 缺点:效率低,特别是需要标记的对象比较多的时候;会产生内存碎片
  • 典型应用:老年代的GC(部分垃圾收集器)

标记-整理算法

根据老年代的特点特出的一种标记算法,标记过程仍然与【标记-清除】算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动(直接覆盖),然后直接清理掉端边界以外的内存。
标记整理算法

  • 优点:内存利用率高,没有闲置内存,没有内存碎片
  • 缺点:效率低,特别是需要标记的对象比较多的时候
  • 典型应用:老年代的GC(部分垃圾收集器)

*垃圾收集器

如果说垃圾收集算法是方法论,那么垃圾收集器就是实践成果了。我们先来看看,当代主流的垃圾收集器有哪些,如下图:
垃圾收集器

适用区域垃圾收集器
年轻代Serial  ParNew  Paralle Scavenge
老年代Serial Old   CMS   Parallel Old  
G1  

根据上图,大致地给大伙介绍一下,不同的垃圾收集器有什么特征,以及他们是如何运作的。

1. Serial收集器

  • 介绍:直译位【串行收集器】。【串行】二字,基本上我们可以窥见一斑了。它在工作时,是使用一个单线程执行垃圾回收,并且需要暂停其他所有线程的,一直到它收集结束为止。
  • 特点:简单粗暴(初代,能用就行,哈哈)
  • JVM控制参数:-XX:+UseSerialGC -XX:+UseSerialOldGC
  • 目前有两个版本【Serial】和【Serial Old】。他们的特点如下:
    • Serial:适用于年轻代,使用复制算法
    • Serial Old:适用于老年代,使用标记整理算法。它可以与其他多种垃圾回收器一起组合使用
  • GC的过程如下所示:
    在这里插入图片描述
    从上图,我们可以看出来,这个垃圾收集器优缺点都很明显。比如:
  • 优点:简单,垃圾回收也还算高效(相对部分垃圾收集器来说)
  • 缺点:单线程,垃圾回收相对多线程比较慢。单核下效率高,但是多核情况下就有点浪费CPU了。

2. Parallel Scanvenge收集器

  • 介绍:直译为【并行清除收集器】。可以看作是【Serial收集器】的多线程版本,所以他的各项行为跟Serial收集器类似。默认的收集线程数跟CPU核心数相等。当然可以通过JVM参数(-XX:ParallelGCThreads)修改,但是一般不推荐。
  • 特点:关注点是吞吐量(高效率的利用CPU)
  • JVM控制参数:-XX:+UseParallelGC -XX:+UseParallelOldGC
  • 目前有两个版本【Parallel Scavenge】和【Parallel Old】。他们的特点如下:
    • Parallel Scavenge:适用于年轻代,使用复制算法
    • Parallel Old:适用于老年代,使用标记整理算法
  • GC过程如下:
    ParallelGC过程

3. ParNew收集器

  • 介绍:跟前面介绍的Parallel收集器其实差不多,只不过关注点不一样。它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
  • 特点:跟CMS组合,关注点是用户体验(缩短STW时间)
  • JVM控制参数:-XX:+UseParNewGC
  • GC过程如下:
    ParNewGC过程

4. *CMS收集器

  • 介绍:CMS全称【并发标记清除(Concurrent Mark Sweep)收集器】。它是一种以缩短STW时间为目标的收集器,也是Hotspot第一款并发收集器(这里的并发指的是:用户线程与垃圾收集线程同时工作)。
  • 特点:关注点是用户体验(缩短STW时间);真并发
  • JVM控制参数:-XX:+UseConcMarkSweepGC
  • GC过程如下:
    CMS过程

上面这个过程略显复杂,整个过程分为5个步骤:(PS:这里说的【标记】,是指将一个对象【标记为可达对象】的过程,【可达】即:正常、非垃圾对象)

  1. 初始标记。STW片刻时间,然后首先标记,并且只标记所有GCRoot。所以STW时间比较短
  2. 并发标记。从初始标记的GCRoot出发,开始遍历所有对象。过程中,不会STW,用户线程与GC线程一同工作,所以这个过程会比较复杂,并且因为并发,天然会存在【多标,把垃圾对象标为正常对象】【漏标,把正常对象标记为垃圾对象】的问题(可能我之前已经标记过的对象随着用户线程的进行,状态发生了改变)。
  3. 重新标记。既然上面【并发标记】存在问题,那肯定要解决啊,不然那不成BUG了。所以这里就是用来修正【并发标记】期间产生的多标、漏标问题(主要是漏标。多标顶多是成为浮动垃圾,漏标那直接是BUG)。这个阶段的STW时间会比【初始标记】要长,但是远比【并发标记】时间短。主要用到了三色标记法里的增量更新算法
  4. 并发清理:GC线程开始对标记后的对象做清理,同时用户线程也在运行。这个阶段如果有新增对象都会先标记为黑色,无论是否为垃圾对象(这个阶段产生的垃圾对象,叫做浮动垃圾),都不会做任何处理了(后面介绍三色标记法的时候会讲到)
  5. 并发重置。这个阶段只是把之前标记了颜色的对象重置而已,方便下次处理

从上面的GC过程,我们可以看得出来,这也是一款优缺点比较明显的垃圾收集器,比如:

  • 优点:将STW时间分到不同阶段,用户无感知;并发收集,效率高
  • 缺点:
    • 对CPU敏感(所有多线程垃圾收集器的缺点)
    • 无法处理浮动垃圾
    • 【标记-清除】算法产生的内存碎片
    • 重点,使用CMS最需要避免的】执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收

要想用好CMS垃圾收集器,我们还需要知道它其他的一些JVM参数设置,这样我们才能在实战中调整。附上CMS相关核心参数:

  1. -XX:+UseConcMarkSweepGC:启用cms
  2. -XX:ConcGCThreads:并发的GC线程数
  3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
  4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
  5. -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
  6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
  7. -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在标记阶段
  8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
  9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

5.G1 收集器

介绍

全称Garbage-First,是一款面向服务器的垃圾收集器,主要针对多核大内存机器。它以极高的概率满足了STW自定义时间的要求,同时还具备高吞吐量的性能特征。
与我们前面看到的垃圾收集器不一样,G1(或许往后发展的其他GC收集器)都不再存在分代收集了。但是G1的“没有分代”,是指“没有物理分代了”,其实仍然存在逻辑上的分代概念。
以往的收集器,不同的分代会占用一块连续的内存空间,即代与代之间存在明显的物理隔阂。而G1的内存模型,将会是如下图所示:
G1内存模型

G1的内存设计
区域逻辑分代

从上图我们可以发现,G1将Java堆划分为多个大小相等的独立区域(Region),每个区域有5种逻辑状态。分别是:eden区、survivor区、old区,以及在G1中新增的Humongous区,最后是空闲区域。一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比。在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。

区域个数及大小

这样的区域,在JVM最多可以有2048个。一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。

G1对大对象的处理

G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放
Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收

GC过程

G1收集过程
G1收集器一次GC的运作过程大致分为以下几个步骤:

  • 初始标记:暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快;
  • 并发标记:同CMS的并发标记
  • 最终标记:同CMS的重新标记
  • 筛选回收:G1的筛选回收阶段比较复杂,也是实现STW时间尽量可控的核心。下面将从原理跟过程上,给大家介绍一下。
    • 筛选回收的原理:G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
    • 筛选回收过程:G1在真正回收之前,会对各个Region进行一次回收成本分析,然后从回收价值和成本上对各个Region做排序。接着,才会根据用户预期的STW时间制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。 另外,从图片中我们也可以看到,这个阶段是仅有垃圾线程运行的,但也不是不可以与用户线程一起,但是为了满足STW可控,所以停顿了用户线程提升效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)
特点

被视为JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短STW停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。但是这个概念是逻辑上的,不具备物理隔阂
  • 空间整合:与CMS的【标记清理】算法不同,G1从整体来看是基于【标记整理】算法实现的收集器;从局部上来看是基于【复制】算法实现的
  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"-XX:MaxGCPauseMillis"指定)内完成垃圾收集。

大家还记的我在最开始形容G1的【STW可控】说到的一个名词吗?——极高概率。毫无疑问, 可以由用户指定期望的停顿时间是G1收集器很强大的一个功能, 设置不同的期望停顿时间, 可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。但是, 这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒, 一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为100ms-300ms会是比较合理的

G1垃圾收集分类

通过上面,我们知道,G1垃圾收集器是作用在整个堆的,但是依然存在分代概念,所以,G1的垃圾收集种类,也分了三种,如下:

  • YoungGC:不同于之前学习的YoungGC,G1的YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做YoungGC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC
  • MixedGC:这个MixedGC不是FullGC。它只有在老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值时才触发,回收所有的年轻代和部分老年代(根据期望的GC停顿时间确定old区垃圾收集的优先顺序),以及大对象区。正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
  • FullGC:停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的(Shenandoah优化成多线程收集了)
JVM参数

-XX:+UseG1GC:使用G1收集器
-XX:ParallelGCThreads:指定GC工作的线程数量
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)
-XX:G1MaxNewSizePercent:新生代内存最大空间
-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
-XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大
-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长
-XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立
即停止混合回收,意味着本次混合回收就结束了

G1垃圾收集器优化建议

假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor
区域的50%,也会快速导致一些对象进入老年代中。所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑
每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc.

什么场景适合使用G1
  • 50%以上的堆被存活对象占用
  • 对象分配和晋升的速度变化非常大
  • 垃圾回收时间特别长,超过1秒
  • 8GB以上的堆内存(建议值)
  • 停顿时间是500ms以内

为什么这样的场景适合G1呢?我们可以看看下面的例子(典型应用:Kafka)

每秒几十万并发的系统如何优化JVM(用例子解释G1使用场景)

Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题

6.ZGC 收集器(拓展篇)

介绍

Z Garbage Collector,也称为ZGC,据说在 jdk 11 中引入的一种可扩展的低延迟垃圾收集器,并且在 jdk 15 中发布稳定版。在旨在满足以下目标:
在这里插入图片描述

  • 支持T量级别的内存(除了听起来很牛逼以外,还很贵啊!)
  • 最大STW时间不超过10ms(这个是真的有点牛逼的感觉了)。并且,它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下
  • 奠定未来GC特性基础
  • 最糟糕的情况下吞吐量会降低15%
ZGC内存布局

ZGC收集器是一款基于Region内存布局的, 暂时不设分代的, 使用了读屏障、 颜色指针等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器。布局如下图所示:
在这里插入图片描述
如上图所示,ZGC的垃圾收集器沿用了G1 Region的概念,但是这里丢掉了逻辑分代概念,随之换成大、 中、 小三类容量:

  • 小型Region(Small Region): 容量固定为2MB, 用于放置小于256KB的小对象
  • 中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象
  • 大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段, 稍后会介绍到)的, 因为复制一个大对象的代价非常高昂
ZGC GC过程

ZGC的运作过程大致可划分为以下四个大的阶段:(深色箭头代表:GC线程;浅色为用户线程)
在这里插入图片描述

  • 并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新染色指针中的Marked 0、 Marked 1标志位
  • 并发预备重分配:这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本
  • 并发重分配:重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存
    活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障)所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力
  • 并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但
    是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了

ZGC最大的问题是浮动垃圾。ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。
目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。

ZGC具有以下特性:

  • 不分代
  • 并发
  • 基于 region 压缩
  • NUMA 感知
  • 使用颜色指针
  • 使用负载屏障

ZGC 的核心是一个并发垃圾收集器,这意味着所有繁重的工作都在Java 线程继续执行的同时完成。这极大地限制了垃圾收集对应用程序响应时间的影响。
另外ZGC【目前】没有分代的概念。我们知道以前的垃圾回收器之所以分代,是因为源于大部分对象朝生夕死的假设,事实上大部分系统的对象分配行为也确实符合这个假设。那么为什么ZGC就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单可用的单代版本,后续会优化(这是JDK11刚出来的事后,现在JDK15已经提出稳定版了,似乎还是没有分代概念)

NUMA-aware

首先先给大家介绍一下,什么是NUMA。
NUMA 即 None Uniform Memory Access Architecture,与之对应的叫做UMA,即 Uniform Memory Access Architecture。UMA 表示内存只有一块,所有的CPU都要去访问这些内存。这会有什么问题呢?当然是典型的多核竞争问题(竞争内存总线访问权)。有竞争就要去加锁,加锁效率就会受到影响,而且 CPU 核心数越多,竞争就越激烈。于是NUMA为了解决这个问题,给每个CPU逻辑对应一个内存块,且这块内存在主板上离这个CPU是最近的,让这个CPU优先访问这块内存,以此提升了效率(偏向锁思想)。
NUMA的内存逻辑如下:
在这里插入图片描述
服务器的NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC是能自动感知NUMA架构并充分利用NUMA架构特性的(你想象一下,在ZGC巨量T级别的内存背景里,将会有多大的并发量去竞争内存资源,如果在这里上锁,效率影响会多大?所以NUMA感知能力,这边的尤其有效率)

颜色指针

Colored Pointers,即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在被引用对象的对象头中,而ZGC的GC信息保存在引用对象类型指针中(即:直接把标记信息记录在对象的引用指针上。这是一个重要的区别,被引用对象和引用对象)
在这里插入图片描述
每个引用对象有一个64位指针,这64位被分为:

  • 18位:预留给以后使用;
  • 1位:Finalizable标识,此位与并发引用处理有关,它表示是否只能通过finalize()方法才能被访问到,其他途径不行;
  • 1位:Remapped标识,表示是否进入了重分配集(即被移动过)
  • 1位:Marked1标识;
  • 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
  • 42位:对象的地址(所以它可以支持2^42=4T内存):

为什么会设置2个mark标记呢?
每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。
GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。
通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)
那么颜色指针有什么优势呢?有3个:

  1. 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集
  2. 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障
  3. 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能
读屏障

ZGC采用的读屏障的方式来修正指针引用,由于ZGC采用的是复制整理的方式进行GC,很有可能在对象的位置改变之后指针位置尚未更新时程序调用了该对象,那么此时在程序需要并行的获取该对象的引用时,ZGC就会对该对象的指针进行读取,判断Remapped标识,如果标识为该对象位于本次需要清理的region区中,该对象则会有内存地址变化,会在指针中将新的引用地址替换原有对象的引用地址,然后再进行返回。

如此,使用读屏障便解决了并发GC的对象读取问题。
在这里插入图片描述
❝ 这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新读取。而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。❞
后面3行代码都不需要加读屏障:Object p = o这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销:
在这里插入图片描述
那么,判断对象是Bad Color还是Good Color的依据是什么呢?就是根据上一段提到的Colored Pointers的4个颜色位,看GC状态。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。更多的详细信息,需要自行去百度了
PS:既然低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到支持更大内存的目的呢?答案肯定是不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T。

ZGC的JVM参数设计

启用ZGC比较简单,设置JVM参数即可:-XX:+UnlockExperimentalVMOptions 「-XX:+UseZGC」。调优也并不难,因为ZGC调优参数并不多,远不像CMS那么复杂。它和G1一样,可以调优的参数都比较少,大部分工作JVM能很好的自动完成。下图所示是ZGC可以调优的参数:
在这里插入图片描述

ZGC触发时机

ZGC目前有4中机制触发GC:

  • 定时触发,默认为不使用,可通过ZCollectionInterval参数配置,GC 日志中的关键字 “Timer”。
  • 预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要统计GC时间,为其他GC机制使用,GC日志关键字 “Warmup”。
  • 分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC,GC日志关键字 “Allocation Rate”。
  • 主动触发,(默认开启,可通过ZProactive参数配置) 距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟一次GC的最大持续时间,超过则触发
  • 元数据分配触发:元数据区不足导致,GC 日志关键中是 “Metadata GC Threshold”
  • 直接触发:代码中显示调用 System.gc() 触发,GC 日志关键字是 “System.gc()”
  • 阻塞内存分配请求触发:垃圾对象来不及挥手,占满整个堆空间,导致部分线程阻塞,GC 日志关键字是 “Allocation Stall”

垃圾收集器底层算法实现

三色标记法(并非CMS独有)

介绍:由于在并发标记期间,用户线程并未停止而是继续在运行,所以,很有可能前面已经标记过的对象后面又发生了改变,接着【多标】、【漏标】现象随之发生。为了解决这个问题,这里引入了【三色标记法】。【三色标记法】把GCRoot可达性分析遍历过程中遇到的对象,按照“是否访问过”这个条件将对象标记为3种颜色:

  • 白色:表示对象未被访问,或者是垃圾对象。在可达性分析刚开始的阶段,所有对象都会预先被标记为白色,如果标记完后某个对象还是白色,显然就是垃圾对象
  • 黑色:表示对象已经被可达性分析遍历过了,并且该对象所有引用也都已经扫描过(是扫描,并非这里说的“标记”),它是安全存活的
  • 灰色:表示对象已经被可达性分析遍历过了,但是该对象的引用没有,或者没有被扫描完全

下面我们用个代码块,以及按照三色标记分析代码的标记流程(再次重申,这里说的【标记】,是指将一个对象【标记为可达对象】的过程,【可达】即:正常、非垃圾对象)

public class ThreeColorRemark {
	public static void main_1(String[] args) {
		A a = new A();
		B b = new B();
	
	
		// 假设在即将开始“重新标记”之前,代码执行到了这里	
		a.c.d = b.d;
		b.d = null;
	}
	
	public static void main_2(String[] args) {
		A a = new A();
		B b = new B();
	
		// 假设在即将开始“并发标记”之前,代码执行到了这里
		a.c.d = b.d;
		b.d = null;
		
		// 即将进入“重新标记”
		...
		...
		...
	}

	class A {
		C c = new C();
	}
	
	class B {
		C c = new C();
		D d = new D();
	}

	class C {
		D d = null;
	}
	class D {
	}
}

下面这张图,是对应的main_1方法的GC过程(注意main_1的条件,只是假设即将开始【重新标记】之前,代码也开始运行到a.c.d = b.d;了 )
在这里插入图片描述

接下来放第二张图,对应的main_2方法的GC过程(注意main_2的条件,只是假设即将开始【并发标记】之前,代码也开始运行到a.c.d = b.d;了)
在这里插入图片描述

看到了没,由于代码在进入“并发标记”之前,已经扫描过C了,并且确认了C并无引用,所以置黑了,到这里没毛病。但是后面没想到B切断了对D的引用,继续遍历B的话,会误以为没有其他可引用对象,接着就置黑了B,但是我们上帝视角看得很清楚,D还没有被扫描啊,完犊子了…(这里就是传说中的【漏标】)

漏标与读写屏障

漏标,会导致被引用的对象,误认为是垃圾对象处理掉了,这是一个严重的BUG,所以,针对这个问题,有两种解决方案。

  • 方案一:增量更新(Increamental Update)。当黑色对象插入一个新的,指向白色对象的引用时,将这个引用记录下来。等到【并发标记】结束之后,在【重新标记】阶段,以这些记录为线索,重新标记一遍。可以理解为,黑色对象插入了对白色对象的引用后,变成了灰色。当然,为了避免跟【并发标记】一样出现问题,这里肯定要STW的
  • 方案二:原始快照(SATB,SnaptShot At The Beginning)。这里侧重点是【原始,旧的】,【快照,记录下来】。当灰色对象删除指向白色对象的引用时,将这个【旧的】删除记录,【记】下来。等到【并发标记】结束之后,在【重新标记】阶段,以这些记录为线索,将被删除的引用直接标记为黑色(跟【增量更新】策略不同,这里直接标记为黑色,目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)

这两个方案,虽然侧重点不同,但是想要实现都需要通过写屏障来实现。

写屏障

写屏障这个东西也没有那么神奇,实现方式就跟AOP里面的环绕通知一样(@Around)。如果你熟悉代理模式的话那就更好理解了
给某个对象的成员变量赋值时,其底层代码大概长这样:

/**
 * @param field 某对象的成员变量
 * @param new_value 新值
 */
void oop_field_store(oop* field, oop new_value) {
	*field = new_value; // 赋值操作

所谓的写屏障,其实就是指在赋值操作前后,加入一些处理:

void oop_field_store(oop* field, oop new_value) {
	pre_write_barrier(field); // 写屏障‐写前操作
	*field = new_value;
	post_write_barrier(field, value); // 写屏障‐写后操作

所以,用写屏障实现【SATB,原始快照】的原理如下,当对象B的成员变量的引用发生变化时,比如引用消失(b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:

// 写屏障-写前操作
void pre_write_barrier(oop* field) {
	oop old_value = *field; // 获取旧值
	remark_set.add(old_value); // 记录原来的引用对象
}

而用写屏障实现【增量更新,IU】的原理则如下,当对象A的成员变量的引用发生变化时,比如新增引用(a.c.d = d),我们可以利用写屏障,将C新的成员变量引用对象D记录下来:

// 写屏障-写后操作
void post_write_barrier(oop* field, oop new_value) {
	remark_set.add(new_value); // 记录新引用的对象
}
读屏障

这里顺便提一嘴读屏障。也是没什么神奇的,直接上代码:

/**
 * @param field 某对象的成员变量
 */
oop oop_field_load(oop* field) {
	pre_load_barrier(field); // 读屏障‐读取前操作
	return *field;
}

读屏障是直接针对第一步:a.c.d = d,当读取成员变量时,一律记录下来:

void pre_load_barrier(oop* field) {
	oop old_value = *field;
	remark_set.add(old_value); // 记录读取到的对象
}

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新
  • G1,Shenandoah:写屏障 + SATB
  • ZGC:读屏障

工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。初次之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

安全点与安全区域

安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。
这些特定的安全点位置主要有以下几种:

  1. 方法返回之前
  2. 调用某个方法之后
  3. 抛出异常的位置
  4. 循环的末尾

大体实现思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的。

安全区域又是什么?Safe Point 是对正在执行的线程设定的。如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。因此 JVM 引入了 Safe Region。Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。

问题

Q1:为什么G1用SATB?CMS用增量更新?
答:首先增量更新是重新扫描一遍有增量对象的黑色对象;而SATB简单来说就是把被删除对象直接置为黑色对象,尽管可能产生浮动垃圾。从上面的阐述不难看出,SATB其实会高效很多,毕竟不需要重新扫描嘛。
然后,我们再看看G1跟CMS的特点。如果使用增量更新,那就需要重新扫描。G1因为将内存划分成很多个region,很多对象都位于不同的region,而CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高。所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描

学习总结

选择垃圾收集器的原则:

  1. 优先调整堆的大小让服务器自己来选择
  2. 如果内存小于100M,使用串行收集器
  3. 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
  4. 如果允许停顿时间超过1秒,选择并行或者JVM自己选
  5. 如果响应时间最重要,并且不能超过1秒,使用并发收集器
  6. 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC

横向对比图:

垃圾收集器线程适用区域垃圾收集算法特点适用场景
Serial单线程新生代复制算法简单粗暴适用于单核环境
Serial Old单线程老年代标记整理算法简单粗暴,可以与Parallel Scavenge和CMS搭配使用适用于单核环境
Parallel Scavenge多线程新生代复制算法多线程,注重吞吐量适用于后台运算而不需要太多交互的场景
Parallel Old多线程老年代标记整理算法多线程,注重吞吐量适用于后台运算而不需要太多交互的场景
ParNew多线程新生代复制算法多线程,注重用户体验,缩短STW时间多CPU环境Server模式下与CMS配合使用
CMS多线程老年代标记清除算法多线程,注重用户体验,缩短STW时间适用于互联网或B/S业务使用
C1多线程整堆标记复制算法STW时间可控,吞吐量可观针对配备多颗处理器及大容量内存的机器
  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

验证码有毒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值