垃圾收集算法和各种垃圾收集器的实现

深入理解Jvm虚拟机第三章

    • 二、对象已死?
      • 3.2.1 引用计数算法
      • 3.2.2 可达性分析算法
      • 3.2.3 再谈引用
      • 3.2.4 生存还是死亡
      • 3.2.5 回收方法区
    • 三、垃圾收集算法
      • 3.3.1 分代收集理论
      • 3.3.2 标记-清除算法
      • 3.3.3 标记-复制算法
      • 3.3.4 标记-整理算法
    • 四、HotSpot的算法细节实现
      • 3.4.1 根节点枚举
      • 3.4.2 安全点
      • 3.4.3 安全区域
      • 3.4.4 记忆集与卡表
      • 3.4.5 写屏障
      • 3.4.6 并发的可达性分析
    • 五、经典垃圾收集器
      • 3.5.1 Serial收集器
      • 3.5.2 ParNew收集器
      • 3.5.3 Parallel Scavenge 收集器
      • 3.5.4 Serial Old收集器
      • 3.5.5 Parallel Old收集器
      • 3.5.6 CMS收集器
      • 3.5.7 Garbage First收集器
    • 六、低延迟垃圾收集器
      • 3.6.1 Shenandoah收集器
      • 3.6.2 ZGC收集器
    • 七、选择合适的垃圾收集器
      • 3.7.1 Epsilon收集器
      • 3.7.2 收集器的权衡
      • 3.7.3 虚拟机及垃圾收集器日志
      • 3.7.4 垃圾收集器参数总结
    • 八、实战:内存分配与回收策略
      • 3.8.1 对象优先在Eden分配
      • 3.8.2 大对象直接进入老年代
      • 3.8.3 长期存活的对象进入老年代
      • 3.8.4 动态对象年龄判定
      • 3.8.5 空间分配担保

二、对象已死?

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

3.2.1 引用计数算法

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

虽然这种方法是简单高效的,但是还有一种例外情况:例如A对象引用B对象,B对象引用A对象,并且两个对象都已经不可能再被访问,这时虽然两个对象应该被回收但是由于计数器值不为0所以回收不了。

3.2.2 可达性分析算法

该算法的基本思路为通过一系列称为“GC Roots”的根对象作为起始节点集,从这些结点开始根据引用关系向下搜索,搜索过程中走过的路径称为“引用链”,如果某个对象到“GC Roots”间没有任何引用链相连,则证明此对象是不可能再被使用的。

在Java技术体系里,固定可作为GC Roots的对象包括以下几种

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

如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GCRoots 集合中去考虑,才能保证可达性分析的准确性。
也就是说,进行

局部回收的时候,也要考虑到该内存区域里的对象是否也被其他内存区域引用到

3.2.3 再谈引用

在JDK1.2之前,Java里面的引用是很传统的定义:如果reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,但是对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间足够时,能保留在内存中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。

在JDK1.2之后,Java堆引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用。这四种引用强度以此逐渐减弱

  • 强引用类似“Object obj = new Object()”,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
  • 软引用是用来描述一些还有用,但非必须的对象。只要被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行二次回收,如果这次回收还没有足够的内存,在会抛出内存溢出异常,SoftReference
  • 弱引用的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前的内存是否足够,都会回收掉只被弱引用关联的对象,WeakReference
  • 虚引用也成为“幽灵引用”或者“幻影引用”,是最弱的一种引用关系。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知,PhantomReference

3.2.4 生存还是死亡

一个对象真正死亡,最多会经历两次标记过程:如果对象不可达,那么会被第一次标记,然后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果对象在finalize()中成功拯救自己,只需要重新与引用链上任何一个对象建立关联即可(重新被引用引用到)。

3.2.5 回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。

判断一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

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

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力

三、垃圾收集算法

垃圾收集算法主要分为‘引用计数式垃圾收集“和”追踪式垃圾收集“两大类,这两类也常被称作”直接垃圾收集“和”间接垃圾收集“,由于主流Java虚拟机中均未涉及引用计数式垃圾收集算法,所以本节介绍的所有算法均属于追踪式垃圾收集范畴

3.3.1 分代收集理论

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

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

显而易见,收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(对象熬过垃圾收集过程的次数)分配到不同的区域中存储。

在堆划分出不同的区域之后,GC才可以每次只回收其中一个或者某部分区域,也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法。并且发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”

