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

如何判断对象已死?

引用计数法

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

引用计数难以解决对象之间相互循环引用的问题,如 a.child = b; b.child = a;

可达性分析算法

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

可作为 GC Roots 的对象包括:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
  • 方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NPE、OOM)等,还有系统类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象
  • 反映 Java 虚拟机内部情况的 JMXBean、JVM TI 中注册的回调、本地代码缓存等。

并发时的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举时,由于 GC Roots 相比整个 Java 堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如 OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。

为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过,显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

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

  • 把原本已消亡的对象错误标记为存活(浮动垃圾,可清理)
  • 把原本存活的对象错误标记为已消亡

Wilson 于 1994 年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

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

显然,要解决并发扫描时的对象消失问题,只需破坏二者之一即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)。

  • 增量更新:破坏第一个条件。当黑色对象插入新的指向白色对象的引用关系时,
    • 将新插入的引用记录下来
    • 在并发扫描结束之后,再以这些记录过的引用关系中的黑色对象为根,重新扫描一次。
    • 可以理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了(白加黑会变灰)。
  • 原始快照:破坏第二个条件。当灰色对象要删除指向白色对象的引用关系时,
    • 将要删除的引用记录下来
    • 在并发扫描结束之后,再以这些记录过的引用关系中的灰色对象为根,重新扫描 一次。
    • 可以理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

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

CMS 基于增量更新实现并发标记,G1、Shenandoah 基于原始快照实现。

四大引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。

引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用(Strong Reference)
    • 最普遍的引用:Object obj = new Object();
    • 抛出 OutOfMemoryError 终止程序也不会回收具有强引用的对象
    • 通过将对象设置为 null 来软化引用,使其被回收。
  • 软引用(Soft Reference)
    • 对象处在有用但非必须的状态
    • 只有当内存空间不足时,GC 才会回收该引用的对象的内存。
    • 可以用来实现高速缓存
  • 弱引用(Weak Reference)
    • 非必须的对象,比软引用更弱一些。
    • GC 时会被回收
    • 被回收的概率也不大,因为 GC 线程优先级比较低。
    • 适用于引用偶尔被使用且不影响垃圾收集的对象
  • 虚引用(Phantom Reference)
    • 不会决定对象的生命周期
    • 任何时候都可能被 GC 回收
    • 跟踪对象被垃圾收集器回收的活动,起哨兵的作用。
    • 必须和引用队列 ReferenceQueue 联合使用
引用类型 被垃圾回收时间 用途 生存时间
强引用 从来不会 对象的一般状态 JVM停止运行时终止
软引用 内存不足时 对象缓存 内存不足时终止
弱引用 垃圾回收时 对象缓存 GC 运行后终止
虚引用 不确定 标记、哨兵 不确定
  • 引用队列(Reference Queue)
    • 无实际存储结构,存储逻辑依赖于内部节点之间的关系来表达。
    • 存储关联的且被 GC 的软引用,弱引用以及虚引用。

To be, or not to be.

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程

  • 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记

  • 随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

  • 如果这个对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。

  • finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 — 只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合,如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class FinalizeDemo {

public static FinalizeDemo SAVE_HOOK = null;

public void isAlive() {
System.out.println("I am still alive");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method called");
FinalizeDemo.SAVE_HOOK = this;
}

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeDemo();
// 第一次拯救自己
SAVE_HOOK = null;
System.gc();
// Finalizer 方法优先级很低 等待 0.5s 保证其被调用
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("Holy crap! I am dead inside.");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
// Finalizer 方法优先级很低 等待 0.5s
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("Holy crap! I am dead inside.");
}
}
}
1
2
3
4
// 执行结果
// finalize method called
// I am still alive
// Holy crap! I am dead inside.

任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会被再次执行,因此第二段代码的自救行动失败了。

回收方法区

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

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

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

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

分代收集理论

分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis): 绝大多数对象都是朝生夕灭的。

  • 强分代假说(Strong Generational Hypothesis): 熬过越多次垃圾收集过程的对象就越难以消亡。

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

很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反之亦然。

遍历整个老年代所有对象的方案虽然理论上可行,但会为内存回收带来很大的性能负担。为了解决这个问题,添加第三条经验法则:

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

这其实是可根据前两条假说逻辑推理得出的隐含推论: 存在互相引用关系的两个对象,是应该倾向于同生共死的。举个🌰,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样难以消亡,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set)。

记忆集:把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描

部分收集(Partial GC): 指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

整堆收集(Full GC): 收集整个Java堆和方法区的垃圾收集。

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是判定对象否属于垃圾的过程。

主要缺点有两个:

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

标记-复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费太多。

标记-复制算法示意图

还有一种做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。默认Eden和Survivor的大小比例是8∶1。

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

当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不选用标记-复制算法。

“标记-整理”(Mark-Compact)算法中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记整理算法示意图

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

  • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,也就是“Stop The World”。
  • 如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器内存访问器来解决。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。

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

经典垃圾收集器

下图展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器

HotSpot虚拟机的垃圾收集器

Serial收集器

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

Serial/Serial Old收集器运行示意图

它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。

ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数都与Serial收集器完全一致。例如:

  • -XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure
  • 收集算法
  • Stop The World
  • 对象分配规则
  • 回收策略

ParNew收集器运行示意图

目前只有它能与CMS收集器配合工作

  • 并行(Parallel): 并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

  • 并发(Concurrent): 并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。

Parallel Scavenge收集器

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

它的关注点与其他收集器不同,

  • CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间
  • Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即

