JVM之垃圾回收

1. 垃圾回收概述

1.1 哪些内存需要回收

 为了提高内存利用率,虚拟机可以对无用的内存进行回收清理。虚拟机的内存分配和内存回收技术已经很成熟,但是我们仍然需要了解垃圾回收相关原理,以便于在发生内存溢出、内存泄露等问题时,可以排定位查。

 前面说过,运行时内存区域分为程序计数器、虚拟机栈、本地方法栈、堆、方法区和运行常量池。其中pc计数器、虚拟机栈、本地方法栈区域随线程而生,随线程而死,因此这3个区域的内存分配和回收都具有确定性,不需要过多考虑垃圾回收问题。

 而java堆和方法区的内存分配是动态的,一个接口的多个实现类所需内存不一样,不同的分支语句所需要内存也不一样,这部分区域内存只有在运行期间才会确定,创建了多少个对象,需要分配多大内存等,这部分内存是动态分配和回收的,因此垃圾收集器关注的就是java堆和方法区的内存管理

1.2 如何判断对象需要回收

 垃圾收集器处理的大多是死掉的对象,程序不再使用的对象,name如何判断对象已撕,需要回收呢?有两种方法:引用计数和可达性分析。

引用计数法

 为每一个对象配置一个计数器,当对象被其他地方引用时,计数器+1,引用失效时,计数器-1。当任何时刻计数器值为0时,说明对象不可能再被引用,该对象可以被回收。

 这种方式原理简单、判定效率高,大多数情况下,是个很好的方式。但是主流的JVM没有选择它,因为这种算法需要考虑的额外情况很多,比如循环引用的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ReferenceCounts {
public Object instance = null;

private static final int _1M = 1024 * 1024;

private byte[] bigSize = new byte[2 * _1M];

public static void main(String[] args) {
testGc();
}

public static void testGc() {
ReferenceCounts a = new ReferenceCounts();
ReferenceCounts b = new ReferenceCounts();
a.instance = b;
b.instance = a;

a = null;
b = null;
//此时发生回收
System.gc();
}
}

 执行javac ReferenceCounts.java java -verbose:gc ReferenceCounts可以看到内存从6767K->472K,并没有因为相互引用而不回收他们,说明jvm使用的并不是引用计数法。-XX:+PrintGCDetails可以看到更细节的内存变化。

1
2
[GC (System.gc()) [PSYoungGen: 6767K->464K(38400K)] 6767K->472K(125952K), 0.0019002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 464K->0K(38400K)] [ParOldGen: 8K->396K(87552K)] 472K->396K(125952K),

可达性分析

 可达性分析是指通过一系列叫做“GC Roots”的根对象,向下搜索对象,形成的链路叫做“引用链(Reference Chain)”,如果某个对象和GC Roots之间没有引用链,则说明对象已死,可被回收。比如下图中的Obj5、6、7。

可达性分析

 可作为“GC Roots”的对象包括以下几种:

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

 除了以上固定的GC Roots,虚拟机还可以加入其它的对象作为GCRoots,各个虚拟机可自行实现。比如后面的分代收集和局部(Partial GC)回收等。

引用

JDK对引用的概念进行了扩充,分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。

  • 强引用:Object A = new Object();强引用关系在,垃圾收集器永远不回收A对象。
  • 软引用:还有用但非必须的对象。软引用关系存在,在系统发生内存溢出前,会把这些对象列入回收范围内进行二次回收。java.lang.ref.SoftReference 具体分析参考https://www.jianshu.com/p/e46158238a77
  • 弱引用:非必须对象。会存在到下次GC之前,即使没有发生内存溢出,GC时,也会回收掉这部分对象。
  • 虚引用:“幽灵引用”或者“幻影引用”。为对象设置虚引用,唯一目的是发生GC时,对象会受到一个系统通知。java.lang.ref.PhantomReference

对象的自救