设计者一般至少会把Java堆划分成新生代和老年代两个区域。但是分代收集并非只是划分一下内存区域那么容易,至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用

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

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

3.3.2 标记-清除算法

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

它的主要缺点有两个:

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

3.3.3 标记-复制算法

这种算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,那么这种算法将会产生大量的内存复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,这样实现简单,运行高效。但是缺点显而易见:复制回收算法的代价是将可用内存缩小为了原来的一半

IBM公司曾经研究出:新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间。

Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor,发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,即每次新生代中可用内存空间为整个新生代容量的90%。但是无法保证每次回收都只有不多于10%的对象存活因此Appel式回收还有一个“逃生门”设计,当Survivor空间不足以容纳一次存活对象时,就需要依赖其他区域(大多数情况下为老年代)进行分配担保

3.3.4 标记-整理算法

标记复制算法在对象存活率较高的老年代中并不适用,因为很大概率会遇见所有对象都存活的极端情况,所以老年代中一般不能直接选用这种算法

针对老年代对象的死亡特征,提出了一种“标记-整理”算法,标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存

是否移动回收后的存活对象是一项优缺点并存的风险决策:

  • 如果要移动存活对象:尤其是老年代这种每次回收都有大量对象存活的区域,移动对象并更新所有这些对象的引用是一项艰巨的操作,并且这项操作需要暂停用户应用程序才能进行。
  • 如果不移动和整理存活对象,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器解决(例如分区空闲分配链表),内存访问是用户程序最频繁的操作,如果这个环节上增加了额外的负担,必然会直接影响应用程序吞吐量

所以是否移动对象都存在弊端,移动对象则回收对象更复杂,不移动对象则内存分配更复杂。吞吐量的本质是复制器于收集器的效率总和,即使不移动对象会使收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率更高,这部分的耗时增加,总吞吐量仍然是下降的

还有一种”和稀泥式“解决方案:让虚拟机平时使用标记-清除算法,直到内存空间的碎片化成都已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间

四、HotSpot的算法细节实现

3.4.1 根节点枚举

现在Java应用越做越大,逐个检查GC Roots下的引用肯定要消耗不少时间。迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,如果分析过程中,根节点集合的对象引用关系还在不断变化,那么分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的一个重要原因

HotSpot中,使用一组称为OopMap的数据结构来得到哪些地方存放着对象引用,这样就不需要一个不漏的检查完所有执行上下文和全局的引用位置

3.4.2 安全点

由于引用关系的变化,如果为每一条指令都生成对应的OopMap,那么将会需要大量的时间和空间,这样垃圾收集伴随而来的空间成本就会变得很高昂

实际上HotSpot只是在”特定的位置“记录了这些信息,这些位置被称为安全点,这就要求了用户程序必须执行到达安全点后才能够暂停。因此,安全点的选定即不能太少也不能太多,太少会让收集器等待时间过长,太多会过分增大运行时的内存负荷。安全点的位置选取基本上是以是否具有让程序长时间执行的特征为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长的原因长时间执行,”长时间执行“的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等。

另一个需要考虑的问题是,如何在垃圾收集发生时让所有线程都跑到最近的安全点,这里有两种方案:

  1. 抢先式中断:抢先式中断不需要线程的执行代码主动去配合,垃圾收集时,系统先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程的执行,直到跑到安全点上。几乎没有虚拟机实现采用这种方案。
  2. 主动式中断:简单的设计一个标志位,各个线程不断的轮询这个标志,一旦发现终端标志为真时就自己在最近的安全点上主动终端挂起。轮询标志的地方和安全点是重合的,还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将发生垃圾收集,避免没有足够内存分配新对象

3.4.3 安全区域

安全点看似已经完美解决了如何停顿用户线程,但程序”不执行的时候“(即没有分配处理器时间,程序处于Sleep状态或者Blocked状态),这时程序无法走到安全的地方中断挂起自己,虚拟机也不可能等待线程重新被激活分配处理器时间。对于这种情况,就必须要引入安全区域来解决。

安全区域可以看作被扩展拉伸了的安全点,在这个区域中,引用关系不会发生变化。

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

3.4.4 记忆集与卡表

为了解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为”记忆集“的数据结构。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果不考虑效率成本,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构:

