文章目录

  • 写在前面
  • 垃圾回收(GC)的概念
  • 垃圾回收判断
    • 垃圾回收区域
    • 对象是否可以被回收
      • 垃圾回收搜索根 GC Roots
      • 安全点 Safepoint
    • 垃圾回收的时刻
  • 垃圾回收算法
    • 标记—清除算法 Mark and Sweep
    • 标记—复制算法 Mark and Copy
    • 标记—整理算法 Mark and Compact
  • 垃圾回收器
    • 分代垃圾回收策略
      • 新生代(Young generation)
      • 老年代(Old generation)
      • 方法区
      • 直接内存
      • 跨代引用问题
    • 垃圾回收器分类
    • 串行垃圾收集器
      • Serial GC
    • 并行垃圾回收器
      • 三色标记法
        • 标记流程
        • 三色标记存在问题
          • 多标:浮动垃圾
          • 漏标,或者叫错杀
      • ParNew 收集器
      • Parallel Scavenge 收集器
    • CMS 收集器
      • 工作原理
      • CMS的优点
      • CMS的缺点
    • G1 收集器
      • 工作原理
        • 可调节参数
        • 变更的分带模型
        • 可预测停顿回收模型
      • G1的垃圾回收模式
        • 年轻代回收
        • 混合回收 收集过程
      • G1相比CMS的优势
    • ZGC 收集器
      • G1的回收时停顿
      • ZGC的标记—整理算法
      • ZGC完全并发原理
        • 着色指针 Color Pointer
        • 内存视图 View
        • 读屏障 Load Barrier
        • 总结
      • ZGC 优点
      • ZGC 缺点
    • Shenandoah 收集器
      • Shenandoah对G1的改进
      • 连接矩阵
      • Shenandoah的收集流程
      • 转发指针 Brooks Pointer
      • Shenandoah 收集器总结
    • C4 收集器
      • C4收集器特性
      • C4连续并发原理
        • C4 回收标记阶段
        • C4算法中的重定位
        • C4算法中的重映射
      • C4的分代
  • 参考文章

写在前面

写这篇文章是为了用简明易懂的写法,尽可能的在较短的篇幅内写出对Java内存垃圾回收策略的理解。解析Java内存垃圾回收策略,算法的文章很多,有些讲的还很深入。但是对平时不常接触Java虚拟机和垃圾回收(Garbage Collection 简称GC)策略的人来说,有些过于晦涩了,概念很多,层次复杂,既不方便理解,也难以记忆。作者也有这方面难题,对Java内存垃圾回收策略有一定了解,但在面试等场合很难有条理的讲解清楚,因此用此文以简洁的写法予以呈现。目标就是读完此文后,对垃圾回收策略(GC策略)有个简明的,全局的,有序的理解,对面试时一些八股文的问题可以有条理的解答。

垃圾回收(GC)的概念

Java ,C# ,Go这类带有内存垃圾回收能力的语言,不需要程序员再手动管理对象生命周期。要有的时候直接新建对象即可,对象的销毁,内存的释放都无需关注,垃圾回收器自然会帮助识别垃圾对象和管理内存。这大大方便了程序开发,减少了开发者的心智负担。不必如C++一般把太多的精力放在程序细节,而是可以更加专注于要实现的目标。
但是Java虚拟机的垃圾回收器并不是万能的,使用不当仍然会造成内存溢出,内存泄漏等问题。因此,还是有必要了解垃圾回收机制。

Java 的垃圾回收器并不是特指一种,Java官方本身就提供了很多个GC回收器供用户选择,随着现代Java的发展,越来越多回收算法也被加入Java主线。还有各个Java虚拟机厂商(例如 Azul 的PCG、C4,RedHat的Shenandoah GC)也自己设计开发了很多优秀的垃圾回收器。

Stop The World是指在垃圾回收过程中,程序停止响应的状态。当 Stop The World 发生时,除垃圾回收所需的线程外,所有的线程都进入等待状态,所有Java代码停止,native代码可以执行,但不能与JVM交互,一切停止直到内存垃圾回收任务完成。显然,Stop The World时程序停止响应对使用方来说是很大的困扰,尤其是时间敏感性应用。由于垃圾回收原理的限制,就算是不同的垃圾回收算法,仍然会导致程序停止响应。所以,每一代的Java垃圾回收器,都把缩减 Stop The World 停顿时间作为很重要的目标。

由于不同虚拟机的实现细节不一样,这里主要讨论的还是Oracle HotSpot虚拟机

垃圾回收判断

垃圾回收区域

了解Java内存结构的人都知道,Java虚拟机分为三大部分,类加载系统,运行时数据区,执行引擎。垃圾回收并不会在每个部分上都发生作用。
Java虚拟机的垃圾回收 主要发生在运行时数据区的Java对象堆区和方法区
有一些文章说垃圾回收只会发生在以上区域,其实并不是的。

直接内存区域(Direct Memory) 的内存,也是可以被垃圾回收器回收的,是需要注意的是直接内存仅能在Full GC时被回收。

栈区的数据,基本类型数据,或者被内联展开的局部变量,在超出作用域后会自动出栈释放掉,所以其不在JVM GC的管理范围内。

对象是否可以被回收

在GC执行垃圾回收之前,首先需要区分出内存中那些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在垃圾回收过程时,释放掉其所占用的内存空间。
那么如何判断对象是否可以被回收,或者说判断对象的生命周期是否结束?

当一个对象已经不再被任何存活的对象引用时,就可以宣判为死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。

  • 引用计数法
    引用计数法(Reference Counting)比较简单,对每一个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
    对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象的引用计数器的值为0,即表示对象A不能在被使用,可进行回收。

    • 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。Python就支持使用引用计数的垃圾回收法
    • 缺点:(1)他需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
      (2)每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
      (3)引用计数器还有一个严重的问题,即无法处理循环引用的问题,这是一条致命的缺陷,导致在Java回收的垃圾回收器中没有使用这类算法。
  • 可达性分析法(根搜索算法)
    Java,C# ,Go都是使用可达性分析算法来判断对象是否存活的,这个算法也可以称之为根搜索算法。

这个算法的基本原理是通过一系列可被作为 GC Roots 的根对象来作为起始节点,从这些节点开始,根据引用关系向下搜索,搜索过程的就是一条引用链(Reference Chain),没有在这个链条上面的对象,也就是根节点通过引用链不可达到这个对象时,就认为这个对象是可以被回收的。

垃圾回收搜索根 GC Roots

上面说了,判断对象是否存活,需要从垃圾回收搜索根 GC Roots开始搜索,遍历全部可达对象。
那么哪些对象可以作为GC根节点呢?这里很多文章列举许多可以作为搜索根节点的对象,但是很散乱,云里雾里的,看不出什么规律。

其实,这块知道了原理,是比较好理解的。判断对象是否存活,还要从根开始搜索。那么自然是程序运行时不会被销毁的对象,和当前程序运行时刻明确已知存活的对象,作为搜索根最合适啊。总之,GC Roots 就是一组必须活跃的引用。

  • 程序运行时不会被销毁的对象
    比如方法区中的类元信息,类文件常量池表,运行时常量池。本地方法栈中的JNI引用的对象,JVM内部支撑运行的Java对象,例如基本数据类型的Class对象,一些常驻的异常对象(NullPointExcepiton),系统类加载器,JNI调用的接口等。这些都是JVM启动时被加载到虚拟机内部的,并且在程序全过程中不会被销毁的对象,自然可以作为GC Roots使用。

  • 当前程序运行时刻明确已知存活的对象
    当前程序运行的这一时刻,CPU正在执行的所有Java线程的虚拟机栈帧中的对象自然是存活的,那么此栈帧中引用的对象,例如线程调用方法时,使用或产生的参数、局部变量、临时变量等都可以作为GC Roots使用。当然还有所有synchronized同步锁的持有对象。换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。

安全点 Safepoint

JVM并不能在程序运行到任意位置都可以开启垃圾回收,只有在那些被标记为安全点的位置,JVM才能开始内存垃圾回收。
安全点的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。
安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
程序运行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。
对于安全点,另一个需要考虑的问题就是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。