$$
吞吐量 = \frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间}​
$$
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是

  • 控制最大垃圾收集停顿时间-XX:MaxGCPauseMillis参数,参数范围为大于 0。
  • 直接设置吞吐量大小-XX:GCTimeRatio参数,参数范围为 (0,100)。

除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy

当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。如果在服务端模式下,它也可能有两种用途:

  • 一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用。
  • 另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

SerialOld收集器运行示意图

Parallel Old收集器

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

直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组 合。

ParallelOld收集器运行示意图

CMS收集器

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

CMS收集器是基于标记-清除算法实现的,它的运作过程分为四个步骤,包括:

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

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。

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

CMS收集器运行示意图

CMS收集器是 HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:

  • CMS收集器对处理器资源非常敏感,CMS 默认启动的回收线程数是(处理器核心数量 +3)/4。
  • 无法处理“浮动垃圾”(FloatingGarbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
    • 适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。这个参数设置得太高将会很容易导致大量的并发失败产生,性能反而降低。
  • 基于“标记-清除”算法实现的收集器,收集结束时可能会有大量空间碎片产生,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
    • 为了解决这个问题, CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认是开启的,JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长
    • 参数-XX:CMSFullGCsBeforeCompaction(JDK9开始废弃)的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入FullGC时都进行碎片整理)。

*浮动垃圾: * 在CMS的并发标记和并发清理阶段,用户线程仍在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好下一次垃圾收集时再清理掉。

G1收集器

Garbage First(简称G1)收集器

  • 设计思路:面向局部收集
  • 内存布局:基于Region的内存布局

在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。

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

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

Region中还有一类特殊的Humongous区域,专门用来存储大对象。

G1 认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待,如下图所示。

G1收集器Region分区示意图
  1. 仍然有新生代与老年代概念之分,但是新生代与老年代不再是固定不变的了,它们都是一系列区域(不需要连续)的动态集合。
  2. 不再坚持回收区域大小。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。因为不再是回收整个老年代或者整个新生代,所以可以比较容易停下。

更具体的思路是让G1收集器去跟踪各个 Region 里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些 Region。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

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

  • CMS收集器采用增量更新算法
  • G1收集器则是通过原始快照(SATB)算法

如果不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1 收集器的运作过程大致可划分为以下四个步骤:

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

G1收集器运行示意图

从上面可以看出,G1

  • 整体上基于标记-整理算法实现;
  • 局部(两个Region之间)角度看是基于标记-复制算法实现。

因为没有采用标记-清除算法,所以 G1 运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。有利于程序长时间运行,在程序需要为大对象分配内存时,因为连续内存空间较多,所以发生 Full GC 的概率也较小。

TAMS(Top at Mark Start)指针:有两个,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。

总结

收集器名称 收集算法 关注点 分代
Serial 标记-复制 新生代
ParNew 标记-复制 新生代
Parallel Scavenge 标记-复制 吞吐量 新生代
Serial Old 标记-整理 老年代
Parallel Old 标记-整理 吞吐量 老年代
CMS 标记-清除 最短停顿时间 老年代
G1 整体上标记-整理,局部上标记-复制。 最短停顿时间 不再区分新生代与老年代

低延迟收集器

下图浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。

各款收集器的并发情况

从图中可以看出:

  • CMS和G1之前的全部收集器,其工作的所有步骤都会产生“Stop The World”式的停顿。
  • CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发。(3.4.6节:并发的可达性分析)
  • Shenandoah 和 ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿。

Shenandoah收集器

Shenandoah 与 G1 的区别:

  • 支持并发的整理算法不同。
  • Shenandoah 没有专门的新生代 Region 或者老年代 Region 的存在,不存在分代。
  • 放弃了记忆集,改用连接矩阵。

连接矩阵示意图

Shenandoah 工作的九个过程:

  • 初始标记(Initial Marking):标记与GC Roots直接关联的对象,仍需 STW,但停顿时间与堆大小无关,只与GC Roots的数量相关。
  • 并发标记(Concurrent Marking):遍历对象图,标记出全部可达的对象。与用户线程并发,用时取决于堆中存活对象的数量以及对象图的结构复杂程度。
  • 最终标记(Final Marking):处理剩余的SATB扫描,统计出回收价值最高的Region,构造回收集。
  • 并发清理(Concurrent Cleanup):用于清理那些整个区域内连一个存活对象都没有找到的Region。
  • 并发回收(Concurrent Evacuation):把回收集里面的存活对象先复制一份到其他未被使用的Region之中。时间取决于回收集的大小。
  • 初始引用更新(Initial Update Reference):把堆中所有指向旧对象的引用修正到复制后的新地址。
  • 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,时间长短取决于内存中涉及的引用数量的多少。它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
  • 最终引用更新(Final Update Reference):修正存在于 GC Roots 中的引用。
  • 并发清理(Concurrent Cleanup):回收Region的内存空间,供以后新对象分配使用。

三个最重要阶段:并发标记、并发回收、并发引用更新。

ZGC收集器

与Shenandoah关注点相同:在吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。

同样基于Region的堆内存布局,在 x64 硬件平台下,ZGC 的 Region 可以有大、中、小三类容量:

  • 小型 Region(Small Region): 容量固定为 2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region): 容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置 4MB或以上的大对象。

Shenandoah 使用转发指针和读屏障来实现并发整理,ZGC 虽然同样用到了读屏障,但是实现思路完全不同。

接下来的内容待深究。

参考

  • 《深入理解Java虚拟机:JVM高级特性与最佳实践》(第3版) - 周志明

评论