2. 垃圾收集器与内存分配策略

84 阅读35分钟

概述

垃圾收集需要完成三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

哪些对象(内存)需要回收

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。

1. 引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

  • 优点
    • 原理简单
    • 判定效率高
  • 缺点
    • 占用了一些额外的内存空间来进行计数
    • 未解决对象之间相互循环引用的问题。

循环引用:导致两个对象的计数都不为0,也就无法被回收

2. 可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是 从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

在 Java 技术体系里面,固定可作为 GC Roots 的对象包括 各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

强、软、弱、虚引用

当 GC Roots 包含过多对象而过度膨胀时,怎么办?

当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。

Java 对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用: 指在程序代码之中普遍存在的引用赋值,例如Object obj=new Object()
  • 软引用: 用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象回收,如果还不够则会抛出内存溢出异常
  • 弱引用: 用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用也称为“幽灵引用”或者“幻影引用”: 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

延长对象存活时间

finalize()方法是Object类中提供的一个方法,在GC准备释放对象所占用的内存空间之前,它将首先调用finalize()方法。

利用finalize()方法最多只会被调用一次的特性,我们可以实现延长对象的生命周期。

重写finalize()方法,让对象变得可达,就能延长对象的存活时间。但是需要注意避免使用该方法,因为调用时机具有不确定性,从一个对象变得不可到达开始。

回收方法区

方法区垃圾收集的“性价比”通常也是比较低的

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的 实例。
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP的重加载等,否则通常是很难达成的。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射 访问该类的方法。

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集” (Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。

1. 分代收集理论

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储

显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;

如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;

也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。

设计者一般至少会把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation) 两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。 新生代中的对象是完全有可能被老年代所引用的

为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。

举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用

只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。

此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GCRoots 进行扫描。

虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增 加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的

2. 标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

主要缺点:

  1. 执行效率不稳定:如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  2. 内存空间的碎片化问题:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3. 标记-复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。

标记-复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。

当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

主要优势:

  • 对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收
  • 分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
  • 这样实现简单,运行高效主要缺点:
  • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销
  • 这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费太多

现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收新生代,IBM 公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照 1∶1 的比例来划分新生代的内存空间。

在 1989 年,提出了一种更优化的半区复制分代策略,现在称为 “Appel 式回收”

具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用Eden 和其中一块 Survivor。

发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8∶1,也即每次新生代中可用内存空间为整个新生代容量的 90%(Eden 的 80%加上一个 Survivor 的 10%),只有一个 Survivor 空间,即 10%的新生代是会被“浪费”的。

但是,没有办法百分百保证每次回收都只有不多于 10%的 对象存活,因此 Appel 式回收还有一个充当罕见情况的“逃生门”的安全设计,当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代

4. 标记-整理算法

其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。 是否移动回收后的存活对象是一项优缺点并存的风险决策:

标记-清除算法和标记-整理算法对比,是否移动对象都存在弊端

  1. 移动则内存回收时会更复杂,不移动则内存分配时会更复杂(使用链表影响内存访问速度,还提高了复杂度)
  2. 从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿
  3. 但是从整个程序的吞吐量来看,移动对象会更划算。
  4. 不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。

吞吐量的实质是赋值器(Mutator),可以理解为使用垃圾收集的用户程序与收集器的效率总和

“和稀泥式”解决方案

可以不在内存分配和访问上增加太大额外负担

做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在

直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。

HotSpot 的算法细节

1. 根节点枚举

所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。

 在HotSpot中使用的是OopMap的结构,用于存储对象的类型,或者存储特定位置记录栈里面的寄存器哪些位置是引用,垃圾收集器扫描的时候就可以直接从对应的位置开始,不需要大范围的扫描动作。  

2. 安全点

导致 OopMap 内容变化可能是任意一条指令。为了减少 OopMap 的空间占用,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint),强制要求必须执行到达安全点后才能够暂停。

因此,安全点的选点既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。

如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来,是安全点另外一个需要考虑的问题。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)

抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。

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

3. 安全区域

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。

但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

  1. 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,告诉虚拟机这段时间里垃圾收集不必管我
  2. 当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举
    1. 如果完成了,那线程就当作没事发生过,继续执行
    2. 否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

4. 记忆集与卡表

垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。

设计实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集

卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。关于卡表与记忆集的关系,不妨按照 Java 语言中 HashMap 与 Map 的关系来类比理解。

HotSpot 虚拟机 卡表是最简单的形式,是一个字节数组,每一个元素都对应着其标识的内存区域中一块特定大小的内存块

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。

在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。

5. 写屏障

使用记忆集可以缩减 GC Roots 扫描范围

但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

当有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。

在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的。

6. 并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。

GC Roots 相比起整个 Java 堆中全部的对象毕竟还算是极少数,可从 GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。

想解决或者降低用户线程的停顿,就要搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?

引入三色标记法,遍历对象图过程中遇到的对象,黑灰白分别代表,已确定非垃圾、正在扫描(或者未扫描完成)、未扫描

如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?

收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。

  • 一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。
  • 另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误

产生错误标记消亡的必要条件?

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

对应的两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)是啥?

  • 增量更新破坏 当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。

    简单地说,即黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

  • 原始快照破坏 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中灰色对象为根,重新扫描一次。

    简化地说,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。

经典垃圾收集器

