深入理解Java虚拟机学习笔记

第一部分 走近Java

1. 走近Java

1.1 概述

Java不仅仅是一门编程语言,还是一个由一系列计算机软件和规范组成的技术体系

Java优点:1. 一次编写,到处运行;2. 避免了绝大部分内存泄露和指针越界问题;3. 实现了热点代码检测和运行时编译及优化,越运行性能越好;4. 完善的类库

1.2 Java技术体系

广义上,Kotlin等运行在JVM上的编程语言都属于Java技术体系
传统上,JCP定义的Java技术体系包含:1. Java程序设计语言;2. 各种硬件平台上的Java虚拟机实现;3. Class文件格式;4 Java类库API;5. 来自商业机构和开源社区的第三方Java类库

JDK:Java程序设计语言+Java虚拟机+Java类库
JRE:Java类库API中的Java SE API子集和Java虚拟机
图 1

1.3 Java发展史

图 2

1.4 Java虚拟机家族

  • Sun Classic/Exact VM。Sun Classic是世界上第一款商用Java虚拟机,纯解释执行代码,如果外挂即时编译器会完全接管执行,两者不能混合工作。Exact VM解决了许多问题但是碰上了引进的HotSpot,生命周期很短
  • HotSpot VM:使用最广泛的Java虚拟机。HotSpot继承了前者的优点,也有许多新特性,例如热点代码探测技术。JDK 8中的HotSpot融合了BEA JRockit优秀特性
  • Mobile/Embedded VM:针对移动和嵌入式市场的虚拟机
  • BEA JRockit/IBM J9 VM:JRockit专注服务端应用,不关注程序启动速度,全靠编译器编译后执行。J9定位类似HotSpot
  • BEA Liquid VM/Azul VM:和专用硬件绑定,更高性能的虚拟机
  • Apache Harmony/Google Android Dalvik VM:Harmony被吸收进IBM的JDK 7以及Android SDK,Dalvik被ART虚拟机取代
  • Microsoft JVM:Windows系统下性能最好的Java虚拟机,因侵权被抹去

1.5 展望Java技术的未来

  • 无语言倾向:Graal VM可以作为“任何语言”的运行平台
  • 新一代即时编译器:Graal编译器,取代C2(HotSpot中编译耗时长但代码优化质量高的即时编译器)
  • 向Native迈进:Substrate VM提前编译代码,显著降低内存和启动时间
  • 灵活的胖子:经过一系列重构与开放,提高开放性和扩展性
  • 语言语法持续增强:增加语法糖和语言功能

第二部分 自动内存管理

2. Java内存区域与内存溢出异常

2.1 概述

C、C++程序员需要自己管理内存,Java程序员在虚拟机自动内存管理机制下不需要为每一个new操作写对应的delete/free代码,但是一旦出现内存泄露和溢出,不了解虚拟机就很难排错

2.2 运行时数据区域

图 3

  • 程序计数器:当前线程执行的下一条指令的地址;线程私有;不会OOM
  • 虚拟机栈:Java方法执行的线程内存模型,每个方法执行时,JVM都会创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息;线程私有;会栈溢出和OOM
  • 局部变量表:存放的变量的类型有8种基本数据类型、对象引用和returnAddress类型(指向字节码指令的地址),这些变量除了64位的long和double占两个变量槽,其它占1个。局部变量表的大小在编译器确定
  • 本地方法栈:和虚拟机栈作用类似,区别在于只是为本地(Native)方法服务,HotSpot将两者合二为一
  • 堆:“几乎”所有对象实例都在此分配内存;可分代,也不分代;逻辑上连续,物理上可以不连续;会OOM
  • 方法区:也叫“非堆”,存储已加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据;实现上,之前HotSpot使用永久代实现方法区,目的是方便垃圾回收,但是这样有OOM问题,JDK8废弃永久代改为使用本地内存的元空间,主要存放类型信息,其它移到堆;内存回收目标主要是常量池和类型信息
  • 运行时常量池:Class文件中的常量池表存放编译期生成的各种字面量和符号引用,这部分内容在类加载后放到方法区的运行时常量池;具有动态性,运行时产生的常量也可以放入运行时常量池,String类的intern()利用了这个特性;会OOM
  • 直接内存:不是运行时数据区的一部分,也不是Java虚拟机规范里定义的内存区域;NIO使用Native函数库直接分配堆外内存;会OOM