这个对这个问题,有两种解决方案:

抢先式中断(Preemptive Suspension)
抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。

主动式中断(Voluntary Suspension)
主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域 Safe Region
指在一段代码片段中,引用关系不会发生变化。在这个区域中任意地方开始GC都是安全的。也可以把Safe Region看作是被扩展了的Safepoint。

垃圾回收的时刻

当程序创建一个新的对象或者基本类型的数据,内存空间不足时,会触发GC的执行。

不同的垃圾回收器,会有不同的回收策略,但大致可以分为两类:分代回收和局部回收两种策略。
垃圾回收策略不等于垃圾回收算法,正因为不同的回收算法有各自的缺陷,所以才会使用不同的垃圾回收策略。

垃圾回收算法

垃圾回收算法,主流的都是基于先标记垃圾对象,然后进行处理的方式。

标记—清除算法 Mark and Sweep

这个算法和它的名字一样,分两个步骤:标记 和 清除。首先标记出所有存活的对象,再扫描整个空间中未被标记的对象直接回收。

内存中的对象构成一棵树。开始垃圾回收时,第一:标记,标记从树根可达的对象(图中水红色),第二:清除(清除不可达的对象)。标记清除的时候程序会停止运行,如果不停止,此时如果存在新产生的对象,这个对象是树根可达的,但是没有被标记(标记已经完成了),会产生漏标记。


标记 - 清除算法一次回收过后,可以看到完整的内存区域产生了大量空洞。这时由于回收后没有进行整理的操作,所以会存在内存空间碎片化的问题,这个确实是缺点,但也是这个算法的特点,正因为它不进行整理,所以效率才高。
另一个问题就是如果要创建一个较大的对象,长度超过每个空洞的,则无法创建。此时虽然总的剩余内存空间大小足够,但由于不能连续分配,导致大对象无法创建。

标记—复制算法 Mark and Copy


把内存分成两块相等区域:空闲区域和活动区域,每次只使用其中的一块,称为活动区域。进行垃圾回收时,第一还是标记可达对象,标记之后把可达的对象复制到空闲区,然后将空闲区变成活动区。同时把以前活动区对象不可达的内存垃圾对象清除掉,变成空闲区,以备下次交换。

这种算法的优点是效率高,因为是整块内存进行清除的,同时复制到空闲区域的对象是在内存中连续分布的,不会有空穴,所有内存不会有碎片化的问题。缺点就是耗费空间,毕竟有一块相等大小的空间不能使用了。
还有个问题就是可能存在劣化,假定活动区域全部是活动对象,这个时候进行交换的时候就相当于多占用了一倍空间,但是没有回收到内存空间,耗费了一次清理时间效果却非常差。

标记—整理算法 Mark and Compact

标记-清除算法会产生内存碎片,可能导致在内存足够的情况下不能分配大对象。而标记-整理算法,就是在其基础之上,增加了整理这个操作,去解决这些内存空间碎片化的问题。
标记—整理算法就是优化了的标记—清除算法。

清理过程和标记-清除算法一样,先标记,但清除之前,会先进行内存碎片整理。把所有存活的对象往内存空间的头部移动,然后清理掉存活对象边界以外的内存,即完成了清除的操作。标记-整理算法是在 标记-清除 算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题。
标记—整理算法和标记-复制算法的,应该是是否存在内存中划分两块相等区域,用于对象互相复制。

垃圾回收器

分代垃圾回收策略

上面介绍的三种垃圾回收算法,都有各自的缺陷。那么有没有办法扬长避短呢?有的,现代Java的垃圾回收器都是采用分代回收策略,组合使用不同的算法,以期实现最优的垃圾回收机制。

大多数的商业虚拟机,都采用分代回收的理论来设计垃圾收集器,这个理论建立在两个分代假说上:

弱分代假说:绝大多数对象都是朝生暮死的。
强分代假说:熬过越多次的垃圾回收的对象,就越难消亡

既然绝大多数对象都熬不过几次垃圾回收,而熬过多次回收的对象又很难消亡,那么可以根据对象的年龄把它们划分到不同的区域,例如新生代区域和老年代区域,然后分而治之。

例如新生代,绝大多数对象都是朝生夕死的,每次触发GC,这个区域里大部分对象都会被回收,使用可达性分析法,从根节点顺着引用链遍历下去,只有在这个引用链上的才是存活的,假设本次触发GC,这个区域里90%的对象都要被回收,但实际上只需要操作引用链上10%的对象就可以了。

对于熬过很多次依然存活的对象,这种对象一般很难被回收了,这样的情况下,每次GC都对他们进行搜索标记,太浪费资源。把它们放到老年代区,这样JVM就能以较少的频率来回收这个区域,假如老年代的空间占比是60%,在不触发老年代回收的情况下,只需要对占比40%内存空间的新生代进行搜索和释放,效率提升还是很明显的!

各区域触发垃圾回收的类型:
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

  • Partial GC:部分区域GC,并不收集整个GC堆的模式

    • Young GC:只收集young gen的GC
    • Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
    • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式
  • Full GC:收集整个堆,包括年轻代,老年代(永久带如果存在的话)元空间等所有部分的模式。

有说法,将Minor GC等同于只回收新生代区域youngGC,Major GC等同于只回收老年代区域的oldGC。

并不是这样的,Minor GC和Major GC是指在Hotspot JVM实现的Serial GC, Parallel GC, CMS, G1 GC收集器收集过程中的,大致可以对应到某个Young GC和Old GC算法组合的阶段。所以Minor GC和Major GC并不是Young GC,Old GC对应的。MajorGC通常和FullGC等价,并不一定等于Old GC。

就Azul的Pauless到C4的发展历程来看,选择实现分代的最大好处是,GC能够应付的应用内存分配速率(allocation rate)可以得到巨大的提升。并发GC根本上要跟应用玩追赶游戏:应用一边在分配,GC一边在收集,如果GC收集的速度能跟得上应用分配的速度,那就一切都很完美;一旦GC开始跟不上了,垃圾就会渐渐堆积起来,最终到可用空间彻底耗尽的时候,应用的分配请求就只能暂时等一等了,等GC追赶上来。所以,对于一个并发GC来说,能够尽快回收出越多空间,就能够应付越高的应用内存分配速率,从而更好地保持GC以完美的并发模式工作。虽然并不是所有应用中的对象生命周期都完美吻合弱分代假说(weak generational hypothesis)的假设,但这个假设在很大范围内还是适用的,因而也可以帮助并发GC改善性能。——Azul JDK研发者RednaxelaFX

新生代(Young generation)

绝大多数新创建的对象都会被分配到这里,这个区域触发的垃圾回收称之为Minor/Young GC。

老年代(Old generation)

对象在新生代周期中存活了下来的,会被晋升到老年代。一些较大的对象,也会被直接分配到老年代。默认情况下这个区域分配的空间要比新生代多,当然这是可以调整的。正是由于对象经历的GC次数越多越难回收,加上相对大的空间,发生在老年代的GC次数要比新生代少得多。这个区域触发的垃圾回收称之为Old GC。

方法区

方法区主要回收废弃的常量和类型,例如常量池里不会再被使用的各种符号引用等等。类型信息的回收相对来说就比较严苛了,必须符合以下3个条件才会被回收:

  • 1、所有实例被回收
  • 2、加载该类的ClassLoader 被回收
  • 3、Class 对象无法通过任何途径访问(包括反射)

直接内存

直接内存并不直接控制于JVM,这些内存只有在DirectByteBuffer回收掉之后才有机会被回收,而 Young GC 的时候只会将年轻代里不可达的DirectByteBuffer对象及其直接内存回收,如果这些对象大部分都晋升到了年老代,那么只能等到Full GC的时候才能彻底地回收DirectByteBuffer对象及其关联的直接内存。因此,直接内存的回收依赖于 Full GC。

跨代引用问题

