深入理解jvm

2022年10月30日10:21:50

文章内容来自《深入理解java虚拟机》

  • 第二章--java内存区域与内存溢出异常
  • 第三章--垃圾收集器与内存分配
  • 第七章--虚拟机类加载机制
  • 第八章--虚拟机字节码执行引擎

第二章--java内存区域与内存溢出异常

2.2 运行时数据区

就这张图还不值得一个赞嘛😳

FullGC:收集整个方法区和堆中的垃圾收集,图中画箭头太丑,就省略了

深入理解java虚拟机笔记 深入理解java虚拟机最新版_深入理解java虚拟机笔记

2.2.1 线程私有

1、程序计数器

定义:当前线程所执行的字节码的行号指示器。

作用:由于jvm多线程通过快速切换线程实现,且在同一时间仅有一条线程的字节码指令被执行,因此为了切换后可以恢复到正确的执行位置,每个线程需要有一个独立的程序计数器。

字节码指令:分支、循环、跳转、异常处理、线程恢复等基础功能都需要计数器来完成,每次执行一条指令后,计数器+1,对应着下一条指令的位置。

2、java 虚拟机栈

定义:方法被执行的时候,java虚拟机会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息;每个方法调用从执行到完毕,就对应一个栈帧出栈入栈的过程

  • 局部变量表:存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型。以变量槽(slot)的方式存储,slot对应图中序号,slot可以复用
  • 操作栈:存储具体的操作指令
  • 动态连接:每一次运行期间符号引用都转化为直接引用【静态解析:在类加载阶段或第一次使用时被转化为直接引用】(每个栈帧都包含)

3、本地方法栈

存储不是java语言写的方法

2.2.2 GC重点(线程共享)

java堆

定义:几乎所有的对象实例以及数组都应当在堆上分配

垃圾收集器管理的区域,线程共享的区域

由于大部分垃圾收集器是基于分代收集理论设计的,将堆内存区域分成新生代和老年代

  • 新生代:分为Eden区、From Survivor区、ToSurvivor区
  • 老年代:就一大块区域,用来存储大对象、Survivor区存活年龄阈值达到15的老对象、新生代存不下的对象等
  • 元空间:当老年代存不下时,会放到元空间内

TLAB:多线程共享同一块内存时,会引发线程安全问题,加锁等方式会降低效率,所以采用为多个线程在堆空间内分配一块区域,这个就是多个线程私有的分配缓冲区(TLAB),可以提升对象分配时的效率

方法区

定义:存储被虚拟机加载后的类型信息、常量、静态变量、 JIT编辑器编译后的代码缓存

运行时常量池

定义:存储符号引用和符号引用翻译出来的直接引用

Class文件中的常量池表:用于存放编译期生成的各种字面量和符号引用

字面量:包括整数、浮点数和字符串字面量。

符号引用:包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。

2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建

  • 检查类的加载 链接 初始化过程是否完成
  • 之后通过虚拟机为对象分配内存,在类加载完成后对象所需内存已经确定,只需要在堆中划出来便可。--划分内存涉及到两个方法:指针碰撞 空闲列表

1、划分可用空间

深入理解java虚拟机笔记 深入理解java虚拟机最新版_面试_02

  • 指针碰撞

条件:堆中内存时绝对归整的,已用的在一边,未用的在一边

优点:简单高效,适合回收新生代的垃圾收集器

缺点:容易产生内存碎片

  • 空闲列表

条件堆中内存不规整,已用和空间交错在一起

优点:没有内存碎片,适合使用标记-清除算法的垃圾老年代收集器

缺点:实现复杂,需要额外的内存空间

2、并发下分配的线程安全问题

  • 采用CAS配上失败重试的方式保证操作的原子性
  • 按照不同线程划分在不同的空间中进行,即使用TLAB,只有在TLAB用尽后才进行同步锁定

3、分配到的内存空间都初始化为零值

对象头不初始化为零值

此步操作便可以给对象的实例字段赋默认值零值,让程序能访问到该数据类型对应的零值

