2、第二部分 自动内存管理-第3章 垃圾收集器与内存分配策略

概述

垃圾收集(Garbage Collection,下文简称GC)不是伴随Java兴起而出现的,在之前就已经存在了。

程序计数器、虚拟机栈、本地方法栈3个区域与线程生命周期一样,栈中的栈帧有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性, 在方法或者线程结束后就会将内存释放。

而Java堆和方法区这两个区域则有着很显著的不确定性。垃圾收集器所关注的正是这部分内存该如何管理。

对象已死

垃圾收集器在对堆进行回收前,需要确定堆中的对象实例哪些还“活着”,哪些已经“死去”

引用计数算法

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

  • 问题
    很难解决对象之间相互循环引用的问题

package part3;

/**
 * 引用计数算法的缺陷
 */
public class ReferenceCountingGC {
    public Object instance = null;
    private 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();
    }

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

可达性分析算法

可达性分析(Reachability Analysis)算法用来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。


利用可达性分析算法判定对象是否可回收.png

分析:如图中object5、object6、object7虽然关联,但是到GC Roots是不可达的,所以将被判定为可回收的对象。
在Java技术系统中,固定可作为GC Roots的对象包括:

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,例如:各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
  6. 所有被同步锁(synchronized关键字)持有的对象
  7. 反映Java虚拟机内部情况的JMXBean、JVM TI中注册的回调、本地代码缓存

再谈引用

强引用(Strongly Reference)

指程序代码中普遍存在的引用赋值,例如:Object obj = new Object()

只要强引用关系还存在,

软引用(Soft Reference)

软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。提供了SoftReference类来实现软引用

弱引用(Weak Reference)

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。\color{red}{当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。}提供了WeakReference类来实现弱引用。

虚引用(Phantom Reference)

虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。\color{red}{为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。}提供了PhantomReference类来实现虚引用。

生存还是死亡?

在GC Roots中判断不可达对象,还需要经历至少两次标记过程:

  1. 对象在GC Roots可达性分析后没有连接的引用链,会被第一次标记

  2. 随后进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法,下面两种情况是没有必要执行的

    • 对象没有覆盖finalize方法
    • finalize方法已经被虚拟机调用过
  3. 如果对象有必要执行finalize方法

    1. 将对象放入F-Queue队列中,并由虚拟机自动建立、低优先级的Finalizer线程执行finalize方法

      执行:是指虚拟机会触发这个方法执行,但是不一定等待该线程运行结束
      原因:如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导 致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
      

      finalize方法是对象逃脱垃圾回收的最后一次机会,稍后收集器将对F-Queue中的对象进行小规模标记(此时对象如果要避免被回收:则需要重新被引用即可逃脱,否则真的被回收)

package part3;