Class RememberedSet{
    Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

这种记录全部含跨代引用对象的实现方案,空间占用和维护成本都相当高昂,但是收集器只需要通过记忆集判断某一块非收集区域是否存在了指向收集区域的指针就可以了。所以设计者在设计之真的时候可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本。

  • 字长精度:每个记录精确到一个字长,也就是一个跨代指针的物理内存地址的指针长度
  • 对象精度:每个记录精确到一个对象,该对象里含有跨代指针
  • 卡精度:每个记录精确到一块内存区域,该区域有对象含有跨代指针

第三种“卡精度”指的是用一种称为卡表的方式去实现记忆集,记忆集是一种抽象的数据结构,只定义了它的行为意图,没有定义行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

卡表最简单的形式可以是一个字节数组,HotSpot虚拟机也是这样做的:

CARD_TABLE[this address >> 9] = 1;

字节数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”。一般来说,卡页大小都是以2的N次幂的字节数,通过上面的代码可以看出HotSpot使用的卡页为2的9次幂。如果卡表内存起始地址是0x0000,数组CARD_TABLE的0、1、2号元素分别对应了地址范围为0x0000-0x01FF,0x0200-0x03FF,0x0400-0x05FF的卡页内存块。

一个卡页的内存通常包含不止一个对象,如果卡页内存在跨代指针,那么对应卡表的数组元素值标识为1,称这个元素变脏。在垃圾收集时只需要把脏元素筛选出来,就能轻易地出哪些卡页内存块中包含跨代指针,并加入GC Roots。

3.4.5 写屏障

经过即时编译的代码已经是纯粹的机器指令流了,这时该如何在对象赋值的那一刻更新维护卡表?这就必须找到一个机器码层面的手段,把维护卡表的动作放到每一个赋值操作中。

写屏障可以看作虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在写之前的写屏障叫做写前屏障,在写之后的叫做写后屏障

为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,先检查卡表标记,当该卡表元素未被标记过的时候才将其标记为变脏:

if(CARD_TABLE[this address >> 9] != 1){
    CARD_TABLE[this address >> 9] = 1;
}

3.4.6 并发的可达性分析

要解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历,为了弄清楚这个问题,我们引入三色标记作为工具辅助推导:

  • 白色:表示对象尚未被垃圾收集器访问过 ,如果在分析结束的阶段,对象仍然是白色的,标识不可达
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,并且是安全存活的
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有扫描过

如果扫描时线程是冻结的,那么不会有任何问题。如果扫描时用户线程和扫描器是并发进行的,那么可能会产生两种后果:

  1. 把原本消亡的对象错误标记为存活,这会产生一些浮动垃圾,下次收集处理掉即可
  2. 把原本存活的对象标记为已消亡,程序肯定会因此发生错误

譬如用户线程将引用链上的一个灰色节点所有引用切断,并且又被黑色节点引用。这时即使白色节点还在引用链上,也不会被扫描到了。

当且仅当下面两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

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

我们要解决并发扫描时的对象消失问题,只需要破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新和原始快照

增量更新要破坏的是第一个条件:黑色对象插入新的白色引用时,将这个新插入的引用记录下来,并发扫描结束后再将这些记录过的黑色对象为根,重新扫描一次。也就是说黑色对象一旦插入新的只想白色对象的引用,就变回灰色对象了。

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

在HotSpot中,增量更新和原始快照这两种解决方案都有实际应用

五、经典垃圾收集器

3.5.1 Serial收集器

Serial收集器是一个单线程收集器,这里的“单线程”不仅仅指的是它只会用一个处理器或者一条收集线程去完成垃圾收集工作,更重要的是它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束

事实上,Serial收集器仍然有着优于其他收集器的地方,那就是简单并且高效。对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集几十兆甚至一两百兆的新生代,垃圾收集的停顿时间完全可以控制在十几、几十毫秒、最多一百多毫秒以内。

3.5.2 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本

ParNew是不少运行在服务端模式下的HotSpot首选的新生代收集器,有一个很重要的原因就是除了Serial收集器外,只有它能与CMS收集器配合工作。

3.5.3 Parallel Scavenge 收集器

Parallel Scavenge也是一款基于标记-复制算法实现的新生代收集器,Parallel Scavenge的特点是它的目标是达到一个可控制的吞吐量。吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

吞吐量 = 运行用户代码时间 / (运行用户代码时间+运行垃圾收集时间)

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数

  • -XX:MaxGCPauseMillis参允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费时间不超过用户设定值。但是垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调的小一点,但也直接导致垃圾收集发生的更频繁,停顿时间的确在下降,但吞吐量也降下来了
  • -XX:GCTimeRatio参数应设置为一个正整数,表示用户期望虚拟机消耗在GC上的时间不超过程序运行时间的1/(N+1)

Parallel Scavenge收集器还有一个参数:-XX:+UseAdaptiveSizePolicy。这是一个开关参数,当这个参数被激活后,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应调节策略。

3.5.4 Serial Old收集器

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

  1. 在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用
  2. 作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用

3.5.5 Parallel Old收集器

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

这个收集器是在JDK6才开始提供的,在此之前,Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年收集器,如CMS无法与他配合工作。

由于老年代SerialOld收集器在服务端应用性能上的拖累,使用ParallelScavenge收集器也未必能在整体上获得吞吐量最大化的效果。

同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS组合来得优秀

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

3.5.6 CMS收集器

CMS收集器是一种以最短回收停顿时间为目标的收集器,一些Java应用的服务端上通常会较为关注服务的响应速度,希望系统停顿时间尽可能的短,以给用户带来良好的交互体验

从名字上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

初始标记、重新标记这两个步骤仍然需要暂停所有用户线程

初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记变动的那一部分对象的标记记录(增量更新和原始快照)。最后是并发清除,清除掉判断的已死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

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

  • CMS对处理器资源非常敏感

面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程变慢,但却会因为占用了一部分线程导致总吞吐量降低。CMS默认启动的回收线程数是(处理器核心数量+3)/4,如果处理器核心数在四个或者以上,并发回收时垃圾收集器线程只占用不少于25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足4个时,应用本来的处理器负载就很高,还要分出一半运算能力执行收集器线程,就可能导致用户线程的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”的CMS收集器变种,在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样做整个垃圾收集过程会很长,但是对用户程序的影响就会显得较少,直观感受就是速度变慢的时间更多了,但是速度下降幅度没有那么明显。

  • CMS收集器无法处理“浮动垃圾”有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC产生。

在并发标记和并发清理阶段,用户线程还是在继续运行的,会有新的垃圾对象不断产生,但是这一部分垃圾对象CMS无法在档次收集中处理掉他们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样由于垃圾收集阶段用户线程需要持续运行,就需要预留足够的内存空间提供给用户线程使用,因此CMS不能等到老年代几乎被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。JDK5的默认设置下,老年代的触发百分比是68%,可以适当调高-XX:CMSInitiatingOccu-pancyFraction的值提高CMS的触发百分比。但是如果CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”,这是虚拟机不得不冻结用户线程的执行,临时调用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿的时间就长了。

  • CMS基于“标记-清除”算法实现

为了解决内存碎片化问题,CMS提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于过程无法并发,会导致停顿时间变长,所以虚拟机还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,用来要求CMS收集器在执行若干次不整理空间的Full GC后,下一次进入Full GC前会先进行碎片整理。

3.5.7 Garbage First收集器

Garbage First(简称G1)开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

设计者们希望做出一款能够建立起”停顿预测模型“的收集器,停顿预测模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾手机上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中软实时垃圾收集器特征了。

如何实现这个目标?首先要有思想上的转变,在G1收集器出现之前的所有其他收集器,包括CMS,垃圾收集的目标范围要么是整个新生代,要么是整个老年代,要么是整个Java堆。G1可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。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里面的垃圾堆积的”价值“大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒)优先处理回收价值收益最大的那些Region,这也是”Garbage First“名字的由来。

