[JVM]-[深入理解Java虚拟机学习笔记]-第三章-垃圾收集器与内存分配策略

判断对象是否存活

引用计数算法

在对象中添加一个 引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的
这种引用计数算法很难解决 对象之间相互循环引用 的问题,即两个对象互相引用着对方,但其它地方却不会再使用到这两个对象,此时两个对象是应该被回收的,但由于引用数不为 0,就无法回收。此外,由于对于每个对象都需要设置计数器,如果对象数量很多,计数器的数量也会很多,占用空间也是非常可观的

可达性分析算法

通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为 引用链,如果某个对象到 GC Roots 间没有任何引用链相连,或者说从 GC Roots 到这个对象不可达时,则说明该对象是不可能再被使用的

真正宣告一个对象死亡至少需要 两次标记 过程:如果对象在进行可达性分析后发现没有存在跟 GC Roots 相连的引用链,那么它会被第一次标记;随后进行一次筛选,筛选的条件是该对象 是否有必要执行 finalize() 方法,如果对象 没有覆盖 finalize() 方法,或者 其 finalize() 方法已经被虚拟机调用过,那么虚拟机认为其没有必要执行,将其第二次标记

因此,对象可以进行 自救:首先有覆盖 finalize() 方法,且在执行的过程中将自己 (也就是 this) 赋值给引用链上任何一个对象即可。由于 finalize() 方法最多只会被执行一次,所以这种自救也只能执行一次,不过并不推荐使用 finalize() 方法

固定可作为 GC Roots 的对象

  1. 在虚拟机栈 (栈帧中的本地变量表) 中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量等
  2. 在本地方法栈 JNI 中 (即通常所说的 native 本地方法) 中引用的对象
  3. 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量
  4. 在方法区中常量引用的对象,譬如字符串常量池 String Table 里的引用
  5. 所有被同步锁 (synchronized 关键字) 持有的对象
  6. 反映 Java 虚拟机内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等

作为 GC Roots 的对象,其本身就应该是正被使用的,据此就可以联想到方法栈中引用的对象,方法栈自然就包括普通方法以及本地方法;全局可用的类静态变量,常量。锁持有的对象不仅正在被使用,而且更不允许其随意消亡

关于引用

JDK 1.2 之后,引用 的概念被扩充,分为了四种:

  1. 强引用 S t r o n g R e f e r e n c e Strong Reference StrongReference:传统的 “引用” 的定义,即代码中普遍存在的引用赋值,即类似 “Object obj = new Object()” 这种引用关系
    任何情况下只要强引用关系还存在,收集器就永远不会回收掉它
  2. 软引用 S o f t R e f e r e n c e Soft Reference SoftReference:只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
  3. 弱引用 W e a k R e f e r e n c e Weak Reference WeakReference:强度比软引用更弱,被弱引用关联的对象 只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
  4. 虚引用 P h a n t o m R e f e r e n c e Phantom Reference PhantomReference:又称 ”幽灵引用“ 或者 ”幻影引用“,一个对象是否有虚引用的存在完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的只是为了在这个对象被回收的时候收到一个系统通知

JDK 1.2 之后提供了 SoftReference 类,WeakReference 类以及 PhantomReference 类来实现 软引用,弱引用 以及 虚引用

垃圾收集算法之追踪式垃圾收集

知道什么样的对象属于要回收的对象后,接下来就是考虑如何进行对象回收了

根据对象是否存活的判断算法,垃圾收集算法可以划分为 引用计数式垃圾收集追踪式垃圾收集。鉴于 JVM 中使用的是 可达性分析算法,这里只讨论追踪式垃圾收集算法

分代收集理论

许多垃圾收集器的设计原则都是奠基于分代收集理论之上的:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的
  2. 强分代假说:熬过越多次垃圾收集过程就越难以消亡
  3. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

第 1 以及第 2 条假说告诉我们:收集器应该 将 Java 堆划分出不同的区域,然后 将回收对象根据年龄分配到不同的区域中存储,根据它们不同的存亡特征,采用不同的收集策略