/**
 * 一次对象自我拯救的演示
 * 此代码演示了两点:
 * 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 InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //因为Finalizer方法优先级很低,暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }

        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}
finalize method executed
yes, i am still alive :)
no, i am dead :(

原因:两端代码一样,第一次逃脱被回收,第二次失败了,这是因为任何一个对象的finalize方法都只会被系统自动调用一次,如果对象下次回收,finalize方法不会被调用,因此第二次自救失败

回收方法区

《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集。方法区垃圾收集的“性价比”通常也是比较低的:新生代在垃圾收集时一般可回收大部分空间,相比方法区回收因为判定条件苛刻,回收效果远低于Java堆中的新生代。

方法区垃圾收集主要两部分内容:

  1. 废弃常量:常量池中的接口、方法、字段的符号引用没有被任何地方引用,则可回收
  2. 不再使用的类型需满足三个条件
    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
    • 加载该类的类加载器已经被回收
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

从如何判断对象消亡的角度出发,垃圾收集算法可划分为“引用计数垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类。

分代收集理论

分代收集(Generational Collection),建立在三个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕死的
  2. 强分代假说(Strong Generational Hypothesis):熬过了多次垃圾收集过程的对象就越难以消亡
  3. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占少部分

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

  • 新生代(Young Generation)区域:在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象
  • 老年代(Old Generation)区域:新生代垃圾收集后存活的少量对象,将会逐步晋升到老年代中存放
  1. 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
  2. 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

标记清除算法

标记-清除算法(Mark-Sweep)

过程
  1. 标记所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象
  2. 统一回收所有未被标记的对象。

上面两个步骤都会暂停用户线程(Stop The World)

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


    标记-清除.png

标记-复制算法

过程

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


标记-复制.png
缺点

可用内存缩小为原来的一般,空间利用率不高

标记-整理算法

标记-整理(Mark-Compact)算法

过程

标记所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象。然后将存活的对象向内存空间一段移动,接着直接清理掉边界以外的内存。


标记-整理.png
缺点

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(Stop The World)

HotSpot的算法细节实现

根节点枚举

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。

当用户线程停顿之后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置。在HotSpot 的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。

安全点

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但有以下问题:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,垃圾收集伴随而来的空间成本高昂。

实际上HotSpot也的确没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。这就决定了用户程序执行时并非在代码指令流的任意位置都能够停下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

位置选定:

  • 安全点不能太少以至于让收集器等待时间过长
  • 不能太多频繁过分增大运行时的内存负荷
    安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

如何在垃圾收集发生时让所有线程(不包括执行JNI调用的线程)都跑到最近的安全点:

  1. 抢先式中断(Preemptive Suspension):现在基本不采用


    抢先式中断.png
  2. 主动式中断(Voluntary Suspension):由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot把轮询操作精简至只有一条汇编指令的程度


    主动式中断.png

安全区域(Safe Region)

解决的问题是:

程序不执行:所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep 状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也不可能持续等待线程重新被激活分配处理器时间。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生改变,因此,在这个区域中任意地方开始垃圾收集都是安全的。


安全区域.png

记忆集与卡表

为了解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用来避免把整个老年代加入GC Roots扫描范围
记忆集是一种用于记录从非收集区指向收集区的抽象数据结构(HotSpot:老年代对象引用新生代对象)。
为了避免空间占用或者维护成本高,可供选择的记录精度:

  1. 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针
  2. 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
  3. 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
    其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是 目前最常用的一种记忆集实现形式。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
//HotSpot卡表标记逻辑
CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块(“卡页”(Card Page))。一般来说,卡页大小都是以2的N次幂的字节数,HotSpot中使用的卡页是2^9(512字节)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块

卡表与卡页对应示意图.png

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

并发的可达性分析

抛出问题:在分析可达性时,当堆内存不大时,性能可以接受;当堆内存越来越大时,存储对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间越长。并且在并发收集时容易产生误回收问题(保障一致性的快照上才能进行对象图的遍历)
解决方案:
三色标记(Tri-color Marking)

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


    三色标记.png

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

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
    只需要破坏其中任意一个条件即可,有两种方案:
  1. 增量更新(Incremental Update):当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。(CMS)
  2. 原始快照(Snapshot At The Beginning, SATB ):当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。(G1、Shenandoah)

经典垃圾收集器

经典垃圾收集器

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

Serial收集器

描述

Serial收集器是最基础、历史最悠久的收集器,在JDK 1.3.1之前是HotSpot虚拟机新生代 收集器的唯一选择。

特点
  1. 是一个单线程工作的收集器,只会使用一个处理器或一条收集线程去完成垃圾收集工作
  2. 垃圾收集时会Stop The World,暂停其他所有工作线程


    Serial/Serial Old手机器运行示意图

使用场景

  1. 资源受限,适用于单核处理器或处理器核心数较少的环境,是所有收集器额外内存消耗最小的
  2. 专心垃圾收集,获得最高的单线程收集器效率

ParNew收集器

描述

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外 , 其 余 的 行 为 包 括 Serial 收 集 器 可 用 的 所 有 控 制 参 数 ( 例 如 : - XX:SurvivorRatio 、 - XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。

ParNew/Serial Old收集器运行示意图

在JDK 7之前系统首选新生代收集器,其中一个很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。

在JDK 5时,CMS收集器是HotSpot虚拟机第一款真正意义支持并发的垃圾收集器,实现了让垃圾收集线程与用户线程同时工作。
,无法与JDK 1.4.0的新生代收集器Parallel Scavenge配合工作,所以在JDK 5采用的收集器:ParNew/Serial新生代收集器+CMS老年代收集器。ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweep GC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它。
ParNew收集器默认开启的收集线程数与处理器核心数相同,可用-XX:ParallelGCT hreads参数来限制垃圾收集的线程数。
概念:

  • 并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
  • 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

Parallel Scavenge收集器

描述

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器

特点

CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间(响应时间短),而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)(最高效率利用处理器资源)
吞吐量=(运行用户代码时间)/(运行用户代码时间+运行垃圾收集时间)
CMS适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量主要适合在后台运算而不需要太多交互的分析任务。

参数设置
  1. -XX:MaxGCPauseMillis:值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的 时间不超过用户设定值。(设置太小,垃圾收集频繁,设置太大,则停顿时间长)
  2. -XX:GCTimeRatio:则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的 比率,相当于吞吐量的倒数。
  3. -XX:+UseAdaptiveSizePolicy:虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

Serial Old收集器

描述

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。


Serial/Serial Old收集器运行示意图
使用场景
  1. 供客户端模式下的HotSpot虚拟机使用
  2. 在服务端模式下,用两种用途
    • 在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用
    • 作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用

Parallel Old收集器

描述

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


Parallel Scavenge/Parallel Old收集器运行示意图

CMS收集器

描述

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。关注系统服务的响应速度,尽可能短的Stop The World的系统停顿时间,给用户带来良好交互体验。基于标记-清除算法实现的

工作流程
  1. 初始标记(CMS initial mark):会Stop The World,只是标记一下GC Roots能直接关联的对象,速度很快;
  2. 并发标记(CMS concurrent mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
  3. 重新标记(CMS remark):修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短;
  4. 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的


    CMS收集器运行示意图
缺点
  1. 在并发阶段,虽然不会导致用户线程停顿,但会占用一部分cpu的计算能力,降低了总吞吐量。CMS默认开启的回收线程数是(cpu数 + 3)/4,当cpu数越多时占比变小,当cpu数越少,占用比较较大。

  2. 在并发标记和并发清理时,用户线程还在继续运行,CMS收集器无法处理浮动垃圾(Floating Garbage),容易出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
    -XX:CMSInitiatingOccupancyFraction:触发老年代回收比例,如果过小,频繁GC,如果过大,则容易导致内存无法满足分配新对象的需要。

  3. 使用标记-清除算法会出现内存碎片,如果需要分配大对象内存空间,容易触发Full GC。

Garbage First收集器

描述

Garbage First(简称G1)收集器是面向部分收集的设计思路和基于Region的内存布局形式。JDK 9后称为默认垃圾收集器。CMS被设置为不推荐使用。

停顿时间模型(Pause Prediction Model):能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

比较:G1之前的收集器要么是整个新生代(Minor GC),要么整个老年代(Major GC),再要么是整个Java堆(Full GC),而G1面向堆内存任何部分组成回收集(Collection Set 简称CSet),衡量标准变为哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1基于Region的堆内存布局,把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。

Region中还有特殊的Humongous区域,专门用来存储大对象(G1超过Region容量一半对象即可判定大对象)。-XX:G1HeapRegionSize,取值范围1MB到32MB,且应为2N。超过整个Region容量超级大对象,将会被存放在N个连续的Humongous Region(G1作为老年代的一部分看待)

G1收集器Region分区示意图

G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)

问题及解决
  1. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

    • 为解决用户线程在垃圾收集时持续产生新对象,G1为每个Region设置了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间分出来用于并发回收过程中新对象的分配,并发回收时新分配对象地址必须要在这两个指针位置以上
    • G1默认在这个地址以上的对象是被隐性标记过的,即默认是存活的,不纳入回收范围
    • 如果内存回收速度赶不上内存分配速度,G1收集器会被迫冻结用户线程运行,导致Full GC而产生长时间Stop The World
  2. 怎样建立可靠的停顿预测模型?
    G1收集器的停顿预测模型是以衰减均值(DecayingAverage)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

回收过程
  1. 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记(Concurrent Marking):从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象
  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
  4. 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。


    G1收集器运行示意图

    指定期望的停顿时间需要在合理范围内,默认是200ms,如果比较低,则会导致每次选出来的回收集只占用堆内存很小的一部分,收集器收集速度<分配器分配速度,导致垃圾慢慢堆积。时间一长,容易导致堆满引发Full GC降低性能。

CMS和G1的比较
  1. G1无论是为了垃圾收集产生的内存占用(Footprint)要比CMS高

    G1的卡表实现更为复杂,而且堆中每个Region,无论是新生代还是老年代,都必须有一份卡表,导致G1的记忆集占用更多的内存空间;而CMS只有唯一一份,而且只需要处理老年代到新生代的引用。

  2. 程序运行时额外执行负载(Overload)都要比CMS高

    CMS使用写后屏障更新维护卡表,运用的是增量更新算法,G1除了使用写后屏障进行更复杂结构的卡表维护操作,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变换情况,虽然G1能减少并发标记和重新标记的消耗,但是用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。而且由于CMS的写屏障实现是直接的同步操作,而G1不得不实现类似消息队列的结构,把写前屏障和写后屏障要做的事放在队列,再异步处理。

低延迟垃圾收集器

衡量垃圾收集器三项最重要指标:

  1. 内存占用(Footprint)
  2. 吞吐量(Throughtput)
  3. 延迟(Latency)
    各收集器的并发情况

    浅色阶段表示必须挂起用户线程,深色代表收集器线程与用户线程并发工作。
    解析:CMS和G1之前的全部收集器,其工作的所有步骤都会产生Stop The World的停顿;CMS和G1分别使用增量更新和原始快照技术,实现标记阶段的并发。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法在设计原理上都存在内存碎片问题,最后还是会Stop The World。G1虽然可以更小粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但是还是需要暂停。

ZGC收集器

ZGC(Z Garbage Collector)是一款在JDK 11中新加入的具有实验性质的低延迟垃圾收集器 。ZGC收集器是一款基于Region内存布局,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC采用基于Region的堆内存布局,但是ZGC的Region具有动态性-动态创建和销毁,以及动态的区域容量大小。ZGC的Region分为大、中、小三类容量:

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象

  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。

  • 大型Region(Large Region):容量不固定,但必须为2MB的整数倍,用于放置 4MB或以上的大对象。每个大型Region中只会存放一个大对象,但实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段) 的,因为复制一个大对象的代价非常高昂。

    ZGC的堆内存布局

    ZGC收集器采用染色指针技术(Colored Pointer,其他类似技术中称Tag Pointer或者Version Pointer)。以前,对象会存储额外的字段(对象的哈希码、分代年龄、锁记录等),其实某个对象的引用关系能决定存活与否,对象上其他所有属性都不能够影响它的存活判定结果。有的把标记直接记录在对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息),
    染色指针是一种直接将少量额外的信息存储在指针上的技术。在64位系统中,指针地址的位数还有空闲空间。ZGC的染色指针技术将剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。ZGC能够管理的内存不可超过4TB(242字节)
    染色指针示意图

    染色指针有4TB的内存限制,不能支持32位平台,不能支持压缩指针(-XX:+UseCompressedOops)等,但有以下三大优势:

  • 染色指针可以使得一旦某个Region的存活对象被移动之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。

  • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,可以省去专门的记录操作。(目前为止ZGC都并未使用任何写屏障,只使用了读屏障)

  • ·染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

前置问题:染色指针重新定义了地址的某些位的作用,如何让操作系统能够正确的按照原有逻辑正确寻址?

保护模式隔离进程:使用分页管理机制把线性虚拟地址空间和物理地址空间分别划分为大小相同的块,这样的内存块被称为页(Page)。通过线性虚拟空间的页与物理地址空间的页之间建立的映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换。
ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了。


多重映射下的寻址

ZGC运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段(初始化GC Root直接关联对象的Mark Start)


ZGC运作过程
  • 并发标记(Concurrent Mark):并发标记是遍历对象图做可达性分析的阶段,需要经过类似G1初始标记、最终标记的暂停停顿。ZGC的标记是在指针上而不是在对象上进行,标记阶段会更新染色指针中的Marked0、Marked1标志位。
    • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC划分Region的目的与G1(做收益优先的增量回收)的目的不一样。ZGC的重分配集都会扫描所有的Region,用范围更大的扫描成本换省去G1中记忆集的维护成本。在JDK12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成。
  • 并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能从引用上就知道一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象 上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self- Healing)能力。只有第一次访问旧对象才会陷入转发,减少了负载开销。由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(转发表不能释放掉)。
  • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表)。ZGC巧妙把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,减少了一次遍历对象图开销。

劣势:由于ZGC没有分代的概念,没有CMS那种只记录新生代和老年代间引用的卡表,也限制了ZGC能承受对象分配速率不太高。例如:在一个应用中,会不断创建大量的新对象,但不会被纳入垃圾回收中,ZGC准备对一个很大的堆做一次完整的并发收集,但是由于创建对象很快,每一次完整的并发收集周期都会很长,回收的内存空间持续小于期间并发产生的浮动垃圾,堆中剩余可用空间会越来越少。目前只能通过增大堆大小解决。

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

Java技术体系的自动内存管理的最根本目标:自动给对象分配内容和自动回收分配给对象的内存。

对象优先在Eden分配

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

public class TestAllocation {
    private static final int _1MB = 1024 * 1024;

    /**
     * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     * 分配三个2MB和一个4MB对象,总共10MB
     * VM参数限制了堆大小为20MB,10MB分配新生代,10MB老年代
     * -XX:SurvivorRatio=8决定新生代Eden区与一个Survivor区的空间比例为8:1
     * 新生代总可用空间为9216K(Eden区+1个Survivor区的总容量)
     */
    public static void main(String[] args) {
        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
    }
}

