深度揭秘垃圾回收底层,这次让你彻底弄懂她(中)

简介: 深度揭秘垃圾回收底层,这次让你彻底弄懂她(中)

半保守式GC

半保守式GC,在对象上会记录类型信息而其他地方还是没有记录,因此从根扫描的话还是一样,得靠猜测。

但是得到堆内对象了之后,就能准确知晓对象所包含的信息了,因此之后 tracing 都是准确的,所以称为半保守式 GC。

现在可以得知半保守式 GC 只有根直接扫描的对象无法移动,从直接对象再追溯出去的对象可以移动,所以半保守式 GC 可以使用移动部分对象的算法,也可以使用标记-清除这种不移动对象的算法。

而保守式 GC 只能使用标记-清除算法。


准确式 GC

相信大家看下来已经知道准确意味 JVM 需要清晰的知晓对象的类型,包括在栈上的引用也能得知类型等。

能想到的可以在指针上打标记,来表明类型,或者在外部记录类型信息形成一张映射表。

HotSpot 用的就是映射表,这个表叫 OopMap。

在 HotSpot 中,对象的类型信息里会记录自己的 OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据,而在解释器中执行的方法可以通过解释器里的功能自动生成出 OopMap 出来给 GC 用。

被 JIT 编译过的方法,也会在特定的位置生成 OopMap,记录了执行到该方法的某条指令时栈上和寄存器里哪些位置是引用。

这些特定的位置主要在:

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

这些位置就叫作安全点(safepoint)

那为什么要选择这些位置插入呢?因为如果对每条指令都记录一个 OopMap 的话空间开销就过大了,因此就选择这些个关键位置来记录即可。

所以在 HotSpot 中 GC 不是在任何位置都能进入的,只能在安全点进入。

至此我们知晓了可以在类加载时计算得到对象类型中的 OopMap,解释器生成的 OopMap 和 JIT 生成的 OopMap ,所以 GC 的时候已经有充足的条件来准确判断对象类型。

因此称为准确式 GC。

其实还有个 JNI 调用,它们既不在解释器执行,也不会经过 JIT 编译生成,所以会缺少 OopMap。

在 HotSpot 是通过句柄包装来解决准确性问题的,像 JNI 的入参和返回值引用都通过句柄包装起来,也就是通过句柄再访问真正的对象。

这样在 GC 的时候就不用扫描 JNI 的栈帧,直接扫描句柄表就知道 JNI 引用了 GC 堆中哪些对象了。


安全点


我们已经提到了安全点,安全点当然不是只给记录 OopMap 用的,因为 GC 需要一个一致性快照,所以应用线程需要暂停,而暂停点的选择就是安全点。

我们来捋一遍思路。首先给个 GC 名词,在垃圾收集场景下将应用程序称为 mutator 。

一个能被 mutator 访问的对象就是活着的,也就是说 mutator 的上下文包含了可以访问存活对象的数据。

这个上下文其实指的就是栈、寄存器等上面的数据,对于 GC 而言它只关心栈上、寄存器等哪个位置是引用,因为它只需要关注引用。

但是上下文在 mutator 运行过程中是一直在变化的,所以 GC 需要获取一个一致性上下文快照来枚举所有的根对象。

而快照的获取需要停止 mutator 所有线程,不然就得不到一致的数据,导致一些活着对象丢失,这里说的一致性其实就像事务的一致性。

而 mutator 所有线程中这些有机会成为暂停位置的点就叫 safepoint 即安全点。

openjdk 官网对安全点的定义是:

A point during program execution at which all GC roots are known and all heap object contents are consistent. From a global point of view, all threads must block at a safepoint before the GC can run.

不过 safepoint 不仅仅只有 GC 有用,比如 deoptimization、Class redefinition 都有,只是 GC safepoint 比较知名。

我们再来想一下可以在哪些位置放置这个安全点。

对于解释器来说其实每个字节码边界都可以成为一个安全点,对于 JIT 编译的代码也能在很多位置插入安全点,但是实现上只会在一些特定的位置插入安全点。

