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

一、概述

Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而死,栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此这几个区域的内存分配和回收具有确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或线程结束时,内存自然就跟随着回收了。
而Java堆和方法区这两个区域则有着很显著的不确定性:只有处于运行期间,我们才能直到程序究竟会创建哪些对象,创建多少对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

二、对象已死?

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。垃圾收集器与内存分配策略 - 图3

2.1 引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

  1. public class ReferenceCountingGc {
  2. Object instance = null;
  3. public static void main(String[] args) {
  4. ReferenceCountingGc objA = new ReferenceCountingGc();
  5. ReferenceCountingGc objB = new ReferenceCountingGc();
  6. objA.instance = objB;
  7. objB.instance = objA;
  8. objA = null;
  9. objB = null;
  10. }
  11. }

2.2 可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者说从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
垃圾收集器与内存分配策略 - 图4
可作为 GC Roots 的对象包括下面几种:

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

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性地加入,共同构成完整GC Roots集合。比如分代收集和局部回收。如果只针对Java堆中某一块区域发起垃圾收集时,必须考虑到内存区域是虚拟机自己的实现细节,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去。

2.3 再谈引用

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

  • JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
  • JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

    1.强引用(StrongReference)

    以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

    2.软引用(SoftReference)

    如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
    软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

    3.弱引用(WeakReference)

    如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
    弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

    4.虚引用(PhantomReference)

    “虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
    虚引用主要用来跟踪对象被垃圾回收的活动。
    虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

    2.4 可达性分析算法中不可达对象并非非死不可

    在可达性分析算法中判定为不可达的对象,并非是非死不可,要真正宣告一个对象的死亡,至少要经历两次标记过程:
  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么它将会被第一次标记;随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
    1. 假如对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为没有必要执行。
    2. 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finzlizer线程去执行它们的finalize()方法。
  2. 稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中重新与引用链上的任何一个对象建立关联即可被移除回收集合。

    2.5 回收方法区

    方法区的垃圾收集主要回收两部分内容:

    1. 废弃的常量

    回收废弃常量和回收Java堆中的对象非常类似。假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。

    2. 不再使用的类型

    方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
    判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  3. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。

  4. 加载该类的类加载器 ClassLoader 已经被回收。
  5. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading-XX:+TraceClassUnLoading查看类加载和卸载信息。

三、垃圾收集算法

从如何判定对象的消亡的角度出发,垃圾收集可以划分为“引用计数式垃圾收集”和“追踪式垃圾收集”两大类,也成为“直接垃圾收集”和“间接垃圾收集”。

3.1 分代收集理论

当前虚拟机的垃圾收集大多数都遵循了“分代收集”的理论进行设计,它建立在两个分代假说之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭。
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难消亡。

这两个分代假说共同奠定了多数垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
把分代收集理论具体放到现在的商用Java虚拟机里,比如HotSpot就把Java堆划分为新生代和老年代两个区域。在新生代中,每次垃圾收集时发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
但是分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。如此需要对分代收集理论添加第三条经验法则:

  1. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

依据这条假说,就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代建立一个全局的数据结构——记忆集,用来把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集
    • 新生代收集(Minor GC/Yong GC):指目标只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

3.2 标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是判定对象是否属于垃圾。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题
  2. 空间问题(标记清除后会产生大量不连续的碎片)

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

3.3 标记-复制算法

它将可用内存按容量划分成大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。垃圾收集器与内存分配策略 - 图6
缺点也非常明显:将可用内存缩小为了原来的一半,空间浪费。于是有了更优化的半区复制分代策略。称其为“Appel 式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。
具体做法是将新生代分为一块较大的Eden空间和两块较小的Survivor空间(HotSpot虚拟机默认Eden和Survivor的大小比例是8:1),每次分配内存只使用Eden和其中一块Survivor空间。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已使用过的那块Survivior空间。当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(大多就是老年代)进行分配担保。

3.4 标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用内存中所有的对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,提出了一种标记-整理算法:
标记过程与标记-清除算法一样,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。垃圾收集器与内存分配策略 - 图7
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。

四、HotSpot的算法细节实现

4.1 根节点枚举

以可达性分析算法中从GC Roots集合找引用链这个操作来介绍:固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并不容易。
迄今为止,所有收集器在根节点枚举这一步骤时都是必须要暂停用户线程的,现在可达性分析算法耗时最长的查找引用链的过程已经可以和用户线程一起并发,但是根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行。这里的一致性是指整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况。
由于目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来以后,其实并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当是由办法直接得到哪些地方存放着对象引用的。
在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用,这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏的从方法区等GC Roots开始查找。

4.2 安全点

