自学内容网 自学内容网

JVM系列 | 对象的消亡——HotSpot的设计细节

HotSpot 的细节实现

笔者寄语:这一篇博客真的写的脑壳痛,很多地方太抽象了,想要仔细的扣细节很费脑袋,本来早就想发布了,但是因为要扣的点太多了,一直拖到了现在。

OopMap 与 根节点枚举

无论是标记复制算法、标记整理算法、标记清除算法,都需要有一个从“根节点”开始的可达性分析工序,因此确定出哪些节点是根节点就很重要。

根节点类型及说明

  1. 全局静态变量(Global Static Variables)
  • 共享和生命周期长:全局静态变量在整个程序生命周期内都是存在的,它们在程序的任何部分都可以被访问。
  • 可达性:因为全局静态变量可以引用任何对象,并且这些对象可能被多个线程使用,所以它们必须作为根节点来追踪。
  1. 栈帧中的局部变量(Local Variables in Stack Frames)
  • 活动性:这些是当前正在执行的方法或函数中的局部变量。因为它们正在被使用,所以引用的对象不能被回收。
    可达性:这些局部变量在栈帧中存储,GC需要从这些变量开始追踪,以确保它们引用的对象不会被回收。
  1. 当前使用中的线程(Currently Active Threads)
  • 线程对象:每个活动的线程对象都会被作为根节点,因为线程本身以及它们的栈帧包含的局部变量和执行上下文需要被追踪。
  1. JNI引用(JNI References)
  • 本地方法接口:在使用Java本地接口(JNI)调用本地代码时,本地代码可以创建引用指向Java对象,这些引用也必须作为根节点来追踪,以确保这些Java对象在本地代码使用期间不会被回收。
  1. 类加载器(Class Loaders)
  • 类和静态变量的管理:类加载器是负责加载Java类的对象,它们持有对类和静态变量的引用,因此它们的引用链必须被追踪,以确保这些类和静态变量不会被错误地回收。
  1. 系统类(System Classes)
  • 核心类库:Java中的一些核心类库,例如java.lang.System、java.lang.Thread等,持有对大量静态变量和对象的引用,这些系统类也必须作为根节点来追踪。
  1. GC Roots in Java Heap (某些Java堆中的对象)
  • 常量池(String Pool):常量池中的字符串对象,以及一些其他常量值会被作为根节点来追踪。
  • 类静态字段(Static Fields of Classes):静态字段属于类的对象,它们在类加载时被创建,在类卸载时被回收。

HotSpot中的实现

在HotSpot中进行垃圾回收的第一个步骤就是“根节点枚举”,根节点枚举的过程必须要停止用户线程(也就是停止代码程序),这一步骤被称为"Stop The World"(时停/酷);且现在程序一般都非常的大,堆等内存动辄几百上千M,如果要一个个对象扫描来判断类型的话耗时实在是可观,因此这种操作肯定是不可取的。

HotSpot为了解决这一问题,引入了OopMap:一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。

如何理解偏移量呢?就是与这个类的起始地址的差,也就是从开头向后数多少个地址是一个什么类型的数据。

这样一来,JVM就无需扫描整个运行时内存空间,而是直接扫描OopMap就可以获得当前运行时内存有哪些根节点,从而解约根节点枚举时的时间。

OopMap 与 安全点

安全点介绍

《深入理解Java虚拟机:原文》可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

可见,引如OopMap并无问题,我们还需要解决什么时候更新OopMap的问题:过多的安全点会导致性能开销过大,而过少的安全点会导致垃圾收集或其他全局操作无法及时执行…经过JVM团队深思熟虑,终于给出了解决方案:在所有线程进入某些特定代码位置之后更新OopMap(通常更新完成之后直接进行垃圾收集操作),这些特定位置就被称为安全点

安全点通常是:

  1. 循环的回边(一次循环结束后 回到循环开始的位置 这两者之间)

  2. 方法的开始与结束

  3. 抛异常的位置

下面用代码进行一个简单的实力

public class GCExample {
    private static Object staticObject = new Object();

    public static void main(String[] args) {
        Object localObject = new Object();
        
        // 模拟一个循环,其中可能触发安全点
        for (int i = 0; i < 1000000; i++) {
            // 模拟方法调用和循环迭代
            someMethod(localObject);
        }

        // 主线程请求垃圾收集
        System.gc();
    }

    private static void someMethod(Object obj) {
        // 模拟方法调用,可能插入安全点
        System.out.println("Processing: " + obj);
    }
}

如何保证程序在安全点上?

要在进行垃圾回收的时候,保证所有的线程都停留在安全点上,有两种方式:

  1. 抢占式
  2. 主动式

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

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

由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式(自陷陷阱),关于自陷陷阱这里不多赘述,可以自己搜一下。

安全区域