新生代中的对象很有可能会被老年代里的对象所引用,当新生代触发GC的时候,只搜索新生代的区域明显是不够的,还得搜索老年代的对象是否引用了新生代中非 GC Roots 引用链上的对象,来确保正确性。但这样做会带来很大的性能开销。为了解决这个问题,Java定义了一种名为记忆集(Remembere Set)的抽象的数据结构,用于记录存在跨区域引用的对象指针集合。

大多数的虚拟机,都采用一种名为卡表(Card Table)的方式去实现记忆集。卡表由一个数组构成,每个卡表数组元素对应一片老年代的内存区域,这块内存区域被称之为卡页(Card Page),每一个卡页,可能会包含N个存在跨区域引用的对象,只要存在跨代引用的对象,这个卡页就会被标识为脏卡Dirty Card。当GC发生的时候,就不需要扫描整个老年代了,只需要把这些被标识为Dirty Card的卡页加入 GC Roots 里一起扫描即可。

垃圾回收器分类

收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。垃圾收集器按不同的分类可以分为多种,按线程数划分(指的是垃圾收集线程),可以分为串行垃圾收集器和并行垃圾收集器。按照工作模式,可以分为并发式垃圾回收器和独占式垃圾回收器。等等。

但是目前常用的就那么几种,一般都是按照工作的内存区间分,可以分为年轻代垃圾收集器和老年代垃圾收集器。下面直接列出来常用的收集器,其中C4GC为非HotSpot JVM中的实现,仅供参考。

新生代收集器 线程 算法 优点 缺点
Serial 单线程(串行) 标记-复制 默认的新生代收集器。无线程交互的开销,简单而高效 GC时程序暂停响应
Parallel Scavenge 多线程(并行) 标记-复制 吞吐量优先,适用在后台运行不需要太多交互的任务,有GC自适应的调节策略开关 不适合响应优先的程序,无法与CMS收集器配合使用
ParNew 多线程(并行) 标记-复制 Serial收集器的多线程版本。程序响应优先,一般采用ParNew和CMS组合。多CPU和多核心的环境中更高效 -
老年代收集器 线程 算法 优点 缺点
Serial Old 单线程(串行) 标记-整理 无线程交互的开销,简单而高效 GC时程序暂停响应
Parallel Old 多线程(并行) 标记-整理 吞吐量优先,适用在后台运行不需要太多交互的任务,有GC自适应的调节策略开关 不适合响应优先的程序,可能会导致长时间StopTheWorld
CMS收集器 多线程(并发) 标记-清除 响应优先,集中在互联网站或B/S系统服务、端上的应用。,并发收集、低停顿 1、对CPU资源非常敏感。收集会占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低
2、无法处理浮动垃圾
3、清理阶段新垃圾只能下次回收
4、标记-清除算法导致的空间碎片
统一收集器 线程 算法 优点 缺点
G1 多线程(并发) 标记-整理 1、面向服务端应用的垃圾收集器
2、分代回收
3、可预测的停顿 这是G1相对CMS的一大优势
占用CPU资源,降低吞吐量
ZGC 多线程(并发) 标记-整理 1、响应度优先,低延迟,亚毫秒级级暂停
2、 良好的可伸缩性,对堆大小不敏感,即使是TB级大小的堆性能无劣化
3、易用性高,无需配置复杂的GC参数
未实现分代,存在浮动垃圾隐患
Shenandoah GC 多线程(并发) 标记-整理 1、响应度优先,低延迟,亚毫秒级暂停
2、类似G1的可预测短暂停顿
3、 类似ZGC的良好的可伸缩性,对堆大小不敏感,即使是TB级大小的堆性能无劣化
未实现分代,存在浮动垃圾隐患
C4 GC 多线程(并发) 标记-整理 几乎无停顿,程序暂停时间接近系统波动误差 仅Azul公司的ZingJVM支持,需要高付费购买

分代垃圾回收策略,就是组合使用不同的垃圾回收器以组合不同的算法。显然单线程的Serial收集器是最低效的选择,一般不去考虑。
其他收集器则区分使用目的,以吞吐量为优先的程序,如计算任务,数据仓库等程序可以使用吞吐量优先的组合:Parallel Scavenge+Parallel Old.
响应优先的程序,在Java8及之前,可以使用ParNew+CMS的组合,Java 8版本之后,优先使用统一收集器G1。而使用Java 17之后版本的程序,则可以考虑使用ZGC这一优秀成果。
而不考虑成本的场合,Azul Zing JVM是最好的选择,无暂停的C4 GC可以提供如C++等无GC语言程序一般的性能。

串行垃圾收集器

串行垃圾收集器是指在同一时间段内只允许执行垃圾回收操作,此时用户线程被暂停,直到垃圾收集完成。

Serial GC

Serail 收集器是最基本、历史最悠久的垃圾收集器了。在 JDK 1.3 之前,是回收新生代的唯一选择。
Serail 收集器采用标记-复制算法、串行回收方式执行内存回收。

Serail 垃圾收集器还提供了用于执行老年代垃圾收集的 Serail Old 收集器。Serail Old 同样采用了 串行回收机制,只不过内存回收算法使用的是“标记-压缩算法”。
Serail Old 作为老年代 CMS 收集器的 后备垃圾收集方案,在CMS并发收集失败时,退化成Serail Old收集器。

Serail 收集器是一个单线程的串行收集器,但是它的“单线程串行”的意义并不仅仅说明它只会使用一个CPU 或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其它所有的工作线程,直到它收集结束(Stop The World)。

Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。在单 CPU 处理器或者较小的应用内存等硬件平台不是特别优秀的场合,串行垃圾收集器的性能表现可以超过并行垃圾收集器。

但时至今日,Java已退出低端设备的应用(Java ME早已废弃)。以及IoT设备都已多核化的当下,Serial GC并不是一个合适的选择。所以,除了测试场景,不推荐任何场合使用。

并行垃圾回收器

并行垃圾回收器不能称之为并发垃圾收集器,因为并发不仅仅是指可以运用多个 CPU 同时执行垃圾回收,而是可以在运行用户程序的同时,进行垃圾回收。
并行垃圾回收器目标是在垃圾收集时多线程并行手机,将程序暂停响应的时间,缩短到最小。

三色标记法

前面讲了,主流的垃圾回收算法都是先标记然后处理。串行标记的性能较低,标记时程序暂停响应,用户体验会很差。因此一般使用并发标记来提升性能。
但是,并发标记一共会有两个问题:一个是错标,标记过不是垃圾的,变成了垃圾(也叫浮动垃圾);第二个是本来已经当做垃圾了,但是又有新的引用指向它。
为了解决这些问题,提出了三色标记法。

三色标记法是一种垃圾回收法,它可以让JVM不发生或仅短时间发生程序暂停响应(Stop The World),从而达到清除JVM内存垃圾的目的。JVM中的「CMS、G1垃圾回收器」所使用垃圾回收算法即为三色标记法。

三色标记法将要进行扫描的对象的颜色分为了黑、灰、白,三种颜色。

  • 「白色」:该对象没有被标记过。(对象垃圾)
  • 「灰色」:该对象已经被标记过了,但该对象下的成员没有全被标记完。(垃圾收集器需要从此对象中去寻找垃圾)
  • 「黑色」:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)

标记流程

从GC Root开始向下查找,用黑灰白的规则,标记出所有跟GC Root相连接的对象。扫描一遍结束后,一般需要进行一次短暂的STW(Stop The World),再次进行扫描,此时因为黑色对象的属性都也已经被标记过了,所以只需找出灰色对象并顺着继续往下标记(且因为大部分的标记工作已经在第一次并发的时候发生了,所以灰色对象数量会很少,标记时间也会短很多), 此时程序继续执行,GC线程扫描所有的内存,找出扫描之后依旧被标记为白色的对象(垃圾),进行清除。

具体流程:
创建白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在 【白色集合】中;
  2. 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
  3. 从灰色集合中获取对象
    3.1 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
    3.2 将本对象 挪到 【黑色集合】里面。
  4. 重复步骤3,直至【灰色集合】为空时结束。
  5. 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。

三色标记存在问题

多标:浮动垃圾