2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建
  1. 当JVM碰到new指令,首先检查指令参数能否在常量池中定位到一个类的符号引用,并且检查该类是否已被加载、解析和初始化,如果没有就进行类加载过程
  2. JVM为新生对象分配内存。
    • 对象所需内存大小在类加载后可确定,分配方法有两种:当垃圾收集器(Serial、ParNew)能空间压缩整理时,堆是规整的,分配内存就是把指针向空闲空间方向移动对象大小的距离,这种叫“指针碰撞”,使用CMS收集器的堆是不规整的,需要维护空闲列表来分配内存。
    • 内存分配可能线程不安全,例如线程在给A分配内存,指针来没来得及修改,另一线程创建对象B又同时使用了原来的指针来分配内存。解决方法有两个:1.对分配内存空间的动作进行同步处理,JVM采用CAS+失败重试的方式保证原子性;2.预先给每个线程分配一小块内存,称为本地线程分配缓冲(TLAB),线程分配内存先在TLAB上分配,TLAB用完了再同步分配新的缓存区
  3. JVM将分配到的内存空间(不包括对象头)初始化为零值,这步保证对象的实例字段可以不赋初始值就直接使用
  4. JVM对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象的对象头中
  5. 执行构造函数,即Class文件中的<init>()
2.3.2 对象的内存布局
  • 对象头

    • 用于存储对象自身的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳,称为“Mark Word”。这部分数据长度32比特或64比特,Bitmap存储,为了在极小空间存储更多数据,不同状态的对象用不同标志位表示不同存储内容
      图 4
    • 类型指针,即对象指向它的类型元数据的指针,JVM通过该指针来确定对象是哪个类的实例。若对象是数组,还必须记录数组长度
  • 实例数据,包括父类继承下来的,和子类中定义的字段

  • 对齐填充,HotSpot要求对象大小必须是8字节的整数倍,不够就对齐填充

2.3.3 对象的访问定位

通过栈上的reference数据来访问堆上的具体对象,访问方式由虚拟机实现决定,主流有两种

  • 句柄访问。Java堆会划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自具体的地址信息。优点是对象被移动时只需要改变句柄中的实例数据指针
    图 5
  • 直接指针访问。reference存储的就是对象地址,类型数据指针在对象中。优点是节省一次指针定位的时间开销,HotSpot使用此方式来访问对象
    图 6

2.4 实现:OutOfMemoryError异常

2.4.1 Java堆溢出
/**
 * VM options:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError 
 * @author panjiahao.cs@foxmail.com
 * @date 2023/6/18 19:54
 */
public class HeapOOM {
    static class OOMObject{

    }
    public static void main(String[] args) {
        ArrayList<OOMObject> list = new ArrayList<>();

        while(true){
            list.add(new OOMObject());
        }
    }
}

图 7

排查思路:首先确认导致OOM的对象是否是必要的,也就是是分清楚是内存泄露了还是内存溢出了

如果是内存泄露了,可以通过工具查看泄露对象到GC Roots的引用链,定位到产生内存泄露的代码的具体位置

如果是内存溢出了,可以调大堆空间,优化生命周期过长的对象

2.4.2 虚拟机栈和本地方法栈溢出

HotSpot不区分虚拟机栈和本地方法栈,所以-Xoss(本地方法栈大小)参数没效果,栈容量由-Xss参数设定。

当线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;当扩展栈容量无法申请到足够内存,将抛出OutOfMemoryError异常。HotSpot不支持扩展栈容量

2.4.3 方法区和运行时常量池溢出

JDK8使用元空间取代了永久代,运行时常量池移动到了堆中,所以可能会产生堆内存的OOM

方法区的主要职责是存放类型信息,如类名、访问修饰符、常量池、字段描述、方法描述等。用CGLib不断生成增强类,可能产生元空间的OOM