因为安全点是需要 check 的,而 check 需要开销,如果安全点过多那么开销就大了,等于每执行几步就需要检查一下是否需要进入安全点。

其次也就是我们上面提到的会记录 OopMap ,所以有额外的空间开销。

那 mutator 是如何得知此时需要在安全点暂停呢?

其实上面已经提到了是 check,再具体一些还分解释执行和编译执行时不同的 check。

在解释执行的时候的 check 就是在安全点 polling 一个标志位,如果此时要进入 GC 就会设置这个标志位。

而编译执行是 polling page 不可读,在需要进入 safepoint 时就把这个内存页设为不可访问,然后编译代码访问就会发生异常,然后捕获这个异常挂起即暂停。

这里可能会有同学问,那此时阻塞住的线程咋办?它到不了安全点啊,总不能等着它吧?

这里就要引入安全区域的概念,在这种引用关系不会发生变化的代码段中的区域称为安全区域。

在这个区域内的任意地方开始 GC 都是安全的,这些执行到安全区域的线程也会标识自己进入了安全区域,

所以会 GC 就不用等着了,并且这些线程如果要出安全区域的时候也会查看此时是否在 GC ,如果在就阻塞等着,如果 GC 结束了那就继续执行。

可能有些同学对counted 循环有点疑问,像for (int i...) 这种就是 counted 循环,这里不会埋安全点。

所以说假设你有一个 counted loop 然后里面做了一些很慢的操作,所以很有可能其他线程都进入安全点阻塞就等这个 loop 的线程完毕,这就卡顿了。


分代收集


前面我们提到标记-清除方式的 GC 其实就是攒着垃圾收,这样集中式回收会给应用的正常运行带来影响,所以就采取了分代收集的思想。

因为研究发现有些对象基本上不会消亡,存在的时间很长,而有些对象出来没多久就会被咔嚓了。这其实就是弱分代假说和强分代假说。

所以将堆分为新生代和老年代,这样对不同的区域可以根据不同的回收策略来处理,提升回收效率。

image.png

比如新生代的对象有朝生夕死的特性,因此垃圾收集的回报率很高,需要追溯标记的存活对象也很少,因此收集的也快,可以将垃圾收集安排地频繁一些。

新生代每次垃圾收集存活的对象很少的话,如果用标记-清除算法每次需要清除的对象很多,因此可以采用标记-复制算法,每次将存活的对象复制到一个区域,剩下了直接全部清除即可。

但是朴素的标记-复制算法是将堆对半分,但是这样内存利用率太低了,只有 50%。

所以 HotSpot 虚拟机分了一个 Eden 区和两个Survivor,默认大小比例是8∶1:1,这样利用率有 90%。

每次回收就将存活的对象拷贝至一个 Survivor 区,然后清空其他区域即可,如果 Survivor 区放不下就放到 老年代去,这就是分配担保机制。


image.png


而老年代的对象基本上都不是垃圾,所以追溯标记的时间比较长,收集的回报率也比较低,所以收集频率安排的低一些。

这个区域由于每次清除的对象很少,因此可以用标记-清除算法,但是单单清除不移动对象的话会有很多内存碎片的产生,所以还有一种叫标记-整理的算法,等于每次清除了之后需要将内存规整规整,需要移动对象,比较耗时。

所以可以利用标记-清除和标记-整理两者结合起来收集老年代,比如平日都用标记-清除,当察觉内存碎片实在太多了就用标记-整理来配合使用。

可能还有很多同学对的标记-清除,标记-整理,标记-复制算法不太清晰,没事,咱们来盘一下。


标记-清除


分为两个阶段:

标记阶段:tracing 阶段,从根(栈、寄存器、全局变量等)开始遍历对象图,标记所遇到的每个对象。

清除阶段:扫描堆中的对象,将为标记的对象作为垃圾回收。

基本上就是下图所示这个过程:


image.png


清除不会移动和整理内存空间,一般都是通过空闲链表(双向链表)来标记哪一块内存空闲可用,因此会导致一个情况:空间碎片

这会使得明明总的内存是够的,但是申请内存就是不足。