G1收集器至少还有以下这些关键的细节问题需要妥善解决:

  • Region内的跨Region引用如何解决

每个Region都维护自己的记忆集,这些记忆集本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种双向的卡表结构更加复杂,因此G1至少要耗费大约Java堆容量的10%至20%的额外内存来维持收集器工作。

  • 并发标记阶段如何保证收集线程和用户线程互不干扰的运行?

CMS是通过增量更新实现的,G1是通过原始快照实现的。G1为每一个Region设计了两个名为TAMS的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,这两个指针上的对象默认是被隐式标记过的,即默认是存活的,不纳入回收范围。如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致FullGC并产生长时间线程停顿。

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

G1会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的”衰减平均值“是指它会比普通的平均值更容易受到新数据影响,平均值代表整体平均状态,但衰减平均值更准确地代表”最近的“平均状态。换句话说,Region的统计状态越新越能决定其回收价值,然后通过这些信息预测现在开始回收的话,由哪些Region组成的回收集才可以在不超过期望停顿时间的约束下获得最高收益。

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

  1. 初始标记:标记一下GC Roots能直接关联到的对象,并且修改TAMS的值,让下一个阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记:进行可达性分析,重新处理SATB(原始快照)记录下的在并发时由引用变动的对象。
  3. 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  4. 筛选回收:更新Region的统计数据,根据用户期望的停顿时间制定回收计划,可以自由选择任意多个Region构成回收集,必须暂停用户线程,由多条用户线程并行完成。