在OopMap的协助下,HotSpot可以快速准确的完成GC Roots枚举,但是有一个问题:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间。
上一节中提到,只是在特定位置记录了这些信息,这些位置称为安全点,有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显的特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
对于安全点,另一个需要考虑的问题就是:如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来。这里有两种方案:

  1. 抢占式中断:不需要线程的执行代码主动去配合,在垃圾收集时,系统首先把所有用户线程安全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
  2. 主动式中断:当垃圾收集需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

    1. HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。test指令就是HotSpot生成的轮询指令,当需要暂停用户线程时,虚拟机把0x160100内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待。
      1. 0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460}
      2. ; *invokeinterface size
      3. ; - Clinet1::main@113 (line 23)
      4. ; {virtual_call}
      5. 0x01b6d62c: nop ; OopMap{[60]=Oop off=461}
      6. ; *if_icmplt
      7. ; - Client1::main@118 (line 23)
      8. 0x01b6d62d: test %eax,0x160100 ; {poll}
      9. 0x01b6d633: mov 0x50(%esp),&esi
      10. 0x01b6d637: cmp %eax,%esi

      4.3 安全区域

      安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是程序“不执行”的时候呢?所谓程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或Blocked状态,这是线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,对于这种情况,就引入了安全区域来解决。
      安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。也可以将安全区域看作被扩展拉伸了的安全点。当用户线程执行到了安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则就必须一直等待,直到收到可以离开安全区域的信号为止。

      4.4 记忆集与卡表

      在讲分代理论时,提到了为了解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。
      记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。这种记录全部含跨代引用对象的实现方案,比较昂贵。
      1. Class RememberedSet{
      2. Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
      3. }
      在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。于是便可以选择更为粗犷的记录粒度来实现:
  3. 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。

  4. 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  5. 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
    1. 指的是用一种称为“卡表”的方式去实现记忆集。它定义了记忆集的记录精度、与堆内存的映射关系等。卡表最简单的形式可以只是一个字节数组,HotSpot默认的卡表标记逻辑: CARD_TABLE [this address >> 9]=0;字节数组CARD_TABLE的每一个元素都对应着标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”。一般来说卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。

image.png

  1. 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称这个元素变脏,没有则标识为0.在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

    4.5 写屏障

    我们已经解决了如何使用记忆集来缩减GC Roots扫描范围,但还没有解决卡表元素如何维护:何时变脏?怎么变脏?
  • 卡表元素何时变脏——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏。变脏时间点原则上应该发生在引用类型字段赋值的那一刻
  • 如何变脏?即如何在对象赋值的那一刻去更新维护卡表?
    • 如果是解释执行的字节码——虚拟机负责每条字节码指令的执行,有充分的介入空间
    • 在编译执行的场景中,即时编译后的代码已经是纯粹的机器指令流了,这就必须要找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作中。

在HotSpot虚拟机里是通过写屏障维护卡表状态的,写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范围内。在赋值前的部分的写屏障叫做写前屏障,在赋值后的则叫做写后屏障。
应用写屏障之后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多。
卡表在高并发场景下还面临着“伪共享”问题。现在中央处理器的缓存系统中是以缓存行为单位存粹的,当多线程修改相互独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响而导致性能降低。
为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。在JDK7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark用来决定是否开启卡表更新的条件判断。

4.6 并发的可达性分析

产生对象消失的问题(即原本应该是黑色的对象被误标成为白色的)需要满足两个条件:

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

解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可,于是分别产生了两种解决方法:

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

    五、垃圾收集器

    如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

    5.1 Serial收集器

    Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。
    新生代采用标记-复制算法暂停所有用户线程,老年代采用标记-整理算法暂停所有用户线程。
    垃圾收集器与内存分配策略 - 图9
    虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
    但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择(HotSpot虚拟机运行在客户端模式下的默认新生代收集器)。

    5.2 ParNew收集器

    ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
    新生代采用标记-复制算法,老年代采用标记-整理算法。
    垃圾收集器与内存分配策略 - 图10
    它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
    并行和并发概念补充:
  • 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

    5.3 Parallel Scavenge收集器

    Parallel Scavenge 收集器也是一款新生代收集器,也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢? ```powershell -XX:+UseParallelGC

    使用 Parallel 收集器+ 老年代串行

-XX:+UseParallelOldGC

  1. 使用 Parallel 收集器+ 老年代并行

``` Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是

  1. 控制最大垃圾收集停顿时间——-XX:MaxGCPauseMillis
  2. 直接设置吞吐量大小——-XX:GCTimeRatio

还有一个参数需要关注一下就是:

  1. 开关参数——-XX:UseAdaptiveSizePolicy,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

新生代采用标记-复制算法,老年代采用标记-整理算法。
垃圾收集器与内存分配策略 - 图11

5.4 Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:

  1. 一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用。
  2. 另一种用途是作为 CMS 收集器的后备方案,在并发收集发生Concurrent Mode Failure时使用。

image.png

5.5 Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

5.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  1. 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  2. 并发标记: 从GC Roots的直接关联对象开始遍历整个对象图的过程,过程耗时长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  3. 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  4. 并发清除:清理删除掉标记阶段的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