/**
 * VM Args:-XX:MaxMetaspaceSize=10M
 * @author panjiahao.cs@foxmail.com
 * @date 2023/6/18 22:13
 */
public class JavaMethodAreaOOM {
    public static void main(String[] args) {
        while(true){
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(HeapOOM.OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object obj, Method method, Object[] objects, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj,args);
                }
            });
            enhancer.create();
        }
    }
    static class OOMObject{

    }
}

图 8

2.4.4 本机直接内存溢出

直接内存通过-XX:MaxDirectMemorySize参数来指定,默认与Java堆最大值一致

虽然使用DirectByteBuffer分配内存会OOM,但它抛出异常时并没有真正向操作系统申请内存,而是通过计算得知无法分配就手动抛异常,真正申请内存的方法是Unsafe::allocateMemory()

/**
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 * @author panjiahao.cs@foxmail.com
 * @date 2023/6/19 20:38
 */
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

图 9

由直接内存导致的内存溢出,一个明显的特征是Heap Dump文件不会看到有什么明显的异常情况。如果内存溢出后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(NIO),可以重点检查直接内存

3. 垃圾收集器与内存分配策略

3.1 概述

线程独占的程序计数器、虚拟机栈、本地方法栈3个区域的内存分配和回收都具备确定性。

而Java堆和方法区有显著的不确定性:一个接口的多个实现类需要的内存可能不一样,一个方法不同分支需要的内存也不同,只有处于运行期间,才知道程序会创建哪些对象,这部分内存的分配和回收是动态的

3.2 判断对象是否存活的算法

  • 引用计数法。看似简单,但必须配合大量额外处理才能正确工作,譬如简单的引用计数法无法解决对象循环引用
  • 可达性分析算法。从GC Roots对象开始,根据引用关系向下搜索,走过的路径称为“引用链”,引用链上对象仍然存活,不在引用链上的对象可回收。
    图 10

GC Roots对象包括以下几种:

  • 虚拟机栈中引用的对象。例如方法参数、局部变量等
  • 方法区中类静态属性引用的对象。例如Java类的引用类型静态变量
  • 方法区中常量引用的对象。例如字符串常量池里的引用
  • 本地方法栈JNI引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)、还有系统类加载器
  • 所有被同步锁(synchronized)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
  • 根据用户选用的垃圾收集器以及回收区域,临时加入其它对象。目的是当某个区域垃圾回收时,该区域的对象也可能被别的区域的对象引用

引用包含以下几种类型:

  • 强引用:被强引用引用的对象不会被回收
  • 软引用:被软引用引用的对象在OOM前会被回收
  • 弱引用:被弱引用引用的对象在下一次垃圾回收时被回收
  • 虚引用:虚引用不会影响对象的生存时间,唯一目的是能在对象被回收时收到一个系统通知

即便对象已经不可达,也不是立即标记为可回收,对象真正死亡要经历两次标记过程:可达性分析发现不可达就第一次标记;如果对象重写了finalize()方法且没过JVM调用过,那么该对象会被放到队列中,由Finalizer线程去执行finalize()方法,这是对象自救的最后一次机会,只要重新与引用链上任意对象建立关联就行,譬如把this赋给某个对象的成员变量,第二次标记时就会被移除“即将回收”集合

/**
 * 此代码演示了两点:
 * 1.对象可以在被GC时自我拯救。
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 * @author panjiahao.cs@foxmail.com
 * @date 2023/6/20 21:52
 */
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();
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no ,i am dead! :(");
        }

        // 自救失败
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no ,i am dead! :(");
        }
    }
}

方法区没有强制要垃圾回收,例如JDK11的ZGC不支持类卸载。

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

回收废弃常量和回收堆中的对象非常类似
回收“不再使用的类”需要满足三个条件:

  • 该类所有实例已被回收,包括子类实例
  • 加载该类的类加载器已被回收,这个条件很难
  • 该类对象的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

3.3 垃圾收集算法

从如何判定对象消亡的角度出发,GC算法可以划分为“引用计数式”和“追踪式”,这两类也被称作“直接垃圾收集”和“间接垃圾收集”。本节介绍追踪式垃圾收集

3.3.1 分代收集理论

分代收集理论建立在两个假说上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡

分代收集在遇到对象之间存在跨代引用时需要遍历其它代的所有对象来确定是否还存在跨代引用,性能负担大,所以给分代收集理论添加第三个假说:

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

分代收集的名词定义:

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS会有单独收集老年代的行为
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1会有这种行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾

3.3.2 标记-清除算法

标记所有需要回收的对象,标记完成后,统一回收
图 11

缺点:执行效率不稳定;内存空间碎片化

3.3.3 标记-复制算法

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

图 12

优点:对于多数对象都是可回收的情况,算法复制开销小;没有碎片
缺点:可用内存小了一半;需要空间担保

1:1划分新生代太浪费空间,HotSpot将新生代划分成Eden:Survivor0:Survivor0 = 8:1:1,每次可以用Eden和一块Survivor,垃圾回收时把存活对象写到另一块Survivor,然后清理掉Eden和已用过的Survivor,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖老年代进行分配担保

3.3.4 标记-整理算法

标记所有存活的对象,然后移动到内存空间一端,清理掉边界以外的内存
图 13

优点:解决了标记-清除算法造成的空间碎片化问题
缺点:整理期间,用户应用程序暂停,这段时间被称为“Stop The World”

整理即移动对象,移动则内存回收时会更复杂,不移动则内存分配会更复杂。从GC停顿时间来看,不移动对象停顿时间短;从吞吐量来看,移动对象更划算。

HotSpot里关注吞吐量的Parallel Scavenge收集器采用标记-整理算法,关注延迟的CMS收集器采用标记-清除算法

3.4 HotSpot的算法细节实现

3.4.1 根节点枚举

固定作为GC Root的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表),Java程序越来越庞大,逐个作为起点进行可达性分析会消耗大量时间

目前,所有收集器在根节点枚举时和整理内存碎片一样必须暂停用户线程,可达性分析算法可以和用户线程一起并发。为了降低STW时间,在不扫描全部的GC Root节点情况下,得知哪些地方存在对象引用,HotSpot提供了OopMap的数据结构保存引用的位置信息

3.4.2 安全点

OopMap可以帮助HotSpot快速完成GC Root枚举,但是如果为每条改变OomMap内容的指令都生成对应的OopMap,会需要大量额外存储空间

因此HotSpot没有为每条指令都生成OopMap,只在特定位置生成,这些位置称为安全点。用户程序只有在执行到安全点才能停顿下来垃圾回收。

安全点的选取考虑:不能太少以至于让收集器等待时间太长,也不能太频繁以至于增大内存负担

如何在垃圾回收时让所有线程(不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。有两种方案:

  • 抢先式中断:先把用户线程全部中断,如果发现有用户线程不在安全点上就恢复这个线程,过一会再中断直至它跑到安全点。现在几乎不使用
  • 主动式中断:设置一个标志位,各个线程执行时主动轮询这个标志,一旦为真主动中断挂起。HotSpot使用内存保护陷阱的方式将轮询操作精简至只有一条汇编指令
3.4.3 安全区域

当用户线程处于Sleep或者Blocked状态时不能执行到安全点。针对这种情况,引入安全区域。

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

当用户线程执行到安全区域时,首先标识自己进入了安全区域,这样在GC时,虚拟机就不会去管这些已标识自己进入安全区域的线程。当线程离开安全区域时,它检查虚拟机是否完成了根节点枚举(或者其它需要暂停用户线程的阶段),如果完成了就继续执行,否则等待直到收到可以离开安全区域的信号为止。

3.4.4 记忆集与卡表

记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构,用于解决对象跨代引用带来的问题

记忆集最简单的实现是非收集区域中所有含跨代引用的对象数组,这种结构浪费太多空间,可以粗化记录粒度:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如32或64),该字包含跨代指针
  • 卡精度:每个记录精确到一块内存区域,该区域有对象含有跨代指针

卡精度使用卡表实现,卡表的实现是一个字节数组,字节数组每个元素都对应着一块特定大小的内存块,称为卡页。只要卡页内有一个或更多对象的字段存在跨代引用,卡表中对应的数组元素的值标识为1,称为变脏。垃圾收集时,只要筛选出卡表中变脏的元素就可以知道哪些卡页内存块有跨代引用,把它们放入GC Roots中一起扫描