零值:不同的数据类型有不同的默认值,并不仅代表0

类的实例:由类构造对象的过程

实例字段:对象中的数据

4、虚拟机对对象头进行设置

  • 对象是哪个类的实例
  • 如何能找到类的元数据信息
  • 对象的哈希码
  • 对象的GC分代年龄
  • 锁状态
  • 线程持有的锁
  • 偏向线程ID
  • 偏向时间戳
  • 指向类型元数据的指针

5、构造函数

将对象按照程序员的意愿进行初始化,至此构造完成

2.3.2 对象的内存布局

1、对象头

存储对象自身的运行时数据

  • 对象是哪个类的实例
  • 如何能找到类的元数据信息
  • 对象的哈希码
  • 对象的GC分代年龄
  • 锁状态
  • 线程持有的锁
  • 偏向线程ID
  • 偏向时间戳

类型指针,对象指向它的类型元数据的指针

作用:通过类型指针确定对象是哪个类的实例

特别:数组还需要在对象头有一块区域用来记录数组长度

2、实例数据

对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容

3、对齐填充

占位符:为了让对象大小是8字节的整数倍

2.3.3 对象的访问定位

  • 句柄访问:在堆划分一块内存作为句柄池;
  • reference:存储的就是对象句柄地址
  • 句柄:存储对象实例数据和类型数据各自的地址信息
  • 优点:GC时,reference中存储的句柄地址不会改变,只会改变实例数据指针
  • 缺点:维护的句柄池时候增大了内存的开销
  • 直接指针访问:HotSpot中采用(因为对象访问频繁,减少开销)
  • reference:存储的直接就是对象地址
  • 优点:速度更快,没有指针定位的时间开销
  • 缺点:GC时reference需要修改,增加了GC的负担

图片来自《深入理解java虚拟机》,马赛克来自我不堪入目的笔记😭

深入理解java虚拟机笔记 深入理解java虚拟机最新版_jvm_03

特别关注:String.intern()方法

  • 首次出现:只需要在常量池中记录一下首次出现的实例引用
  • 剩下的百度,我不会,嘿嘿😏

第三章--垃圾收集器与内存分配

3.2 对象已死

如果你点个赞,调用我的finalize方法,我还可以活的

3.2.1 引用计数算法

首先jvm不采用,宋老师说了解一下就行

方式:对象中添加一个引用计数器,有一个地方调用,计数器就加一,引用失效,就减一,为零送走

缺点:会发生循环引用,明明是垃圾,还不想被回收,和我一样,明明是垃圾,还不想被时代抛弃😿

3.2.2 可达性分析算法

GC Roots 由以下几种能当

  • 在虚拟机栈(栈帧中的本地变量表,就局部变量表呗)中引用的对象(方法参数、局部变量、临时变量等)
  • 方法区中静态属性引用的对象(java类的引用类型静态变量)
  • 方法区中常量引用的对象(字符串常量池里的引用)
  • 本地方法引用的对象
  • jvm内部的引用(基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器)
  • 所有被同步锁持有的对象

方式:从GC Roots向下做搜索,搜索过的路径称为引用链,当GC Roots到这个对象不可达的时候,这个对象就挂了,就这个图,简明概要来自那本书,小米相机的文档模式确实好用,比起自己去做这个图来说

深入理解java虚拟机笔记 深入理解java虚拟机最新版_老年代_04

3.2.3 再谈引用

强软弱虚,嗯.....懂得都懂

深入理解java虚拟机笔记 深入理解java虚拟机最新版_老年代_05

强引用

定义:指程序代码中普遍存在的引用赋值

只要强引用存在就永远不会被GC

软引用

有用非必须的对象

在内存溢出之前,FullGC的时候进行回收

手动进行普通GC并不会影响对象

深入理解java虚拟机笔记 深入理解java虚拟机最新版_jvm_06

弱引用

非必须对象,更弱

只能活到下次GC

普通GC就直接挂掉了,会报 NullPointerException