垃圾收集器与内存分配策略 - 图13
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

    5.7 Garbage First 收集器

    G1 (Garbage-First) 开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
    作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎是实时Java的中软实时垃圾收集器特征了。
    G1可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量多,回收收益最大,这就是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仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,他们都是一系列区域不需要连续的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单词回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的价值大小,价值即回收所获得的空间大小以及回收所需要的时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region。
    G1 收集器的运作大致分为以下几个步骤:
  1. 初始标记——仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记——从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理STAB记录下的在并发时有引用变动的对象。
  3. 最终标记——对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  4. 筛选回收——负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作设计存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

image.png

1. G1的实现细节

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

使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂的多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来要更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。G1至少要消耗大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

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

这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果错误。CMS收集器采用增量更新算法实现;G1收集器则是通过原始快照SATB算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

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

用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?
G1收集器的停顿预测模型是以衰减均值为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费成本,并分析得出平均值、标准偏差、置信度等统计信息。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

六、低延迟垃圾收集器

6.1 Shenandoah收集器

Shenandoah基于Region的对内存布局,有着用于存放大对象的Humongous Region,默认的回收策略是优先处理回收价值大的Region。但在管理堆内存方面,与G1至少有三个明显的不同之处:

  • 支持并发的整理算法,G1的回收阶段是多线程并行的,但却不能与用户线程并发。
  • 默认不使用分代收集。
  • 改用连接矩阵的全局数据结构来记录跨Region的引用关系。

Shenandoah收集器的工作过程大概可以划分为九个阶段:

  1. 初始标记——与G1相同,首先标记与GC Roots直接关联的对象,这个阶段仍然是Stop The World的,但停顿时间与堆大小无关,只和GC Roots的数量相关。
  2. 并发标记——与G1相同,遍历对象图,标记出全部可达的对象,这个阶段是和用户线程并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
  3. 最终标记——与G1相同,处理剩余SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集。最终标记阶段也会有一小段短暂的停顿。
  4. 并发清理——这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(被称为Immediate Garbage Region)。
  5. 并发回收——在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决。并发回收阶段运行的时间长短取决于回收集的大小。
  6. 初始引用更新——并发回收阶段复制对象结束后,还需要把对象中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。这个阶段实际上并未做什么具体的处理,只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给他们的对象移动任务而已。初始引用更新时间很短,会产生一个短暂的停顿。
  7. 并发引用更新——真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少,并发引用更新与并发标记不同,他不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改成新值即可。
  8. 最终引用更新——解决堆中的引用更新后,还要修正存在于GC Roots的引用。这个阶段是最后一次停顿,停顿时间只与GC Roots数量相关。
  9. 并发清理——经过并发回收和引用更新后,整个回收集中所有的Region已再无存活对象,这些Region都变为Immediate Garbage Region了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

    6.2 ZGC收集器

    ZGC收集器是一款基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

    1. ZGC的Region

    ZGC也采用基于Region的堆内存布局,但是不同的是ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小。在X64硬件平台下,ZGC的Region可以具有大、中、小三种类型的容量:

  10. 小型Region:容量固定为2MB,用于放置小于256KB的小对象。

  11. 中型Region:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  12. 大型Region:容量不固定,可以动态变化,但必须是2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象。大型Region在ZGC的实现中是不会被重分配的。

    2. 并发整理算法的实现——染色指针技术

    Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样用到了读屏障但是用的确实一条更加复杂精巧的思路。
    ZGC收集器有一个标志性的设计就是它采用的染色指针技术,染色指针是一种直接将少量额外的信息存储在指针上的技术。Linux下64位指针的高18位不能用来寻址,ZGC的染色指针技术使用剩下的46位指针宽度的高4位用来存储四个标志信息。通过这些标志位虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集、是否通过finalize()方法才能被访问到。
    染色指针的三大优势:

  13. 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉。而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。,而Shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region。

  14. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象的变动情况,如果将这些信息直接维护在指针中,显然可以省去一些专门的记录操作。ZGC只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC不支持分代收集,就没有跨代引用的问题)。
  15. 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据。Linux下64位指针还有前18位未使用,虽然不能用来寻址,却可以通过其他手段用于信息记录。

    3. 多重映射技术

    Java虚拟机作为一个普通的进程,这样随意重新定义内存中某些指针的前几位,操作系统是否支持?
    Linux/x86-64平台上的ZGC使用了多重映射将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射。

    4. ZGC的工作阶段

  16. 并发标记——与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记的短暂停段,而且这些停顿阶段所做的事情在目标上也是类似。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked0、Marked1标志位。

  17. 并发预备重分配——这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集。重分配集与G1的回首集还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。相反ZGC每次回收都会扫描所以的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。ZGC的重新分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。
  18. 并发重分配——重分配是ZGC执行过程中的核心阶段,这个过程要把重新分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”能力。
  19. 并发重映射——重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必要迫切去完成的任务,因为前面说过,即使是旧引用也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益)。因此ZGC把并发重映射阶段要做的工作,合并到下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。