根据不同的存亡特征,至少可以把 Java 堆分为 新生代 以及 老年代 两个区域,新生代即对应 “朝生夕灭” 的对象,老年代即对应 “难以消亡” 的对象
根据不同区域进行的收集操作包括 Minor GC(针对新生代的收集),Major GC(针对老年代的收集),Full GC(针对整个Java堆以及方法区的收集) 等
针对不同区域,采取不同的算法,如 标记-清除算法标记-复制算法标记-整理算法

总而言之,这一切都源于分代收集理论

分代收集存在的一个明显问题就是 对象之间跨代引用 的问题:新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 外额外遍历整个老年代中所有对象来确保可达性分析算法的正确性,而遍历整个老年代的对象无疑是很大的性能负担

因此引出了上面提到的第 3 条经验法则,这条法则其实是根据前两条推理得到的:存在互相引用的两个对象,是应该趋向于 同时生存 或 同时消亡 的。例如,假设某个新生代对象存在跨代引用,那么由于老年代对象难以消亡,导致这个新生代对象一直存活下来,进而在年龄逐渐增长之后,这个新生代对象也会成为老年代,这时跨代引用也消除了

因此,就没有必要为了少量的跨代引用去遍历整个老年代了。只需在新生代上建立一个全局的数据结构 记忆集 (Remember Set),这个结构把老年代划分为若干小块,然后标识其中哪些块中会存在跨代引用。此后进行 Minor GC 的时候,只需要将记忆集中标识了有出现跨代引用的老年代区域中的对象加入 GC Roots 即可

使用记忆集的方法虽然需要 在对象引用关系改变的时候维护所记录的数据,但跟遍历整个老年代对象的开销相比,还是划算的

垃圾收集算法

标记-清除算法

算法:首先标记出所有需要回收的对象,标记完成后统一回收被标记的对象;也可以反过来,标记所有存活的对象,回收所有未被标记的对象

缺点

  • 执行效率不稳定:如果Java堆中包含大量对象,且其中 大部分是需要被回收的,那么所需的标记以及清除动作也会更多,导致 标记跟清除两个过程的执行效率都随着对象数量的增长而降低
  • 内存空间碎片化:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后需要分配较大对象的时候无法找到足够的连续内存空间,而不得不提前触发一次垃圾收集
    标记-清除算法

标记-复制算法

半区复制

为了 解决标记-清除算法中面对大量可回收对象时执行效率不稳定 的问题,提出了 半区复制 的算法:将 Java 堆可用内存划分为大小相等的两块,每次只使用其中的一块,当被使用的这一块的内存用完了,就将还存活的对象复制到另一块上,然后把已使用过的内存空间 一次清理掉
相当于有一半的空间满了就进行一次清除,而不是等到整个空间都满了才清除,这样每次清除时所需回收的对象也就相应地变少了,执行效率的问题也得到了一定的缓解

如果内存中多数对象都是会存活的,那么会产生大量的内存间复制的开销;而当内存中大多数对象都是要回收的,那么复制存活对象所需的开销就是很小的,所以该算法更适用于 新生代,而且将一个半区中存活的对象复制到另一个半区时可以 规整,按顺序地放置,不会出现空间碎片的问题

缺点:将可用内存缩小为了原来的一半,空间浪费过多
标记-复制算法

Appel 式回收

Appel 式回收是一种更优化的 半区复制 分代策略,后文会讲到的 SerialParNew 等新生代收集器均采用了这种策略

具体做法:把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间 (分别是 From Survivor 以及 To Survivor),每次分配内存只使用 Eden 和 From Survivor,发生垃圾收集时,将 Eden 跟 From Survivor 中仍然存活的对象一次性复制到另外一块 To Survivor 空间上,然后直接清理掉 Eden 跟 From Survivor 的空间。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,即每次新生代中可用的内存空间为整个新生代容量的 90%,这就解决了半区复制空间浪费过大的问题