在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),由于不会再对黑色标记过的对象重新扫描,所以不会被发现,这个对象不是白色的不会被清除,重新标记也不能从GC Root中去找到,所以成为了浮动垃圾,本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。

漏标,或者叫错杀

这个问题是比较致命的,如果错杀了,就会出现运行结果不符合预期的情况。并发标记的过程中,一个业务线程将一个未被扫描过的白色对象断开引用成为垃圾(删除引用),同时黑色对象引用了该对象(增加引用)(这两部可以不分先后顺序);因为黑色对象的含义为其属性都已经被标记过了,重新标记也不会从黑色对象中去找,导致该对象被程序所需要,却又要被GC回收,此问题会导致系统出现问题。

如下面的例子,D是黑色的,E是灰色的,但是D又指向了G,和E断开了指向G。 因为D已经标记了是黑色,但是E断开了引用,所以G就当做了是白色的。这个时候如果不操作的话,就会把G错杀掉。这种问题是必须解决掉的。

底层使用CPU的读写屏障来解决漏标问题,有两种解决方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB)。
CMS与G1,两种垃圾收集器在使用三色标记法时,都采取了一些措施来应对这些问题,「CMS对增加引用环节进行处理,也就是写屏障+增量更新(Incremental Update) ,G1则对删除引用环节进行处理,也就是写屏障+原始快照(SATB)。」
而Shenandoah GC 类似CMS,使用写屏障 + 原始快照,ZGC则最保守的使用读屏障来应对这些问题。

增量更新就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来(记录到一个集合里), 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根(A对象), 重新扫描一次。 这可以简化理解为:黑色对象(A对象)一旦新插入了指向白色对象的引用之后, 它(A对象)就变回灰色对象了,灰色对象还会继续被扫描的。

原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)。

简单说,增量更新就是并发扫描完,暂停程序,将引用关系有变化的对象再扫一次。
原始快照就是,并发扫描过程中,记录下灰色对象的被删除的子引用对象,然后把这些子引用对象直接记为黑色对象,本次不处理,下次处理(很显然这些对象可能会变成浮动垃圾)

ParNew 收集器

ParNew 收集器 则是 Serail 收集器的多线程版本。
ParNew 中的 Par 是 Parallel 的缩写,New 代表是新生代,就是ParNew 新生代并行收集器。
ParNew 垃圾收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew 在新生代采用标记—复制算法机制

Parallel Scavenge 收集器

HotSpot 的年轻代中除了 ParNew 收集器是基于并行的回收以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和“Stop The World”机制。

和 ParNew 不同,Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量,也被称为吞吐量优先的垃圾收集器。
自适应调节策略也是 Parallel Scavenge 与 ParNew 的一个重要区别。

高吞吐量则可以高效率的利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge 收集器同样提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serail Old 收集器。Parallel Old 收集器采用标记-整理算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。
Java 8默认的新生代和老年代收集器就是Parallel收集器。

CMS 收集器

Concurrent Mark Sweep 并发标记清除收集器,是一种以获取最短回收停顿时间为目标的收集器。对于响应速度有较高要求,对停顿时间忍受度低的应用,非常适合使用CMS作为垃圾收集器。

CMS是一款需要重点了解的收集器,毕竟在用上G1之前,绝大多数互联网公司的Java应用使用的就是CMS收集器。

当然在JDK17 LTS已经发布的时间点,CMS已经是一种老旧的产品了,JDK14已经毫不留情的删除了CMS收集器。

工作原理

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  • 1)初始标记(CMS initial mark)
    独占CPU,stop-the-world, 仅标记GCroots能直接关联的对象,速度比较快;
  • 2)并发标记(CMS concurrent mark)
    可以和用户线程并发执行,通过自GCRoots遍历引用链标记所有可达对象;
  • 3)重新标记(CMS remark)
    独占CPU,stop-the-world, 对并发标记阶段用户线程运行产生的垃圾对象进行标记修正,以及更新逃逸对象;
  • 4)并发清除(CMS concurrent sweep)
    可以和用户线程并发执行,清理在重复标记中被标记为可回收的对象。

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快;

并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;

重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;

最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需STW才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。

CMS的优点

  • 支持并发收集
  • 低停顿

CMS的缺点

  • CMS收集器对CPU资源敏感
    在并发阶段虽然不会导致用户线程停顿,但是会因为占用了一部分CPU资源,这个期间会降低一部分吞吐量,如果在CPU资源不足的情况下应用会有明显的卡顿。
    当然在现代服务器场景下,这已经不是很严重的问题了。

  • 浮动垃圾过多时可能会性能劣化
    在执行‘并发清理’步骤时,用户线程也会同时产生一部分可回收对象,但是这部分可回收对象只能在下次执行清理是才会被回收,这些不能在本次垃圾回收过程中被处理的垃圾对象就是浮动垃圾。
    浮动垃圾如果过多,那么可能在下次清理过程中预留给用户线程的内存不足,此时就会出现并发收集模式失败Concurrent Mode Failure,一旦出现此错误时便会切换到SerialOld收集方式。SerialOld单线程收集器,收集时stop-the-world,程序完全停止响应,对时间敏感型应用很不友好。
    面对这个问题,CMS收集器也通过一些策略进行优化。JDK5的时候,在老年代内存空间使用了68%的时候就会触发一次CMS Old GC,到了JDK6,觉得JDK5的这个设置太保守了,所以调整到了92%。

可以通过-XX:CMSInitiatingOccupancyFraction 调整这个阈值

  • 产生大量的内存碎片
    CMS清理后会产生大量的内存碎片,当有不足以提供整块连续的空间给新对象/晋升为老年代对象时又会触发Full GC。此时程序会直接暂停响应,FullGC一般耗时较长,数秒停顿都有可能。如果是时间敏感型应用,上游程序又设置了超时之类的,可能就会引发请求异常。

G1 收集器

G1 GC,全称Garbage-First Garbage Collector,回收优先的垃圾回收器。在JDK 9及之后中,G1被设置为默认垃圾收集器(JEP 248)取代了 Parallel Scavenge收集器。

启动参数

-XX:+UseG1GC

G1收集器目标是应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。它是专门针对以下应用场景设计的:

  • 像CMS收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要垃圾回收停顿时间更好预测。
  • 不希望牺牲大量的吞吐性能。
  • 不需要更大的Java Heap。

G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色:

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。
  • G1的Stop The World(STW)更可控。
  • G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。
    可以看到G1针对CMS的一些缺陷,比如产生大量内存碎片,不可控的FullGC停顿时间,进行了针对性的优化。

简单来说,G1 收集器的设计理念是:实现一个停顿时间可控的低延迟垃圾收集器

G1也是一款需要重点了解的垃圾收集器,可以说有了G1就完全没必要使用CMS了,尤其是在Java 9之后的版本上。

工作原理

可调节参数

参数 含义
-XX:G1HeapRegionSize=n 设置Region大小,并非最终值
-XX:MaxGCPauseMilli 设置G1收集过程目标时间,默认值200ms,不是硬性条件
-XX:G1NewSizePercent 新生代最小值,默认值5%
-XX:G1MaxNewSizePercent 新生代最大值,默认值60%
-XX:ParallelGCThreads STW期间,并行GC线程数
-XX:ConcGCThreads=n 并发标记阶段,并行执行的线程数
-XX:InitiatingHeapOccupancyPercent 设置触发标记周期的 Java 堆占用率阈值。默认值是45%。这里的java堆占比指的是non_young_capacity_bytes,包括old+humongous
-XX:G1ReservePercent=n 设置保留java堆大小比例,用于防止晋升失败,默认值是10%。
-XX:G1HeapWastePercent 触发MixedGC的老年代垃圾占比阈值

变更的分带模型

G1 依然遵循分代回收的设计理论,但它对堆(Java Heap)内存进行了重新布局。不再是传统的(Oracle HotSpot JDK 7 CMS收集器)按照新生代、老年代分成,永久带等几个固定大小的区域了,这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。


