第3章 垃圾收集器与内存分配策略
垃圾收集需要完成的三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
一、 回收内容
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个
区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
二、 对象已死
引用计数算法
引用计数算法(Reference Counting):在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
可达性分析算法
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为"GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链"(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,即从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
可作为GC Roots的对象:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
- 除此以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"地加入,共同构成完整GC Roots集合。
引用
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
- 强引用:指在程序代码之中普遍存在的引
用赋值,即类似"Object obj=new Object()"这种引用关系。只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。 - 软引用:用来描述一些还有用,但非必须的对象。只被软引用关联的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
- 弱引用:与软引用类似,但是它的强度更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
- 虚引用:或"幽灵引用","幻影引用",最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
生存还是死亡
可达性分析后:
- 有与GC Root相连接的引用链
- 没有与GC Root相连接的引用链:第一次被标记,随后进行一次筛选:
- 没有必要执行finalize()方法:对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过
- 有必要执行finalize()方法:对象会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法[1]
稍后收集器将对F-Queue中的对象进行 第二次小规模的标记:- 在finalize()方法中重新与引用链上的任一对象建立管理:被移出队列[2]
- 否则,基本真的要被回收
[1]注:这里所说的"执行"是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
[2]注:finalize()方法是对象逃脱死亡命运的最后一次机会。且这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统调用一次。
测试用例:
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
运行结果:
finalize method executed!
yes, i am still alive :)
no, i am dead :(
尽量避免使用finalize(),因为它并不能等同于C和C++语言中的析构函数,运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。
@Deprecated(since="9")
protected void finalize() throws Throwable { }
回收方法区
方法区垃圾收集的"性价比"通常比较低:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容:
-
废弃的常量
回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串"java"曾经进入常量池中,但是当前系统又没有任何字符串对象引用常量池中的"java"常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个"java"常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。 -
不再使用的类:需同时满足以下3个条件
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,而不是必然被回收。
HotSpot虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及-XX:+TraceClass-Loading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息。
三、 垃圾收集算法
从如何判定对象消亡的角度出发,垃圾收集算法可以划分为"引用计数式垃圾收集"(Reference Counting GC)和"追踪式垃圾收集"(Tracing GC)两大类,这两类也常被称作"直接垃圾收集"和"间接垃圾收集"。
由于引用计数式垃圾收集算法在主流Java虚拟机中未涉及,以下介绍的所有算法均属于追踪式垃圾收集的范畴。
基本概念:
-
部分收集(Partial GC):目标不是完整收集整个Java堆的垃圾收集
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
-
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
注:"Major GC"这个说法现在有点混淆,需按上下文区分到底是指老年代的收集还是整堆收集。
1. 分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了"分代收集"((Generational Collection))。
分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
设计原则:收集器应将Java堆划分出不同的区域,并将回收对象依据其年龄(熬过垃圾收集过程的次数)分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
问题:对象不是孤立的,对象之间会存在跨代引用。
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性。这会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
解决:依据这条假说,我们只需在新生代上建立一个全局的数据结构(记忆集,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。
虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
2. 标记——清除算法
最早出现也是最基础的垃圾收集算法是"标记-清除"(Mark-Sweep)算法。算法分为"标记"和"清除"两个阶段:
- 首先标记出所有需要回收的对象
- 在标记完成后,统一回收掉所有被标记的对象
缺点:
- 执行效率不稳定:如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
- 内存空间的碎片化:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3. 标记——复制算法
简称复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为"半区复制"(Semispace Copying)的垃圾收集算法,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
- 优点:实现简单,运行高效
- 缺点:将可用内存缩小为了原来的一半,空间浪费大
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究表明:新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。
优化
Andrew Appel针对具备"朝生夕灭"特点的对象,提出了一种更优化的半区复制分代策略,HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。
- 把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。
- 发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
- 当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(大多是老年代)进行分配担保(Handle Promotion)(Suvivor无法容纳的对象直接送入老年代)。
分配担保:
发生Minor GC前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间
- 是:Minor GC确保安全
- 否:虚拟机会查看
-XX:HandlePromotionFailure
参数的设置值是否允许担保失败- 允许担保失败:检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
- 大于:尝试进行一次Minor GC(存在风险) 若失败,则进行一次Full GC
- 小于:进行一次Full GC
- 不允许担保失败:进行一次Full GC
- 允许担保失败:检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
注:HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%。
4. 标记——整理算法
针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的"标记-整理"(Mark-Compact)算法,其中的标记过程仍然与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
问题
在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(Stop The World)。但若不移动整理存货对象,面对碎片化的空间,内存分配会更加复杂。
解决
让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。
四、 HotSpot 算法细节实现
1. 根节点枚举
迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。由于目前主流Java虚拟机使用的都是准确式垃圾收集(虚拟机可以知道内存中某个位置的数据具体是什么类型),所以当用户线程停顿下来之后,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。
在HotSpot的解决方案里,使用一组称为OopMap的数据结构。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置(安全点)记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息,并不需要真正一个不漏地从方法区等GC Roots开始查找。
下面代码清单是HotSpot虚拟机客户端模式下生成的一段String.hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer,OOP)的引用,有效范围为从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录
的偏移量)=0x026eb7be,即hlt指令为止。
[Verified Entry Point]
0x026eb730: mov %eax,-0x8000(%esp)
............
;; ImplicitNullCheckStub slow case
0x026eb7a9: call 0x026e83e0 ; OopMap{ebx=Oop [16]=Oop off=142}
; *caload
; - java.lang.String::hashCode@48 (line 1489)
; {runtime_call}
0x026eb7ae: push $0x83c5c18 ; {external_word}
0x026eb7b3: call 0x026eb7b8
0x026eb7b8: pusha
0x026eb7b9: call 0x0822bec0 ; {runtime_call}
0x026eb7be: hlt
2. 安全点
通过OopMap,HotSpot可以快速准确地完成GC Roots枚举。但若为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间。因此HotSpot只在安全点(Safepoint)记录信息。
有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。
安全点位置的选取基本上是以"是否具有让程序长时间执行的特征"为标准进行选定的,而每条指令执行的时间都非常短暂,所以"长时间执行"的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等,只有具有这些功能的指令才会产生安全点。
问题:如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。
解决
- 抢先式中断(Preemptive Suspension):系统把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,直到跑到安全点上再次中断。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
- 主动式中断(Voluntary Suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,而是设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
3. 安全区域
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但程序不执行时(没有分配处理器,如用户线程处于Sleep状态或者Blocked状态),线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己。对于这种情况,就必须引入安全区域(Safe Region)来解决。
安全区域:能够确保引用关系不会发生变化的区域,可以看作被扩展拉伸了的安全点。
- 当用户线程执行到安全区域里面的代码时,会标识自己进入了安全区域,虚拟机要发起垃圾收集时不必去管这些已声明的线程。
- 当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段)
- 完成,线程继续执行
- 否则,一直等待,直到收到可以离开安全区域的信号
4. 记忆集与卡表
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构,空间占用和维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。因此记录粒度可以更为粗犷:
- 字长精度:每个记录精确到一个机器字长,该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
"卡精度"所指的是用一种称为"卡表"(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。
卡表最简单的形式可以只是一个字节数组。
CARD_TABLE [this address >> 9] = 0; // 卡页大小512字节
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块(卡页,Card Page)。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
5. 写屏障
问题:卡表如何维护
假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
解决:写屏障(Write Barrier)
写屏障可以看作在虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。可以利用写后屏障更新卡表。
缺点:
- 额外开销:应用写屏障后,虚拟机会为所有赋值操作生成相应的指令,不区分是否跨区,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
- 伪共享:现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。对应的卡页总内存为32KB(64×512字节)。如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。
为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
注:写屏障并非解决并发乱序执行问题的内存屏障
6. 并发的可达性问题
问题:为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
分析:把遍历对象图过程中遇到的对象,按照"是否访问过"这个条件标记成以下三种颜色:
- 白色:表示对象尚未被垃圾收集器访问过。若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过,代表安全存活的对象。
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
可达性分析的扫描过程,可以看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程。
如果用户线程与收集器并发工作,收集器在对象图上标记颜色,同时用户线程在修改引用关系,这样可能出现两种后果:
- 把原本消亡的对象错误标记为存活:这可以容忍,只不过产生了逃过本次收集的浮动垃圾而已。
- 是把原本存活的对象错误标记为已消亡:这就是非常致命的后果了,程序肯定会因此发生错误。
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生"对象消失"的问题,即原本应该是黑色的对象被误标为白色:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
解决
- 增量更新(Incremental Update):破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
- 原始快照(Snapshot At The Beginning,SATB):破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这可以理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
五、 垃圾收集器
如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。《Java虚拟机规范》中并没有规定垃圾收集器应如何实现,因此不同厂商、版本的虚拟机所包含的垃圾收集器可能会有很大差别,不同的虚拟机一般会提供各种参数供用户根据自己的应用特点和要求组合出各个内存分代所使用的收集器。
经典垃圾收集器
经典垃圾收集器指的是在JDK 7 Update 4之后、JDK 11正式发布之前,OracleJDK中的HotSpot虚拟机所包含的全部可用的垃圾收集器。
与几款目前仍处于实验状态,但执行效果上有革命性改进的高性能低延迟收集器区分开来,这些经典的收集器尽管已经算不上是最先进的技术,但它们曾在实践中千锤百炼,足够成熟,基本上可认为是现在到未来两、三年内,能够在商用生产环境上放心使用的全部垃圾收集器了。
1. Serial:新生代 + 标记——复制
Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。到现在,依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。
Serial是一个单线程工作的收集器,即使用一个处理器或一条收集线程去完成垃圾收集工作,且垃圾收集时,必须暂停其他所有工作线程,直到收集结束(Stop The World)。
优点:简单高效
- 对于内存资源受限的环境,是所有收集器里额外内存消耗最小的
- 对于单核处理器或处理器核心数较少的环境来说,由于没有线程交互的开销,专心垃圾收集可以获得最高的单线程收集效率
2. ParNew:新生代 + 标记——复制
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。
ParNew收集器在单核心处理器的环境中不会比Serial收集器更好,它默认开启的收集线程数与处理器核心数量相同,可以使用-XX:ParallelGCThreads
参数来限制垃圾收集的线程数。
ParNew收集器成为不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器,一个重要原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。ParNew收集器是激活CMS后的默认新生代收集器,也可以使用-XX:+/-UseParNewGC
选项来强制指定或者禁用它。
然而随着更先进的作为CMS继承者的G1收集器的出现,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案。官方甚至直接取消了-XX:+/-UseParNewGC
参数,这意味着ParNew和CMS从此只能互相搭配使用。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。
3. Parallel Scavenge:新生代 + 标记——复制 + 吞吐量
Parallel Scavenge收集器也是一款新生代收集器,同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。
特点:Parallel Scavenge的目标是达到一个可控制的吞吐量,而其他收集器关注缩短垃圾收集时用户线程的停顿时间。
- 停顿时间短:适合需要与用户交互或需要保证服务响应质量的程序
- 吞吐量高:最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
Parallel Scavenge参数:
- -XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间
参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。
垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。 - -XX:GCTimeRatio:直接设置吞吐量大小
参数的值是一个大于0小于100的整数,即垃圾收集时间占总时间的比率,近似于吞吐量的倒数。 - -XX:+UseAdaptiveSizePolicy:自适应调节
当这个参数被激活之后,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整参数(新生代大小,Eden与Survivor比例等)以提供最合适的停顿时间或者最大的吞吐量。
如果手工优化收集器存在困难的话,可以使用Parallel Scavenge收集器配合自适应调节策略:只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成
了。
自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。
4. Serial Old:老年代 + 标记——整理
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
5. Parallel Old:老年代 + 标记——整理 + 吞吐量
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
6. CMS:老年代 + 标记——清除 + 最短回收停顿时间
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。
步骤:
- 初始标记(CMS initial mark):标记GC Roots直接关联的对象,速度很快,需要停顿用户线程
- 并发标记(CMS concurrent mark):从直接关联对象开始遍历整个对象图,过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运
行 - 重新标记(CMS remark):为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,停顿时间通常会比初始标记阶段稍长一些,需要停顿用户线程
- 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段可以与用户线程同时并发
由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
优点:并发收集,低停顿
缺点:
-
对处理器资源非常敏感
在并发阶段,它不会导致用户线程停顿,但会因为占用了一部分线程(即处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
CMS默认启动的回收线程数是(处理器核心数量+3)/4,如果处理器核心数在四个或以上,并发回收时垃圾收集线程占用不超过25%的处理器运算资源。当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大,可能导致用户程序的执行速度忽然大幅降低。 -
无法处理浮动垃圾,导致并发失败从而进行Stop The Wold的Full GC
浮动垃圾(Floating Garbage):在CMS的并发标记和并发清理阶段,用户线程还在运行,程序还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。
同样也是由于在垃圾收集阶段用户线程还需要持续运行,所以需要预留足够内存空间提供给用户线程使用。- 在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,可以适当调高参数
-XX:CMSInitiatingOccu-pancyFraction
的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。 - 到了JDK 6时,CMS收集器的启动阈值默认提升至92%。若CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次"并发失败"(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。
- 在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,可以适当调高参数
-
收集结束时会有大量空间碎片产生
空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
7. Garbage First:全功能 延迟可控的情况下获得尽可能高的吞吐量
Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
设计目标
主要面向服务端应用,替换掉JDK 5中发布的CMS收集器。
建立"停顿时间模型"(Pause Prediction Model):指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒。
设计思想
在G1收集器出现之前的所有其他收集器,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。
而G1可以面向堆内存任何部分来组成回收集(Collection Set,CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
内存布局
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。大小超过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,这也就是"Garbage First"名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
从整体来看基于"标记-整理"算法
从局部(两个Region之间)来看基于"标记-复制"算法
细节问题
-
将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?
使用记忆集避免全堆作为GC Roots扫描。每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这比原来的卡表实现起来更复杂,并且由于Region数量比传统收集器的分代数量要多得多,因此G1收集器有更高的内存占用负担。
根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。 -
并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
即保证对象图结构,CMS收集器采用增量更新算法实现,G1收集器通过原始快照算法来实现。
此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配。G1收集器默认在这个地址以上的对象是被隐式标记过的,不纳入回收范围。
与CMS中的"Concurrent Mode Failure"失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间"Stop The World"。 -
怎样建立可靠的停顿预测模型以满足
-XX:MaxGCPauseMillis
指定的期待值?
G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
注:"衰减平均值"是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表"最近的"平均状态。
运作过程
-
初始标记(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开始,最先进的垃圾收集器的设计导向变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。
缺点
- 高内存占用(Footprint)
虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间。
相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。 - 高额外执行负载(Overload)
CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。
相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。
低延迟垃圾收集器
衡量垃圾收集器的三项最重要的指标是:
- 内存占用(Footprint)
- 吞吐量(Throughput)
- 延迟(Latency)
三者共同构成了一个"不可能三角"。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的"完美"收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。
在三项指标里,延迟的重要性日益凸显。其原因是随着计算机硬件的发展、性能的提升,内存占用变得可以忍受,吞吐量会随之增加。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果。
1. Shenandoah
Shenandoah是第一款不由Oracle(Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器。最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一。
这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。
Shenandoah与G1在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码。改进在于:
- 支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发
- 默认不使用分代收集
- 摒弃了耗费大量内存和计算资源的记忆集,改用连接矩阵(Connection Matrix)来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。
连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region
M,就在表格的N行M列中打上一个标记。
工作过程:
-
初始标记(Initial Marking):与G1一样
首先标记与GC Roots直接关联的对象,这个阶段仍是"Stop The World"的,但停顿时间与堆大小无关,只与GC Roots的数量相关。 -
并发标记(Concurrent Marking):与G1一样
遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。 -
最终标记(Final Marking):与G1一样
处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。 -
并发清理(Concurrent Cleanup):
这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。 -
并发回收(Concurrent Evacuation): 核心差异
Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。
难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。Shenandoah会通过 读屏障 和被称为"Brooks Pointers"的 转发指针 来解决。
并发回收阶段运行的时间长短取决于回收集的大小。 -
初始引用更新(Initial Update Reference):
并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址。
引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。
初始引用更新时间很短,会产生一个非常短暂的停顿。 -
并发引用更新(Concurrent Update Reference):
真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。
并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。 -
最终引用更新(Final Update Reference):
解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。 -
并发清理(Concurrent Cleanup):
经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。
转发指针:
在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己(类似与句柄)。
因此,只需要修改旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。
问题:并发写入时,必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中。
解决:通过比较并交换(Compare And Swap,CAS)操作来保证并发时对象的访问正确性
对于一门面向对象的编程语言来说,对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等,这些操作都属于对象访问的范畴,都需要转发指针。Shenandoah不得不同时设置读、写屏障去拦截。
读屏障的代价,比写屏障更大。代码里对象读取的出现频率要比对象写入的频率高出很多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作。
它的开发者也意识到数量庞大的读屏障带来的性能开销会是Shenandoah被诟病的关键点之一,所以计划在JDK 13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier)的实现,即内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。
性能表现:
2016,使用ElasticSearch对200GB的维基百科数据进行索引
从结果来看,Shenandoah并没有完全达成预定目标,停顿时间比其他几款收集器确实有了质的飞跃,但也并未实现最大停顿时间控制在10毫秒以内的目标,而吞吐量方面则出现了很明显的下降,其总运行时间是所有测试收集器中最长的。
可见:Shenandoah
- 弱项在于高运行负担使得吞吐量下降
- 强项在于低延迟时间
2. ZGC
ZGC(Z Garbage Collector)是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器,是由Oracle公司研发的。
ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。
但是ZGC和Shenandoah的实现思路又是差异显著的。
内存布局:
ZGC的Region(或称Page,Zpage)堆内存布局具有动态性——动态创建和销毁,以及动态的区域容量大小。
在x64硬件平台下,ZGC的Region可以具有大、中、小三类容量:
- Small Region:容量固定为2MB,用于放置小于256KB的小对象
- Medium Region:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象
- Large Region:容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象
每个大型Region中只会存放一个大对象,大型Region在ZGC的实现中不会被重分配。因为复制一个大对象的代价非常高昂。
染色指针(Colored Pointer):一种直接将少量额外的信息存储在指针上的技术。
在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。
实际上,基于需求、性能和成本的考虑,在AMD64架构(x86-64)中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。
此外,操作系统一侧也还会施加自己的约束,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,64位的Windows系统甚至只支持44位(16TB)的物理地址空间。
尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术,将剩余46位的高4位提取出来存储四个标志信息。
通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。但是也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)。
优势
- 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量。设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。
- 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
运作过程
-
并发标记(Concurrent Mark)
与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记的短暂停顿。
与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。 -
并发预备重分配(Concurrent Prepare for Relocate)
这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。
重分配集与G1收集器的回收集还是有区别的。ZGC每次回收会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,Region会被释放。 -
并发重分配(Concurrent Relocate)
重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据R转发表记录将访问转发到新对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的"自愈"(Self-Healing)能力。
这样做的好处是只有第一次访问旧对象会陷入转发,因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。 -
并发重映射(Concurrent Remap)
重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。
重映射清理这些旧引用的主要目的是为了不变慢,所以说这并不是很"迫切"。因此,ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
优势
- ZGC没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。
- 支持"NUMA-Aware"的内存分配。
劣势
- ZGC能承受的对象分配速率不会太高
ZGC准备要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以上,在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范围,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。
目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。
性能表现:
图3-23和图3-24是ZGC与Parallel Scavenge、G1三款收集器通过SPECjbb 2015的测试结果。
- 在ZGC的"弱项"吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。如果将吞吐量测试设定为面向SLA(Service Level Agreements)应用的"Critical Throughput"的话,ZGC的表现甚至还反超了Parallel Scavenge收集器。
- 在ZGC的强项停顿时间测试上,它就毫不留情地与Parallel Scavenge、G1拉开了两个数量级的差距。不论是平均停顿,还是95%停顿、99%停顿、99.9%停顿,抑或是最大停顿时间,ZGC均能毫不费劲地控制在十毫秒之内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显示不了ZGC的柱状条,必须把结果的纵坐标从线性尺度调整成对数尺度才能观察到ZGC的测试结果。
六、 垃圾收集器使用
日志
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
selector
:选择器,由标签(Tag)和日志级别(Level)共同组成。
- Tag:可理解为虚拟机种某个功能模块的名字(gc)
- Level:决定输出信息的详细程度。从低到高:Trace,Debug,Info(默认),Warning,Error,Off
decorators
:修饰器,每行日志输出附加的额外内容
参数
参考资料:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 周志明 著