G1与CMS:

G1优点:可以指定最大停顿时间、分Region的内存布局、按收益动态确定会收集这些创新型设计带来的红利,运作期间不会产生内存空间碎片,有利于程序长时间运行。

G1缺点:卡表实现更复杂,记忆集可能会占整个堆容量的20%甚至更多的内存空间;相比起来CMS的卡表相对简单,而且只需要处理老年代到新生代的应用,反过来则不需要。由于G1的写屏障操作要比CMS占用更多的运算资源,所以G1不得不将其时限为类似消息队列的结构,将写前屏障和写后屏障中要做的事放到队列里,然后再异步处理。

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

六、低延迟垃圾收集器

衡量垃圾收集器的三项重要指标是:内存占用、吞吐量、延迟,三者共同构成了一个“不可能三角”。

硬件规格提升,准确来说是内存的扩大,对延迟反而会带来负面的效果:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的堆内存耗费更多时间。

Shenandoah和XGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上都是固定的,与对的容量、队中对象的数量没有正比例关系。

3.6.1 Shenandoah收集器

Shenandoah相较于G1的改进:

  1. 支持并发的整理算法
  2. 默认不使用分代收集,
  3. 摒弃了记忆集,改用名为“连接矩阵”的全局数据结构来记录跨Region的引用关系,也降低了伪共享问题的发生概率。(邻接矩阵)

Shenandoah收集器的工作大致可以划分为以下几个阶段:

  • 初始标记:标记与GC Roots直接关联的对象,仍然需要停顿线程,但停顿时间只与GC Roots的数量有关
  • 并发标记:标记出全部可达的对象,这个阶段可并发执行,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度
  • 最终标记:处理剩余的SATB(原始快照)扫描,统计出回收价值最高的Region,将这些Region构成一组回收集,这个阶段也会有一小段短暂的停顿
  • 并发清理:清理那些整个区域连一个存活对象都没有的Region(Immediate Garbage Region)
  • 并发回收:这个阶段Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。对于并发回收阶段遇到的指针并发访问问题等,Shenandoah会通过读屏障和被称为“Brooks Pointers”的转发指针来解决。时间长短取决于回收集大小。
  • 初始引用更新:需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行收集线程都已完成分配给他们的对象移动任务而已,会产生一个非常短暂的停顿
  • 并发引用更新:真正开始进行引用更新操作,这个阶段是并发的,时间长短取决于内存中涉及的引用数量的多少。它只需要按照内存物理地址的顺序,线性的搜索出引用类型,把旧值改为新值即可。
  • 最终引用更新:修正存在于GC Roots中的引用,这个阶段是Shenandoah的最后一次停顿,停顿时间与GC Roots的数量相关。
  • 并发清理:整个回收集中的Region已再无存活对象,最后再调用一次并发清理来回收这些Region的内存空间,供新对象使用。

支持并发整理的核心概念:转发指针

转发指针是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,当对象有了一个新的副本,便只需要更改转发指针的值指向新的副本即可。Shenandoah收集器使用CAS操作来保证并发时对象的访问正确性。

3.6.2 ZGC收集器

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

  • ZGC的内存布局:

ZGC可以有大、中、小型Region

  1. 小型Region:容量固定为2MB,用于放置小于256KB的小对象
  2. 中型Region:容量固定为32MB,用于放置大于等于268KB但小于4MB的对象
  3. 大型Region:容量可以动态变化,但必须为2MB的整数倍,用于放置4MB以上的对象。每个大型Region中只会存放一个大对象,最小容量可以低至4MB。
  • ZGC工作四个阶段:
  1. 并发标记:遍历对象图做可达性分析,需要经过短暂停顿,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
  2. 并发预备重分配:根据特定的查询条件统计出来哪些Region需要清理,将这些Region组成重分配集。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取记忆集的维护成本。ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放。
  3. 并发重分配:这个过程中要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被顶置的内存屏障截获,然后根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”能力。
  4. 并发重映射:重映射就是修正整个堆中指向重分配集中旧对象的所有引用。ZGC很巧妙的把重映射要做的工作合并到了下一次并发标记阶段里去完成,反正都是要遍历所有对象的,这样就节省了因此遍历对象图的开销。所有指针都被修正后,原来记录新旧对象关系的转发表就可以被释放掉了。