深入理解java虚拟机笔记 深入理解java虚拟机最新版_面试_07

虚引用

幽灵引用,没啥用

为了能在对象被回收时,收到一个系统通知

3.2.4 生存还是死亡

在可达性分析后发现了不可达的对象,该对象就会标记,进行下一次筛选。

筛选的条件是:是否有必要执行finalize方法。

  • 如果没有被复写或虚拟机已经执行过一次了(finalize只能被调用一次),则被认为没要执行
  • 如果有必要执行finalize方法,则该对象会被放到F-Queue队列中,由虚拟机自动建立一条低调度优先级的线程
  • 执行:指虚拟机会触发这个方法允许,但不一定会等到它结束,防止某个方法执行缓慢,导致队列阻塞,让整个内存回收子系统崩溃

一次对象自我拯救的演示(来自《深入理解java虚拟机》)

拯救自己:只要和引用链上的任何一个对象建立关联即可,如代码所示,虽然自救的机会只有一次,但是你点赞收藏的机会不止一次🥰

/**
 * @author Aa潘七岁
 * @date 2022/10/29 14:30
 * @description: 生存还是死亡 模拟对象被finalize救起来的代码 对象在死之前回调用重写的finalize的方法 在那里被重新调用,无法回收,但只可以执行一次
 */
class FinalizeEscapeGC {


    private 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 :(");
        }
    }
}

3.2.5 回收方法区

主要回收内容:废弃的常量 和 不再使用的类型

判定条件 --太苛刻

  • 类所有实例都已经被回收
  • 加载该类的类加载器已经被回收
  • 类对应的java.lang.Class对象没有在任何地方被引用

3.3 垃圾收集算法

  • 标记-复制
  • 标记-清除
  • 标记-整理

3.3.1 分代收集理论

弱分代假说:绝大多数对象都是朝生夕死的(新生代)

强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡(老年代)

目前用到的大部分垃圾收集器都是按照分代收集理论做的

问题:对象不是孤立的,对象之间会存在跨代引用

解决跨代引用的方法

产生原因:新生代的对象可能被老年代引用,反之亦然。

出现的问题:为了找到该新生代的存活对象,不得不再固定GC Roots后,额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反之亦然(理论上只有新生代能发生单独收集,老年代实际上没人管)