尽管新生代中存活的对象很少,但任何人都没有办法百分百保证每次回收都只有不多于 10% 的对象存活,一旦存活的对象大于 10%,剩下的 To Survivor 就不够存放这些对象了
这时就需要 分配担保 机制来解决这个问题:当 To Survivor 中没有足够空间存放上一次 Minor GC 存活下来的对象,无法容纳的对象就直接进入老年代

大多数情况下,对象在新生代中的 Eden 中分配,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

为什么要有两个 Survivor

两个 Survivor 解决的是内存碎片的问题
假设只有一个 Survivor,Eden 中存活的对象全部放在这个 Survivor 中。在下次标记时,Eden 中存活的对象依然可以整齐地复制到 Survivor 中存储,但是 Survivor 中原先存储的对象中可能也有 部分回收,部分存活,但它却没有一个地方能给它去复制存放这些部分存活的对象,因为没有其它的 survivor 了,这样在其中又会出现内存碎片的问题
也就是说,只要一块区域中有存放对象,那么就避免不了这块区域要出现内存碎片,解决方法就是 引入一块永远为空,不会存放对象的区域

所以需要两个 Survivor,一个 From Survivor,一个 To Survivor,每次存放对象都存放在 Eden 跟 From 中,当垃圾收集时,将 Eden 跟 From 中的存活对象都整齐地复制到 To 中存放,对 Eden 跟 From 进行全部回收,然后 From 跟 To 交换身份

这样就保证了 To Survivor 中 永远都是空的,可以供 Eden 跟 From 中存活的对象进行复制整理,解决了空间碎片的问题

而为什么不要更多的 Survivor?空间划分得越多,每一块 Survivor 的空间就越小,空间就越容易被占满,而且对于每次能存活的对象的数量是不可能做出预测的,当存活的对象较多,太小的 Survivor 就存放不了了。而且两个 Survivor 已经足够解决问题,没必要继续增多 Survivor 数量

为什么 Eden 跟 Survivor 要 8:1:1

首先主要还是弱分代理论,绝大多数对象是会被回收的,所以 Survivor 空间不需要那么大。经过一系列性能测试,发现这个比例最好,就选定这个比例了

标记-整理算法

标记-复制算法主要还是面向新生代的,因为 当对象存活率较高的时候所需的复制操作就很多,效率将会降低,更关键的是,用于保存存活对象的空间是比较小的,如果大多数垃圾收集的时侯对象存活率都较高,就需要额外的空间进行分配担保

标记-整理算法就是针对于老年代对象的死亡特征设计的。其 算法 为:标记过程仍与 “标记-清除算法” 一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,这样也不会有内存碎片的问题发生

标记-清除算法与标记-整理算法的本质差异在于 前者是一种非移动式的回收算法,而后者是移动式的

HotSpot 算法实现细节

根节点枚举

进行根节点枚举这一步都是必须暂停用户线程的,会与之前整理内存碎片一样面临相似的 “Stop The World” 的困扰。他必须在系统空间一个 能保障一致性的快照 中进行,保证分析过程中根节点集合的对象引用关系不会发生变化,才能保证分析结果的准确性

目前主流 JVM 使用的都是 准确式内存管理,即能明确地知道内存中某个位置的数据具体是什么类型,例如某个整数是一个指向某个内存地址的引用类型,还是只是一个单纯的整数。

所以虚拟机是应当有办法直接得到哪些地方存放着对象引用的。在 HotSpot 中使用一组称为 OopMap 的数据结构来达到这个目的,会在 特定的位置 (即下文的 安全点) 记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息,不需要真正一个不漏地从方法区等 GC Roots 开始开始查找

安全点

在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举

但导致引用关系变化,即导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap 将会需要大量的额外空间
所以 HotSpot 只是在 “特定的位置” 记录这些信息,这些位置称为 安全点 (Safepoint)。安全点的设定也就要求代码指令流必须执行到安全点才能暂停下来进行垃圾收集

记忆集与卡表