3.4.5 写屏障

卡表元素变脏的时间点是引用类型字段赋值那一刻,HotSpot通过写屏障来维护卡表状态。写屏障可以看作JVM层面对“引用类型字段赋值”这个动作的AOP切面。

写屏障会导致伪共享问题,伪共享是指,现代CPU的缓存系统是以缓存行为单位存储的,当多线程修改相互独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低

伪共享的一种简单解决方法是不采用无条件的写屏障,而是先检查卡表标记,只有卡表元素未被标记时才将其变脏。HotSpot参数-XX:+UseCondCardMark决定是否开启卡表更新,开启会多一次判断开销,但能够避免伪共享带来的性能损耗

3.4.6 并发的可达性分析

可达性分析在标记阶段会暂停用户线程以在一致性的快照上进行对象图的遍历,不一致情况下会出现“对象消失”问题。原因可以由三色标记方法推导

图 14

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

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

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

解决并发扫描时的对象消失问题,只需破坏两个条件之一即可,因此有两种方案:

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

对引用关系记录的插入和删除都是通过写屏障实现。

3.5 经典垃圾收集器

图 15

图中连线表示可以搭配使用

3.5.1 Serial收集器

图 16

适合资源(cpu和内存)受限的场景,新生代一两百兆以内的垃圾收集停顿时间最多一百多毫秒以内

3.5.2 ParNew收集器

实质是多线程版的Serial

图 17

3.5.3 Parallel Scavenge收集器

与ParNew类似,不同点是其它收集器关注停顿时间,Parallel Scavenge收集器目标是可控制的吞吐量

图 18

-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间,-XX:GCTimeRatio直接设置吞吐量,-XX:+UseAdaptiveSizePolicy会根据系统运行情况动态调整虚拟机参数以获得最合适的停顿时间或者最大的吞吐量

3.5.4 Serial Old收集器

Serial收集器的老年代版本

图 19

3.5.5 Parallel Old收集器

Parallel Scavenge的老年代版本,在注重吞吐量或者处理器资源稀缺场合,可以考虑使用Parallel Scavenge+Parallel Old

图 20

3.5.6 CMS收集器

四步骤:
1)初始标记
2)并发标记
3)重新标记
4)并发清理

初始标记和重新标记仍然要暂停用户线程。初始标记仅仅标记GC Roots能直接关联的对象,速度很快;并发标记从关联对象开始遍历整个对象图,不需要暂停用户线程;重新标记用来修正并发标记期间的变动(增量更新)

图 21

优点:并发收集、低停顿
缺点:

  • 对处理器资源敏感。并发会占用处理器,降低吞吐量
  • 无法处理“浮动垃圾”。“浮动垃圾”指并发标记和清理期间用户线程产生的垃圾在下一次GC时清理,预留空间不足会,预留空间不足会导致“Concurrent Mode Failure”进而导致Full GC或者Serial Old重新回收老年代。
  • 内存空间碎片化
3.5.7 Garbage First(G1)收集器

开创面向局部收集的设计思路和基于Region的内存布局形式

局部收集只收集范围不是新生代或老年代,而是堆中任意部分。

基于Region的堆内存布局:G1不再坚持固定大小和数量的分代区域划分,而是把连续的堆划分为多个大小相等的独立区域Region,每个Region都可以根据需要扮演新生代或者老年代。超过Region容量一半的对象会被存到Humongous区域中,超过整个Region容量的对象会被存到N个连续的Humongous Region中,G1大多数行为把Humongous Region当老年代处理
图 22

四个步骤:

  • 初始标记:标记GC Roots能直接关联的对象,修改TAMS指针的值,让下一阶段用户并发运行时能正确在可用的Region中分配新对象
  • 并发标记:递归扫描对象图。扫描完成后重新处理SATB记录下的在并发时有引用变动的对象
  • 最终标记:处理并发阶段遗留的少量SATB记录
  • 筛选回收:负责更新Region统计数据,制定回收计划

图 23

3.6 低延迟垃圾收集器