而G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的区域(Region),每个区域占有一块连续的虚拟内存地址。新、老年代也不再固定在某个区域了,每一个Region都可以根据运行情况的需要,扮演年轻代的Eden、Survivor区域、老年代区域、或者大对象(Humongous)区域。如下图所示:

巨大对象会被存储到Humongous区域,G1大多数情况下会把这个区域当作老年代来看待。巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,如果对象占用空间超过Region的容量,就会存放到N个连续的 Humongous Region 中。这里和CMS的逻辑类似,都是大对象直接存放到老年代区域。

可预测停顿回收模型

Pause Prediction Model 即停顿预测模型。
G1 GC是一个响应时间优先的GC算法,它与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,参数-XX:MaxGCPauseMillis指定一个G1收集过程目标停顿时间,默认值200毫秒,不过它不是硬性条件,只是期望值。那么G1怎么满足用户的期望呢?就需要这个停顿预测模型了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的区域数量,从而尽量满足用户设定的目标停顿时间。

停顿预测模型是以衰减标准偏差为理论基础实现的。在G1 GC过程中,每个可测量的步骤花费的时间都会记录到一个序列中,然后根据每个步骤的耗时,统计出衰减(耗时)均值、衰减变量,衰减标准偏差。然后根据回收时间预测计算公式

 MAX2(seq->davg() + sigma() * seq->dsd(),seq->davg() * confidence_factor(seq->num()));

在这个预测计算公式中:davg表示衰减均值,sigma()返回一个系数,表示信赖度,dsd表示衰减标准偏差,confidence_factor表示可信度相关系数。而方法的参数TruncateSeq,顾名思义,是一个截断的序列,它只跟踪了序列中的最新的n个元素。

根据公式,停顿预测时间就是取以下两个值中的最大值

  • 1衰减均值+信赖度x衰减标准偏差
  • 2衰减均值x(记录序列的最新n个元素耗时的可信度相关系数)

那么一次回收中,某个区域的预测回收时间也就是回收成本,就是根据以上公式计算出来的。

G1的垃圾回收模式

G1提供了两种垃圾回收模式,Young GC和Mixed GC,两种都是存在完全Stop The World阶段的。

  • Young GC:年轻代回收,选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
  • Mixed GC:混合回收,选定所有年轻代里的Region,外加根据垃圾回收全阶段统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

由上面的描述可知,Mixed GC不是full GC,对于老年代,它只能回收部分老年代的区域。如果Mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC执行Full GC任务,收集整个需要GC的堆和其他区域。所以我们可以知道,G1是不提供full GC的。
Mixed GC相当于混合了年轻代回收young GC和老年代回收old GC,所以叫做混合回收 MixedGC。每次发生年轻代回收之后,JVM会检查老年代有多少空间需要被回收,当垃圾占比达到一个阈值时,下次发生的youngGC,即会变为MixedGC,这个占比阈值由参数G1HeapWastePercent控制

G1的在进行垃圾回收前它会创建一个清理集CSet(Collection Set),存放需要被清理的Region。选择合适的分区放入清理集是为了让G1达到用户期望的合理的停顿时间。

G1的年轻代GC基于标记-复制算法,就是把需要回收的年轻代分片中活着的对象都拷贝到Survivor的特定区域(Survivor to),剩下的Eden和Survivor from就可以全部回收清理了。由于G1的年轻代分片,并未明确的区分Survivor to,Survivor from两块区域,而是根据需要扮演不同的角色。此时G1使用标记—复制算法,其实更类似标记—整理算法。

那么,混合回收(Mixed GC)就是把一部分老年代的分区加到Eden和Survivor from的后面。G1会把年轻代需要回收的分区和这部分老年代分区都添加到清理集CSet之中,下次Mixed GC清理把他们所有都一并清理。选老年代回收区域的顺序是垃圾多的(存活对象少)优先,这也是garbage first的来历。这也是利用了G1堆逻辑分区(Region)的灵活性。

void G1Policy::finalize_collection_set(...) {// 先选择新生代Region,用户期望的最大停顿时间是target_pause_time_ms
// G1计算出清理新生代Region的可能用时后,会将剩下的时间(time_remaining_ms)给老年代
double time_remaining_ms =
_collection_set->finalize_young_part(...);
_collection_set->finalize_old_part(time_remaining_ms);
}

从JVM源码中可以看到清理集是由年轻代分片和老年代分片组成。

年轻代回收

G1收集器整个YoungGC期间应用程序是停顿的(STW),YoungGC时G1使用线程组并行处理以尽量减少STW时间.
主要分为三个阶段:
1)存活对象标记并复制
G1从垃圾回收搜索根集GC Roots出发寻找存活对象,并将根集直接可达的对象复制到Survivor Region,并将这些对象的成员放入队列,然后更新根集指向。
2)处理记忆集(RSet)
前面说了记忆集主要用于处理跨代引用,G1会遍历记忆集合,找到引用者并将其作为起点开始标记存活对象。
3)最终存活对象复制
最终存活对象复制负责将之前步骤只标记未处理的存活对象进行移动,移动到Survivor 区或者晋升到老年代。之后就可以对Eden区进行彻底清理。

可以看到G1的年轻代回收虽然全程停顿,但是只要扫描根基可达对象,跨代引用对象即可。根据弱分代假说,年轻代大部分对象都是朝生暮死的,所以这两组集合包含对象数量都会较少,后续进行对象复制(移动)的代价也较小。而且由于G1会控制年轻代的要回收的分区个数,所以G1 YoungGC停顿较短,不会会影响程序响应。

混合回收 收集过程

  • 1)初始标记(Initial Marking)- Stop the World
    只标记 GC Roots 能直接关联的对象,还有一些额外的细节操作例如修改TAMS指针的值,保证后续阶段用户程序并发运行的时候,新对象分配在正确的位置。这个阶段需要暂停用户线程,但耗时很短。

  • 2)根区域扫描 (Root Region Scanning) - No Stop the World
    根区域扫描是从Survior区的对象出发,标记被引用到老年代中的对象,并把它们的字段在压入扫描栈(marking stack)中等到后续扫描。与Initial Mark不一样的是,Root Region Scanning不需要STW与应用程序是并发运行。根区域扫描必须在YGC开始前完成。

  • 3)并发标记(Concurrent Marking)- No Stop the World
    从根节点(GC Root)开始,顺着引用链遍历整个堆,找出存活的对象。这个步骤耗时较长,但用户线程可以和GC线程并发执行。

  • 4)最终标记(Final Marking)- Stop the World
    处理并发标记阶段,用户线程继续运行产生的引用变动,这个阶段需要暂停用户线程,支持收集器多线程并行处理。此时,G1收集器会清空原始快照的缓冲区,跟踪未被标记的存活对象,并执行标记引用处理。(详见三色标记法一节)

  • 5)筛选回收(Live Data Counting and Evacuation)- Stop the World
    G1收集器根据以上三个阶段标记完成的数据,计算出各个Region的回收价值和成本,再根据用户期望的停顿时间来决定要回收多少个Region。 在统计期间,G1收集器会识别完全空的区域和可供进行混合垃圾回收的区域。
    回收使用的是复制算法,把需要回收的这些分区里存活的对象,复制到空闲的分区中,然后清理掉旧分区全部空间。因为需要移动存活的对象,所以不可避免的要暂停用户线程,这个步骤支持多条线程并行回收。

G1相比CMS的优势

  • G1在压缩空间方面有优势;
  • G1通过将内存空间分成区域 (Region) 的方式避免内存碎片问题;
  • Eden、Survivor、Old区不再固定, 在内存使用效率上来说更灵活;
  • G1可以通过设置预期停顿时间(Pause Time) 来控制垃圾收集时间,避免应用雪崩现象
  • G1在回收内存后会马上同时做合并空闲内存的工作,而CMS默认是在STW(stop the world) 的时候做;
  • G1会在Young GC中使用, 而CMS只能在Old区使用

ZGC 收集器

ZGC收集器(Z Garbage Collector)是由Oracle公司为HotSpot JDK研发的,最新一代垃圾收集器。有说法使用这个名目标是取代之前的大部分垃圾收集器,所以才叫ZGC,表示极致的Extremely,或者最后的,垃圾收集器。