讲到分代理论的时候,提到为了解决对象跨代引用所带来的问题,在新生代引入了 记忆集 这种数据结构,用来避免把整个老年代加进 GC Roots 扫描范围。实际上除了新生代与老年代之间会有跨代引用问题,所有涉及部分区域收集 (Partial GC) 行为的垃圾收集器都会面临相同的跨代引用的问题

记忆集是一种用于 记录从非收集区指向收集区的指针集合抽象 数据结构。最简单的实现方案是在这个数据结构中记录全部含跨代引用的对象。

但对于垃圾收集的场景来说,收集器只需要知道 某一块非收集区 是否 存在 指向收集区的指针就可以了,而并不需要知道这些跨代指针的具体信息,所以记录全部含跨代引用的对象的话就会非常多余,非常浪费空间,完全可以选择更粗的记录粒度:

字长 精度:每个记录精确到 一个机器字长,表示该字中包含跨代指针
对象 精度:每个记录精确到 一个对象,表示该对象中有字段含有跨代指针
精度:每个记录精确到 一块内存区域,表示该区域内 有对象含有跨代指针

其中,“卡精度” 这种方案指的是用一种称为 卡表 (Card Table) 的方式来实现记忆集,也是目前最常用的一种记忆集 实现形式。HotSpot 默认的卡表标记逻辑使用一个字节数组:

CARD_TABLE[this.address >> 9] = 0;

字节数组 CARD_TABLE 上的每个元素都对应着其表示的内存区域中的 一块特定大小的内存块,称为 卡页。一般来说,卡页大小都是 2 的 N 次幂 的字节数,通过上面代码可以看出 HotSpot 使用的卡页是 2 的 9 次幂,即 512 字节 (将地址右移 9 位得到其在卡表数组中对应的元素,那么0 ~ 511 B 的地址右移 9 位后都是 0 ,说明 0 ~ 511 B 这些地址对应的是同一个 0 号卡页,所以卡页大小为 512 B)

只要卡页内有一个或更多的对象的字段存在着跨代指针,那么其对应的卡表数组元素的值就为 1 ,称为这个元素变脏;否则标识为 0

在垃圾收集的时候,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针 (即这些指针是来自其它非当前进行垃圾收集的区域的,但它指向的是当前进行垃圾收集区域中的对象,所以要把这些指针加入 GC Roots),然后把它们加入 GC Roots 一并扫描

写屏障

那么卡表元素如何维护呢?卡表元素变脏的时间是有其它分代区域中对象引用了本区域对象的时候。在 HotSpot 里是通过 写屏障 (Write Barrier) 技术维护卡表状态的

写屏障可以看作在虚拟机层面对 “引用类型字段赋值” 这个动作的 AOP 切面。在引用对象赋值时会产生一个 环形 (Around) 通知,供程序执行额外的动作,在赋值前的部分的写屏障叫做写前屏障,在赋值后的则叫做写后屏障
应用写屏障后,虚拟机会为所有赋值操作生成相应的指令,无论更新的是不是老年代对新生代的引用,每次只要对引用进行更新,就会产生额外的开销,不过当然,这个开销跟 Minor GC 时扫描整个老年代的开销相比还是低得多的

除此之外,卡表在高并发场景下还面临着 伪共享 (False Sharing) 的问题,因为多个卡表元素会共享一个缓存行,如果不同线程更新的对象正好处于这些卡表元素对应的内存区域中,就会导致更新卡表时正好写入同一个缓存行而影响性能。
一种简单的方案是不采用无条件的写屏障,而是先检查卡表标记,只有当卡表元素未被标记过时才将其变脏:

if(CARD_TABLE[this.address >> 9] != 0)   CARD_TABLE[this.address] = 0;

并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行才能进行根节点枚举以及遍历对象图进行标记过程。那么为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?

如果用户线程与收集器是并发工作的,可能会出现两种后果:一种是 把原本消亡的对象错误标记为存活,这是可以容忍的,只不过产生了一点逃过本次收集的 浮动垃圾 而已;另一种是 把原本存活的对象错误标记为已消亡,这种错误就很严重,会导致程序发生错误