大对象直接进入老年代

大对象是指需要大量连续内存空间的Java对象,例如:很长的字符串或元素数量庞大的数组。
避免大对象的原因:

  • 在分配空间时,容易导致内存明明还有不少空间时就提前触发垃圾收集,以获得足够连续空间存放
  • 复制对象时,大对象需要额外的内存复制开销
    VM参数(只对Serial和ParNew有效:-XX:PretenureSizeThreshold)避免在Eden区及两个Survivor区来回复制,产生大量的内存复制操作。
public class TestPretenureSizeThreshold {
    private static final int _1MB = 1024 * 1024;
    /**
     * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728
     */
    public static void main(String[] args) {
        byte[] allocation;
        allocation = new byte[4 * _1MB];
    }
}

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

虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区诞生,如果经过一次Minor GC仍然存活,并且能被Survivor容纳,则该对象会被移动到Survivor空间,并且Age会加1,对象在Survivor区熬过一次Minor GC,Age都会加1,当达到设置阈值(默认15:-XX:MaxTenuringThreshold),会晋升到老年代。

public class TestTenuringThreshold {
    private static final int _1MB = 1024 * 1024;

    /**
     * 长期存活的对象进入老年代
     * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
     */
    public static void main(String[] args) {
        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];
    }

动态对象年龄判定

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

public class TestTenuringThreshold2 {
    private static final int _1MB = 1024 * 1024;

    /**
     * 动态对象年龄判定
     * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
     */
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4];//allocation1+allocation2大于survivor空间一半
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }
}

空间分配担保

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


空间分配担保

源自书籍:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)-周志明

你可能感兴趣的:(2、第二部分 自动内存管理-第3章 垃圾收集器与内存分配策略)