image.png

而且在申请内存的时候也有点麻烦,需要遍历链表查找合适的内存块,会比较耗时。

所以会有多个空闲链表的实现,也就是根据内存分块大小组成不同的链表,比如分为大分块链表和小分块链表,这样根据申请的内存分块大小遍历不同的链表,加快申请的效率。

image.png


相关文章
|
29天前
|
存储 算法 Java
精华推荐 | 【JVM深层系列】「GC底层调优专题」一文带你彻底加强夯实底层原理之GC垃圾回收技术的分析指南(GC原理透析)
精华推荐 | 【JVM深层系列】「GC底层调优专题」一文带你彻底加强夯实底层原理之GC垃圾回收技术的分析指南(GC原理透析)
55 0
|
29天前
|
存储 Java 索引
《深入浅出Java虚拟机 — JVM原理与实战》带你攻克技术盲区,夯实底层基础 —— 吃透class字节码文件技术基底和实现原理(核心结构剖析)
《深入浅出Java虚拟机 — JVM原理与实战》带你攻克技术盲区,夯实底层基础 —— 吃透class字节码文件技术基底和实现原理(核心结构剖析)
32 0
|
1月前
|
开发框架 算法 Java
C垃圾回收:原理与代码实践揭秘
C垃圾回收:原理与代码实践揭秘
9 0
|
9月前
|
存储 算法 安全
15-大厂面试题-JVM垃圾回收采用的是什么算法,有什么区别和优劣?
通过之前的学习,我们知道了JVM会通过**可达性算法**来筛选出哪些对象是可回收的,哪些对象是不可回收的,GCRoots对象是哪些,java的引用类型有哪些以及finlize()方法的作用。同时我们也知道了当一个对象在创建的时候是存放在堆内存中的新生代里的,那么当新生代内存满了后就会触发Minor GC;但是问题是我们如何针对新生代内存进行管理,以及如何进行回收这也是一个值得分析和探讨的问题。
68 0
|
缓存 算法 Java
底层原理:垃圾回收算法是如何设计的?
理解Java虚拟机垃圾回收机制的底层原理,是成为一个高级Java开发者的基本功。本文从底层的垃圾回收算法开始,着重去阐释不同垃圾回收器在算法设计和实现时的一些技术细节,去探索「why」这一部分,通过对比不同的垃圾回收算法和其实现,进一步感知目前垃圾回收的发展脉络。
13287 2
底层原理:垃圾回收算法是如何设计的?
|
机器学习/深度学习 存储 算法
关于“堆”,看看这篇文章就够了(附堆的两种应用场景)
堆(heap)是计算机科学中一类特殊的数据结构的统称,堆通常是一个可以被看做一棵树的数组对象,因此堆常常是通过数组的形式来实现的,不过堆在实现时必须遵守两个原则
120 0
关于“堆”,看看这篇文章就够了(附堆的两种应用场景)
|
存储 安全 Java
【新】虚拟机深层系「GC本质底层机制」SafePoint 的深入分析和底层原理探究指南
【新】虚拟机深层系「GC本质底层机制」SafePoint 的深入分析和底层原理探究指南
140 0
|
存储 缓存 Java
JVM内存结构详细总结(结合自身使用经历介绍各个知识点使用场景)
如图JVM运行时数据区域划分为以下6个主要部分:①程序计数器,②虚拟机栈,③本地方法栈,④虚拟机堆,⑤方法区,⑥直接内存,下面对6个部分详细总结,希望可以对路过的朋友有所帮助。
102 0
JVM内存结构详细总结(结合自身使用经历介绍各个知识点使用场景)
|
存储 算法 Java
JVM垃圾清理机制详解 ✨ 每日积累
JVM垃圾清理机制详解 ✨ 每日积累
JVM垃圾清理机制详解 ✨ 每日积累
|
存储 监控 算法
全网最硬核 JVM TLAB 分析 1. 内存分配思想引入
全网最硬核 JVM TLAB 分析 1. 内存分配思想引入
全网最硬核 JVM TLAB 分析 1. 内存分配思想引入