【JVM】第二章 垃圾收集器与内存分配策略

文章目录

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人向进去,墙里面的人想出来


2.1 概述

​ ​ ​ ​ ​ ​ 每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或线程结束时,内存自然就跟随着回收了。

​ ​ ​ ​ ​ ​ 而Java堆方法区这个两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。GC所关注的正是这部分内存该如何管理。


2.2 对象已死?

​ ​ ​ ​ ​ ​ 在堆里面存放着Java世界中几乎所有的对象实例,GC在对堆进行回收前,第一件事情就是确定哪些这些对象哪些还“活着”,哪些已经“死去”(“死去”:不可能在被任何途径使用的对象)了。


2.2.1 引用计数算法

​ ​ ​ ​ ​ ​ 很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1,当引用失效时,计数器值减1;任何时刻计数器为0的对象就是不可能在被使用的。

​ ​ ​ ​ ​ ​ 客观地说,引用计数算法虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的的算法。但是在Java领域,至少主流的JVM里面都没有引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难剞劂对象之间相互循环引用的问题。

​ ​ ​ ​ ​ ​ 举个简答的例子:对象objA和objB都有字段instance,赋值令objA.instance = objB及objB.instacne = objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能在被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,引用计数算法也就无法回收它们。

​ ​ ​ ​ ​ ​ ​ 用就放弃回收它们,这也从侧面说明了JVM并不是通过引用计数算法来判断对象是否存活的。


2.2.2 可达性分析算法

