程序计数器、虚拟机、本地方法 3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

而 Java堆 和 方法区 则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

如何判定对象已死

1、引用计数法

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

实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如

微软公司的COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言和在游戏脚本领域被广泛应用的Squirrel中都使用了引用计数算法进行内存管理。

但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间【相互循环引用】的问题。

举例:赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。

/**

*testGC()方法执行后,objA和objB会不会被GC呢?

*@author zzm

*/

public class ReferenceCountingGC{

public Object instance=nullprivate static final int_1MB=1024*1024/**

*这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过

*/

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

public static void testGC(){

ReferenceCountingGC objA=new ReferenceCountingGC();

ReferenceCountingGC objB=new ReferenceCountingGC();

objA.instance=objB;

objB.instance=objA;

objA=null;

objB=null//假设在这行发生GC,objA和objB是否能被回收?
System.gc();

}

}

从运行结果中可以清楚看到,GC日志中包含“4603K->210K”(缩小了,被回收了),意味着虚拟机并没有因为这两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。

2、可达性分析

在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的。

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图3-1所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

在Java语言中,可作为GC Roots的对象包括下面几种:

  1. 虚拟机(栈帧中的本地变量表)中引用的对象(局部变量)。
  2. 方法区中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. 本地方法栈中JNI(即一般说的Native方法)引用的对象。
public class GCRootDemo {
 
    // 第二种,方法区中的【类静态属性】引用的对象
    private static GCRootDemo2 t2;
 
    // 第三种,方法区中的【常量】引用,GC Roots 也会以这个为起点,进行遍历
    private static final GCRootDemo3 t3 = new GCRootDemo3(8);
 
    public static void m1() {
        // 第一种,【虚拟机栈中】的引用对象
        GCRootDemo t1 = new GCRootDemo();
        System.gc();
        System.out.println("第一次GC完成");
    }
    public static void main(String[] args) {
        m1();
    }
}

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

/**
 * 此代码演示了两点:
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 * @author zzm
 */
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_LOCK 的 finalize 方法之前已经调用过一次,这次不会再调用,也就没法在 finalize 里拯救自己
    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 :(

 

3、回收方法区

  有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java虚 拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整 实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载)。

  方法区垃圾收集 的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常 可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

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

判断废弃的常量:

    1. 已经没有任何字符串对象引用 常量池中的“java”常量,
    2. 虚拟机中也没有其他地方引用这个字面量。

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

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

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是 和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX: +TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在 Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版[1]的虚拟机支持。

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

垃圾收集算法

1、分代收集理论

建立在两个分 代假说之上:

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

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

共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

    • 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活不是去标记那些大量将要被回收的对象,以较低代价回收到大量的空间;
    • 如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,减少时间开销。
  • 在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 ——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;
  • 也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算 法”“标记-整理算法”等针对性的垃圾收集算法。

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

分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。 假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则

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

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

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数 据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

 

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。新生代满时主动触发。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指, 读者需按上下文区分到底是指老年代的收集还是整堆收集。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代(不同角色的 region 组成的回收集)的垃圾收集。目前只有 G1 (以前要么是 minor 要么是 major 以及 Full )收集器会有这种行为。
  • 整堆收集(Full GC):收集 整个Java堆 和 方法区 的垃圾收集。

 

2、标记-清除算法(回收老年代)

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来。标记过程就是对象是否属于垃圾的判定过程。

主要缺点有两个:

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

优点(比起标记-整理):由于不需要移动存活着的对象,所以可以不暂停用户线程,与用户线程并发进行。着重于低停顿的 CMS 就用了标记-清除。

 

 

 

3、标记-复制算法(回收新生代)

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

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。

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

其缺陷 也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一 点。

  现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。但新生代中的对象有98%熬不过第一轮收集。因此 并不需要按照1∶1的比例来划分新生代的内存空间。

  在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设 计新生代的内存布局。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间每次分配内存只使用Eden和其中一块Survivor发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性 复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空 间。

  HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%,剩下 10% 是给第一次回收后复制仍然存活的对象的)。

  任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安 全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

 