借助 三色标记 的例子,可以得到下面的结论:当且仅当以下两个条件同时满足时会产生 “对象消失” 的问题,即原本应该是 黑色 的对象被误标为 白色:赋值器 (可以理解为用户线程) 插入了一条或多条从黑色对象到白色对象的新引用赋值器删除了全部从灰色对象到该白色对象的直接或间接引用 (因为黑色对象的引用关系都已经检查过了,不会再检查,这样如果有白色对象被其引用了,且白色对象没有再被任何灰色对象引用,那么虚拟机就不会发现这个引用关系,所以会删掉这个白色对象,其实应该说黑色对象,因为其已经被黑色对象引用了)

所以只需要破坏两个条件其中一个即可:

  1. 增量更新 (Incremental Update) 破坏的是第一个条件:
    当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次
    换言之,黑色对象一旦新插入了指向白色对象的引用它就变回灰色对象了
  2. 原始快照 (Snapshot At The Beginning,SATB) 破坏的是第二个条件:
    当灰色对象要删除指向白色对象的引用时,就将这个要删除的引用记录下来,并发扫描结束后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,换言之,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索,相当于 灰色对象到白色对象的引用没有删掉

通过这两种方法,就可以实现并发扫描,不过第二次扫描还是需要停止用户进程。但第二次扫描时停止用户进程的时间与单次扫描停止用户进程的时间相比,还是算少的。
以上对引用关系记录的插入或者删除都是通过 写屏障 实现的
CMS 是基于增量更新来做并发标记的;G1 则是用原始快照实现的

经典的垃圾收集器

经典垃圾收集器

Serial

Serial 不仅 只会使用一个处理器或一条收集线程 去完成垃圾收集工作,而且在它进行收集时,必须暂停其它所有工作线程,直到它收集结束
Serial 使用 复制 算法进行 Minor GC;Serial Old 使用 标记-整理 算法进行 Major GC

ParNew

ParNew 收集器实质上是 Serial 收集器的 多线程 并发版本,同样采用 复制 算法进行 Minor GC

Parallel Scavenge

Parallel Scavenge 基于 标记-复制 算法进行 新生代收集。它的特点是 它的关注点与其它收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 的目标则是 达到一个可控制的吞吐量 (Throughput),所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值:

吞吐量 = 运行用户代码时间  /   ( 运行用户代码时间 + 运行垃圾收集时间 ) 吞吐量 = 运行用户代码时间\ /\ (运行用户代码时间 + 运行垃圾收集时间) 吞吐量=运行用户代码时间 / (运行用户代码时间+运行垃圾收集时间)

停顿时间越短就越适合需要与用户交互或者需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器时间,尽快完成程序的运算任务,主要适用在后台运算而不需要太多交互的分析性任务

Parallel Old 是 Parallel Scavenge 收集器的 老年代 版本,支持 多线程并发收集,基于 标记-整理 算法实现。与 Parallel Scavenge 一样,注重吞吐量

CMS

CMS ( C o n c u r r e n t   M a r k   S w e e p Concurrent\ Mark\ Sweep Concurrent Mark Sweep) 收集器是一种以 获取最短回收停顿时间 为目标的收集器,符合关注服务的响应速度,希望系统停顿事件尽可能短以带来用户良好的交互体验的应用的需求