对各个收集器进行比较,但并非为了挑选一个最好的收集器出来

虽然垃圾收集器的技术在不断进步,但直到现在还没有最好的收集器出现,更加不存在“万能”的收集器,

所以我们选择的只是对具体应用最合适的收集器

1. Serial 收集器

  • 这个收集器是一个单线程工作的收集器
  • 它进行垃圾收集时,必须暂停其他所有工作线程,Stop The World,直到它收集结束。

事实上,迄今为止,它依然是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方

  • 简单高效(与其他收集器的单线程相比)
  • 内存消耗小
  • 对于单核处理器或处理器核心数较少的环境,没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
  • 垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。

2. ParNew 收集器

  • 实质上是 Serial 收集器的多线程并行(注意不是并发)版本,同时使用多条线程进行垃圾收集
  • 其余行为包括控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致 有一个与功能、性能无关但其实很重要的原因是: 除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。

CMS 收集器

这款收集器是 HotSpot 虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。

JDK 1.4.0 :CMS 作为老年代的收集器,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

JDK 9 开始:ParNew 合并入 CMS,成为它专门处理新生代的组成部分。ParNew 可以说是 HotSpot 虚拟机中第一款退出历史舞台的垃圾收集器。

  • ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果
  • 当然,随着可以被使用的处理器核心数量的增加,ParNew 对于垃圾收集时系统资源的高效利用还是很有好处的。

3. Parallel Scavenge 收集器

也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器

与 ParNew 收集器的区别:

  1. CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量 停顿时间越短,就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;

而高吞吐量,则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务

由于与吞吐量关系密切,Parallel Scavenge 收集器也经常被称作“吞吐量优先收集器”。

虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量自适应调节策略也是 Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特性。

4. Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

  • 这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用
  • 如果在服务端模式下,它也可能有两种用途
    • 与 Parallel Scavenge 收集器搭配使用
    • 作为 CMS 收集器发生失败时的后备预案

5. Parallel Old 收集器

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

6. CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

  1. 初始标记(CMS initial mark) 初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要“Stop The World”
  2. 并发标记(CMS concurrent mark) 从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
  3. 重新标记(CMS remark) 而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要“Stop The World”,会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
  4. 并发清除(CMS concurrent sweep) 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

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

但是它也有三个缺点:

  1. CMS 收集器对处理器资源非常敏感 在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量
  2. 无法处理“浮动垃圾” 在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
  3. CMS 是一款基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。 解决方法:要求 CMS 收集器在执行过若干次(数量由参数值决定)不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理

7. Garbage First 收集器

它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。

G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、 Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

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

将 Region 作为单次回收的最小单元,即每次收集到的内存空间都 是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。

将 Java 堆分成多个独立 Region 后,Region 里面存在的跨 Region 引用对象 如何解决?

使用记忆集避免全堆作为 GC Roots 扫描每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

根据经验,G1 至少要耗费大约相当于 Java 堆容量 10%至 20%的额外内存来维持收集器工作。

在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

CMS 收集器采用增量更新算法实现,而 G1 收集器则是通过原始快照(SATB)算法来实现的。

此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1 为每一个 Region 设计了两个名为 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配

如果内存回收的速度赶不上内存分配的速度, G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间“Stop The World”。

怎样建立起可靠的停顿预测模型?

Region 的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些 Region 组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

G1 收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发 执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  • 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

可以指定期望的停顿时间是 G1 收集器很强大的一个功能,可使得 G1 在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。

如果停顿目标时间太短,将会导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发 Full GC 反而降低性能

G1的优点,相比于CMS

  • 可以指定最大停顿时间
  • 分 Region 的内存布局
  • 按收益动态确定回收集
  • 从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个 Region 之间)上看又是基于“标记-复制”算法实现,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。
  • 有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

G1的缺点

  • 内存占用要比 CMS 要高。
  • 程序运行时的额外执行负载都要比 CMS 要高。 它们都使用到写屏障,CMS 用写后屏障来更新维护卡表; 而 G1 除了使用写后屏障, 为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。

G1和CMS的适用场景

哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。

小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势

低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量 (Throughput)和延迟(Latency)

但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。

1. Shenandoah 收集器

Shenandoah 更像是 G1 的下一代继承者,它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码

它与 G1 至少有三个明显的不同之处:

  • 支持并发的整理算法
  • 目前是默认不使用分代收集的,不会有专门的新生代 Region 或者老年代 Region 的存在
  • 摒弃了在 G1 中耗费大量内存和计算资源去维护的记忆集 改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨 Region 的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题(见 3.4.4 节)的发生概率。

连接矩阵可以简单理解为一张二维表格,如果 Region N 有对象指向 Region M,就在表格的 N 行 M 列中打上一个标记

Shenandoah 用以支持并行整理的核心概念——Brooks Pointer 转发指针,来实现对象移动与用户程序并发的一种解决方案。

该方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己

2. ZGC 收集器

ZGC 和 Shenandoah 的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。

内存布局

ZGC 也采用基于 Region 的堆内存布局,但与它们不同的是,ZGC 的 Region具有动态性——动态创建和销毁,以及动态的区域容量大小。

并发整理算法的实现

Shenandoah 使用转发指针和读屏障来实现并发整理,ZGC 虽然同样用到了读屏障,但有一个标志性的设计是它采用的染色指针技术?