4、标记-整理算法(回收老年代)

  为什么不在老年代用 标记-复制 算法?

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

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

 

 

     标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

  移动则内存回收时(移动内存需要更新所有引用这些对象的地方,并暂停用户程序)更复杂不移动 内存分配时(碎片化,大对象不连续分配,增加内存访问开销)会更复杂。

    • 移动内存时的停顿被最初的虚拟机设计者形象地描述为“Stop The World”
    • 但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量

HotSpot虚拟机里面 关注吞吐量 的Parallel Scavenge收集器是基于标记-整理(会移动,内存连续,吞吐高)算法的,而关注延迟的CMS收集器则是基于标记-清除(不移动,不暂停程序)算法的,这也从 侧面印证这点。

另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

 

HotSpot的算法细节实现

1、根节点枚举(列出所有根节点)

GC roots: Java应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个查以这里为起源的引用肯定得消耗不少时间

根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。

现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发(具体见3.4.6节)

根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况。若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因。

由于目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地从方法区等 GC roots 开始查找,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot 的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以快速地完成根节点枚举,但这相当于是进行了预处理,将根节点枚举(列出所有根节点)的消耗转移到了程序运行期间。

2、安全点

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个问题:导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂

实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录 OopMap,这些位置被称为安全点(Safepoint)为了减少根节点枚举的时间消耗,强制要求必须执行到达安全点后才能够停顿下来开始垃圾收集。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。

通常安全点是以“能否让程序长时间运行”为标准来选定的,最常见的就是指令序列的复用:如【方法调用】、【循环跳转】和【异常跳转】等。

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括 执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:

  • 抢先式中断 (Preemptive Suspension)

不需要线程的执行代码 主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚 拟机实现采用抢先式中断来暂停线程响应GC事件。

  • 主动式中断(Voluntary Suspension)

当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一 个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他 需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式, 把轮询操作精简至只有一条汇编指令的程度。

3、安全区域

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己。

这种情况,就必须引入安全区域(Safe Region)来解决。 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化因此,这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(在安全区域内要完成根节点枚举),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以 离开安全区域的信号为止。

4、记忆集与卡表

所有涉及部分区域收集行为的垃圾收集器,都会面临跨代引用对象的问题。典型的如G1、ZGC和Shenandoah收集器。

记忆集是一种 在收集区用于记录从非收集区域指向收集区域的指针集合 的抽象数据结构。

如果记录全部含跨代引用对象,无论是空间占用还是维护成本都相当高昂。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择:

    • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个 精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
    • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
    • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是 目前最常用的一种记忆集实现形式。(类比 HashMap 是 Map 的一种实现形式)

卡表最简单的形式可以只是一个字节数组,字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。

一个卡页的内存中通常包含不止一个对象卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏(将对应卡表的数组元素的值标识为1)。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

 

 

 

5、写屏障

使用记忆集来缩减GC Roots扫描范围,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)。

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,在写屏障中增加了【更新卡表】操作,就会产生额外 的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

——————————————

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题:现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低。

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

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

在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启 卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题。

6、并发的可达性分析

根节点枚举(见3.4.1节)这个步骤中,由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。

从GC Roots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长。

想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进 行对象图的遍历?引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

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

黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是【把原本消亡的对象错误标记为存活】, 这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是【把原本存活的对象错误标记为已消亡】,这就是非常致命的后果了,程序肯定会因此发生错误。

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

    • 赋值器插入了一条或多条从黑色对象到白色对象的新引用(白色新变成了黑色)
    • 赋值器删除了全部从灰色对象到白色对象的直接或间接引用。(黑色对象不可能直接(不经过灰色对象)指向某个白色对象。所以这样没有任何一个灰色对象可以到该白色对象,使之与标黑的引用链相连)

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

    • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

实现:写后屏障记录 并发遍历阶段 黑色对象指向白色对象的新引用

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

实现:写前屏障记录 并发遍历阶段 灰色对象指向白色对象的初始引用关系

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

 