对象的二次标记

 如上图所示,对象第一次被判定会已死,并不会直接进入被GC,而是会做一次筛选,是否需要执行final()方法,如果对象已经执行过了,或者没有实现finalize()方法,则会进入“被回收”集合,下次gc时候被清理。如果实现了finalize()方法,则会进行调用,在finalize()中如果重新建立连接,则会被移除“被回收”集合。否则会被GC回收掉。如下这个例子:

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
40
41
42
43
44
45
46
47
48
49
50
51
/**
* 對象的自我拯救
* @author Administrator
*
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK=null;

//判斷是否還活著
public void isAlive(){
System.out.println("我還活著");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize方法执行了");
FinalizeEscapeGC.SAVE_HOOK=this;//与对象简历联系,自救
}

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK=new FinalizeEscapeGC();

//第一次自救
SAVE_HOOK=null;
System.gc();
//因为finalize的优先级低,先等一下
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else{
System.out.println("第一次自救失败");
}


//第二次自救
//因为每个对象的finalize()方法只会被系统自动调用一次,所以第一次调用后,
//第二次自救时不会再调用finalize(),自然也就不会与其他的对象产生联系,从而自救失败
SAVE_HOOK=null;
System.gc();
//因为finalize的优先级低,先等一下
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else{
System.out.println("第二次自救失败");
}
}
//finalize()虽然能够实现对象自救,但是不要轻易使用,因为他的运行代价太大,不确定性高,无法确定各个对象的调用顺序。
//而且finalize()能做的事,try-finally都能做,而且做得更好
}

方法区的回收

 方法区存放的主要是class文件内容等信息,这部分区域需要回收的内容主要是 废弃的常量不再使用的类型。比如常量池中“java”这个字符串,如果没有地方引用它,那么他就需要回收。类型也是如此。

 判断一个类型不再使用需要满足以下三个条件:

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

2. 垃圾收集算法

2.1 分代收集理论

 根据对象的存货时长,把java堆划分为新生代(Young Generation)和老年代(Old Generation)区域,新生代中每次gc都有大量对象死去,少量存活的对象会移动到老年代区域中去。对不同的领域进行回收,主要分为:部分收集整堆收集

  • 部分收集(Partial GC):对java堆中的部分进行GC。
    • 新生代收集(Minor GC/Young GC):只收集新生代的垃圾。
    • 老年代收集(Major GC/Old GC):只收集老年代的垃圾。目前只有CMS会单独收集老年代。
    • 混合收集(Mixed GC):收集整个新生代和部分老年代的垃圾。目前只有G1收集器具有该行为。
  • 整堆收集(Full GC):对整个java堆和方法区进行GC。

2.2 标记-清除算法

 标记所有可回收对象,然后统一回收。清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。如下图所示:

标记-清除

 缺点:

  • 效率不算高
  • 在进行GC的时候,需要停止整个应用程序,导致用户体验差
  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表

2.3 标记-复制算法

 将内存按容量等分为两块,每次只使用其中的一块。当这一块内存使用完的时候,就将所有存活的对象逐一复制到另一块内存,然后把这一块内存的空间一次性清理掉(只要移动堆指针,按顺序分配内存即可)。

标记-复制

 实现简单、运行高效、解决了内存碎片问题,但是内存缩小了一半。由于新生代中98%的对象是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的名为Eden的空间,两块较小的名为Survivor的空间。

 每次将一块Survivor的空间保留,将另一块Survivor与Eden一起拿来使用。进行垃圾回收时,将所有存活的对象复制被到保留的那块Survivor的空间上,然后将Eden和之前使用的Survivor的空间清理掉。两块Survivor交替着与Eden一起使用。HotSpot虚拟机默认Eden和Survivor的大小比例为8:1,也就是说,浪费的空间由原来的50%降到10%。

 当surivivor的空间不足以存储时,这些对象会进入到老年代(Tenured)。

gc前

gc后

2.4 标记-整理算法

 标记清理后会产生大量碎片,标记-整理算法其实是对他的一个优化。不直接对可回收对象进行清理,而是让所有可用的对象都向一端移动。然后直接清理掉边界意外的内存。

标记 - 整理

 该种方式会花费额外的时间(整理时,整个程序都会暂停)来整理内存,但是相比标记-清除算法的内存碎片,这种方式会划算的多。CMS采用的是标记-清除和标记-整理的混合体,即刚开始使用标记-清除算法,当内存碎片达到一定阈值时,采用标记-整理算法进行碎片清理。

2.4 GC触发条件

对于Minor GC,其触发条件非常简单,当Eden区空间满时,就将触发一次Minor GC。

 Full GC的触发条件有以下五种:

  • 调用System.gc()
    • 不建议使用,-XX:+ DisableExplicitGC来禁止RMI调用System.gc()。
  • 老年代空间不足时会触发FullGC
  • 空间分配担保失败
    • 使用复制算法的Minor GC需要老年代的内存空间作担保,如果出现了HandlePromotionFailure担保失败,则会触发Full GC。
  • JDK 1.7及以前的永久代空间不足
    • HotSpot虚拟机中的方法区是用永久代实现的,永久代中存放的为一些class的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下也会执行Full GC。在1.8及其之后,元空间替换了永久代作为方法区的实现,元空间是本地内存,因此减少了一种Full GC触发的可能性。
  • Concurrent Mode Failure
    • 执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC),便会报Concurrent Mode Failure错误,并触发Full GC。

3. HotSpot算法实现细节

3.1 枚举根节点

 虚拟机大多采用可达性分析算法判断对象是否需要回收,可作为GC Roots根节点的一般都是全局性的引用(常量、静态属性等)和执行上下文(栈帧中的局部变量表)。如果每次gc时,对内存进行遍历,那可就太耗时了,现在有可能一个方法就上百兆。因此遍历的方式不可取。虚拟机选择在类加载时就把这些引用类型存储起来,后面OopMap会详细解释。

3.2 GC停顿(Stop the world)

 为了保证可达性分析结果的准确性,必须保证分析过程中对象引用关系是一致的,因此此时必须停顿所有的java执行线程,这种行为被称为“stop the world”。枚举根节点的时候,必须stop the world。

3.3 准确式GC与OopMap

 GC所关心的是某块数据是不是指针,通过能否直接判断数据是否属于指针,将GC分为保守式GC、半保守式GC和准确式GC。

保守式GC:

  如果jvm在类加载时没有记录数据的类型,它就无法区分某个位置的数据到底是引用类型还是其他的类型,这时候进行GC时,jvm会从一些已知位置(比如虚拟机栈)开始扫描内存,检查它“是否属于一个指向GC堆中的指针”。会涉及上下边界检查(GC堆的上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针)之类的。递归扫描。这种方式叫做保守式GC。

 简单概括就是,就是不能识别指针和非指针的GC,GC时通过GC Roots,从上到下扫描,需要回收的直接进行回收。保守式GC一般使用标记-清除算法。

  • 优点:实现简单。
  • 缺点:部分对象本来应该已经死了,但有疑似指针指向它们,使它们逃过GC的收集。而且,由于有疑似指针,所以对象的值不敢随意更改,更改了疑似指针也需要修正。因此无法使用复制算法。
  • 改进:为了在保守式GC时仍然可以修改对象,可以在中间加一层句柄池,修改对象后,更正句柄表即可,具体可见HotSpot对象访问定位。但是这样对象的访问速度就降低了。Sun JDK的Classic VM用过这种全handle的设计,但效果实在不算好。

 由于jvm支持丰富的反射功能,本身就需要了解对象本身结构,因此很少会有jvm采用保守式GC。

半保守式GC

 在对象上记录类型的信息,在扫描的时候进行判断进行回收。类型信息可能是类加载器或者对象模型的模块里计算得到的。由于保守式GC在堆内部的数据是准确的,所以即便是直接指针引用,它也可以实现部分对象的移动。将保守扫描到的对象设为不可移动,再从这些对象出发,接下来扫描到的对象都是可移动的。半保守式GC可以使用标记-清除、复制算法。

准确式GC

 为了让JVM准确判断出所有位置上的数据是不是指向GC堆里的引用(包括活动记录(栈+寄存器)里的数据),有三种方式:

  • 让数据自身带上标记(tag)。半保守式GC通常这样实现,jvm基本不使用这种方式。
  • 让编译器为每个方法生成特别的扫描代码。jvm不使用。
  • 从外部记录下类型信息,存成映射表。目前主流的jvm均使用该种方式,HotSpot把这种数据结构称为OopMap、JRockit里叫做livemap,J9和Apache Harmony的DRLVM把它叫做GC map。

将类的信息存放到OopMap中,然后进行准确性的扫描。这种GC称为准确式GC。

OopMap

 oopMap可以简单理解成是调试信息。 在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了,oopMap就是一个附加信息,告诉你栈上哪个位置本来是什么东西。OopMap和编译器相关,类加载完成时,HotSpot会把对象内什么偏移量上是什么类型数据记录下来,在即时编译过程中,会在特定位置记录下栈和寄存器里,哪些位置是引用。有了OopMap,垃圾收集器在扫描时,不需要遍历GC Root,可以直接获取信息。

3.4 安全点(Safepoint)

 随着程序的执行,引用关系可能发生变化,如果为每一条指令都生成OopMap,那样会耗费大量额外空间,GC的成本会变高。因此HotSpot不为每条指令都生成OopMap,只在“特定的位置”记录这些信息,这些位置便被称为安全点(Safepoint)

 程序并非在任何地方都可以停下来执行GC,只有在达到安全点时才能暂停。安全点的选择以“是否具有让程序长时间执行的特征”为标准选定,如果安全点太少GC等待时间过长,如果太多又会导致额外的符合。一般安全点的位置选在:

  • 循环的末尾
  • 方法临返回前 / 调用方法的call指令后
  • 可能抛异常的位置

 每个方法可能会有好几个oopMap,就是根据safepoint把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的oopMap就会包含多条记录。

 当发生GC时,需要让所有线程(不包括执行JNI调用的线程)“跑”到最近的安全点上。有两种实现方式:

  • 抢先式中断:不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
  • 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,所有创建对象和其他需要在java堆上分配内存的地方也有轮询标志,这样当分配内存时先GC,尽量保证有内存可分配。

3.5 安全区域(Safe Region)

 安全点保证运行中的线程在GC时跑到安全点,但是当个线程处于阻塞或者休眠状态时,怎么办呢?这时候需要安全区域就发挥作用了。

 安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。`我们也可以把Safe Region看做是被扩展了的Safepoint。

 在线程执行到安全区域中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

3.6 记忆集(RememberedSet)与卡表(Card Table)

 在新生代和老年代之间,经常会发生跨代引用,比如老年代对象引用了新生代中的某个对象,这时候如果我们需要对新生代进行GC,就比较麻烦,因为被引用的新生代不应该被回收掉。为了解决这个问题,虚拟机是在引用关系发生时,在新生代边上专门开辟一块空间来记录引用关系,这块空间被称为RememberedSet。因此“新生代的 GC Roots ” + “ RememberedSet 存储的内容”,才是新生代收集时真正的 GC Roots。据此再进行可达性分析。

 记忆集的实现方式有一下三种:

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

 其中第三种方式,卡精度使用的是“卡表”去实现记忆集。这是最常用的实现方式。卡表定义了记忆集的记录精度、与堆内存的映射关系等。HotSpot中的卡表具体实现如下:

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

 字节数组CARD_TABLE每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page),一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。如下图所示:

卡表与卡页对应关系

 一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0。

GC时,只要筛选卡表中变脏的元素加入GCRoots。

3.7 写屏障

 当其他的分代区域引用本区域的对象, 本区域对应的卡表元素就应该变脏(即变为1)。理论上,当本区域对象被引用的时刻,就应该其卡表就应该变脏,但是卡表如何变脏,也就是卡表具体是如何维护的呢?

 在HotSpot里面,通过“写屏障”技术来维护卡表的状态。写屏障可以看做在虚拟机层面对“引用类型字段赋值”动作的AOP切面,在赋值时产生一个环形通知。赋值前后都属于写屏障,赋值前称为“写前屏障(Pre-Write Barrier)”,赋值后称为“写后屏障(Post-Write Barrier)”。在G1出现之前,其他的收集器均使用写后屏障。简化逻辑如下:

1
2
3
4
5
6
void oop_field_store(oop* field, oop new_value){
//引用字段赋值操作
*field = new _value;
//写后屏障,在这里完成卡表状态更新
post_writer_barrier(field, new_value);
}

 写屏障问题:

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

 2. 伪共享问题:为了解决该问题,虚拟机会先检查卡表状态,当其未被标记时,才标记其变脏。

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

 在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

3.8 并发的可达性分析

 当用户线程和垃圾收集工作同时进行时,有可能发生错误标记的情况,比如本来被标记为已死的对象被错误标记为存活,这种可以忍受,但是如果把存货对象标记为已死,则整个程序可能发生崩溃,这可是严重事故不可容忍的。

 为了解决这个问题,我们先分析,“对象消失”可能产生的原因(具体参考网上的三色标记理论):

  • 赋值器在存活对象中又引用了一条或多条其他其他新引用。
  • 赋值器删除了全部本应该引用其他对象的直接或间接引用。

 为了解决“对象消失”问题,破坏以上2个条件之一即可。由此产生了两种解决方案:增量更新原始快照

  • 增量更新:当插入新引用时,记录该新引用关系,等并发扫描结束后,再从引用关系中的GC Roots重新扫描一次。
  • 原始快照:同上,记录新的引用关系,结束后再扫描一次。

 CMS用到使用增量更新,G1和Shenandoah使用了原始快照。

4. 垃圾收集器

 下图所示,7种作用域不同分代的收集器,如果有连线,说明他们可以搭配使用。每种收集器适合不同的场景,目前并没有哪一款是最好的。

经典垃圾收集器关系

经典垃圾收集器

4.1 Serial收集器

 Serial收集器作用域新生代,采用复制算法进行gc,Serial Old作用域老年代,采用标记-整理算法。他们都是单线程的,gc时会暂停当前所有的用户线程,称之为“stop the world”。

Serial/Serial Old收集器

 Serial收集器简单高效,对于资源受限(单核)的环境(比如客户端模式下的虚拟机),是一个良好的选择。

4.2 ParNew收集器

 ParNew也作用域新生代,它是Serial的多线程版本,他的可用参数(-XX: SurviviorRatio等)、收集算法、stop the world、对象分配规则、回收策略等都和Serial一样。正由于它支持多线程,因此它适合于服务端模式下的虚拟机。此外,它是唯一能和CMS配合的收集器

 从本节开始的图中可以看到,收集器组合可以为:Serial + CMS、ParNew + CMS、ParNew + Serial Old。G1出世后,作为面向全堆的垃圾收集器,不需要和其他收集器配合使用。官方也希望G1取代其他收集器,JDK9后取消了Serial + CMS和ParNew + Serial Old的组合。因此ParNew成为唯一和CMS搭配使用的收集器。

ParNew

4.3 Parallel Scavenge收集器

 是一款新生代收集器,也支持多线程gc。类似于ParNew,但他的特别之处在于,他关注的是GC的吞吐量,其他的收集器关注点是如何减少stop the world的时间。

吞吐量 = 用户运行代码时间 / (用户运行代码时间 + gc时间)

-XX: MaxGCPauseMillisgc最大耗时,尽量控制在该范围内。

-XX: GCTimeRatio垃圾收集占总时间的比例,0~100.。假设为19,则gc最大耗时占比为1 / (1 + 19) = 5%。默认值为99,即1 / (1 + 99) = 1%。默认gc最大耗时占比为1%。

-XX: UseAdaptiveSizePolicy,该参数被激活,虚拟机会根据系统运行情况,动态调整各个参数,无需手动设置。

Parallel Scavenge

4.4 Serial Old收集器

 Serial Old也是单线程收集器,作用域老年代。它主要供客户端模式下的HotSpot虚拟机使用。如果是服务器模式下,jdk5及其之前可与Parallel Scavenge收集器搭配,或者作为CMS收集器发生失败时的后备方案。

 Parallel Scaveng中自带有PS MarkSweep老年代收集器,它的实现与Serial Old几乎一样,因此大多数说法是Parallel Scaveng和Serial Old搭配使用,实质上不是直接掉Serial Old。

Serial/Serial Old收集器

4.5 Parallel Old收集器

  Parallel Old是老年代收集器,支持多线程gc,它的出现是为了搭配Parallel Scavenge。在它出现之前,Parallel Scavenge无法和CMS配合使用,只能搭配Serial Old,但是Serial Old是单线程,无法提高Parallel Scavenge的吞吐量控制。因此Parallel Old应运而生。
Parallel Old

4.6 CMS收集器(Concurrent Mark Sweep)

CMS是第一款支持并发的收集器,它可以让gc线程和用户线程同时工作,以上提到的所欲收集器则必须是stop the world。
 CMS作用与老年代,基于标记-清除算法实现。它的gc过程分为四步:

  • 初始标记(CMS initial mark):标记GC Roots能直接关联上的对象,速度很快,需要stop the world
  • 并发标记(CMS concurrent mark):从上一步标记的对象开始遍历整个图,耗时,但不需要停顿用户线程。
  • 重新标记(CMS remark):更新并发标记期间因用户程序运作产生变动的标记,stop the world,且停顿时间比初始标记长,但短于并发标记时长。
  • 并发清除(CMS concurrent sweep):清理标记死亡的对象,无需暂停用户线程。

CMS优点

  • 并发收集、低停顿。Oracle也称之为“并发低停顿收集器”

CMS缺点

  • 对CPU资源非常敏感
  • 无法处理浮动垃圾
    • 浮动垃圾:标记为存活对象但是由于用户线程同时在进行,清除之前变为了垃圾对象。这种只能在下一次GC回收。
    • 浮点垃圾可能会使得虚拟机出现Concurrent Mode Failure失败,进而导致Full GC。
  • 收集结束时会有大量空间碎片产生
    • 由于它基于标记-清除算法,所以会产生大量空间碎片,由于大量的空间碎片可能会出现老年代无法找到大片连续的空间来分配对象,从而提前触发FullGC。
    • -XX: UseCMS-CompactAtFullCollection参数开启,会让FullGC结束后进行碎片整理。JDK9移除。

4.7 GarbageFirst收集器(G1)

G1收集器的是垃圾收集器的里程碑。它将新生代,老年代的物理空间划分取消了。取而代之的是Regin区域。

以往的垃圾回收算法,如CMS,使用的堆内存结构如下:

  • 新生代:eden space + 2个survivor
  • 老年代:old space
  • 持久代:1.8之前的perm space
  • 元空间:1.8之后的metaspace

Region

 在G1中,采用了另外一种完全不同的方式组织堆内存,堆内存被划分为多个大小相等的内存块(Region),每个Region是逻辑连续的一段内存,结构如下:

 每个Region被标记了E、S、O、H等,说明region可以根据存储不同的数据。E代表该region存储的是Eden区数据,S是survivor、O是old。H是Humongous,表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。

 堆内存中一个Region的大小可以通过-XX:G1HeapRegionSize参数指定,大小区间只能是1M、2M、4M、8M、16M和32M等2的幂次方。默认把堆内存按照2048份均分,最后得到一个合理的大小。

GC模式

 G1中提供了三种模式垃圾回收模式,young gc、mixed gc 和 full gc,在不同的条件下被触发。

  • young gc

    • 一般的对象发生在年轻代,当eden region中内存不够时,会触发一次young gc。活跃对象会被拷贝到survivor region或者晋升到old region中。
  • mixed gc

    • 当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region。

    • -XX:InitiatingHeapOccupancyPercent=80表示老年代的使用率达到80%会触发一次mixed gc

    • mixed gc的执行过程有点类似cms,主要分为以下几个步骤:

      1
      2
      3
      4
      1. initial mark: 初始标记过程,整个过程stop the world,标记了从GC Root可达的对象
      2. concurrent marking: 并发标记过程,整个过程gc collector线程与应用线程可以并行执行,标记出GC Root可达对象衍生出去的存活对象,并收集各个Region的存活对象信息
      3. remark: 最终标记过程,整个过程stop the world,标记出那些在并发标记过程中遗漏的,或者内部引用发生变化的对象
      4. clean up: 垃圾清除过程,如果发现一个Region中没有存活对象,则把该Region加入到空闲列表中
  • full gc

    • 如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc。

参考

4.2 低延迟垃圾收集器

4.8 Shenandoah收集器

 Shenandoah是一款非官方的收集器,只有OpenJDK包含,OracleJDK不包含。Shenandoah收集器同G1收集器一样,也是基于Region布局。Shenandoah相比G1,做了三个改进:

  • 支持并发的整理算法,Shenandoah的回收阶段可以和用户线程并发执行
  • 目前不使用分代收集,Region就是Region,不会标记是否为OEDH
  • 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。

连接矩阵

 连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向RegionM,就在表格的N行M列中打上一个标记,如图所示,如果Region 5中的对象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那连接矩阵中的5行3列、3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。

工作流程

 Shenandoah收集器工作分为一下9个阶段:

  1. 初始标记(Initial Marking):与G1一样,只标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。

  2. 并发标记(Concurrent Marking) :与G1一样,从GC Root开始对堆中对象进行可达性分析,找出存活的对象,可与用户线程并发执行,不会造成停顿,时间的长度取决于堆中存活对象的数量和对象图的结构复杂度。

  3. 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set),会有一小段短暂的停顿。

  4. 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。

  5. 并发回收(Concurrent Evacuation) :首先把回收集里面的存活对象先复制一份到其他未被使用的Region之中,然后通过读屏障和Brooks Pointers转发指针技术来解决在垃圾回收期间用户线程继续读写被移动对象的问题,并发回收阶段运行的时间长短取决于回收集的大小。

  6. 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。

  7. 并发引用更新(Concurrent Update Reference) :真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。

  8. 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GCRoots中的引用。会产生一个非常短暂的停顿,停顿时间只与GC Roots的数量相关。

  9. 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,所以最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

Brooks Pointer转发指针技术

 Brooks提出了使用转发指针技术来实现对象移动与用户程序并发的一种解决方案。Brooks 在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己(类似句柄,一个是放在句柄池中,一个是放在对象头前面),如图:

 在对象移动的时候我们只需要将Brooks Pointer 指向新对象,在对象访问过程中,只通一条mov指令就可以完成对新对象的访问了,如图:

 当写操作发生时,Shenandoah收集器是通过CAS(Compare And Swap)操作,来保证收集器线程或者用户线程只有其中之一可以进行修改操作,以此来保证并发时对象访问的正确性。

优缺点

  • 优点:延迟低
  • 缺点:高运行负担使得吞吐量下降;使用大量的读写屏障,尤其是读屏障,增大了系统的性能开销;

4.9 ZGC收集器

 ZGC(Z Garbage Collector)是一款由Oracle公司研发的,以低延迟为首要目标的一款垃圾收集器。它是基于动态Region内存布局,(暂时)不设年龄分代,使用了读屏障染色指针内存多重映射等技术来实现可并发的标记-整理算法的收集器。在JDK 11新加入,还在实验阶段,主要特点是:回收TB级内存(最大4T),停顿时间不超过10ms

动态Region

 ZGC的Region具有动态性——动态创建和销毁,区域动态容量。

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。·
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,最小容量可低至4MB,因此大型Region可能小于中型Region。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。

动态容量

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

 HotSpot虚拟机的标记实现方案有如下几种:

  1. 把标记直接记录在对象头上(如Serial收集器);
  2. 把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息);
  3. 直接把标记信息记在引用对象的指针上(如ZGC)

染色指针是一种直接将少量额外的信息存储在指针上的技术。目前在Linux下64位的操作系统中高18位是不能用来寻址的,但是剩余的46为却可以支持64T的空间,到目前为止我们几乎还用不到这么多内存。于是ZGC将46位中的高4位取出,用来存储4个标志位,剩余的42位可以支持4T的内存,如图所示:

  • Linux下64位指针的高18位不能用来寻址,所有不能使用;
  • Finalizable:表示是否只能通过finalize()方法才能被访问到,其他途径不行;
  • Remapped:表示是否进入了重分配集(即被移动过);
  • Marked1、Marked0:表示对象的三色标记状态;
  • 最后42用来存对象地址,最大支持4T;

染色指针的三大优势:

  • 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。
  • 可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
  • 染色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

内存多重映射

 将多个不同的虚拟内存地址映射到同一个物理内存地址上。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,效果如图:

内存多重映射

 ZGC的多重映射只是它采用染色指针技术的伴生产物

ZGC运作流程

 可以分为四个阶段:

  1. 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记和最终标记也会出现短暂的停顿,整个标记阶段只会更新染色指针中的Marked 0、Marked 1标志位。
  2. 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
  3. 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。
  4. 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。

优缺点

  • 优点:低停顿,高吞吐量,ZGC收集过程中额外耗费的内存小
  • 缺点:浮动垃圾。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!