垃圾收集器“不可能三角”:内存占用、吞吐量、延迟。延迟是最重要指标

下图,浅色表示挂起用户线程,深色表示gc线程和用户线程并发

图 24

Shenandoah和ZGC在可管理的堆容量下,停顿时间基本是固定的,两者被命名为低延迟垃圾收集器

3.6.1 Shenandoah收集器

目标:能在任何堆内存大小下都可以把GC停顿时间限制在10ms以内

Shenandoah和G1有相似的堆内存布局,在初始标记、并发标记等阶段的处理思路高度一致。
有三个明显的不同:

  • 支持并发的整理算法
  • 默认不分代,即不使用专门的新生代Region和老年代Region
  • 放弃G1中耗费大量内存和计算资源去维护的记忆集,改为“连接矩阵”记录跨Region的引用关系

图 25

收集器的工作分为九个阶段:

  • 初始标记:与G1一样,首先标记GC Roots直接关联的对象,停顿时间与堆大小无关,与GC Roots数量有关
  • 并发标记:与G1一样,遍历对象图,与用户线程并发,时间取决于对象数量和对象图的结构复杂程度
  • 最终标记:与G1一样,处理剩余的SATB扫描,统计价值最高的Region组成回收集,有一小段停顿时间
  • 并发清理:清理一个存活对象都没有的Region
  • 并发回收:将回收集中的存活对象复制到未被使用的Region中,通过读屏障和“Brooks Pointers”转发指针解决和用户线程并发产生的问题
  • 初始引用更新:一个非常短暂的停顿,用于确保所有并发回收阶段中收集器线程都已完成分配对象移动任务
  • 并发引用更新:真正开始进行引用更新,与用户线程并发
  • 最终引用更新:修正存在于GC Roots中的引用,停顿时间与GC Roots的数量有关
  • 并发清理:经过并发回收和引用更新后,整个回收集的Region已经没有存货对象,回收这些Region的内存空间

图 26

对象移动和用户程序并发,原来的解决方案是在被移动对象原有的内存上设置保护陷阱,一旦用户程序访问到原有的地址就产生自陷中断,进入预设的异常处理器,将访问转发到新对象。这个方案会导致用户态频繁切换到核心态

Brooks Pointers是在原有对象布局结构的最前面加一个新的引用字段,移动前指向自己,移动后指向新对象。这里需要用CAS解决对象访问的并发问题

图 27

3.6.2 ZGC收集器

ZGC收集器是一款基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。收集过程全程可并发,短暂停顿时间只与GC Roots大小相关而与堆内存大小无关

  1. ZGC的Region是动态的:动态创建和销毁、动态大小

    图 28

  2. 染色指针:把标记信息记在引用对象的指针上,标记信息有4个bit,虚拟机可以直接从指针中看到其引用对 象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到

    图 29

    三大优势:

    • 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用 掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理
    • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的 目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作
    • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以 便日后进一步提高性能

    标志位影响了寻址地址,ZGC通过多重映射,将不同标志位的指针映射到同一内存区域

    图 30

  3. ZGC的收集过程分为四大阶段

    图 31

    • 并发标记:与G1、Shenandoah类似,区别是标记在指针上而不是对象上,标记会更新染色指针的Marked 0、Marked 1标志位
    • 并发预备重分配:根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集
    • 并发重分配:把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系
    • 并发重映射:修正整个堆中指向重分配集中旧对象的所 有引用

3.7 选择合适的垃圾收集器

3.7.1 Epsilon收集器

不收集垃圾,负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责

响应微服务而生,在堆内存耗尽前就退出,不收集垃圾就非常合适

3.7.2 收集器的权衡

三个因素:

  • 应用程序关注点是什么?吞吐量、延时还是内存占用
  • 基础设施如何?处理器的数量、内存大小、操作系统
  • JDK的发行商是谁?版本号是多少

例如直接面向用户的B/S系统,延迟是主要关注点。

  • 预算充足就用商业的
  • 预算不足但能掌控基础设施,可以尝试ZGC
  • 对ZGC的稳定性有疑虑就考虑Shenandoah
  • 软硬件和JDK都老的考虑CMS