解决方法:只需要在新生代上建立一个全局的数据结构--记忆集(Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。这样在发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。这个合理的额外空间是划算的

3.3.2 标记-清除算法

主要给老年代回收--适合停顿时间优先的GC

步骤

  • 首先标记出所有需要回收的对象(或者标记所有存活的对象)
  • 标记完成后,统一回收掉被标记的对象(或者回收没被标记的对象)

优点:基础算法,嗯,对,没有浪费空间

缺点

  • 执行效率不稳定:需要被回收的对象太多,这时必须进行大量标记和清除的动作,效率随着对象的增加而降低
  • 内存碎片:就产生大量不连续的内存碎片,大对象无法存储,需要触发另一次垃圾收集
  • 改进:依赖更复杂的内存分配器和内存访问器
    如 分区空闲分配链表,
    图片来自《深入理解java虚拟机》

3.3.3 标记-复制算法

主要给新生代的Survivor区做回收

参考From Survivor 和 To Survivor,每次他俩都会有一个空的,用来在下次回收的时候放入Eden中的对象,和通过复制算法从From Survivor区过来的。如果Survivor区空间不足容纳一次Minor GC存活的对象时,就会用一个“逃生门”,也就是依赖其他内存区域进行内存的分配担保(一般是老年代的内存),之后进入老年代。因为概率极低,所以很安全

定义:就一大块内存,划分出一半用作复制

优点:高效、无碎片、适合朝生夕死的新生代对象

缺点:浪费内存(还得弄内存分配担保)、大量复制降低效率(存活的对象太多)

图片来自《深入理解java虚拟机》

深入理解java虚拟机笔记 深入理解java虚拟机最新版_面试_08

3.3.4 标记-整理算法

适合吞吐量优先的GC

跟标记-清除算法最大的区别就是,它会让所有的存活的对象像一端移动,然后再清理掉边界以外的内存

优点:无碎片、无内存浪费

缺点:移动对象时必须全程暂停用户应用程序才能进行,这样的停顿俗称STW

深入理解java虚拟机笔记 深入理解java虚拟机最新版_jvm_09

和稀泥做法

平时采用标记-清除算法,碎片太多影响对象分配时再采用标记-整理算法收集一次(如CMS)

3.4 HotSpot的算法细节实现

3.4.1 根节点枚举

用途:寻找所有GC Roots

缺点:需要暂停用户线程,也就是会有STW的困扰

改进:可达性分析算法中耗时最长的查找引用链的过程可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个保障一致性的快照中才得以进行

OopMap:用来让虚拟机直接得到哪些地方存放着对象引用,借此使得虚拟机不需要一个不漏的检查完所有执行上下文和全局的引用位置。当类加载完成后,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。

3.4.2 安全点

定义:是否具有让程序长时间执行的特征为标准选定的,强制让用户程序必须到安全点后才能够暂停

作用:为了让OopMap只在特定位置记录信息,防止可能导致引用关系变化或者导致OopMap内容变化的指令太多,如果每条指令都生成OopMap,那将会需要大量的额外空间,带来高额的成本。

缺点:在程序不执行(Sleep或Blocked)的时候,线程就无法响应虚拟机的中断请求,就不能中断挂起自己了,这个时候就需要安全区域

看懂了么?反正我没看懂,反正就 用户线程在这块停,OopMap在这块记录信息,之后减少根节点枚举的消耗,对没错,就这样😶

抢先式中断

把所有用户线程都停了,之后看哪个线程中断的地方不在安全点上,就恢复这条线程执行,直到安全点,再中断

这.....我只能说怪不得GC不用,要不然万一所有线程都不在,这不得慢死啊,当然他还是有用途的,例如:我不信你不会😏

主动式中断

需要中断的时候,仅仅简单的设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现标志为真,就让自己在最近的安全点上主动中断挂起。

轮询标志的地方和安全点是重合的,还要加上所有创建对象和其他需要在堆上分配内存的地方,为了检测是否要发送GC,看看自己有没有那么多内存。

3.4.3 安全区域

定义:能够确保在某一段代码片段中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的,也可以看做扩展的安全点。

凡是当用户线程执行到安全区域里面的代码时,

  • 首先就会标识自己已经进入了安全区域,这时GC就会不管这些线程。
  • 当线程要离开时,它会检查虚拟机是否已经完成了根节点枚举(或垃圾收集中需要暂停用户线程的阶段)
  • 如果完成,线程就溜达出去
  • 没完成就继续等着可以离开的信号为止。

从此以下内容会引起身体不适,建议收藏后阅读,防止砸坏手机后找不到😼

3.4.4 记忆集与卡表

记忆集(Remembered Set)

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

极细致粒度的做法:可以用非收集区内中所有含跨代引用的对象数组来实现记忆集。

由于这种方式在空间占用和维护成本上都特别高昂,所有选择更粗狂的记录粒度来节省记忆集的存储和维护成本

下面列出了一些可供选择的记录精度

字长精度

每个记录精确到一个机器字长(处理器的寻址位置,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针

对象精度

每个记录精确到一个对象,该对象里有字段含义跨代指针

卡精度

每个记录精确到一块内存区域,该区域有对象含有跨代指针

有卡表的方式去实现记忆集,常用的实现形式

卡表

具体实现记忆集,记忆集是抽象的,图不是那么准确,对付理解一下吧

深入理解java虚拟机笔记 深入理解java虚拟机最新版_java_10

定义了记忆集的记录精度、与堆内存的映射关系等

最简单的形式:一个字节数组,HotSpot也是这么做的

默认的卡表标记逻辑:CARD_TABLE [this address >> 9] =1;

卡页:CARD_TABLE字节数组中的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块就叫卡页。由代码可知,HotSpot的卡页是2的9次幂也就是512字节。一个卡页的内存中通常包含不止一个对象,只要卡页中有一个或多个对象的字段存在着跨代指针,那就将对应的卡表的数组元素标识为1,称其为Dirty,没有则标识为0。在GC的时候,只要筛选出卡表中变脏的元素,就能轻易得出卡页内存块中包含跨代指针,放入GC Roots中一并扫描

3.4.5 写屏障

干啥的:解决卡表元素维护的问题

卡表元素啥时候变脏:有其他分代区域中对象引用了本区域对象时

如何在对象赋值的那一刻去更新维护卡表呢(变脏):把维护卡表的动作放到每一个赋值操作之中。HotSpot用的是写屏障

定义:虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值的时候会产生一个环形通知,供程序做额外的动作。赋值的前后都在写屏障范围内

简单来说:赋完之后,借助写后屏障做更新卡表的操作

写前屏障

在赋值前的部分写屏障叫写前屏障

写后屏障

在赋值后的则叫写后屏障

应用写后屏障后,虚拟机就会为所有的赋值操作生成相应的指令,一旦在写屏障中增加了更新卡表操作,每次引用都会产生额外的开销

伪共享

定义:当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会影响效率

缓存行(Cache Line):CPU的缓存系统的单位

3.4.6 并发的可达性分析

刚刚更新了下系统,MIUI终于做了波正优化,之前从来没用过这个文档模式,看来之前的图片我应该重拍一下了,这个图能看懂就看,看不懂就看书吧,反正我说不明白,如果大佬们能讲明白,教教我🆘

就是说这个可达性分析算法理论要求全过程都基于一个能保障一致性的快照中才能分析,这意味所有用户线程都得被冻结;它和根节点枚举不一样,由于CG Roots对象相比整个堆还算极少数,并且在各种优化技巧(OopMap)加持下,它带来的停顿已经非常短暂且相对固定;然而随着对象的增多,对象图(就是一堆GC Roots加一堆引用链的对象 构成的图)就越复杂,要标记更多对象产生的停顿时间就会更长。

之后为了搞清楚为什么必须在一个保障一致性的快照上才能进行对象图的遍历,于是来了个三色标记,解释遍历对象图的过程

白色:未被GC访问过的对象

黑色:已经被GC访问过的对象,包括他的所有引用

灰色:已经被访问过,但至少还有一个没被访问

图片来自《深入理解java虚拟机》

深入理解java虚拟机笔记 深入理解java虚拟机最新版_老年代_11

上面的图解释了,为什么会发生对象消失的问题,仅当以下两个条件同时满足时,才会消失

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

增量更新

作用:解决并法扫描时,对象消失的问题。用来破坏第一个条件

简化理解为:当黑色对象一旦新插入了指向白色对象的引用之后,就变回灰色对象

原始快照

作用:解决并法扫描时,对象消失的问题。用来破坏第二个条件

简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象快照来进行搜索

3.5 经典垃圾收集器

这图看着清晰吧,横线上面的是新生代的收集器,下面是老年代,G1通吃,写JDK9的位置告诉你从JDK9开始这个组合就不可以用了,好像后面CMS都不能用了,但是不影响CMS在历史中的地位,最新的是官方的ZGC,据说很优秀,不过优秀到看不懂,所以这块只有经典的收集器,下面来细致了解一下吧。

图片来自《深入理解Java虚拟机》

深入理解java虚拟机笔记 深入理解java虚拟机最新版_面试_12

3.5.1 Serial 收集器

单线程,适合不频繁发生垃圾收集的客户端模式下的虚拟机

新生代回收:采用标记-复制算法,STW

优点:简单高效、适合内存资源受限的环境、额外消耗内存最小的、单核CPU或较少的CPU的时候由于没有线程交互的开销,可以获得最高的单线程收集效率

缺点:收集过程(无论新老)全程STW、停顿时间过久、影响用户线程、体验不好

3.5.2 ParNew 收集器

Serial 收集器的多线程版本,全程依然STW,就是新生代回收的时候并行了多个线程,并行的垃圾收集器

新生代回收:采用标记-复制算法,STW

并行:一堆人一起做饭。在jvm中并行描述的是:多条垃圾收集器线程之间的关系,通常用户线程都是等待状态(STW)

并发:一个人洗菜、切菜、洗碗、刷碗、拿筷子、放调料等等等等,来回快速的切换。jvm中并发描述的是:垃圾收集器线程和用户线程之间的关系,说明用户线程未被冻结(无STW),但由于垃圾收集器线程占用一部分系统资源,此时应用程序的处理的吞吐量将受到影响。

3.5.3 Parallel Scavenge 收集器

多线程并行,别人关注用户线程的停顿时间,它关注吞吐量,它的目标是使吞吐量可控。

新生代回收:采用标记-复制算法,STW

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

3.5.4 Serial Old 收集器

单线程,和Serial是一对,看名字也知道,一个管新生一个管老年

老年代回收:采用标记-整理算法,STW

主要意义:提供客户端模式下的HotSpot的虚拟机使用

次要意义:一种和parallel Scavenge配对使用;另一种作为CMS发生失败的后备预案,后面CMS的时候会说到

3.5.5 Parallel Old 收集器

多线程并行,吞吐量优先

老年代回收:采用标记-整理算法,STW

3.5.6 CMS 收集器(Concurrent Low Pause Collector【并发低停顿收集器】)

深入理解java虚拟机笔记 深入理解java虚拟机最新版_面试_13

第一个支持多线程并发的垃圾收集器,以获取最短会回收停顿时间为目标的收集器,具有高的响应速度,作为服务端会有更好的交互体验

老年代回收:采用标记-清除算法,和用户线程并发执行,无STW

优点:并发、低停顿

 

回收步骤

  • 初始标记(STW):仅仅标记GC Roots能直接关联到的对象,速度很快
  • 并发标记:从GC Roots直接关联的对象开始对遍历整个对象图的过程,过程耗时,但不需要停顿用户线程,二者并发。此时吞吐量会受到一定影响
  • 重新标记(STW):修改并发标记期间,用户程序继续运行而导致的标记产生变动的那一部分对象的标记记录(增量更新过程),略长STW
  • 并发清除:清除掉标记阶段判断的意见死亡的对象,不需要移动对象,并发

缺点

之所以被淘汰了,证明它还不够完美

  • 降低吞吐量:因为并发的时候会占用一部分线程,从而导致应用程序变慢。尤其是当CPU核心数在四个以下的时候(默认回收线程数是:(CPU核心数量+3)/ 4),CMS对用户程序的影响可能就变得很大,可能导致应用程序执行速度大幅度降低。为了解决这个问题,虚拟机提供了一种称为“增量式并发收集器”的CMS收集器的变种,就是并发的时候让收集器线程、用户线程交互运行,但是效果一般,已经被抛弃了
  • 无法处理浮动垃圾:有可能出现“Concurrent Model Failure”失败,而导致另一次完全的STW的Full GC的产生。
  • 浮动垃圾:在CMS并发标记和并发清理阶段,用户线程是在继续运行的,程序在运行自然就会有新的垃圾对象不断产生,但这一部分出现在标记过程结束以后,CMS无法在本次收集处理掉它们,只好在下一次时清理掉
  • 同样由于垃圾收集阶段用户线程持续运行,那就需要预留足够的空间给用户线程使用,必须预留一部分空间供并发收集时的程序运作使用。
  • 如果CMS预留的内存无法满足分配新对象的需要,就会出现一次“并发失败”(Concurrent Model Failure),这个时候就需要启动备案:冻结用户线程,临时启用Serial Old收集器重新进行老年代的垃圾收集,这样停顿的时间就非常久了
  • 内存碎片:CMS采用标记-清除 算法,自然有大量内存碎片产生,一旦无法给大对象分配内存时,就需要提前触发Full GC
  • 解决办法:在Full GC时,开启内存碎片的合并整理过程。STW停顿时间增加。另一个方法:CMS在执行若干次不整理空间的Full GC之后,下一次Full GC前会提前整理

3.5.7 Garbage First 收集器

深入理解java虚拟机笔记 深入理解java虚拟机最新版_深入理解java虚拟机笔记_14

里程碑的收集器--开创了收集器面向局部收集的设计思路和基于Region的内存布局形式

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

G1的Mixed GC模式:G1可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。基于Region的内存布局是实现Mixed GC的关键

基于Region的堆内存布局:G1仍然遵循分代收集理论设计,但G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的java堆划分为多个大小相等的独立区域每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代空间

Humongous区域:专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。而对于那些超过整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

G1为什么能建立可预测的停顿时间模式

是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,可以有计划的避免了在堆中进行全区域的垃圾收集。

更具体的处理思路:G1收集器去跟踪各个Region里面的垃圾堆积的价值的大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,然后根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region,这也是“Garbage First”“名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限时间内获取尽可能高的收集效率

G1将堆内存化整为零中存在的问题

1、Region里面存在的跨Region引用对象如何解决?

使用记忆集避免全堆作为GC Roots扫描

区别

  • 每个Region都维护了自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
  • 记忆集的存储结构:本质上是哈希表,Key是别的Region的起始地址,Value是一个集合(里面存储了卡表的索引号)
  • 这种双向的卡表结构(卡表是“我指向谁”,这个结果还记录了“谁指向我”),更加复杂
  • G1收集器要比传统的有着更高的内存占用担负:因为Region数量比传统的分代数量多很多。至少要花费堆中10%-20%的内存来维持收集器工作

2、在并发标记阶段如何保证收集线程和用户线程互不干扰

G1使用原始快照 来保证用户线程改变对象引用关系时,不打破原本的对象图结构,导致标记结果出现错误。

回收过程中新对象的内存分配上:G1为每个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的地址都必须要在这两个指针位置以上。G1默认这个地址以上的对象是被隐式标记的,默认认为它们存活,不列入GC

如果内存的回收速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间的STW

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

G1的停顿预测模型是以衰减均值为理论基础来实现的

在垃圾收集过程中:G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息

4、G1的运作步骤

  • 初始标记:仅仅标记GC Roots能直接关联到的对象,并修改TAMS的值,让下一阶段用户进程并发运行时,能正确地在可用的Region中分配新对象。STW,耗时短,并借助Minor GC时同步完成,所以没有额外的停顿
  • 并发标记:从GC Root开始对堆中的对象进行可达性分析,递归整个对象图,找出要回收的对象。和用户线程并发执行,耗时较长。当对象图扫描完成以后,还要重新处理SATB(原始快照)记录下的再并发时有引用变动的对象
  • 最终标记:对用户线程做一个短暂的暂停,用于处理并发阶段结束后留下来的最后那少量的SATB记录
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收机,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。STW,并行完成存活对象的移动

5、优点

  • 创新性红利
  • 指定最大停顿时间
  • 分Region的内存布局
  • 按受益动态确定回收集
  • 算法理论
  • 整体看采用标记-整理算法
  • 局部看采用标记-复制算法
  • 都不会产生内存碎片,有利于程序长时间运行
  • 大对象分配时不容易 因无法找到连续内存空间提前触发下一次收集

6、缺点

  • 内存占用和程序运行时额外执行负载都要更高
  • 内存占用:G1的卡表实现更复杂,所有Region都要有一份卡表
  • 执行负载:除了写后屏障更新卡表外,为了实现SATB,还要使用写前屏障来跟踪并发时的指针变化情况
  • 相比增量更新:SATB搜索能够减少并发标记和重新标记阶段的消耗,避免了CMS在最终标记停顿时间长的缺点,但在用户程序运行过程确实会产生有跟踪引用带来的额外负担。
  • 由于G1写屏障复杂的操作要更加消耗运算资源,所以G1不得不将其实现为消息队列的结构,把写前写后要做的事情放到队列里,异步处理

3.6 低延迟垃圾收集器