​ ​ ​ ​ ​ ​ 当前主流的商用程序语言(Java,C#,古老的Lisp)的内存管理子系统,都是通过可达性分析算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为**“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

​ ​ ​ ​ ​ ​ 如图,对象object5、object6、object7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收对象。

在这里插入图片描述

在Java技术体系里面,固定可作为 GC Roots 的对象包括以下几类:
在虚拟机栈(栈帧中的本地变量表)中引用的对象,例如:当前正在运行的方法所使用到的参数、局部变量、临时变量等。
在方法区中类静态属性引用的对象,例如:Java类的引用类型静态变量。
在方法区中常量引用的对象,例如:字符串常量池(String Table)里的引用。
在本地方法栈中JNI(通常所说的Native方法)引用的对象。
JVM内部的引用,例如:基本数据类型对应的Class对象异常对象(OutOfMemoryError,NullPointException),还有系统类加载器
所有被同步锁(synchronized关键字)持有的对象
反映JVM内部情况的JMXBean,JVMTI中注册的回调、本地代码缓存等。

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

​ ​ ​ ​ ​ ​ 目前最新的几款GC(如 OpenJDK 中的G1、Shenandoah、ZGC 以及 Azul 的 PGC、C4)无一例外都具备了局部回收的特性,为了避免 GC Roots 包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。


2.2.3 再谈引用

​ ​ ​ ​ ​ ​ 无论是通过引用计数算法判断对象的引用数量,,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和**“引用”**离不开关系。在JDK1.2版本之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态。例如我们希望能描述一类对象:当内存还足够时,能保留在内存之中,如果内存空间在进行GC后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。

在JDK1.2版本之后,Java对引用的概念进行了扩充,将引用分为:强引用、软引用、弱引用、虚引用。引用强度依次逐渐减弱。
强引用:程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()” 无论任何情况下,只要强引用关系还在,GC器就永远不会回收掉被引用的对象。
软引用:一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用:非必须对象。被弱引用关联的对象只能生存到下一次GC发生为止。当GC器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用:幽灵引用 或 幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被GC器回收时收到一个系统通知。

2.2.4 生存还是死亡?

​ ​ ​ ​ ​ ​ 即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象的死亡,最多会经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相关联的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假设对象没有覆盖finalize()方法,或者finalize()方法以及被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

​ ​ ​ ​ ​ ​ 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,比如把自己(this关键字)赋值给某个类变量或或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它真的要被回收。从以下代码中我们可以看到一个对象的finalize()被执行,但它任可以存活。


/*此代码演示了两点:
1.对象可以在被GC时发生自救
2.这种自救的机会只有一次,因为一个对象的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 :(

​ ​ ​ ​ ​ ​ SAVE_HOOK对象的finalize()方法确实被GC器触发过,并且在收集前成功逃脱了。

​ ​ ​ ​ ​ ​ 执行结果一次成功,一次失败。这是因为任何一个对象的finalize()方法只会被系统调用一次,因此第二段代码自救失败。

2.2.5 回收方法区

​ ​ ​ ​ ​ ​ 有些人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~99%的空间,而永久代的垃圾收集效率远低于此。

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

​ ​ ​ ​ ​ ​ 判定一个常量是否“废弃”:回收废弃常量与回收Java堆中的对象非常类似。假如一个字符串“java”曾经进入常量池中,但是当前系统已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

​ ​ ​ ​ ​ ​ 要判定一个类型是否属于“不再被使用的类”:同时满足三个条件

​ ​ ​ ​ ​ ​ 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版的虚拟机支持。

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


2.3 垃圾收集算法

​ ​ ​ ​ ​ ​ 垃圾收集算法的实现涉及大量的程序细节,在本节中只重点介绍分代收集理论和几种算法

2.3.1 分代收集理论

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

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

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

​ ​ ​ ​ ​ ​ 这两个分代假说共同奠定了多款常用垃圾收集器一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象根据年龄分配到不同的个区域进行存储。(年龄:对象熬过垃圾收集过程的次数)

​ ​ ​ ​ ​ ​ 显而易见,如果一个区域中大多数对象都是朝生夕灭,很难熬过垃圾收集过程的话,那么将它们集中在一起,每次回收只关注如何保留少量存活而不是标记那些大量的回收对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那么就将它们集中在一起,虚拟机就可以使用较低的频率来回收这个区域

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

​ ​ ​ ​ ​ ​ 如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用就会使得新生代对象在收集时同样存活,进而在年龄增长后上升到老年代,这时跨代引用也随即消除了。


注意:

​ ​ ​ ​ ​ ​ 部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 老年代收集(Magor GC/Old GC):**只是老年代的垃圾收集。(目前只有CMS收集器拥有

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 混合收集(Mixed GC):整个新生代+部分老年代。(目前只有G1收集器拥有

​ ​ ​ ​ ​ ​ 整堆收集(Full GC):收集整个Java堆和方法区。


2.3.2 标记——清除算法

​ ​ ​ ​ ​ ​ 非移动式回收算法

​ ​ ​ ​ ​ ​ 最早出现也是最基础的算法:算法分为两个阶段:

​ ​ ​ ​ ​ ​ 1.首先标记所有需要回收的对象

​ ​ ​ ​ ​ ​ 2.标记完成后,统一回收掉被标记的对象

​ ​ ​ ​ ​ ​ or

​ ​ ​ ​ ​ ​ 1.首先标记存活的对象

​ ​ ​ ​ ​ ​ 2…标记完成后,统一回收掉未被标记的对象

缺点:

​ ​ ​ ​ ​ ​ 1.执行效率不稳定:如果Java堆中有大量的对象,而且大部分对象都需要被回收,执行效率就会随着对象数量的增加而降低。

​ ​ ​ ​ ​ ​ 2.内存空间的碎片化:标记、清除之后会产生大量不连续的内存碎片,碎片太多可能会导致以后在分配较大对象的时候无法找到连续的内存空间。


2.3.3 标记——复制算法

​ ​ ​ ​ ​ ​ 解决标记-清除算法的执行效率低

​ ​ ​ ​ ​ ​ 它将内存区域按容量划分为两块大小相同的区域(这不双倍快乐吗),每次只是用其中的一块。当这一块用完了,就将还存活的对象复制到另一块上,然后再把使用过的内存空间清理掉。

​ ​ ​ ​ ​ ​ 优点:没有内存碎片。

​ ​ ​ ​ ​ ​ 缺点:需要双倍的内存空间


2.3.4 标记——整理算法

​ ​ ​ ​ ​ ​ 移动式回收算法

​ ​ ​ ​ ​ ​ 老年代一般不能直接选用这种算法!!!

​ ​ ​ ​ ​ ​ 第一步和标记——清除算法一样,第二步不一样,即整理过程。让所有存活的对象向一个和方向移动,就让对象变得更加紧凑。

​ ​ ​ ​ ​ ​ 优点:整理之后,就能发现内存变的更紧凑了,即连续的空间就更多了,这样就不会造成内存碎片。

​ ​ ​ ​ ​ ​ 缺点:

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 1.“标记-整理算法”是移动式回收算法,在老年代中大部分对象都是存活的,因此在回收对象时,会伴随大量的对象移动,从而会导致对象回收阶段会占用相对多的时间

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 2.如果那些局部变量或对象引用了这个,那么还得改变那些变量或对象的引用地址,因为内存地址变了,所以涉及的工作就比较多一些,牵扯到内存区块的拷贝移动,牵扯到所有引用的地址加以改变。所以速度较慢,即影响应用程序的吞吐量


2.4 HotSpot算法细节实现


2.4.1 根节点枚举

​ ​ ​ ​ ​ ​ 迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,枚举过程必须在一个能保障 ”一致性“ 的快照中才得以进行。

​ ​ ​ ​ ​ ​ 通俗来说,整个枚举期间整个系统看起来就像被冻结在某个时间点上,不会出现在分析过程中,用户进程还在运行,导致根节点集合的对象引用关系还在不断变化的情况,若这点都不能满足的话,可达性分析结果的准确性显然也就无法保证。

​ ​ ​ ​ ​ ​ 也就是说,根节点枚举与我们之前提到的标记-整理算法中的移动存活对象操作一样会面临相似的 “Stop The World” 的困扰。

​ ​ ​ ​ ​ ​ 另外,众所周知,可作为 GC Roots 的对象引用就那么几个,主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如虚拟机栈中引用的对象)中,尽管目标很明确,但查找过程要做到快速高效其实并不是一件容易的事情。

​ ​ ​ ​ ​ ​ 现在 Java 应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是一大堆,要是把这些区域全都扫描检查一遍显然太过于费事。

​ ​ ​ ​ ​ ​ 那有没有办法减少耗时呢?

​ ​ ​ ​ ​ ​ 一个很自然的想法,空间换时间!
​ ​ ​ ​ ​ ​ 把引用类型和它对应的位置信息用哈希表记录下来,这样到 GC 的时候就可以直接读取这个哈希表,而不用一个区域一个区域地进行扫描了。Hotspot 就是这么实现的,这个用于存储引用类型的数据结构叫 OopMap(存放普通指针的Map)。


2.4.2 安全点 Safe Point

​ ​ ​ ​ ​ ​ 在 OopMap 的协助下,HotSpot 可以快速完成根节点枚举了,但一个很现实的问题随之而来:由于引用关系可能会发生变化,这就会导致 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

​ ​ ​ ​ ​ ​ 所以实际上 HotSpot 也确实没有为每条指令都生成 OopMap,只是在 “特定的位置” 生成 OopMap,换句话说,只有在某些 ”特定的位置“ 上才会把对象引用的相关信息给记录下来,这些位置也被称为安全点(Safepoint)。

​ ​ ​ ​ ​ ​ 有了安全点的设定,也就决定了用户程序执行时并不是随便哪个时候都能够停顿下来开始 GC 的,而是强制要求程序必须执行到达安全点后才能够进行 GC(因为不到达安全点话,没有 OopMap,虚拟机就没法快速知道对象引用的位置呀,没法进行根节点枚举)。

​ ​ ​ ​ ​ ​ 因此,安全点的设定既不能太少以至于让垃圾收集器等待时间过长,也不能太多以至于频繁进行垃圾收集从而导致运行时的内存负荷大幅增大。所以,安全点的选定基本上是以 “是否具有让程序长时间执行的特征” 为标准进行选定的,最典型的就是指令序列的复用:例如方法调用、循环跳转、异常跳转等,所以只有具有这些功能的指令才会产生安全点。

​ ​ ​ ​ ​ ​ 对于安全点,另外一个需要考虑的问题是,如何在 GC 发生时让所有用户线程都执行到最近的安全点,然后停顿下来呢?。这里有两种方案可供选择:

​ ​ ​ ​ ​ ​ 抢先式中断(Preemptive Suspension):

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 这种思路很简单,就是在 GC 发生时,系统先把所有用户线程全部中断掉。然后如果发现有用户线程中断的位置不在安全点上,就恢复这条线程执行,直到跑到安全点上再重新中断。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 缺点:时间成本的不可控,进而导致性能不稳定和吞吐量的波动,特别是在高并发场景下这是非常致命的,所以现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。

​ ​ ​ ​ ​ ​ 主动式中断(Voluntary Suspension):

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 主动式中断不会直接中断线程,而是全局设置一个标志位,用户线程会不断的轮询这个标志位,当发现标志位为真时,线程会在最近的一个安全点主动中断挂起。现在的虚拟机基本都是用这种方式。


2.4.3 安全区域 Safe Region

​ ​ ​ ​ ​ ​ ​ 安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。

​ ​ ​ ​ ​ ​ ​ 对于主动式中断来说,用户线程需要不断地去轮询标志位,那对于那些处于 sleep 或者 blocked 状态的线程(不在活跃状态的线程)来说怎么办?

​ ​ ​ ​ ​ ​ ​ 这些不在活跃状态的线程没有获得 CPU 时间,没法去轮询标志位,自然也就没法找到最近的安全点主动中断挂起了。

​ ​ ​ ​ ​ ​ ​ 换句话说,对于这些不活跃的线程,我们没法掌控它们醒过来的时间。很可能其他线程都已经通过轮询标志位到达安全点被中断了,然后虚拟机开始根节点枚举了(根节点枚举需要暂停所有用户线程),但是这时候那些本不活跃的用户线程又醒过来了开始执行,破坏了对象之间的引用关系,那显然是不行的。

​ ​ ​ ​ ​ ​ ​ 对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域的定义

​ ​ ​ ​ ​ ​ ​ 确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中的任意地方开始 GC 都是安全的。

​ ​ ​ ​ ​ ​ ​ 可以简单地把安全区域看作被拉长了的安全点。

​ ​ ​ ​ ​ ​ ​ 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。那样当这段时间里虚拟机要发起 GC 时,就不必去管这些在安全区域内的线程了。当安全区域中的线程被唤醒并离开安全区域时,它需要检查下主动式中断策略的标志位是否为真(虚拟机是否处于 STW 状态),如果为真则继续挂起等待(防止根节点枚举过程中这些被唤醒线程的执行破坏了对象之间的引用关系),如果为假则标识还没开始 STW 或者 STW 刚刚结束,那么线程就可以被唤醒然后继续执行。


2.4.4 记忆集与卡表
​ ​ ​ ​ ​ ​ 为什么需要记忆集?

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 跨代引用假说(IntergenerationalReferenceHypothesis):跨代引用相对于同代引用来说仅占极少数。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 这时候便引出了记忆集(RememberedSet)概念。用以避免把整个老年代加进GCRoots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(PartialGC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 实际上就是一种备忘录思想,空间换时间。

什么是记忆集

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

​ ​ ​ ​ ​ ​ 卡表

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 卡表(CardTable)是记忆集一种粗粒度的实现方式。来节省存储和维护成本。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 记忆集是抽象的数据结构,那么卡表就是记忆集的一种具体实现。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 一种简单的数组结构。字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作**“卡页”**(CardPage)。一般来说,卡页大小都是以2的N次幂的字节数。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x00000x01FF、0x02000x03FF、0x0400~0x05FF的卡页内存块。

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


2.4.5 写屏障

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

​ ​ ​ ​ ​ ​ 卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
​ 但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

​ ​ ​ ​ ​ ​ 在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。下面这段代码是一段更新卡表状态的简化逻辑:

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

​ ​ ​ ​ ​ ​ 伪共享

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,
当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。

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


2.4.5 并发的可达性分析
可达性分析

​ ​ ​ ​ ​ ​ ​ 为了验证堆中的对象是否为可回收对象(Garbage)标记上的对象,即是存活的对象,不会被垃圾回收器回收,没有标记的对象会被垃圾回收器回收,在标记的过程中需要stop the world (STW)。
​ 缺点:当堆中的对象很多、很复杂时,用。等待时间会很长。

什么是并发可达性分析

​ ​ ​ ​ ​ ​ ​ 并发的意思是指和用户的线程进行并行运行,在运行时不需要进行STW。

三色算法理论-引入三种颜色

​ ​ ​ ​ ​ ​ ​ 白色:尚未访问过。
​ ​ ​ ​ ​ ​ ​ 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
​ ​ ​ ​ ​ ​ ​ 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。
在这里插入图片描述
在这里插入图片描述

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

​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 1.赋值器插入了一条或者多条从黑色对象到白色对象的新引用;

​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 2.赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

​ ​ ​ ​ ​ ​ ​ 要解决对象消失的问题,就要使引发问题的根源条件处理,这就引出了2种解决对象消失问题的方式,增量更新或原始快照。

1.如何解决漏标-增量更新(Incremental Update)

​ ​ ​ ​ ​ ​ ​ 代表回收器:CMS
​ ​ ​ ​ ​ ​ ​ 增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用时,就将这个新加入的引用记录下来,待并发标记完成后,重新对这种新增的引用记录进行扫描;
​ ​ ​ ​ ​ ​ ​ 简记:黑色对象一旦插入新的白色对象,黑色就变成灰色(需要重新扫描)

2.如何解决漏标-原始快照(Snapshot At The Beginning,SATB)

​​ ​ ​ ​ ​ ​ 代表回收器:G1、Shenandoah
​ ​ ​ ​ ​ ​ ​ 原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,也是将这个记录下来,并发标记完成后,对该记录进行重新扫描。
​​ ​ ​ ​ ​ ​ 简记:如果灰色对象下的所有白色节点之间的引用删掉,那么灰色节点将变为根节点,重新进行扫描。


2.5 经典垃圾收集器

​​ ​ ​ ​ ​ ​ 概述
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 如果说前面介绍的收集算法(JVM之垃圾回收-垃圾收集算法)是内存回收的抽象策略,那么垃圾收集器就是内存回收的具体实现。

​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ JVM规范对于垃圾收集器的应该如何实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器差别较大,这里只看HotSpot虚拟机。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 就像没有最好的算法一样,垃圾收集器也没有最好,只有最合适。我们能做的就是根据具体的应用场景选择最合适的垃圾收集器。


2.5.1 Serial收集器

​ ​ ​ ​ ​ ​ ​Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器了(新生代采用复制算法,老生代采用标志整理算法)。大家看名字就知道这个收集器是一个单线程收集器了。

​ ​ ​ ​ ​ ​ ​ 它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” :将用户正常工作的线程全部暂停掉),直到它收集结束。

​ ​ ​ ​ ​ ​ ​ 新生代采用复制算法,Stop-The-World
​ ​ ​ ​ ​ ​ ​ 老年代采用标记-整理算法,Stop-The-World
​ ​ ​ ​ ​ ​ ​ 当它进行GC工作的时候,虽然会造成Stop-The-World,正如每种算法都有存在的原因,该串行收集器也有存在的原因:因为简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,没有线程交互的开销,专心做GC,自然可以获得最高的单线程效率。所以Serial收集器对于运行在client模式下的应用是一个很好的选择(到目前为止,它依然是虚拟机运行在client模式下的默认新生代收集器)
​ ​ ​ ​ ​ ​ ​ 串行收集器的缺点很明显,虚拟机的开发者当然也是知道这个缺点的,所以一直都在缩减Stop The World的时间。
在后续的垃圾收集器设计中停顿时间在不断缩短(但是仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)

​ ​ ​ ​ ​ ​ ​ 整理一下前面关于Serial收集器的知识

​ ​ ​ ​ ​ ​ 特点

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 针对新生代的收集器;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 采用复制算法;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 单线程收集;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 进行垃圾收集时,必须暂停所有工作线程,直到完成;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 即会"Stop The World";

​ ​ ​ ​ ​ ​ 应用场景

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 依然是HotSpot在Client模式下默认的新生代收集器;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 也有优于其他收集器的地方:简单高效(与其他收集器的单线程相比);对于限定单个CPU的环境来说,Serial收集器没有线程交互(切换)开销,可以获得最高的单线程收集效率;在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M),可以在较短时间内完成垃圾收集(几十MS至一百多MS),只要不频繁发生,这是可以接受的。

​ ​ ​ ​ ​ ​ 设置参数

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 添加该参数来显式的使用串行垃圾收集器: “-XX:+UseSerialGC”


2.5.2ParNew收集器(Serial收集器的多线程版本-使用多条线程进行GC)

​ ​ ​ ​ ​ ​ ​ ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器完全一样。
​ ​ ​ ​ ​ ​ ​ 它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
​ ​ ​ ​ ​ ​ CMS收集器是一个被认为具有划时代意义的并发收集器,因此如果有一个垃圾收集器能和它一起搭配使用让其更加完美,那这个收集器必然也是一个不可或缺的部分了。

​ ​ ​ ​ ​ ​ 特点

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 除了多线程外,其余的行为、特点和Serial收集器一样;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 如Serial收集器可用控制参数、收集算法、Stop The World、内存分配规则、回收策略等;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ Serial收集器共用了不少代码;

​ ​ ​ ​ ​ ​ 应用场景

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 在Server模式下,ParNew收集器是一个非常重要的收集器,因为除Serial外,目前只有它能与CMS收集器配合工作;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 但在单个CPU环境中,不会比Serail收集器有更好的效果,因为存在线程交互开销。

​ ​ ​ ​ ​ ​ 设置参数

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 指定使用CMS后,会默认使用ParNew作为新生代收集:“-XX:+UseConcMarkSweepGC”
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 强制指定使用ParNew: “-XX:+UseParNewGC”
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相:“-XX:ParallelGCThreads”

​ ​ ​ ​ ​ ​ 为什么只有ParNew能与CMS收集器配合

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;CMS作为老年代收集器,但却无法与JDK1.4已经存在的新生代收集器Parallel Scavenge配合工作;
因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架,而另外独立实现;而其余几种收集器则共用了部分的框架代码;


2.5.3 Parallel Scavenge收集器

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ Parallel Scavenge收集器关注点是吞吐量(如何高效率的利用CPU)。
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。(吞吐量:CPU用于用户代码的时间/CPU总消耗时间的比值,即=运行用户代码的时间/(运行用户代码时间+垃圾收集时间)。比如,虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。)

​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,不进行手工优化,可以选择把内存管理优化交给虚拟机去完成。

​ ​ ​ ​ ​ ​ 特点

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 新生代收集器;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 采用复制算法;
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 多线程收集;
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;而Parallel Scavenge收集器的目标则是达一个可控制的吞吐量(Throughput);

​ ​ ​ ​ ​ ​ 应用场景

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交 互;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 例如,那些执行批量处理、订单处理(对账等)、工资支付、科学计算的应用程序;

​ ​ ​ ​ ​ ​ 设置参数

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

​ ​ ​ ​ ​ ​ 控制最大垃圾收集停顿时间

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ “-XX:MaxGCPauseMillis”
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 控制最大垃圾收集停顿时间,大于0的毫秒数;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ MaxGCPauseMillis设置得稍小,停顿时间可能会缩短,但也可能会使得吞吐量下降;因为可能导致垃圾收集发生得更频繁;

​ ​ ​ ​ ​ ​ 设置垃圾收集时间占总时间的比率

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ “-XX:GCTimeRatio”

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 设置垃圾收集时间占总时间的比率,0 < n < 100的整数;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ GCTimeRatio相当于设置吞吐量大小;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 垃圾收集执行时间占应用程序执行时间的比例的计算方法是: 1 / (1 + n) 。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 例如,选项-XX:GCTimeRatio=19,设置了垃圾收集时间占总时间的5% = 1/(1+19);默认值是1% = 1/(1+99),即n=99;

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 垃圾收集所花费的时间是年轻一代和老年代收集的总时间;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 如果没有满足吞吐量目标,则增加代的内存大小以尽量增加用户程序运行的时间;

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ GC自适应的调节策略(GC Ergonomics)
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 另外还有一个参数:“-XX:+UseAdptiveSizePolicy”
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 开启这个参数后,就不用手工指定一些细节参数,如:

​ ​ ​ ​ ​ ​ ​ 新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等;
​ JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics);
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 这是一种值得推荐的方式:
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (1)、只需设置好内存数据大小(如"-Xmx"设置最大堆);
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (2)、然后使用"-XX:MaxGCPauseMillis"或"-XX:GCTimeRatio"给JVM设置一个优化目标;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (3)、那些具体细节参数的调节就由JVM自适应完成;


2.5.4 Serial Old收集器

​​ ​ ​ ​ ​ ​ Serial收集器的老年代版本,它同样是一个单线程收集器。
​ 它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案

​ ​ ​ ​ ​ ​ 特点

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 针对老年代;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 采用"标记-整理-压缩"算法(Mark-Sweep-Compact);
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 单线程收集;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ Serial/Serial Old收集器运行示意图在前面有。

​ ​ ​ ​ ​ ​ 应用场景

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 主要用于Client模式(客户端);
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 而在Server(服务端)模式有两大用途:
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (A)、在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配Parallel Scavenge收集 器);
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (B)、作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用;


2.5.5 Parallel Old收集器

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

​ ​ ​ ​ ​ ​ 特点

​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 针对老年代;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 采用"标记-整理-压缩"算法;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 多线程收集;

​ ​ ​ ​ ​ ​ 应用场景

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ JDK1.6及之后用来代替老年代的Serial Old收集器;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 特别是在Server模式,多CPU的情况下;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge(新生代)加Parallel Old(老年代)收集器的"给力"应用组合;

​ ​ ​ ​ ​ ​ 设置参数

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 指定使用Parallel Old收集器:“-XX:+UseParallelOldGC”


2.5.6 CMS(Concurrent Mark Sweep)收集器

​ ​ ​ ​ ​ ​ ​ CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常适合在注重用户体验的应用上使用。

​ ​ ​ ​ ​ ​ 特点

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 针对老年代
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 基于"标记-清除"算法(不进行压缩操作,会产生内存碎片)
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 以获取最短回收停顿时间为目标
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 并发收集、低停顿
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 需要更多的内存
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;
​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;

​ ​ ​ ​ ​ ​ 应用场景

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 与用户交互较多的场景;(如常见WEB、B/S-浏览器/服务器模式系统的服务器上的应用)
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 希望系统停顿时间最短,注重服务的响应速度;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 以给用户带来较好的体验;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ CMS收集器运作过程

​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程可分为四个步骤:

​ ​ ​ ​ ​ ​ 初始标记

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 暂停所有的其他线程,初始标记仅仅标记GC Roots能直接关联到的对象,速度很快;

​ ​ ​ ​ ​ ​ 并发标记

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 并发标记就是进行GC Roots Tracing的过程;同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方;

​ ​ ​ ​ ​ ​ 重新标记

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(采用多线程并行执行来提升效率);需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;

​ ​ ​ ​ ​ ​ 并发清除

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 开启用户线程,同时GC线程开始对为标记的区域做清扫,回收所有的垃圾对象;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 由于整个过程耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 所以总体来说,CMS的内存回收是与用户线程一起“并发”执行的。

​ ​ ​ ​ ​ ​ 设置参数

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 指定使用CMS收集器 “-XX:+UseConcMarkSweepGC”

​ ​ ​ ​ ​ ​ 缺点
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (一)对CPU资源敏感

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 面向并发设计的程序都对CPU资源比较敏感(并发程序的特点)。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。(在对账系统中,不适合使用CMS收集器)。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ CMS的默认收集线程数量是=(CPU数量+3)/4; 当CPU数量越多,回收的线程占用CPU就少。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。(比如 CPU=2时,那么就启动一个线程回收,占了50%的CPU资源。)
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (一个回收线程会在回收期间一直占用CPU资源)

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 针对这种情况,曾出现了"增量式并发收集器"(Incremental Concurrent Mark Sweep/i-CMS);
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 类似使用抢占式来模拟多任务机制的思想,让收集线程和用户线程交替运行,减少收集线程运行时间;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 但效果并不理想,JDK1.6后就官方不再提倡用户使用。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (二)无法处理浮动垃圾

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 解决办法
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 也可以认为CMS所需要的空间比其他垃圾收集器大; 可以使用"-XX:CMSInitiatingOccupancyFraction",设置 CMS预留老年代内存空间; (详解见名词解释)

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (三)产生大量内存碎片

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 由于CMS是基于“标记+清除”算法来回收老年代对象的,因此长时间运行后会产生大量的空间碎片问题,可能导致新生代对象晋升到老生代失败。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 由于碎片过多,将会给大对象的分配带来麻烦。因此会出现这样的情况,老年代还有很多剩余的空间,但是找不到连续的空间来分配当前对象,这样不得不提前触发一次Full GC。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 解决办法
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 使用"-XX:+UseCMSCompactAtFullCollection"和"-XX:+CMSFullGCsBeforeCompaction",需要结合使用。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ UseCMSCompactAtFullCollection
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ “-XX:+UseCMSCompactAtFullCollection”

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 为了解决空间碎片问题,CMS收集器提供−XX:+UseCMSCompactAlFullCollection标志,使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程;

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 但合并整理过程无法并发,停顿时间会变长;
​ 默认开启(但不会进行,需要结合CMSFullGCsBeforeCompaction使用);
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ CMSFullGCsBeforeCompaction
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 由于合并整理是无法并发执行的,空间碎片问题没有了,但是有导致了连续的停顿。因此,可以使用另一个参数 −XX:CMSFullGCsBeforeCompaction,表示在多少次不压缩的Full GC之后,对空间碎片进行压缩整理。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 可以减少合并整理过程的停顿时间;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 默认为0,也就是说每次都执行Full GC,不会进行压缩整理;

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 由于空间不再连续,CMS需要使用可用"空闲列表"内存分配方式,这比简单实用"碰撞指针"分配内存消耗大;

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ CMS&Parallel Old
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 总体来看,CMS与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间;

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (原因:CMS不进行内存空间整理节省了时间,但是可用空间不再是连续的了,垃圾收集也不能简单的使用指针指向下一次可用来为对象分配内存的地址了。相反,这种情况下,需要使用可用空间列表。即,会创建一个指向未分配区域的列表,每次为对象分配内存时,会从列表中找到一个合适大小的内存区域来为新对象分配内存。这样做的结果是,老年代上的内存的分配比简单实用碰撞指针分配内存消耗大。这也会增加年轻代垃圾收集的额外负担,因为老年代中的大部分对象是在新生代垃圾收集的时候从新生代提升为老年代的。)
当新生代对象无法分配过大对象,就会放到老年代进行分配。


2.5.7 G1收集器

​ ​ ​ ​ ​ ​ ​ 上一代的垃圾收集器(串行serial, 并行parallel, 以及CMS)都把堆内存划分为固定大小的三个部分: 年轻代(young generation), 年老代(old generation), 以及持久代(permanent generation)。

​ ​ ​ ​ ​ ​ ​ 注:堆内存中都可以认为是Java对象。

​ ​ ​ ​ ​ ​ ​ G1(Garbage-First)是JDK7-u4才推出商用的收集器;

​​ ​ ​ ​ ​ ​ G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。被视为JDK1.7中HotSpot虚拟机的一个重要进化特征。

​​ ​ ​ ​ ​ ​ G1的使命是在未来替换CMS,并且在JDK1.9已经成为默认的收集器。

特点
​ ​ ​ ​ ​ ​ 并行与并发

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。

​ ​ ​ ​ ​ ​ 分代收集

​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 能够采用不同方式处理不同时期的对象;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 虽然保留分代概念,但Java堆的内存布局有很大差别;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 将整个堆划分为多个大小相等的独立区域(Region);
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;

​ ​ ​ ​ ​ ​ 空间整合

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 从整体看,是基于标记-整理算法;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 从局部(两个Region间)看,是基于复制算法;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 这是一种类似火车算法的实现;
​ ​ ​ ​ ​ ​ ​ 不会产生内存碎片,有利于长时间运行;
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ (火车算法是分代收集器所用的算法,目的是在成熟对象空间中提供限定时间的渐进收集。在后面一篇中会专门介绍)

​ ​ ​ ​ ​ ​ 可预测的停顿

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿 时间模型。可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒。在低停顿的同时实现高吞吐量。

​ ​ ​ ​ ​ ​ 问题
​ ​ ​ ​ ​ ​ 为什么G1可以实现可预测停顿

​ ​ ​ ​ ​ ​ ​ 可以有计划地避免在Java堆的进行全区域的垃圾收集;

​ ​ ​ ​ ​ ​ ​ G1收集器将内存分大小相等的独立区域(Region),新生代和老年代概念保留,但是已经不再物理隔离。
​ ​ ​ ​ ​ ​ ​ G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表;
​ ​ ​ ​ ​ ​ ​ 每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来);
​ ​ ​ ​ ​ ​ ​ 这就保证了在有限的时间内可以获取尽可能高的收集效率;

​ ​ ​ ​ ​ ​ 一个对象被不同区域引用的问题

​ ​ ​ ​ ​ ​ ​ 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?

​ ​ ​ ​ ​ ​ ​ 在其他的分代收集器,也存在这样的问题(而G1更突出):回收新生代也不得不同时扫描老年代?
​ ​ ​ ​ ​ ​ ​ 这样的话会降低Minor GC的效率;

​ ​ ​ ​ ​ ​ ​ 解决方法:
​ ​ ​ ​ ​ ​ ​ 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:

​ ​ ​ ​ ​ ​ ​ 每个Region都有一个对应的Remembered Set;

​ ​ ​ ​ ​ ​ ​ 每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;

​ ​ ​ ​ ​ ​ ​ 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);

​ ​ ​ ​ ​ ​ ​ 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;

​ ​ ​ ​ ​ ​ ​ 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;

​ ​ ​ ​ ​ ​ ​ 就可以保证不进行全局扫描,也不会有遗漏。

​ ​ ​ ​ ​ ​ 应用场景

​ ​ ​ ​ ​ ​ ​ 面向服务端应用,针对具有大内存、多处理器的机器;
​ ​ ​ ​ ​ ​ ​ 最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;
​ ​ ​ ​ ​ ​ ​ 如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;
​ ​ ​ ​ ​ ​ ​ (实践:对账系统中将CMS垃圾收集器修改为G1,降低对账时间20秒以上)
​ ​ ​ ​ ​ ​ ​ 具体什么情况下应用G1垃圾收集器比CMS好,可以参考以下几点(但不是绝对):

​ ​ ​ ​ ​ ​ ​ 超过50%的Java堆被活动数据占用;
​ ​ ​ ​ ​ ​ ​ 对象分配频率或年代的提升频率变化很大;
​ ​ ​ ​ ​ ​ ​ GC停顿时间过长(长于0.5至1秒);
​ ​ ​ ​ ​ ​ ​ 建议:

​ ​ ​ ​ ​ ​ ​ 如果现在采用的收集器没有出现问题,不用急着去选择G1;
​ ​ ​ ​ ​ ​ ​ 如果应用程序追求低停顿,可以尝试选择G1;
​ ​ ​ ​ ​ ​ ​ 是否代替CMS只有需要实际场景测试才知道。(如果使用G1后发现性能还没有使用CMS好,那么还是选择CMS比较好)

​ ​ ​ ​ ​ ​ 设置参数

​​ ​ ​ ​ ​ ​ 可以通过下面的参数,来设置一些G1相关的配置。

​ ​ ​ ​ ​ ​ ​ 指定使用G1收集器:“-XX:+UseG1GC”

​ ​ ​ ​ ​ ​ ​ 当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45:“-XX:InitiatingHeapOccupancyPercent”

​ ​ ​ ​ ​ ​ ​ 为G1设置暂停时间目标,默认值为200毫秒:“-XX:MaxGCPauseMillis”

​ ​ ​ ​ ​ ​ ​ 设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region:“-XX:G1HeapRegionSize”

​ ​ ​ ​ ​ ​ ​ 新生代最小值,默认值5%:“-XX:G1NewSizePercent”

​ ​ ​ ​ ​ ​ ​ 新生代最大值,默认值60%:“-XX:G1MaxNewSizePercent”

​ ​ ​ ​ ​ ​ ​ 设置STW期间,并行GC线程数:“-XX:ParallelGCThreads”

​ ​ ​ ​ ​ ​ ​ 设置并发标记阶段,并行执行的线程数:“-XX:ConcGCThreads”

​ ​ ​ ​ ​ ​ 运作过程

​ ​ ​ ​ ​ ​ ​ 不计算维护Remembered Set的操作,可以分为4个步骤(与CMS较为相似)。

​ ​ ​ ​ ​ ​ 1.初始标记(Initial Marking)

​ ​ ​ ​ ​ ​ ​ 仅标记一下GC Roots能直接关联到的对象;

​ ​ ​ ​ ​ ​ ​ 且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;

​ ​ ​ ​ ​ ​ ​ 需要"Stop The World",但速度很快;

​ ​ ​ ​ ​ ​ 2.并发标记(Concurrent Marking)

​ ​ ​ ​ ​ ​ ​ 从GC Roots开始进行可达性分析,找出存活对象,耗时长,可与用户线程并发执行
​ ​ ​ ​ ​ ​ ​ 并不能保证可以标记出所有的存活对象;(在分析过程中会产生新的存活对象)

​ ​ ​ ​ ​ ​ 3.最终标记(Final Marking)

​ ​ ​ ​ ​ ​ ​ 修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录。

​ ​ ​ ​ ​ ​ ​ 上一阶段对象的变化记录在线程的Remembered Set Log;

​ ​ ​ ​ ​ ​ ​ 这里把Remembered Set Log合并到Remembered Set中;

​ ​ ​ ​ ​ ​ ​ 需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;

​ ​ ​ ​ ​ ​ ​ G1采用多线程并行执行来提升效率;且采用了比CMS更快的初始快照算法:Snapshot-At-The-Beginning (SATB)。

​ ​ ​ ​ ​ ​ 4.筛选回收(Live Data Counting and Evacuation)

​ ​ ​ ​ ​ ​ ​ 首先排序各个Region的回收价值和成本;

​ ​ ​ ​ ​ ​ ​ 然后根据用户期望的GC停顿时间来制定回收计划;

​ ​ ​ ​ ​ ​ ​ 最后按计划回收一些价值高的Region中垃圾对象;

​ ​ ​ ​ ​ ​ ​ 回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;

​ ​ ​ ​ ​ ​ ​ 可以并发进行,降低停顿时间,并增加吞吐量;

​ ​ ​ ​ ​ ​ 总结

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ G1在标记过程中,每个区域的对象活性都被计算,在回收时候,就可以根据用户设置的停顿时间,选择活性较低的区域收集,这样既能保证垃圾回收,又能保证停顿时间,而且也不会降低太多的吞吐量。Remark(重新标记)阶段新算法的运用,以及收集过程中的压缩,都弥补了CMS不足。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 引用Oracle官网的一句话:“G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS)”。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ G1计划作为并发标记-清除收集器(CMS)的长期替代品


2.6 低延迟垃圾收集器

​​ ​ ​ ​ ​ ​ 概述
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 衡量垃圾收集器的三项指标分别是:内存占用吞吐量延迟。这三者共同构成一个“不可能三角”,即一款优秀的收集器最多可以同时达成其中两项。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 随着硬件性能的提升,对内存占用和吞吐量也有所助益,但对延迟却并非如此。比如内存扩大了,对延迟反而会带来负面效果,因为回收 1TB 的堆内存毫无疑问会比回收 1GB 的堆内存耗费更多时间。因此,延迟成为了垃圾收集器最重视的性能指标。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 在 CMS 和 G1 之前的全部收集器,其工作的所有步骤都会产生 Stop The World。CMS 和 G1 分别使用增量更新和原始快照技术,实现了标记阶段的并发,但对标记后的清理仍未得到妥善解决。CMS 使用标记 - 清除算法,虽然可与用户线程并发执行,但会产生空间碎片,一旦碎片淤积过多就必然会 Stop The World。G1 虽然可以按更小粒度进行回收,但会出现短暂的停顿。

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 本文要介绍的两款收集器:Shenandoah 和 ZGC,几乎整个工作过程都是并发的,只有初始标记、最终标记阶段有短暂的停顿,并且停顿时间基本固定,与堆的容量、对象数量无关。这两款目前仍处于实验状态的收集器,被官方命名为低延迟垃圾收集器。


​ ​ ​ ​ ​ ​ 2.6.1 Shenandoah收集器

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ Shenandoah 作为一款第一个不由 Oracle 开发的 HotSpot 收集器,被官方明确拒绝在 OracleJDK12 中支持 Shenandoah 收集器,因此 Shenandoah 收集器只在 OpenJDK 才会包含。Shenandoah 收集器能实现在任何堆内存大小下都把垃圾停顿时间限制在十毫秒以内,这意味着相比 CMS 和 G1,Shenandoah 不仅要进行并发的垃圾标记,还要并发低进行对象清理后的整理。

​ Shenandoah 和 G1 有相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路都高度一致,甚至直接共享一部分代码。不同的是,虽然 Shenandoah 也是基于 Region 的堆内存布局,回收策略也和 G1 一致,但在管理堆内存方面,它与 G1 至少有三个明显的不同:

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 支持并发的整理算法,G1 的回收阶段可以多线程并行,但不能与用户线程并发。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ Shenandoah 默认不使用分代收集。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ Shenandoah 摒弃了在 G1 中需耗费大量资源去维护的记忆集,改用连接矩阵的全局数据结构来记录跨 Region 的引用关系。

​ ​ ​ ​ ​ ​ 工作过程:
​ ​ ​ ​ ​ ​ 1.初始标记

​ ​ ​ ​ ​ ​ ​ 首先标记与 GC Roots 直接关联的对象,需要 Stop The World。

​ ​ ​ ​ ​ ​ 2.并发标记

​​ ​ ​ ​ ​ ​ 遍历对象图,标记出全部可达对象,这个阶段与用户线程一起并发执行。

​ ​ ​ ​ ​ ​ 3.最终标记

​ ​ ​ ​ ​ ​ ​ 处理剩余的 SATB 扫描,并统计出回收价值最高的 Region,并构成一组回收集,该阶段会有短暂停顿。

​ ​ ​ ​ ​ ​ 4.并发清理

​ ​ ​ ​ ​ ​ ​ 这个阶段用于清理那些整个区域内连一个存活对象都没有找到的 Region。

​ ​ ​ ​ ​ ​ 5.并发回收

​ ​ ​ ​ ​ ​ ​ 把回收集里面的存活对象先复制一份到其他未被使用的 Region,并发执行的困难在于移动对象的同时,用户线程可能会对移动对象进行读写访问,移动对象是一次性行为,但移动之后整个内存中所有指向对象的引用还是旧对象的地址,还难在一瞬间全部改变过来。Shenandoah 将会通过读屏障和被称为 Brooks Pointers 的转发指针来解决。

​ ​ ​ ​ ​ ​ 6.初始引用更新

​ ​ ​ ​ ​ ​ ​ 并发回收复制对象结束后,还需把堆中所有指向旧对象的引用修正到复制后的新对象,这个操作称为引用更新。引用更新的初始化阶段实际上并没有做什么具体处理,只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已经完成分配给它们的对象移动任务,会有短暂的停顿。

​ ​ ​ ​ ​ ​ 7.并发引用更新

​ ​ ​ ​ ​ ​ ​ 真正开始引用更新操作,与并发标记不同,它不再需要沿着对象图来搜索,只需按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值。

​ ​ ​ ​ ​ ​ 8.最终引用更新

​ ​ ​ ​ ​ ​ ​ 修正 GC Roots 中的引用,这个阶段是 Shenandoah 的最后一次停顿。

​ ​ ​ ​ ​ ​ 9.并发清理

​ ​ ​ ​ ​ ​ ​ 经过并发回收和引用更新后,整个回收集中所有的 Region 已无存活对象,最后一次并发清理回收这些 Region 的内存空间,供新对象分配使用。

​​ ​ ​ ​ ​ ​ 了解了 Shenandoah 收集器的工作过程,再来看一下 Shenandoah 用于支持并发整理的核心概念 —— 转发指针(Brooks Pointer)。此前,要做类似的并发操作,通常要在被移动对象原有的内存上设置保护指针,一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中断,进入预设好的异常处理器,再由其中的代码逻辑把访问转发到复制后的新对象。这种方式虽然能实现对象移动和用户线程并发,但如果没有操作系统层面的直接支持,将导致用户态频繁切换到核心态,代价巨大。

​ ​ ​ ​ ​ ​ ​ 转发指针是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常情况下,该引用指向对象自己。当对象拥有一份新的副本时,只需修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,虚拟机内存中所有通过旧地址访问的代码仍可继续使用,都会被转发到新对象继续工作。


2.6.2 ZGC收集器

​ ​ ​ ​ ​ ​ ​ ZGC 全称 Z Garbage Collector,是一款在 JDK11 新加入的具有实验性质的低延迟垃圾收集器,由 Oracle 公司研发。ZGC 与 Shenandoah 的目标高度相似,都希望在对吞吐量影响不大的前提下,实现任意堆内存大小下垃圾收集停顿时间限制在十毫秒以内,但两者的实现思路又有显著差异。ZGC 是一款基于 Region 内存布局的,不设分代的,使用读屏障、染色指针和内存多重映射等技术来实现可并发的标记 - 整理算法的,以低延迟为首要目标的一款垃圾收集器

​ ​ ​ ​ ​ ​ ​ 首先从 ZGC 的内存布局说起,ZGC 的 Region 具有动态性,即动态创建和销毁,以及动态的区域容量大小。然后是 ZGC 的并发整理算法的实现,ZGC 采用的是染色指针技术(Colored Pointer)。从前,如果我们要在对象上存储一些额外信息,通常会在对象头中增加额外的存储字段,如哈希码、分代年龄、锁记录等。这种方式在有对象访问的场景下是很自然流程的,不会有问题,但如果对象存在被移动过的可能性,即不能保证能成功访问对象呢?又或者有一些根本就不会访问对象,但又希望得知对象的某些信息的场景呢?能不能从指针或者与对象内存无关的地方获取这些信息呢?

​ ​ ​ ​ ​ ​ 染色指针

​​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 一种直接将少量额外信息存储在指针上的技术,ZGC 甚至直接把标记阶段的标记信息记录在引用对象的指针上,因此,与其说可达性分析是遍历对象图来标记对象,不如说是遍历引用图来标记引用。使用染色指针有三大优势:

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 1.染色指针可以使得某一 Region 的存活对象被移走之后,该 Region 能立即被释放和重用,而不必等待整个堆中所有指向该 Region 的引用都被修正才能清理。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 2.染色指针可以直接记录对象引用的变动信息,减少内存屏障(尤其是写屏障)的使用。
​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 3.染色指针可以作为一种可扩展的存储结构,用来记录更多与对象标记、重定位相关的数据。

​​ ​ ​ ​ ​ ​ ZGC 的运行过程大致可划分为以下四个大的阶段,都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段:

​ ​ ​ ​ ​ ​ 工作过程:
​ ​ ​ ​ ​ ​ 1.并发标记(Concurrent Mark)

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 遍历对象图做可达性分析,前后也要经历类似 G1、Shenandoah 的初始标记、最终标记的短暂停顿。与 G1、Shenandoah 不同的是,ZGC 的标记是在指针上而非对象,标记阶段会更新染色指针中的 Marked 0、Marked 1 标志位。

​ ​ ​ ​ ​ ​ 2.并发预备重分配(Concurrent Prepare for Relocate)

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 根据特定的查询条件统计出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set)。ZGC 划分 Region 的目的并非像 G1 是为了做收益优先的增量回收,ZGC 每次回收都会扫描所有 Region,用范围更大的扫描成本换取维护记忆集的成本。ZGC 的标记过程是针对全堆的,ZGC 的重分配集只是决定里面的存活对象会被重新复制到其他的 Region 中,里面的 Region 会被释放,而不能说回收行为就只针对这个集合里面的 Region。

​ ​ ​ ​ ​ ​ 3.并发重分配(Concurrent Relocate)

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表,记录从旧对象到新对象的转发关系。如果用户线程此时并发访问位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,并根据 Region 上的转发表记录将访问转发到新复制的对象上,同时更新该引用的值,使其指向新对象,这种行为称为指针的自愈(Self-Healing)能力。

​ ​ ​ ​ ​ ​ 4.并发重映射(Concurrent Remap)

​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ 修正整个堆中指向重分配集中旧对象的所有引用,不过这并不是一项迫切完成的任务,因为即使是旧引用,它也是可以自愈的。因此,ZGC 把并发重映射阶段要做的工作,合并到下一次垃圾收集循环中的并发标记阶段去完成,反正都是要遍历所有对象图,这样还可以节省一次遍历对象图的开销。一旦所有指针被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。


2.7 选择合适的垃圾收集器


2.7.1 Epsilon收集器

​ 在G1、Shenandoah或者ZGC这些越来越复杂、越来越先进的垃圾收集器相继出现的同时,也有一个“反其道而行”的新垃圾收集器出现在JDK 11的特征清单中——Epsilon,这是一款以不能够进行垃圾收集为“卖点”的垃圾收集器,这种话听起来第一感觉就十分违反逻辑,这种“不干活”的收集器要它何用?

​ Epsilon收集器由RedHat公司在JEP 318中提出,在此提案里Epsilon被形容成一个无操作的收集器(A No-Op Garbage Collector),而事实上只要Java虚拟机能够工作,垃圾收集器便不可能是真正“无操作”的。原因是“垃圾收集器”这个名字并不能形容它全部的职责,更贴切的名字应该是本书为这一部分所取的标题——“自动内存管理子系统”。一个垃圾收集器除了垃圾收集这个本职工作之外,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,其中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持,是一个最小化功能的垃圾收集器也必须实现的内容。从JDK10开始,为了隔离垃圾收集器与Java虚拟机解释、编译、监控等子系统的关系,RedHat提出了垃圾收集器的统一接口,即JEP 304提案,Epsilon是这个接口的有效性验证和参考实现,同时也用于需要剥离垃圾收集器影响的性能测试和压力测试。在实际生产环境中,不能进行垃圾收集的Epsilon也仍有用武之地。很长一段时间以来,Java技术体系的发展重心都在面向长时间、大规模的企业级应用和服务端应用,尽管也有移动平台(指Java ME而不是Android)和桌面平台的支持,但使用热度上与前者相比要逊色不少。

总结

​ Epsilon收集器不做垃圾收集,负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责。隔离垃圾收集器与Java虚拟机解释、编译、监控等子系统的关系。如果应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。


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

​ Java技术体系的自动内存管理,最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存。

象的内存分配,从概念上讲,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配)。在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代。对象分配的规则并不是固定的,《Java虚拟机规范》并未规定新对象的创建和存储细节,这取决于虚拟机当前使用的是哪一种垃圾收集器,以及虚拟机中与内存相关的参数的设定。

​ 本节出现的代码如无特别说明,均使用HotSpot虚拟机,以客户端模式运行。由于并未指定收集器组合本节验证的实际是使用Serial加SerialOld客户端默认收集器组合下的内存分配和回收的策略,这种配置和收集器组合也许是开发人员做研发时的默认组合(其实现在研发时很多也默认用服务端虚拟机了),但在生产环境中一般不会这样用,所以大家主要去学习的是分析方法,而列举的分配规则反而只是次要的。


2.8.1 对象优先在Eden分配

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

​ HotSpot虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际的问题排查中,收集器日志常会打印到文件后通过工具进行分析,不过本节实验的日志并不多,直接阅读就能看得很清楚。

​ 在代码的testAllocation()方法中,尝试分配三个2MB大小和一个4MB大小的对象,在运行时通过-Xms20M、-Xmx20M、-Xmn10M这三个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:Survivor-Ratio=8决定了新生代中Eden区与一个Survivor区的空间比例是8∶1,从输出的结果也清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。

​ 执行testAllocation()中分配allocation4对象的语句时会发生一次Minor GC,这次回收的结果是新生代6651KB变为148KB,而总内存占用量则几乎没有减少(因为allocation1、2、3三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。产生这次垃圾收集的原因是为allocation4分配内存时,发现Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,因此发生Minor GC。垃圾收集期间虚拟机又发现已有的三个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代去。

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

​ 代码新生代Minor 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.8.2 大对象直接进入老年代

​ 大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,本节例子中的byte[]数组就是典型的大对象。大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

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

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

​ 代码大对象直接进入老年代:

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.

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

​ HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中(详见第2章)。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

​ 读者可以试试分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来执行代码中的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:Survivor-
   Ratio=8 -XX:MaxTenuringThreshold=1
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
    byte[] allocation1, allocation2, allocation3;
    allocation1 = new byte[_1MB / 4];   // 什么时候进入老年代决定于XX:MaxTenuring-
                                                   Threshold设置
    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.

2.8.4 动态对象年龄判定

​ 为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

​ 执行代码中的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.

2.8.5 空间分配担保

​ 在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次FullGC。

​ 解释一下“冒险”是冒了什么风险:前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况——最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代,这与生活中贷款担保类似。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

​ 取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次MinorGC存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好老老实实地重新发起一次Full GC,这样停顿时间就很长了。虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将-XX:HandlePromotionFailure开关打开,避免Full GC过于频繁。参见代码清,请先以JDK 6 Update 24之前的HotSpot运行测试代码。

​ 代码空间分配担保:

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-Handle-
   PromotionFailure
 */
@SuppressWarnings("unused")
public static void testHandlePromotion() {
    byte[] allocation1, allocation2, allocation3, allocation4, allocation5, alloca-tion6, allocation7;
    allocation1 = new byte[2 * _1MB];
    allocation2 = new byte[2 * _1MB];
    allocation3 = new byte[2 * _1MB];
    allocation1 = null;
    allocation4 = new byte[2 * _1MB];
    allocation5 = new byte[2 * _1MB];
    allocation6 = new byte[2 * _1MB];
    allocation4 = null;
    allocation5 = null;
    allocation6 = null;
    allocation7 = new byte[2 * _1MB];
}

​ 以-XX:HandlePromotionFailure=false参数来运行的结果:

[GC [DefNew: 6651K->148K(9216K), 0.0078936 secs] 6651K->4244K(19456K), 0.0079192 secs] [Times: user=0.00 sys=0.02, real=0.02 secs]
[GC [DefNew: 6378K->6378K(9216K), 0.0000206 secs][Tenured: 4096K->4244K(10240K), 0.0042901 secs] 10474K->4244K(19456K), [Perm : 2104K->2104K(12288K)], 0.0043613 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

​ 以-XX:HandlePromotionFailure=true参数来运行的结果:

[GC [DefNew: 6651K->148K(9216K), 0.0054913 secs] 6651K->4244K(19456K), 0.0055327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [DefNew: 6378K->148K(9216K), 0.0006584 secs] 10474K->4244K(19456K), 0.0006857 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

​ 在JDK 6 Update 24之后,这个测试结果就有了差异,-XX:HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了-XX:HandlePromotionFailure参数,但是在实际虚拟机中已经不会再使用它。JDK 6Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC。

​ 代码HotSpot中空间分配检查的代码片段:

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;
}

Times: user=0.00 sys=0.02, real=0.02 secs]
[GC [DefNew: 6378K->6378K(9216K), 0.0000206 secs][Tenured: 4096K->4244K(10240K), 0.0042901 secs] 10474K->4244K(19456K), [Perm : 2104K->2104K(12288K)], 0.0043613 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]


​	以-XX:HandlePromotionFailure=true参数来运行的结果:

```java
[GC [DefNew: 6651K->148K(9216K), 0.0054913 secs] 6651K->4244K(19456K), 0.0055327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [DefNew: 6378K->148K(9216K), 0.0006584 secs] 10474K->4244K(19456K), 0.0006857 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

​ 在JDK 6 Update 24之后,这个测试结果就有了差异,-XX:HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了-XX:HandlePromotionFailure参数,但是在实际虚拟机中已经不会再使用它。JDK 6Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC。

​ 代码HotSpot中空间分配检查的代码片段:

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;
}

  • 8
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

凇:)

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值