但是ZGC官方文档说ZGC这只是个名字,不代表任何含义。看你相信哪种了,笑

  • 设计目标
    希望能在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10ms以内的低延迟。

    • 停顿时间不超过10ms;
    • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
    • 支持8MB~16TB级别的堆。

主流的常见操作系统,比如Linux,Windows,MacOS,FreeBSD都是非实时操作系统。非实时操作系统的一个处理器时间片都在5~20毫秒,面向服务端的系统一个线程调度事件需要3-5个时间片,客户端系统则更多。10毫秒停顿已经可以认为是系统误差级的停顿,此时ZGC基本已经成为无停顿GC。

ZGC设计目标停顿时间在10ms以下,并且ZGC目前的进展很快,在JDK17的测试中和shenandoah gc双双实现了亚毫秒(<1ms)的GC暂停。
Shenandoah in OpenJDK 17: Sub-millisecond GC pauses | Red Hat Developer
不负极致之名,Java17之后采用ZGC是最好的选择。

G1的回收时停顿

G1和ZGC都基于标记-整理算法,但算法具体实现的不同就导致了巨大的性能差异。

已知G1混合回收(MixedGC)中包括的四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。 复制-转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制算法中的复制-转移阶段的程序停顿 。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。

ZGC的标记—整理算法

与G1类似,ZGC也采用标记-整理算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。


ZGC只有三个程序停顿(STW)阶段:初始标记,再标记,初始转移
其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。

ZGC几乎所有暂停都只依赖于GC Roots集合大小,那么随着GC Roots集合的增大,ZGC的暂停时间是否会增长?初始版ZGC是如此的,当扫描根集合较大时,ZGC停顿时间有会显著正常,超过10毫秒暂停的目标,达到30~50毫秒的暂停时间。但是ZGC也是在不断发展的,JDK15之后这一问题也基本得到解决,ZGC暂停时间被控制到了接近常数级别,并且远小于10毫秒。改进的ZGC将停顿时间控制成只和扫描根集中某些固定的部分相关,所以其停顿时间也基本固定为常数级别。

ZGC完全并发原理

ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,在GC发生时,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。

着色指针 Color Pointer

已知Java虚拟机垃圾回收时的可达性分析使用了标记-处理类算法。从垃圾回收扫描根集合开始标记存活对象,那么这些标记被储存在哪里?在ZGC中标记信息被直接记在引用对象的指针上。

着色指针是一种直接将少量额外的信息存储在对象指针上的技术。目前在X64架构的操作系统中高16位是不能用来寻址的。程序只能使用低48位,
ZGC将低48位中的高4位取出,用来存储4个标志位。剩余的44位可以支持16TB(2的44次幂)的内存,也即ZGC可以管理的内存不超过16TB。
4个标志位即着色位,所以这种指针被称为着色指针。这个4个着色位可以表示Marked0,Marked1,Remapped三个状态,用以表示不同的GC标记。
在ZGC中标记信息被直接记在引用对象的着色指针上,这样通过对象着色指针就可以获取 GC 标记。

因此,ZGC只能在64位系统上,因为ZGC的着色指针使用的是44-48位,32位的x86架构系统显然不支持,并且因为ZGC已经把48位可用的指针地址空间全部使用了,自然也不支持压缩指针。

内存视图 View

  • 和着色指针适配的三种内存视图
    ZGC 将我们所看到的堆内存的视图分为 3 种:marked0、marked1、remapped,同一时刻只能处于其中一种视图。比如:在没有进行垃圾回收时,视图为 remapped 。在 GC 进行标记开始,将视图从 remapped 切换到 marked0/marked1 。在 GC 进行转移阶段,又将视图从marked0/marked1 切换到 remapped 。

  • “好”指针和“坏”指针
    当前访问指针的着色状态(地址视图)和当前所处的内存视图匹配时,则当前指针为“好”指针;当前指访问针的着色状态和当前所处的内存视图不一致时,则为“坏指针”。

  • 触发读屏障
    读取到“坏”指针时,则需要读屏障进行 GC 相关处理,也就是下面提到的指针自愈。

读屏障 Load Barrier

读屏障是一小段在特殊位置由 JIT 注入的代码,类似我们 JAVA 中常用的 AOP 技术;主要目的是处理地址转发,保证被GC线程移动过的对象,仍能被应用线程正确访问,这一能力又叫指针自愈

  • 指针的自愈能力
    在ZGC中,当读取处于需要重分配地址集合中的对象时,会被读屏障拦截,通过转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象。ZGC将这种行为叫做指针的“自愈能力”。
    好处是:第一次访问旧对象访问会变慢,但也只会有一次变慢,当“自愈”完成后,后续访问就不会变慢了。

总结

在ZGC中,在GC运行到清理阶段时,GC线程和应用线程并发运行。当应用线程访问对象时,会进行内存视图状态检查,如果检查到内存视图状态和着色指针状态不一致,将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把对象的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。
ZGC通过着色指针,内存视图,读屏障解决了标记-复制算法的复制-转移阶段被转移的对象定位的问题,使此阶段GC线程和应用线程可以并发,大大缩减了程序暂停响应时间。

ZGC 优点

  • 低停顿,高吞吐量,ZGC收集过程中额外耗费的内存小。

    • 低停顿,几乎所有过程都是并发的,只有短暂的STW。
    • 占用额外的内存小。G1通过写屏障维护记忆集,才能处理跨代指针,得以实现增量回收。记忆集占用大量内存,写屏障对正常程序造成额外负担。而ZGC没有写屏障,卡表之类的。(但这主要得益于ZGC目前没有实现分代回收,要是分代回收实现之后,还会不会这样不好说了)
    • 吞吐量方面,在ZGC的‘弱项’吞吐量方面,因为和用户线程并发,还是有影响的。但是以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge收集器的99%,直接超越了G1
  • 支持NUMA架构
    现在多CPU插槽的服务器都是NUMA架构,比如两颗CPU插槽(24核),64G内存的服务器,那其中一颗CPU上的12个核,访问从属于它的32G本地内存,要比访问另外32G远端内存要快得多。
    在支持NUMA架构的多核处理器下,ZGC优先在线程当前所处的处理器的本地内存上分配对象,以保证内存高效访问。
  • ZGC采用并发的标记-整理算法。没有内存碎片。

ZGC 缺点

  • 承受的对象分配速率不会太高,因为浮动垃圾。
  • ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。
    假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。可能造成回收到的内存空间小于期间并发产生的浮动垃圾所占的空间。
  • ZGC目前不支持分代回收
    ZGC目前没有实现分代回收,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。所以就不存在Young GC、Old GC,所有的GC行为都是Full GC。
  • ZGC在OpenJDK上只有在JDK17以后才正式可用
    Oracle HotSpotJDK,Adopt OpenJDK等常用JDK在低版本均无生产可用的ZGC。可以通过选择AliJDK,TencentJDK等试用规避此问题。

Shenandoah 收集器

ShenandoahGC最初是由RedHat公司独立发展的新型低延迟收集器项目,在2014年RedHat把Shenandoah贡献 给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一,也就是后来的JEP 189

  • 设计目标
    ShenandoahGC的初始设计目标和Oracle HotSpotJDK ZGC的目标相似,同样希望能在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10ms以内的低延迟。

从代码历史渊源上讲,比起另起炉灶设计甚至截止JDK18仍未实现分代回收的ZGC,Shenandoah反而更像是G1 的下一代继承者。它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上 都高度一致,甚至还直接共享了一部分实现代码,这使得部分对G1的打磨改进和Bug修改会同时反映 在Shenandoah之上,而由于Shenandoah加入所带来的一些新特性,也有部分会出现在G1收集器中,譬如在并发失败后作为“逃生门”的Full GC的支持

Shenandoah对G1的改进

