0
点赞
收藏
分享

微信扫一扫

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

产品喵dandan米娜 2022-05-01 阅读 87
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 中注册的回调,本地代码缓存等

关于引用

JDK1.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:又称”幽灵引用“或者”幻影引用“,一个对象是否有虚引用的存在完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的只是为了在这个对象被回收的时候收到一个系统通知

JDK1.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式回收

Apple式回收是一种更优化的半区复制分代策略,后文会讲到的Serial,ParNew等新生代收集器均采用了这种策略
具体做法:把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间 (分别是From Survivor以及To Survivor),每次分配内存只使用 Eden 和其中一块 Survivor,发生垃圾收集时,将 Eden 跟 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 跟已用过的那块 Survivor 空间,HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,即每次新生代中可用的内存空间为整个新生代容量的90%,这就解决了半区复制的问题
尽管新生代中存活的对象很少,但任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,一旦存活的对象大于10%,剩下的那个Survivor就不够存放这些对象了。这就需要分配担保机制来解决这个问题:当To Survivor空间没有足够空间存放上一次Minor GC存活下来的对象,无法容纳的对象就直接进入老年代
大多数情况下,对象在新生代中的Eden中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

为什么要有两个Survivor

两个Survivor 解决的是内存碎片的问题
假设只有一个Survivor,Eden中存活的对象全部放在这个Survivor中,下次标记时,Eden中存活的对象可以整齐地复制到Survivor中存储,但是Survivor中原先存储的对象中可能也有部分回收部分存活,这样在其中又会出现内存碎片的问题。也就是说,只要一块区域中有存放对象,那么就避免不了这块区域要出现内存碎片,解决方法就是引入一块永远为空,不会存放对象的区域
所以需要两个Survivor,一个From Survivor,一个To Survivor,每次存放对象都存放在Eden跟From中,当垃圾收集时,将Eden跟From中的存活对象都整齐地复制到To中存放,对Eden跟From进行全部回收,然后From跟To交换身份
这样就保证了To Survivor中永远都是空的,可以供Eden跟From中存活的对象进行复制整理,解决了空间碎片的问题
而为什么不要更多的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 这些地址对应的是同一个卡页,所以卡页大小为 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;

并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行才能进行根节点枚举以及遍历对象图进行标记过程。那么为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
如果用户线程与收集器是并发工作的,可能会出现两种后果:一种是把原本消亡的对象错误标记为存活,这是可以容忍的,只不过产生了一点逃过本次收集的 浮动垃圾 而已;另一种是把原本存活的对象错误标记为已消亡,这种错误就很严重,会导致程序发生错误
借助 三色标记 的例子,可以得到下面的结论:当且仅当以下两个条件同时满足时会产生 “对象消失” 的问题,即原本应该是黑色的对象被误标为白色:赋值器 (可以理解为用户线程) 插入了一条或多条从黑色对象到白色对象的新引用;赋值器删除了全部从灰色对象到该白色对象的直接或间接引用 (因为黑色对象的引用关系都已经检查过了,不会再检查,这样如果有白色对象被其引用了,且白色对象没有再被任何灰色对象引用,那么虚拟机就不会发现这个引用关系,所以会删掉这个白色对象)
所以只需要破坏两个其中一个条件即可:
增量更新 (Incremental Update) 破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次,换言之,黑色对象一旦新插入了指向白色对象的引用它就变回灰色对象了
原始快照 (Snapshot At The Beginning,SATB) 破坏的是第二个条件,当灰色对象要删除指向白色对象的引用时,就将这个要删除的引用记录下来,并发扫描结束后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,换言之,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索,相当于灰色对象到白色对象的引用没有删掉
以上堆引用关系记录的插入或者删除都是通过写屏障实现的
CMS 是基于增量更新来做并发标记的;G1,Shenandoah则是用原始快照实现

经典的垃圾收集器

经典垃圾收集器

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 进行扫描
    >>>执行负载的角度上,例如,两者都使用写后屏障来维护卡表,而 G1 为了实现原始快照搜索算法,还需要使用写前屏障来跟踪并发时的指针变化情况,比 CMS 的写屏障实现更复杂更耗时
举报

相关推荐

0 条评论