经典垃圾收集器

本节标题中“经典”二字并讨论的是在JDK 7 Update 4 之后(在这个版本中正式提供了商用的G1收集器,此前G1仍处于实验状态)、JDK 11正式发布之 前,OracleJDK中的HotSpot虚拟机所包含的全部可用的垃圾收集器。使用“经典”二字是为了与几款目前仍处于实验状态,但执行效果上有革命性改进的高性能低延迟收集器区分开来。

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

从JDK 1.3开始,一直到现在最新的JDK 13,HotSpot虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至现在垃圾收集器的最前沿成果Shenandoah和ZGC 等,我们看到了一个个越来越构思精巧,越来越优秀,也越来越复杂的垃圾收集器不断涌现,用户线 程的停顿时间在持续缩短,但是仍然没有办法彻底消除(这里不去讨论RTSJ中的收集器),探索更优秀垃圾收集器的工作仍在继续。

 

 

1、Serial收集器(新生代收集器,单线程 标记-复制)

Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代 收集器的唯一选择。大家只看名字就能够猜到,这个收集器是一个单线程工作的收集器。

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

   似乎已经把Serial收集器描述成一个最早出现,但目前已经老而无用,食之无味, 弃之可惜的“鸡肋”了,但事实上,迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比)

    • 对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的(不用维护根节点枚举时的 OopMap, 和遍历对象图时增量更新和原始快照时的引用记录);
    • 对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的 内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以, Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

 

2、ParNew收集器(新生代收集器,多线程 标记-复制)

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其他与Serial收集器相比并没有太多创新之处,包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX: PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码

却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS 收集器配合工作。

遗憾的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作

所以在JDK 5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者 Serial收集器中的一个。ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它。

可以说直到CMS的出现才巩固了ParNew的地位,但成也萧何败也萧何,随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。

所以自JDK 9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被G1所取代,甚至还取消了 ParNew加 Serial Old以及 Serial加CMS这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了 XX:+UseParNewGC参数,这意味着 ParNew和CMS从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。读者也可以理解为从此以后,ParNew合并入CMS,成为它专门处理新生代的组成部分。

ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。 ParNew收集器在单核心处理器的环境中绝对不会有比单线程Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时 系统资源的高效利用还是很有好处的。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如32个,现在CPU都是多核加超线程设计,服务器达到或超过32个逻辑核心的情况非常 普遍)的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

 

3、Parallel Scavenge 收集器(新生代收集器,多线程 标记-复制,关注吞吐量)

Parallel Scavenge(平行觅食?)的诸多特性从表面上看和ParNew非常相似,但是特点是它的关注点与其他收集器不同,目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于 运行用户代码的时间 与 处理器总消耗时间 的比值, 即:

 

 

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量:

  • 控制最大垃圾收集停顿时间  -XX:MaxGCPauseMillis 参数,值是一个大于0的毫秒数

垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间 的确在下降,但吞吐量也降下来了。

  • 直接设置吞吐量大小 -XX:GCTimeRatio参数,值是一个大于0小于100的整数。是垃圾收集时间是 1 的话,用户代码运行时间是它的多少倍

譬如设为19,那垃圾收集时间 1,用户代码时间 19。允许的最大垃圾收集时间就占总时间的5% (即1/(1+19))

默认值为 99,那垃圾收集时间 1,用户代码时间 99。即允许最大1%(即1/(1+99))的垃圾收集时间。

Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区 的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数 了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。

 

 

 

 

 

 

4、Serial Old 收集器(老年代收集器,单线程 标记-整理)

主要意义也是供客户端模式下的HotSpot虚拟机使用。

如果在服务端模式下,它也可能有两种用 途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

 

5、Parallel Old 收集器(老年代收集器,多线程 标记-整理,关注吞吐量)

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

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

老年代Serial Old收集器在服务端应用性能、单线程的无法充分利用服务器多处理器的并行处理能力,拖累 Parallel Scavenge

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

 

 

6、CMS 收集器(老年代收集器,多线程 标记-清除(可与用线并发),低停顿)

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

