jvm学习-垃圾回收的一些知识点

部分图片和描述来自参考资料 ,非原创

对象回收处理过程

img

如何标定对象是否存活

两种方法 :

  • 引用计数方法
  • 可达性分析算法

引用计数方法

就和 ReentrantLock 可重入锁一样 ,内部维系着一个 state , 当同个线程重入结束后就会归零 , 但是这种方法有点问题

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

两个对象互相引用使得引用计数大于0 , 但是又没有给使用到.

可达性分析算法

就是先确定一个 GC-Root , 然后往下搜索, 途径的对象就进行染色 ,没有被染色的肯定就是应该回收的对象.

img

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

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

img

实现细节知识点

OopMap

OopMap 只存储与 GC Root 相关的引用信息,而不会存储所有的引用信息。
定义 : 一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译(见第11章)过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。

动机 : 避免了每个内存角落寻找 GC-Root

img

img

oopMap的全称是Object-oriented Programming Map,它是Java虚拟机中的一种数据结构,用于记录Java对象中的字段信息和类型信息。(通俗地说就是 内存哪个位置的对象引用到了 GC-Root) , 那么如果每次创建对象的时候就判断一下是不是引用到了 GC-Root , 如果是, 那么就加入到 oopMap 中去 ,这样的话 ,效率就太慢了, hotspot 是集中在一个时间点才更新 oopmap , 这个时间点就是安全点

安全点 safepoint

每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。这些特定的位置主要在:

**1、循环的末尾 **
**2、方法临返回前 / 调用方法的call指令后 **
3、可能抛异常的位置

这种位置被称为“安全点”(safepoint)。之所以要选择一些特定的位置来记录OopMap,是因为如果对每条指令(的位置)都记录OopMap的话,这些记录就会比较大,那么空间开销会显得不值得。选用一些比较关键的点来记录就能有效的缩小需要记录的数据量,但仍然能达到区分引用的目的。

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的 (原因至今不知道)

img

假如此时JVM 在 t1 时间点 , 进行 GC , 那么 Thread1 ``Thread2 ``Thread3 此时并没有到安全点上, JVM 处理这样的问题会有两种方式

  • 主动式中断
  • 抢先式中断

如果是主动式中断 :

在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上

如果是抢先式中断 :

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

毫无疑问, 后者更加消耗性能 , 但是好处就是不会打断线程 .

安全区域

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

动机 : 安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集
过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。

线程在安全区域的工作过程是这样子的 :

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

老生代引用新生代

下面的记忆集卡表 , 写屏障这些都是为了解决 "老生代引用新生代" 这个主题 ,记住这一点就好理解了

记忆集与卡表

记忆卡的动机 ,可以参考下面这张图 :

img

假如我们没有记忆卡 , 那么年轻代的偏红色的元素是否要回收呢 ?

为了解决跨代引用的问题,JVM 中引入了 Remembered Set。Remembered Set 是一个记录了跨代引用的数据结构,用于记录新生代对象引用的老年代对象的地址。在进行 Minor GC 时,JVM 会扫描 Remembered Set 中记录的地址,将相关的老年代对象标记为存活对象,从而避免因跨代引用而导致的对象丢失问题。

那么卡表是什么 ?

前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。关于卡表与记忆集的关系,读者不妨按照Java语言中HashMap与Map的关系来类比理解。

也就是说一个是思想 ,一个是具体的实现罢了 . 卡表的每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

Remembered Set 只是一种抽象的数据机构,根据不同的记录粒度,有不同的具体实现。

  • 字长精度:每个记录精确到一个机器字长
    (处理器的寻址位数,如32位、64位)

  • 对象精度:每个记录精确到一个对象

  • 卡精度:每个记录精确到一块内存区域 (卡表)

在 HotSpot 中,默认的卡表标记逻辑如下:

CARD_TABLE [this adress >> 9] = 0

这意味着,卡页的大小是2的9次幂,512字节。

以新生代的 Card Table 为例,Card Table 的每一个元素用来 标记 老年代的某一块内存区域(Card Page)的所有对象是否引用了新生代对象。

只要存在一个对象引用了新生代对象,那么将对应 Card Table 的数组元素的值标记为 0,说明这个元素变脏(Dirty)。

以图1为例,新生代的Card Table 和 Card Page 如下图所示:

img

那么,在 新生代GC 的时候,不需要全量扫描老年代的内存空间,只需要筛选出 Card Table 中标记为 0 的元素,扫描老年代指定范围的内存块。