Shenandoah作为G1同根的继承者,自然在很多特性上都类似G1

  • Shenandoah与G1的相同点

    • 相似的堆内存布局
      同样使用基于分片(Region)的堆内存布局,同样有用于存放大对象的分片(Humongous Region)
    • 相同的回收策略
      同样是优先处理回收价值最大的分片
    • 相似的处理阶段
      在初始标记、并发标记等阶段的思路都高度一致,甚至直接共享了一部分代码

但三个明显的方面与G1不同。

  • 1.最重要的当然是支持并发的整理算法
    G1的回收阶段是多个GC线程并行处理的,但却不能与用户线程并发。这点是Shenandoah最核心的功能改进
  • 2.其次,Shenandoah(目前)是默认不使用分代收集的
    Shenandoah不会有 专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值, 这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。
  • 3.Shenandoah改进了跨分区引用的记录结构
    Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨分区(Region)的引用关系,降低了处理跨代指针时的记忆集维护消耗。

连接矩阵

连接矩阵
在G1收集器中记忆集实现复杂,需要耗费10%~20%的堆容量维持收集器工作,Shenandoah摒弃了这种消耗大量内存和计算资源的记忆集,改用连接矩阵。
连接矩阵可以简单理解为一张二维表格(二维数组),如果Region N有对象指向Region M内的对象,就在表格的N行M列中标记。

连接矩阵是一种全局数据结构,不需要像G1的记忆集实现一样每个分区单独计算维护。使用连接矩阵降低了处理跨分区指针时的记忆集维护消耗,也降低了伪共享发生的概率。

Shenandoah的收集流程

Shenandoah收集器的收集流程与G1收集器类似,也是大体分为初始标记,并发标记,最终标记,筛选回收。

只是在筛选回收阶段,Shenandoah与G1不同。Shenandoah的筛选回收是与用户线程并发的,因此又称之为并发回收阶段(Concurrent Evacuation)。
此阶段要把回收集中存活的对象先复制到未被使用的Region中,此步骤需要两步以保证后续的正常访问,第一步将对象复制到新地址中,第二步再将指向被复制对象的引用改为新地址。
此阶段不同于G1收集器,Shenandoah不会冻结用户线程,因此在对象移动的同时,用户线程仍然会不断的对被移动的对象进行访问,而修改引用为新地址的动作又很难一瞬间全部完成,为了避免垃圾回收过程中用户线程可能访问到旧地址的问题,Shenandoah使用了读屏障和转发指针(Brooks Pointers)。此阶段的运行时间长短取决于回收集CSet的大小。

筛选回收阶段:

  • 引用更新

    • 初始引用更新(Initial Update Reference)-STW
      创建一个线程集合点,确保所有并发回收阶段中进行收集行为的线程都已完成分配给他们的对象移动任务。此阶段耗时很短,会产生一个非常短暂的停顿。
    • 并发引用更新(Concurrent Update Reference)
      将堆内存中所有指向旧对象的引用修正为复制后的新地址,此操作只需要按照内存物理地址的顺序,线性地搜索出引用类型,旧值改为新值即可。此阶段并发,时间长短取决于涉及到的引用数量多少。
    • 最终引用更新(Final Update Reference)-STW
      和并发引用更新目的一样,只是将目标从堆换成GC Roots,此阶段需要停顿,时间与GC Roots数量相关。
  • 并发清理
    经过此前的一系列操作,回收集中便已再无存活对象,直接回收全部回收集中的Region即可。

转发指针 Brooks Pointer

Shenandoah使用支持转发指针Brooks Pointer以支持并行整理。“Brooks”是一个人的名字,Brooks提出一种方案是在Java虚拟机原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,在处于并发移动状态下,该引用指向新地址,如图所示。

转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转 发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址 访问的代码便仍然可用,都会被自动转发到新对象上继续工作。

Shenandoah 收集器总结

  • Shenandoah 收集器的实现了低延迟(<10ms)的GC停顿
    在垃圾回收过程中,Shenandoah实现了大部分时间垃圾收集线程都能够与用户线程并发,并且在JDK16上实现了亚毫秒级暂停。
  • Shenandoah 收集器改进了跨分区记录的处理,节省了内存
    Shenandoah 采用了名为连接矩阵的全局数据结构记录跨分区引用关系,相较G1降低了处理跨分区引用时记忆集的维护消耗,节省了内存。
  • Shenandoah 收集器发展改进了G1收集器
    引入转发指针解决了 G1收集器在垃圾回收时收集线程与用户线程并发时不能准确定位对象转移的问题。
  • Shenandoah 不支持分代回收
    与ZGC类似,Shenandoah 收集器目前(截止JDK18)也不支持分代回收。
  • Shenandoah 支持更大的堆内存
    Shenandoah收集器使用转发指针而不是着色指针技术,所以对象指针的低48位是完全可用的。因此Shenandoah收集器理论上最大可支持256TB的堆内存。

C4 收集器

Azul Zing JVM的独牌秘籍,Continuously Concurrent Compacting Collector,连续并发压缩回收器,简称C4 GC。
C4收集器由Azul的无暂停垃圾收集器PauseLessGC发展而来,相比PauseLess收集器,C4收集器最大的改进就是支持了分代回收模型。
这有点像ZGC的发展历程,目前(截止JDK18)的ZGC都是不支持分代的,而支持分代的ZGC正在开发中。

有观点认为ZGC就是重写的,纯软件实现的Azul PauseLessGC。目前正在追逐接近C4GC的目标。

C4收集器是一个强大的低延迟垃圾收集器,在OracleJDK的还在使用CMS、G1,发生FullGC可达10秒级甚至更久停顿的时代,C4收集器就已经向使用者提供了10毫秒级停顿的低延迟垃圾回收能力。

C4最大的缺陷就是这是一款昂贵的商业化JDK

C4收集器特性

C4收集器的回收算法使用“所有的阶段都设计为可以并发的,包括移动对象的阶段”的方法来实现低延迟的分代式垃圾回收。相比于Paraller,CMS等分代式垃圾回收器,C4的不同之处在于它认为垃圾回收并不是什么坏事(即应用程序产生垃圾很正常),而压缩是不可避免的。在设计之初,C4就是满足需要长时间运行的服务器端应用程序的需求。

C4连续并发原理

C4算法的一个基本假设是“垃圾回收不是坏事”和“压缩不可避免”。C4算法的设计目标是实现垃圾回收的并发与协作,剔除stop-the-world式的垃圾回收。C4垃圾回收算法包含一下3个大阶段:
(1)标记(Marking) — 找到活动对象
(2)重定位(Relocation) — 将存活对象移动到一起,以便可以释放较大的连续空间,这个阶段也被称为“压缩(compaction)”
(3)重映射(Remapping) — 更新被移动的对象的引用。

对ZGC有了解的可知,ZGC的收集过程也可以被概括为初始标记,并发标记/重定位,初始转移,并发转移清理几个阶段。ZGC只有三个程序停顿(STW)阶段:初始标记,再标记,初始转移。C4GC也是如此。

C4 回收标记阶段

在C4算法中,标记阶段(marking phase)使用了并发标记(concurrent marking)和引用跟踪(reference-tracing)的方法来标记活动对象。

  • 初始标记阶段
    由GCRoots根集遍历所有的引用,标记所有可达的对象,这个阶段和ZGC类似,也是停顿的STW。

  • 并发标记阶段
    在并发标记阶段中,C4使用标记指针和“载入值屏障”(Loaded Value Barrier)来确保GC线程不漏标对象,且GC线程转移后,还能被应用线程正确访问。由于载入值屏障提供的指针自愈能力,C4垃圾回收器或另一个应用程序线程不会重复遍历该对象。这样就节省了标记时间,消除了递归重标记(recursive remark)的风险。

C4算法中的重定位

C4算法中,重定位阶段(reloacation phase)是由GC线程和应用程序线程以协作的方式,并发完成的。这是因为GC线程和应用程序线程会同时工作,而且无论哪个线程先访问将被移动的对象,都会以协作的方式帮助完成该对象的移动任务。因此,应用程序线程可以继续执行自己的任务,而不必等待整个垃圾回收周期的完成。
ZGC也是如此进行应用线程与GC线程协作的。
当所有的活动对象都从某个内存也中移出后,剩下的就都是垃圾数据了,这个内存页也就可以被整体回收了