整个过程分为四个步骤,包括:

  1)初始标记(CMS initial mark)会  Stop The World

在安全点 【根节点枚举】:标记一下GC Roots能直接关联到的对象,速度很快;

  2)并发标记(CMS concurrent mark)耗时长,并发

从GC Roots的直接关联对象开始 遍历 整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;

  3)重新标记(CMS remark)会  Stop The World

修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(详见前面增量更新,新增的从黑色根节点指向未扫描的白节点

这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短

  4)并发清除(CMS concurrent sweep)耗时长,并发(因为是标记-清除,而不是标记-整理)

清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发

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

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

(1)在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢。CMS默认启动的回收线程数是(处理器核心数量 +3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。

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

不能像其他老年代收集器 那样 等待到老年代几乎完全被填满了再进行收集,必须 预留一部分空间  并发收集时应用程序产生的新老年代对象(浮动垃圾)使用在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值 来提高CMS的触发百分比。

到了JDK 6时,CMS收集器的启动 阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是 CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”。这时候虚拟机将不 得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集。

(3)标记-清除,大量空间碎片。出现即使剩余空间多,大对象也无法分配,而不得不提前触发一次Full GC。会有一些参数用来指定出现这种情况进行碎片整理,但整理要移动对象,不能并发,又导致停顿时间变长。

 

 

7、Garbage First 收集器(G1,Region,整体标记-整理,局部标记-复制,用户可控制停顿时间)

从2004年Sun实验室发表第一篇关于G1的 论文后一直拖到2012年4月JDK 7 Update 4发布,用将近10年时间才倒腾出能够商用的G1收集器来。

G1是一款主要 面向服务端应用 的垃圾收集器。HotSpot开发团队最初赋予它的期望是(在比较长 期的)未来可以替换掉JDK 5中发布的CMS收集器。现在这个期望目标已经实现过半了,JDK 9发布之 日,G1 宣告取代 Parallel Scavenge 加 Parallel Old 组合,成为服务端模式下的默认垃圾收集器,CMS则 沦落至被声明为不推荐使用(Deprecate)的收集器。如果对JDK 9及以上版本的HotSpot虚拟机使用 参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未 来将会被废弃。

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

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收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC)要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免 在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默 认值是200毫秒),优先处理【回收价值收益最大】的那些Region,这也就是“Garbage First”名字的由来。

但有(不限于)以下这些关键的细节问题需要妥善解决:

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

它的 每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

G1的记忆集在存储结构的本质上是一 种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号(指向这个 region 的 region 里,有哪些卡表元素)。

因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

2、在并发标记(由GC Roots 遍历)阶段如何保证收集线程与用户线程互不干扰地运行?

CMS收集器采用增量更新算法实现,而G1 收集器则是通过原始快照(SATB)算法来实现的。(写前屏障:记录下灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。

并发标记阶段产生的新对象G1为每一个Region设 计了两个名为TAMS(Top at Mark Start)的指针把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。与CMS中 的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度, G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。

3、怎样建立起可靠的停顿预测模型?用户通过-XX:MaxGCPauseMillis参数指定的停顿时间 只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?

在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得 出平均值、标准偏差、置信度等统计信息。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在【不超过期望停顿时间】的约束下获得【最高的收益】。

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

1)初始标记(Initial Marking):会  Stop The World,枚举 GC Roots,较短,借用进行Minor GC的时候同步完成

在安全点 标记一下GC Roots能直接关联到的对象。并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象(并发阶段的新对象要在两个 TAMS 指针之间)。