例如,图2 中的 Page1 和 Page2。

和页表有点像

写屏障

我们上面讲了利用卡表来记录对应的老生代内存中的对象引用年轻代对象的情况 , 那么什么时候标记呢 ? 怎么标记呢?

什么时候标记 ?
肯定是在有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻.

那么如何标记呢 ?
jvm 对引用赋值操作的时候 ,进行 AOP (切面编程) , 称之为 "写屏障"

这里的写屏障和 volatile 中的不同 ,不是同一个东西, 这里的写屏障特指 JVM 为实现更新卡表而进行的 AOP

并发标记的实现过程

并发标记的内容实际就是 CMS 收集器上运用的技术 .
首先要弄明白 ,并发标记的动机是什么? 同步标记不行吗 ? 问题和难点又在哪里

前面讲的gc-root 那些标记 , 以前的垃圾回收器都是会 stop the world , 例如下面的垃圾回收器 :

img

都是把用户线程按下暂停键的, 我们可以看到以前的垃圾回收器都是同步标记 ,这是假如不是同步标记 ,那么有可能在标记的时候引用关系发生了变化, 那么就有可能发生错误, 就像我们事务一样, 得保证独立事务数据才不受影响, 但是同步标记肯定是没有并发标记的效率高 (一个不恰当的比喻就是 : 打扫房间时 ,那么此时的另外一个人就不能再产生垃圾了, 手头的工作得停下 ; 另外一种情况就是打扫房间时 , 还可以边产生垃圾 ,边工作,那么效率肯定高点)

三色标记法

哪三色

  • 白色:尚未访问过。
  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。
    工作过程 :

img

img

并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标漏标的情况就有可能发生。

多标-浮动垃圾

img

假设已经遍历到E(变为灰色了),此时应用执行了

D.E = null :

D > E 的引用断开
此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存。
这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

即是多标导致浮动垃圾 ,虽然会占用内存, 但也不会出现程序意外.

漏标-程序异常

假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:

var G = objE.fieldG; 
objE.fieldG = null;  // 灰色E 断开引用 白色G 
objD.fieldG = G;  // 黑色D 引用 白色G

img

步骤一 : E -> G 断开
步骤二 : D引用到 G

即是跳过了扫描 ,而颜色没有变黑

此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。

漏标必须要同时满足以下两个条件:

  • 赋值器插入了一条或者多条从黑色对象到白色对象的新引用; (对应步骤二)

  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。 (对应步骤一)
    这两个条件必须全部满足,才会出现对象消失的问题。那么我们只需要对上面条件进行破坏,破坏其中的任意一个,都可以防止对象消失问题的产生。这样就产生了两种解决方案:

  • 增量更新:Incremental Update。 (记录下新增的引用关系)

  • 原始快照:Snapshot At The Beginning,SATB。 (记录下删除前的引用关系)

增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用时,就将这个新加入的引用记录下来,待并发标记完成后,重新对这种新增的引用记录进行扫描;

原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,也是将这个记录下来,并发标记完成后,对该记录进行重新扫描。

增量更新与原始快照在 HotSpot 中都有实际应用,其中

  • 增量更新用在 CMS 中
  • 原始快照用在了 G1、Shenandoah 等回收器中。

解决漏标的情况-增量更新

还是上面的那个例子, 漏标的例子
img

解决方案 , 如果新引用的对象 newobj 没有被标记,那么就将其标记后堆到标记栈里。换句话说, 如果 newobj 是白色对象,就把它涂成灰色。这样操作后的结果如下图所示:

img

另外一种解决方法 :

img

解决漏标的情况-原始快照

之前灰色引用到到的白色节点 ,记录下来后变成灰色 , 然灰色节点继续染色.
img

下面章节来自参考资料 : https://www.cnblogs.com/hongdada/p/14578950.html

三色标记法与现代垃圾回收器

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新
  • G1:写屏障 + SATB(原始快照)
  • ZGC:读屏障
    工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

值得注意的是,CMS中使用的增量更新,在重新标记阶段,除了需要遍历 写屏障的记录,还需要重新扫描遍历GC Roots(当然标记过的无需再遍历了),这是由于CMS对于astore_x等指令不添加写屏障的原因,具体可参考这里

参考资料

posted @ 2023-07-10 16:30  float123  阅读(28)  评论(0编辑  收藏  举报