CMS 收集器基于 标记-清除 算法,它的运作过程分为:初始标记( C M S   i n i t i a l   m a r k CMS\ initial\ mark CMS initial mark),并发标记( C M S   c o n c u r r e n t   m a r k CMS\ concurrent\ mark CMS concurrent mark),重新标记( C M S   r e m a r k CMS\ remark CMS remark),并发清除( C M S   c o n c u r r e n t   s w e e p CMS\ concurrent\ sweep CMS concurrent sweep)

  • 初始标记 仅仅是标记一下 GC Roots 能直接关联到的对象,速度很快。需要停止用户线程 (“Stop The World”)
  • 并发标记 就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程 耗时较长 但是 不需要停顿用户线程,可以与垃圾收集线程一起并发运行 (因为使用了增量更新的方式解决并发时 “对象消亡” 的问题)
  • 重新标记 则是为了修正并发标记期间,因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,通过增量更新的方式进行操作,也需要停顿用户线程
  • 并发清除 阶段清理删除掉标记阶段判断的已经死亡的对象,可以跟用户线程同时并发,因为不需要移动存活的对象而且已经死亡的对象也不会被重新引用 (标记-清除算法)

CMS 是 HotSpot 虚拟机追求低停顿的第一次成功尝试,但它至少有以下三个明显缺点:

  • 对 处理器资源 非常敏感,因为它追求低停顿,所以是面向并发设计的,那么在并发阶段,虽然达到了不会导致用户线程停顿的目的,但却会因为占用了一部分线程,也即占用了一部分处理器的计算能力,而导致应用程序变慢,影响总吞吐量

  • 由于 CMS 无法处理 “浮动垃圾”,有可能出现 “Concurrent Mode Failure” 失败进而导致另一次完全 “Stop The World” 的 Full GC 的产生。“浮动垃圾” 指的是,在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉,这部分垃圾就称为 “浮动垃圾”

    同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还 需要预留足够内存空间提供给用户线程使用,因此 CMS 收集器不能像其它收集器那样等待到老年代几乎被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用,即老年代的使用空间大到某个阈值时就要进行垃圾收集,而不是等到空间都被使用了才进行收集

  • CMS 基于 标记-清除算法,因此收集结束时会有大量空间碎片的产生

Garbage First (G1)

停顿时间模型

G1 开创了收集器 面向局部收集 的设计思路和基于 Region 的内存布局形式。基于 Region 的堆内存布局是 G1 实现 建立起 “停顿时间模型” 的目标的关键,“停顿时间模型” 的意思是能够支持 在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标

Mixed GC

在 G1 收集器出现之前的所有收集器,垃圾收集的范围要么是整个新生代,要么就是整个老年代,再要么就是整个 Java 堆,而 G1 可以面向堆内存的 任何部分 来组成 回收集 (Collection Set,CSet) 进行回收,衡量标准 不再是它属于哪个分代,而是 哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式

不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域,即 Region,每一个 Region 都可以根据需要扮演新生代的 Eden 空间,Survivor 空间,或者 老年代空间,收集器能够对扮演不同角色的 Region 采用不同的策略去处理

Region 中还有一类特殊的 Humongous 区域,专门用于存储大对象,G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象;而对于那些超过了整个 Region 的容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中

回收方式

虽然 G1 仍保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列不需要连续的区域的动态集合

G1 收集器之所以能建立可预测的停顿时间模型的处理思路是,让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的 “价值” 大小,价值即 回收所获得的空间大小以及回收所需时间 的经验值,然后在后台维护一个优先级列表,每次根据用户设定的允许的收集停顿时间 (使用参数 -XX:MaxGCPauseMills 指定,默认 200 ms),优先处理回收价值收益最大的那些 Region,这也是名字 “Garbage First” 的由来

这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获得尽可能高的收集效率

一些细节的处理

  1. G1 收集器上 记忆集 的应用更加复杂,它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围内。本质上是一种哈希表,其中 Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表的索引号

  2. G1 收集器通过 原始快照 算法来解决并发标记阶段用户线程可能对对象图结构的破坏问题;此外,垃圾收集受用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行肯定就持续有新对象被创建,G1 为每个 Region 设计了两个名为 TAMS (Top at Mark Start) 的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上

    G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。类似于 CMS 中的 “Concurrent Mode Failure” 失败会导致 Full GC,如果内存回收的速度赶不上内存分配的速度,G1 也要被迫冻结用户线程的执行而导致 Full GC

运作过程

