JVM重要概念

原文地址 https://blog.csdn.net/g6u8w7p06dco99fq3/article/details/109554559

一 JVM内存结构

  Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。

  

二 如果判断对象是垃圾

  可达性算法

三 什么是跟对象

  标记-清除具体的做法是定期或者内存不足时进行垃圾回收,从根引用(GC Roots)开始遍历扫描,将所有扫描到的对象标记为可达,然后将所有不可达的对象回收了。

  所谓的根引用包括

  1 线程栈,本地方法栈

  2 常量和静态变量

  3 JNI

  4 寄存器

四 OopMap

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简单理解成是调试信息。在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。oopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在JIT编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个oopMap,就是根据safepoint把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的oopMap就会包含多条记录。

安全点

  我们已经提到了安全点,安全点当然不是只给记录 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 的线程完毕,这就卡顿了。

  总结就是,安全点是JVM在字节码中埋入的一些点,至于在哪里埋入有一套规则,但是决定权在jvm。当JVM决定现在要暂停所有线程,就会设置polling page不可读,当Java thread发现该内存页不可读时,最终会被阻塞挂起。

跨代引用

  我们已经根据对象存活的特性进行了分代,提高了垃圾收集的效率,但是像在回收新生代的时候,有可能有老年代的对象引用了新生代对象,所以老年代也需要作为根,但是如果扫描整个老年代的话效率就又降低了。

所以就搞了个叫记忆集(Remembered Set)的东西,来记录跨代之间的引用而避免扫描整体非收集区域。

因此记忆集就是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。根据记录的精度分为

  • 字长精度,每条记录精确到机器字长。

  • 对象精度,每条记录精确到对象。

  • 卡精度,每条记录精确到一块内存区域。

最常见的是用卡精度来实现记忆集,称之为卡表。

我来解释下什么叫卡。

拿对象精度来距离,假设新生代对象 A 被老年代对象 D 引用了,那么就需要记录老年代 D 所在的地址引用了新生代对象。

那卡的意思就是将内存空间分成很多卡片。假设新生代对象 A 被老年代 D 引用了,那么就需要记录老年代 D 所在的那一块内存片有引用新生代对象。

  

  也就是说堆被卡切割了,假设卡的大小是 2,堆是 20,那么堆一共可以划分成 10 个卡。

  因为卡的范围大,如果此时 D 旁边在同一个卡内的对象也有引用新生代对象的话,那么就只需要一条记录。

  一般会用字节数组来实现卡表,卡的范围也是设为 2 的 N 次幂大小。来看一下图就很清晰了。

  

  假设地址从 0x0000 开始,那么字节数组的 0号元素代表 0x0000~0x01FF,1 号代表0x0200~0x03FF,依次类推即可。

  然后到时候回收新生代的时候,只需要扫描卡表,把标识为 1 的脏表所在内存块加入到 GC Roots 中扫描,这样就不需要扫描整个老年代了。

  用了卡表的话占用内存比较少,但是相对字长、对象来说精度不准,需要扫描一片。所以也是一种取舍,到底要多大的卡。

  而卡表一般都是通过写屏障来维护的,写屏障其实就相当于一个 AOP,在对象引用字段赋值的时候加入更新卡表的代码。

  这其实很好理解,说白了就是当引用字段赋值的时候判断下当前对象是老年代对象,所引用对象是新生代对象,于是就在老年代对象所对应的卡表位置置为 1,表示脏,待会需要加入根扫描。

posted on 2020-11-14 22:46  MaXianZhe  阅读(206)  评论(0)    收藏  举报

导航