C4算法中并没有清理阶段(sweep phase),因为C4并不需要程序停顿的去执行整理对象的操作,因此也就不需要这个在大多数垃圾回收算法中比较常用的操作。当一个虚拟页上的存活对象都被转移后,C4会直接回收物理页。
无需执行stop-the-world式的移动对象是有很大好处的。由于在重定位阶段,所有活动对象都是并发移动的,因此它们可以被更有效率的放入到相邻的地址中,并且可以充分的压缩。通过并发执行重定位操作,堆被压缩为连续空间,也无需挂起所有的应用程序线程。

C4算法中的重映射

在重定位阶段,某些指向被移动的对象的引用会自动更新。但是,在重定位阶段,那些指向了被移动的对象的引用并没有更新,仍然指向原处,所以它们需要在后续完成更新操作。C4算法中的重映射阶段(re-mapping phase)负责完成对那些活动对象已经移出,但仍指向那些的引用进行更新。当然,重映射也是一个协作式的并发操作。
在重定位之后,GC线程立即开始更新那些仍然指向之前的虚拟地址空间的引用,将它们指向那些被移动的对象的新地址。垃圾回收器会一直执行此项任务,直到所有的引用都被更新,这样原先虚拟内存空间就可以被整体回收了。
但如果在GC完成对所有引用的更新之前,应用程序线程想要访问这些引用的话,会出现什么情况呢?如果在重映射阶段,应用程序线程访问了处于非稳定状态的引用,通过C4的载入值屏障,屏障会更新指针指向的位置,用户线程得以找到该引用的正确指向。如果应用程序线程找到了正确的引用,它会更新该引用的指向。当完成更新后,应用程序线程会继续自己的工作。

C4的分代

与追逐者ZGC不同,C4收集器早已实现分代收集。根据弱分代假说,大量对象是朝生夕死的,这一点对于大量应用都是成立的。

Azul Pauseless GC虽然算法上可以完全并发,但完成一整个并发收集周期毕竟还是要时间的。例如说在很大的堆上,并发收集整个堆需要几分钟也是很正常的事。在这并发收集周期中,新创建的对象大致上会被当作活对象来处理——即便它们在这次收集的过程中其实已经变成死对象可以收集了。

于是可以想象,如果新对象都是在一个专门的区域中创建,然后这个区域会比整个堆收集得更频繁,那么专门针对这个区域的收集就会完成得更快(换句话说周期更短),意外留活的对象就会更少,这个空间能回收的空间的比例也会比整个堆的比例要高。所以加上了分代支持后,C4能承受的对象分配速率大概提升到了Azul PGC的10倍。

而ZGC在承担高速对象分配时却可能存在一些问题,需要通过一些调优参数去解决,比如降低ZGC触发的堆占用大小阈值。

参考文章

Java性能优化之JVM GC

JVM架构和GC垃圾回收机制

详解三色标记法

简明易懂的JVM垃圾回收理解相关推荐

  1. java jvm垃圾回收算法_深入理解JVM虚拟机2:JVM垃圾回收基本原理和算法

    本文转自互联网,侵删 本系列文章将整理到我在GitHub上的<Java面试指南>仓库,更多精彩内容请到我的仓库里查看 喜欢的话麻烦点下Star哈 文章将同步到我的个人博客: www.how ...

  2. 深入理解JVM虚拟机2:JVM垃圾回收基本原理和算法

    JVM GC基本原理与GC算法 Java的内存分配与回收全部由JVM垃圾回收进程自动完成.与C语言不同,Java开发者不需要自己编写代码实现垃圾回收.这是Java深受大家欢迎的众多特性之一,能够帮助程 ...

  3. 深入理解Java虚拟机——JVM垃圾回收机制和垃圾收集器详解

    一:概述 说起垃圾回收(Garbage Collection,GC),很多人就会自然而然地把它和Java联系起来.在Java中,程序员不需要去关心内存动态分配和垃圾回收的问题,顾名思义,垃圾回收就是释 ...

  4. 深入理解JVM—垃圾回收机制

    一.前言 明确垃圾收集器关注的部分:堆和方法区.着重学习如何确定哪些垃圾需要回收.垃圾回收算法以及GC触发条件. 二.如何确定哪些垃圾需要回收 1.引用计数算法 在对象中添加一个引用计数器,每当有一个 ...

  5. 【深入理解Java虚拟机】读后感:JVM垃圾回收小结

    JVM垃圾回收小结 1.GC 问题侧重点 虚拟机栈.本地方法栈.程序计数器随线程而生,随线程而灭.栈中的栈帧随方法的进入和退出而有条不紊地执行着出栈与入栈操作,每个栈帧分配多少内存基本是在类结构确定下 ...

  6. JVM垃圾回收机制GC理解

    目录 JVM垃圾回收 分代收集 如何识别垃圾 引用计数法 可达性分析法 引用关系四种类型: 强.软.弱.虚 强引用 软引用 SoftReference 弱引用 WeakReference WeakHa ...

  7. 【探究JVM九】深入理解JVM垃圾回收的8种算法

    文章目录 1. 垃圾回收概述 什么是垃圾 为什么需要GC 对象是否存活 2. 标记阶段-引用计数算法 3. 标记阶段-可达性分析算法 GC Roots 4. 对象的finalize机制 生存?还是死亡 ...

  8. 【深入理解JVM 七】JVM垃圾回收机制

    前边几篇Blog分别介绍了JVM的类加载机制.运行时数据区域,字节码的执行,在执行完成后程序发挥完了自己的作用,线程独有的程序计数器.虚拟机栈.本地方法栈3个区域随线程而生,随线程而灭,而线程共享的堆 ...

  9. java学习笔记-4 JVM垃圾回收(GC)

    引言 jvm垃圾回收相关的问题是老生常谈的问题了,相信大家都有所了解,这里再进行相关的探讨,以加深理解.若文中有不正之言,望不吝指正. 本文将围绕以下几个点展开 1.为什么要进行垃圾回收 我们知道jv ...

最新文章

  1. win设置计算机网络,Win10怎么修改网络类型,Win10网络类型怎么设置?
  2. Adobe Flex 3.0 和 AIR 1.0 正式发布
  3. 智力题:砝码称重问题
  4. OS / 进程和线程的区别和联系
  5. 2020年第十一届蓝桥杯 - 国赛 - Python大学组 - H.答疑
  6. video 标签存在的一些坑
  7. Maven的pom报错的解决方法
  8. pip下载python包
  9. 计算机原理的教学,计算机组成原理教学方法探析
  10. 使用instsrv和srvany注册windows系统服务
  11. reg类型变量综合电路_基本门电路逻辑符号.doc
  12. matlab保存pdf图片太大,matlab中的图片保存方法精选.pdf
  13. php addslash,php addslashes用法详解
  14. 网站打开速度慢如何解决
  15. 华为matex搭载鸿蒙系统,华为MateX推迟至9月上市,或预装鸿蒙系统
  16. Centos7下的zabbix安装与部署
  17. 开酒馆前的注意事项 (上)
  18. 计算机应用基础模块三项目二,计算机应用基础 高职计算机大类专业 刁爱军模块三 项目二 海报的制作.pptx...
  19. HDCP@SKE交互
  20. Push rejected by evil dragon bureaucrats

热门文章

  1. ThinkPHP导出百万条数据量
  2. IL思路及遇到的问题、解决方法
  3. 两个向量组的秩相等说明什么_若两个向量组等价,它们的秩是否相等?
  4. python怎么用根号_python中根号怎么表示
  5. 服务器项目技术方案,直播项目技术实现方案(工作室)
  6. 域名系统就是域名服务器吗,域名系统五个服务器有什么区别吗
  7. 彻底解密C++宽字符
  8. Matlab Mathematica 向量(行,列) 矩阵
  9. Python编程:从入门到实践+爬虫开发与项目实战+网络编程基础+项目开发实战
  10. 执行rm -rf /效果