(2并发标记(Concurrent Marking):耗时长,并发

从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。(当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来)

(3最终标记(Final Marking):  Stop The World,较短

处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。原始快照 STAB(在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。)

(4筛选回收(Live Data Counting and Evacuation):会  Stop The World(标记-复制

负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。

从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的, 换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。

 这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至 接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度(并发时产生的新对象空间不够),导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常 把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求【能够应付应用的内存分配速率 】(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度【能跟得上对象分配的速度】,那一切就能运作得很完美。这种新的收集器设计思路 从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间。比起CMS,G1的弱项:

1、就内存占用来说,

虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色都必须有一份卡表,所以是双向这导致G1的记忆集(和 其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用(新生代(收集区)维护的老年代指向新生代的),反过来则不需要,由于新生代的对象具有朝 生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。

2、在执行负载的角度上

它们都使用到写屏障,CMS 和 G1 都用写【后】屏障来更新维护卡表,而由于G1的卡表结构复杂,其实是更烦琐的。

为了实现原始快照 (SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点, 但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

 

总结-读写屏障

  写前屏障:

    1. STAB 原始快照,记录 并发遍历阶段 灰色对象指向白色对象的 在写前的初始引用关系。(G1)

  写后屏障:

    1. 卡表记录跨代引用(卡页里有一个对象则这个卡页对应的元素变脏):CMS 在新生代维护老年代指向新生代的;G1 在每个 Region 维护别的 Region 指向这个 Region 的
    2. 增量更新,记录 并发遍历阶段 黑色对象指向白色对象的 新引用(CMS)

 

低延迟垃圾收集器

1、Shenandoah 收集器

Shenandoah作为第一款不由Oracle(包括以前的Sun)公 司的虚拟机团队所领导开发的HotSpot垃圾收集器,不可避免地会受到一些来自“官方”的排挤。换句话说,Shenandoah是一款只有 OpenJDK才会包含,而OracleJDK里反而不存在的收集器,“免费开源版”比“收费商业版”功能更多,这 是相对罕见的状况。如果读者的项目要求用到Oracle商业支持的话,就不得不把Shenandoah排除在选 择范围之外了。

待续

选择合适的垃圾收集器

1、Epsilon 收集器

在G1、Shenandoah或者ZGC这些越来越复杂、越来越先进的垃圾收集器相继出现的同时,也有一 个“反其道而行”的新垃圾收集器出现在JDK 11的特征清单中——Epsilon,这是一款以不能够进行垃圾 收集为“卖点”的垃圾收集器。

事实上只要Java虚拟机能够工作,垃圾收集器便不可能是真正“无操 作”的。原因是“垃圾收集器”这个名字并不能形容它全部的职责,更贴切的名字应该是本书为这一部分 所取的标题——“自动内存管理子系统”。一个垃圾收集器除了垃圾收集这个本职工作之外,它还要负 责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,其 中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持,是一个最小化功能 的垃圾收集器也必须实现的内容。

在实际生产环境中,不能进行垃圾收集的Epsilon也仍有用武之地。

很长一段时间以来,Java技术 体系的发展重心都在面向长时间、大规模的企业级应用和服务端应用,尽管也有移动平台(指Java ME而不是Android)和桌面平台的支持,但使用热度上与前者相比要逊色不少。可是近年来大型系统 从传统单体应用向微服务化、无服务化方向发展的趋势已越发明显,Java在这方面比起Golang等后起 之秀来确实有一些先天不足,使用率正渐渐下降。

传统Java有着内存占用较大,在容器中启动时间 长,即时编译需要缓慢优化等特点,这对大型应用来说并不是什么太大的问题,但对短时间、小规模 的服务形式就有诸多不适。为了应对新的技术潮流,最近几个版本的JDK逐渐加入了提前编译、面向 应用的类数据共享等支持。Epsilon也是有着类似的目标,如果读者的应用只要运行数分钟甚至数秒, 只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为 的Epsilon便是很恰当的选择。

2、收集器的权衡

·应用程序的主要关注点是什么?

    • 如果是数据分析、科学计算类的任务,目标是能尽快算出结果, 那吞吐量就是主要关注点;
    • 如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务 超时,这样延迟就是主要关注点;
    • 而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是 不可忽视的。

·运行应用的基础设施如何?

    • 譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是 ARM/Aarch64;
    • 处理器的数量多少,分配内存的大小;
    • 选择的操作系统是Linux、Solaris还是Windows 等。

·使用JDK的发行商是什么?版本号是多少?

    • 是ZingJDK/Zulu、OracleJDK、Open-JDK、OpenJ9抑 或是其他公司的发行版?
    • 该JDK对应了《Java虚拟机规范》的哪个版本?

一般来说,收集器的选择就从以上这几点出发来考虑。

举个例子,假设某个直接面向用户提供服务的B/S系统准备选择垃圾收集器,一般来说延迟时间是这类应用的主要关注点,那么:

    • 如果你有充足的预算但没有太多调优经验,那么一套带商业技术支持的专有硬件或者软件解决方 案是不错的选择,Azul公司以前主推的Vega系统和现在主推的Zing VM是这方面的代表,这样你就可以 使用传说中的C4收集器了。
    • 如果你虽然没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那ZGC很值得尝试。
    • 如果你对还处于实验状态的收集器的稳定性有所顾虑,或者应用必须运行在Win-dows操作系统 下,那ZGC就无缘了,试试Shenandoah吧。
    • 如果你接手的是遗留系统,软硬件基础设施和JDK版本都比较落后,那就根据内存规模衡量一 下,对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,而对于更大的堆内存,可重点考察一下G1

 

基本的内存分配原则

本节出现的代码如无特别说明,均使用HotSpot虚拟机,以客户端模式运行。由于并未指定收集器组合,因此,本节验证的实际是使用 Serial加Serial Old客户端 默认收集器组合下的内存分配和回收的策 略,这种配置和收集器组合也许是开发人员做研发时的默认组合(其实现在研发时很多也默认用服务 端虚拟机了),但在生产环境中一般不会这样用。

前情:

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

  HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%,剩下 10% 是给第一次回收后复制仍然存活的对象的)。

-Xms 堆内存的初始大小,默认为物理内存的1/64

-Xmx 堆内存的最大大小,默认为物理内存的1/4

-Xmn 堆内新生代的大小。通过这个值也可以得到老生代的大小:-Xmx减去-Xmn

1、对象优先在 Eden 分配

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

在运行 时通过-Xms20M、-Xmx20M、-Xmn10M这三个参数限制了Java堆大小为20MB,不可扩展,其中 10MB分配给新生代(Xmn),剩下的10MB分配给老年代(Xmx - Xmn)。

-XX:Survivor-Ratio=8决定了新生代中Eden区与一 个Survivor区的空间比例是8∶1,从输出的结果也清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量。虽然总容量 10MB ,但是不是全部可用的)。

执行testAllocation()中分配allocation4对象的语句时会发生一次Minor GC,因为新生代 Eden + 1个Survivor 不够 10MB 可用,已经被用了 6MB,剩余空间已不足以分配allocation4所需的4MB内存。

这次回收的结果是新生 代6651KB变为148KB,而总内存占用量则几乎没有减少(因为allocation1、2、3三个对象都是存活 的,虚拟机几乎没有找到可回收的对象)。

垃圾收集期间虚拟机又发现已有的三个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有 1MB大小),所以只好通过分配担保机制提前转移到老年代去。

这次收集结束后,4MB的allocation4对象顺利分配在Eden中。因此程序执行完的结果是Eden占用 4MB(被allocation4占用),Survivor空闲,老年代被占用6MB(被allocation1、2、3占用)。通过GC 日志可以证实这一点。

private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
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
}

   

[GC[DefNew:6651K->148K(9216K),0.0070106 secs]6651K->6292K(19456K),
0.0070426 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
Heap
  def new generation total 9216K,used 4326K[0x029d0000,0x033d0000,0x033d0000)
    eden space 8192K,51%used[0x029d0000,0x02de4828,0x031d0000)
    from space 1024K,14%used[0x032d0000,0x032f5370,0x033d0000)
    to space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
  tenured generation total 10240K,used 6144K[0x033d0000,0x03dd0000,0x03dd0000)
    the space 10240K,60%used[0x033d0000,0x039d0030,0x039d0200,0x03dd0000)
  compacting perm gen total 12288K,used 2114K[0x03dd0000,0x049d0000,0x07dd0000)
    the space 12288K,17%used[0x03dd0000,0x03fe0998,0x03fe0a00,0x049d0000)
No shared spaces configured.

 

2、大对象直接进入老年代

大对象对虚拟机的内存分配来说 就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。

在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易 导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复 制对象时,大对象就意味着高额的内存复制开销

HotSpot虚拟机提供了-XX:PretenureSizeThreshold 参数,指定 大于该设置值 的对象 直接在老年代 分配,这样做的目的就是避免在Eden区及两个Survivor区 之间来回复制,产生大量的内存复制操作。(注意 -XX:PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款新生代收集器有效,HotSpot 的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑 ParNew加CMS的收集器组合。)

 

testPretenureSizeThreshold()方法后,我们看到Eden空间几乎没有被使用,而老年代的10MB空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中,这是因为 XX:PretenureSizeThreshold被设置为3MB(就是3145728,这个参数不能与-Xmx之类的参数一样直接 写3MB),因此超过3MB的对象都会直接在老年代进行分配。

private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold() {
  byte[] allocation;
  allocation = new byte[4 * _1MB]; //直接分配在老年代中
}
Heap
  def new generation total 9216K, used 671K [0x029d0000, 0x033d0000, 0x033d0000)
    eden space 8192K, 8% used [0x029d0000, 0x02a77e98, 0x031d0000)
    from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
    to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
  tenured generation total 10240K, used 4096K [0x033d0000, 0x03dd0000, 0x03dd0000)
    the space 10240K, 40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
  compacting perm gen total 12288K, used 2107K [0x03dd0000, 0x049d0000, 0x07dd0000)
    the space 12288K, 17% used [0x03dd0000, 0x03fdefd0, 0x03fdf000, 0x049d0000)
No shared spaces configured.

 

 

3、长期存活的对象将进入老年代

内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中(详见第2章)。

对象通常在Eden区里诞生,如果经过第一次 Minor GC后仍然存活,并且 能被Survivor容纳 的话,该对象会被 移动到Survivor空间 (不能容纳则通过分配担保提前晋升老年代)中,并且将其对象 年龄设为1岁。

对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代。对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold设置。

 

可以试试分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来 执行代码清单3-9中的testTenuringThreshold()方法,此方法中allocation1对象需要256KB内存,Survivor 空间可以容纳。

当-XX:MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代, 新生代已使用的内存在垃圾收集以后非常干净地变成0KB。

当-XX:MaxTenuringThreshold=15时, 第二次GC发生后,allocation1对象则还留在新生代Survivor空间,这时候新生代仍然有404KB被占用。

private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
  byte[] allocation1, allocation2, allocation3;
  allocation1 = new byte[_1MB / 4]; // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置
  allocation2 = new byte[4 * _1MB];
  allocation3 = new byte[4 * _1MB];
  allocation3 = null;
  allocation3 = new byte[4 * _1MB];
}

  以-XX:MaxTenuringThreshold=1参数来运行的结果:

[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 414664 bytes, 414664 total
: 4859K->404K(9216K), 0.0065012 secs] 4859K->4500K(19456K), 0.0065283 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 1)
: 4500K->0K(9216K), 0.0009253 secs] 8596K->4500K(19456K), 0.0009458 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
  def new generation total 9216K, used 4178K [0x029d0000, 0x033d0000, 0x033d0000)
    eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
    from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
    to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
  tenured generation total 10240K, used 4500K [0x033d0000, 0x03dd0000, 0x03dd0000)
    the space 10240K, 43% used [0x033d0000, 0x03835348, 0x03835400, 0x03dd0000)
  com\pacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
    the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

   以-XX:MaxTenuringThreshold=15参数来运行的结果:

[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 15 (max 15)
- age 1: 414664 bytes, 414664 total
: 4859K->404K(9216K), 0.0049637 secs] 4859K->4500K(19456K), 0.0049932 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 15 (max 15)
- age 2: 414520 bytes, 414520 total
: 4500K->404K(9216K), 0.0008091 secs] 8596K->4500K(19456K), 0.0008305 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
  def new generation total 9216K, used 4582K [0x029d0000, 0x033d0000, 0x033d0000)
    eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
    from space 1024K, 39% used [0x031d0000, 0x03235338, 0x032d0000)
    to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
  tenured generation total 10240K, used 4096K [0x033d0000, 0x03dd0000, 0x03dd0000)
    the space 10240K, 40% used [0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
  compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
    the space 12288K, 17% used [0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

 

 

4、动态对象年龄判定

为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到 -XX:MaxTenuringThreshold才能晋升老年代,

如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold中要求的年龄。

 

执行代码清单3-10中的testTenuringThreshold2()方法,并将设置-XX:MaxTenuring-Threshold=15, 发现运行结果中Survivor占用仍然为0%,而老年代比预期增加了6%,也就是说allocation1、allocation2 对象都直接进入了老年代,并没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB, 并且它们是同年龄的,满足同年对象达到Survivor空间一半的规则。我们只要注释掉其中一个对象的 new操作,就会发现另外一个就不会晋升到老年代了。

private static final int _1MB = 1024 * 1024;
/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=15
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
  byte[] allocation1, allocation2, allocation3, allocation4;
  allocation1 = new byte[_1MB / 4]; // allocation1+allocation2大于survivo空间一半
  allocation2 = new byte[_1MB / 4];
  allocation3 = new byte[4 * _1MB];
  allocation4 = new byte[4 * _1MB];
  allocation4 = null;
  allocation4 = new byte[4 * _1MB];
}

  

[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 1 (max 15)
- age 1: 676824 bytes, 676824 total
: 5115K->660K(9216K), 0.0050136 secs] 5115K->4756K(19456K), 0.0050443 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]
[GC [DefNew
Desired Survivor size 524288 bytes, new threshold 15 (max 15)
: 4756K->0K(9216K), 0.0010571 secs] 8852K->4756K(19456K), 0.0011009 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
  def new generation total 9216K, used 4178K [0x029d0000, 0x033d0000, 0x033d0000)
    eden space 8192K, 51% used [0x029d0000, 0x02de4828, 0x031d0000)
    from space 1024K, 0% used [0x031d0000, 0x031d0000, 0x032d0000)
    to space 1024K, 0% used [0x032d0000, 0x032d0000, 0x033d0000)
  tenured generation total 10240K, used 4756K [0x033d0000, 0x03dd0000, 0x03dd0000)
    the space 10240K, 46% used [0x033d0000, 0x038753e8, 0x03875400, 0x03dd0000)
  compacting perm gen total 12288K, used 2114K [0x03dd0000, 0x049d0000, 0x07dd0000)
    the space 12288K, 17% used [0x03dd0000, 0x03fe09a0, 0x03fe0a00, 0x049d0000)
No shared spaces configured.

 

 

5、空间分配担保

新生代使用复制收集算法,但为了内存利用率, 只使用其中一个Survivor空间来作为轮换备份。

因此当出现大量对象在Minor GC后仍然存活的情况 ——最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保把 Survivor无法容纳的对象 直接送入老年代。

在JDK 6 Update 24之后,-XX:HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,实际虚拟机中已经不会再使用它。JDK 6 Update 24之 后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC。

   在JDK 6 Update 24之后,-XX:HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,实际虚拟机中已经不会再使用它。JDK 6 Update 24之 后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC。

bool TenuredGeneration::promotion_attempt_is_safe(size_t
max_promotion_in_bytes) const {
  // 老年代最大可用的连续空间
  size_t available = max_contiguous_available();
  // 每次晋升到老年代的平均大小
  size_t av_promo = (size_t)gc_stats()->avg_promoted()->padded_average();
  // 老年代可用空间是否大于平均晋升大小,或者老年代可用空间是否大于当此GC时新生代所有对象容量
  bool res = (available >= av_promo) || (available >= max_promotion_in_bytes);
  return res;
}