七、选择合适的垃圾收集器

3.7.1 Epsilon收集器

如果应用只需要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择

3.7.2 收集器的权衡

我们应该如何选择一款适合自己应用的收集器,主要受以下三个因素影响:

  • 应用程序的主要关注点是什么?吞吐量、低延迟、内存占用
  • 运行应用的基础设施?硬件规格、系统架构、处理器的数量、分配内存大小、操作系统
  • JDK的发行商和版本号,对应的《Java虚拟机规范》的版本

3.7.3 虚拟机及垃圾收集器日志

日志级别从低到高共有六种级别:Trace,Debug,Info,Warning,Error,Off。日志级别决定了输出信息的详细程度,默认级别为Info,HotSpot的日志规则与Log4j、SLF4j类日志框架大体上是一样的

还可以使用修饰器(Decorator)来要求每行日志输出都附加上额外的内容。

垃圾收集算法和各种垃圾收集器的实现_第1张图片

垃圾收集算法和各种垃圾收集器的实现_第2张图片

3.7.4 垃圾收集器参数总结

垃圾收集算法和各种垃圾收集器的实现_第3张图片

垃圾收集算法和各种垃圾收集器的实现_第4张图片

八、实战:内存分配与回收策略

之前几个小节已经探讨了如何回收对象的问题,接下来几个小节主要探讨如何分配对象的问题

3.8.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

vm参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC

private static final int _1MB = 1024 * 1024;
public static void testAllocation(){
    byte[] allocation1 , allocation2 , allocation3 , allocation4;
    allocation1 = new byte[2 * _1MB];
    allocation2 = new byte[2 * _1MB];
    allocation3 = new byte[2 * _1MB];
    allocation4 = new byte[4 * _1MB];  //Minor GC
}

分配allocation4对象时会发生一次Minor GC,原因是Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,因此发生Minor GC。垃圾收集器期间1虚拟机又发现已有的三个2MB大小的对象全部无法放入Survivor空间,所以只好通过分配担保机制提前转移到老年代去。

3.8.2 大对象直接进入老年代

分配空间时,大对象容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好他们,当复制对象时,大对象意味着高额的内存复制开销。

HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代进行分配,这样做的目的是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

-XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用这个参数进行调优,可考虑ParNew加CMS的收集器组合

VM参数:-verbose:gc -Xmx20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728 -XX:UseConcMarkSweepGC

private static final int _1MB = 1024 * 1024;
public static void testAllocation(){
    byte[] allocation;
    allocation = new byte[4 * _1MB];
}

垃圾收集算法和各种垃圾收集器的实现_第5张图片

4MB对象直接进入了老年代

3.8.3 长期存活的对象进入老年代

虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头中。对象通常在Eden区诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳,就将其年龄设为1岁,对象在Survivor中每熬过一次Minor GC,年两就增加1岁,当年龄增加到一定程度(默认为15),就会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置

vm参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution

private static final int _1MB = 1024 * 1024;
    public static void main(String[] args){
        byte[] allocation1 , allocation2 , allocation3;
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation3 = new byte[4 * _1MB];
    }

垃圾收集算法和各种垃圾收集器的实现_第6张图片
MaxTenuringThreshold=15:

垃圾收集算法和各种垃圾收集器的实现_第7张图片

3.8.4 动态对象年龄判定

为了能更好地适应不同程序的内存状况,如果在Survivor空间中低于或等于某年龄的所有对象大小的综合大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

private static final int _1MB = 1024 * 1024;
    public static void main(String[] args){
        byte[] allocation1 , allocation2 , allocation3 , allocation4;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }

垃圾收集算法和各种垃圾收集器的实现_第8张图片

3.8.5 空间分配担保

进行Minor GC之前,虚拟机必须先检查老年代是否有足够空间进行分配担保和-XX:HandlePromotionFailure参数,如果老年代没有足够空间并且参数设置不允许冒险,那么这时就要改为一次FullGC

你可能感兴趣的:(jvm,算法,jvm,java,java-ee,学习)