运作过程大致可分为以下四个步骤

  1. 初始标记 (Initial Marking):仅仅标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值让下一阶段用户线程并发时能正确地在可用的 Region 中分配新对象
  2. 并发标记 (Concurrent Marking):从 GC Roots 开始进行可达性分析找出要回收的对象;当对象图扫描完成后还要重新处理 SATB 记录下的在并发时有引用变动的对象
  3. 最终标记 (Final Marking):处理并发阶段结束时得到的 SATB 记录
  4. 筛选回收 (Live Data Counting and Evacuation):更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。
    由于涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成

关于停顿时间的设定

可以由用户指定期望的停顿时间是 G1 很强大的一个功能,但设置的 “期望值” 必须是符合实际的

尽管我们期望停顿时间越短越好,但如果把停顿时间调得非常低,很可能出现的结果就是由于停顿目标时间太短,导致每次所能选出来的回收集只占堆内存很小的一部分,即每次清理的堆内存空间较少,每次清理的效率不高,而且收集器收集的速度跟不上分配器分配的速度,那么垃圾就会慢慢堆积,最终占满堆引发 Full GC 反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒是比较合理的

收集器技术发展的里程碑

从 G1 开始,最先进的垃圾收集器设计导向都不约而同地变为 追求能够应付应用的内存分配速率,而不追求一次把整个 Java 堆全部清理干净,这样,应用分配的同时收集器也在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美
这种新的收集器设计思路从工程实现上看是从 G1 开始兴起的,所以说 G1 是收集器技术发展的一个里程碑

个人理解:这两种设计导向可以说是一种动态,一种静态:前者可以根据停顿时间的设定等其它信息,动态地调整每次收集的任务量;而后者就是不去考虑任何信息,每次都尝试把堆清理干净。动态的做法就使得收集器更加灵活,更加 “聪明”

G1 与 CMS 比较

  1. G1 跟 CMS 都非常关注停顿时间的控制

  2. 相比 CMS,G1 有很多优点。除了可以指定最大停顿时间,分 Region 的内存布局,按收益动态确定回收集等这些创新性设计,从传统的算法理论上看,CMS 采用的是 “标记-清除” 算法,而 G1 从整体上看是基于 ”标记-整理“ 算法实现的,但从局部上看,即两个 Region 之间,又是基于 ”标记-复制“ 算法实现的,这两种算法都意味着 G1 运作期间不会产生内存碎片,垃圾收集完成之后能提供规整的可用内存,而这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而被迫提前触发下一次收集

  3. 当然相比之下 G1 也有一些弱项,如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比 CMS 高

    • 内存占用 来说:两者都使用 卡表 来处理跨代指针,但 G1 的卡表实现更为复杂,而且 每个 Region 不论扮演何种角色都必须有一份卡表,这导致 G1 的记忆集会占很多的内存空间;相比起来 CMS 的卡表就很简单,只有唯一的一份,而且只需要处理老年代到新生代的引用,反过来不需要,由于新生代对象 ”朝生夕灭“ 的不稳定性,引用变化频繁,所以能省下新生代到其它区域的引用的维护开销是很划算的
      当然,代价就是当 CMS 发生 Old GC 时 (所有收集器中只有 CMS 有针对老年代的 Old GC),要把整个新生代作为 GC Roots 进行扫描,因为 CMS 并没有维护从新生代指向老年代的卡表,而 G1 的卡表是基于 Region 进行维护的,不论 Region 当前是扮演老年代的角色还是新生代的角色,它都持有非收集代指向收集代的卡表

    • 执行负载 的角度上,例如,两者 都使用写后屏障 来维护卡表,而 G1 为了实现原始快照搜索算法,还需要使用 写前屏障 来跟踪并发时的指针变化情况 (因为原始快照要在引用被扫描前记录这个引用,否则如果这个引用真的在扫描后被删除了就找不回来了;在扫描后如果该引用真的被删除了,才能记录下这个引用),比 CMS 的写屏障实现更复杂更耗时

猜你喜欢

转载自blog.csdn.net/Pacifica_/article/details/123114023