针对一些无法进入安全点的线程(如Sleep或Blocked的线程),虚拟机设置了安全区域来应对。

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

记忆集与卡表

记忆集

万事俱备,接下来似乎要进行垃圾回收了,这里又有一个问题,如果存在跨代引用怎么办?

跨代引用是指老年代中的对象引用年轻代中的对象,或者反过来年轻代引用老年代。由于垃圾收集是分代进行操作的,老年代的收集频率远低于年轻代,那么在对年轻代进行垃圾收集的时候,如果一个老年代的对象O引用了年轻代对象Y,而JVM并不知道这一点,在扫描完整个年轻代之后发现没有对象引用对象Y,这时候将Y给销毁了,就会造成程序运行错误。

如何解决这一点问题?JVM给出的答案是:采用一块专门的内存区域用于记录哪一些对象发生了跨代引用,这便是记忆集

比如如果老年代O引用了年轻代的Y,那么就在记忆集中记录下O对Y的引用,这样在对年轻代进行垃圾收集的时候(我是指标记阶段),就能顺便给O也带进来标记一下,这样便能标记到Y。反之亦是如此。

卡表

但是JVM团队还是觉得这样的记录太过于浪费内存空间,如果存在大量的跨代引用的话,使用的内存空间确实十分可观,因此JVM团队决定放大粒度,一般来说,存在以下三个精度:

  1. 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针

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

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

卡精度就是卡表,使用卡精度,JVM将不再记录内存或对象,而是记录一个大致的范围,在这个内存范围内存在跨代引用,在进行垃圾回收的时候,只需要额外将这一块内存区域也扫描进去即可。

在这里插入图片描述

比如这张图中,由于O7所在的内存区域与O20所在的内存区域存在对新生代的跨带引用,因此对新生代进行垃圾回收的时候,也会将上图两个红色的区域算进去。


*在一些新的垃圾回收器中不存在年轻代与老年代,而是使用了更复杂的区域收集,也会涉及跨带引用

写屏障

写屏障是十分好理解的,在这里就引用一些原文内容了,简单来说就是指令层面的AOP操作。

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

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


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

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

并发的可达性分析(与用户线程)

解决了根节点枚举、安全点、记忆集、写屏障之后,终于可以进行可达性分析了。

在最初的垃圾收集器中(如Serial、ParNew收集器),在进行可达性分析的时候都需要暂停用户线程,直到CMS收集器的出现,才终于实现了用户线程与垃圾收集线程共同工作的场景。

小故事:在最初始版本的垃圾收集器中,JVM没执行一段时间都需要Stop The World(时停)进行垃圾收集工作,那时候Java是十分“慢”的,而JVM的开发者们也很委屈:你总不能在你妈妈收拾房间的时候还要乱丢垃圾吧,这样什么时候能打扰完?在你妈妈收拾房间的时候你就应该乖乖坐在沙发上。

并发可达性分析 存在的问题

为了让垃圾回收更快!!就必须要让垃圾回收线程与用户线程同时执行!!但若是用户线程与垃圾收集线程同步进行,那么就可能造成已经扫描过的确定被引用的对象不再被引用了,或者已经扫描过的确定不再被引用的对象又被引用了;这两种情况。这就是我们本章节要弄懂的问题。

为了更好说明对象的引用关系,这里采用三色标记法进行说明:

  1. 黑色:已经被标记是被引用的对象
  2. 白色:未扫描到的对象
  3. 灰色:已经确认被引用,但是还没有扫描它的引用关系的对象

整个图就像是一个波纹,黑色在波纹内,白色在波纹外,灰色是波峰…

在这里插入图片描述

上图前三张图片说明了用户线程在静止状态下的扫描过程;后两张图说明了用户线程与垃圾回收线程同时工作时可能会造成的情况。


注意,如果垃圾回收线程与用户线程并行操作,那么会出现两种情况:

  1. 已经被标记为黑色(被引用)的对象突然不再引用了(浮动垃圾)
  2. 已经被标记为白色(未被引用)的对象突然被引用了(危险!!)

如果是情况1的话,这些对象不被清理问题不大,顶多是占用一些内存,产生一些“浮动垃圾”,但如果是情况2对象小时,那么就很危险了,原本存在引用关系的对象被清除了,那么会造成致命错误,可能会导致程序崩溃。

解决方案

为了解决这一问题,1994年Wilson在理论上证明了,当满足两个条件的时候,才会产生对象消失问题:

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

这两句话理解起来十分拗口,也就是说,新插入了一个由黑色对象对白色对象的引用关系,且该白色对象(原本就)无法被扫描到(原本就不可达或删除了其可达关系)。

只需要破坏上面的任意一个条件,就不会造成“消失对象”问题。

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

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


原文地址:https://blog.csdn.net/Hsk_03/article/details/140853108

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!