《深入Java虚拟机》之GC Roots及拓展

这篇文章本来是想写在《对象内存布局及使用》篇的,但是无意间发现了R大的《找出栈、堆上的引用》,决定对其进行拓展。单独写了一篇笔记。


根据可达性分析算法,GC Roots节点是查找引用链的关键,所以需要知道每个GC Roots在什么地方(一个个枚举得话很消耗时间)。而且回收时得知道某个时刻,所有的GC Roots及引用链。换句话说在某个时刻,大家都应该停下来等待统计引用(好比扫地,一边扫一边丢垃圾,最终是扫不干净得)。

简而言之,现在的问题是:

  • JVM如何知道哪些位置有引用类型的变量?
  • 如何让大家都停下来,拍个快照,获取某一个瞬间的引用链?

Q1. JVM如何知道哪些位置有引用类型的变量?

这要由 虚拟机是否记录类型信息 而决定的,而且该决定会影响GC的实现方式和引用方式(这也是为什么存在句柄引用和直接引用得原因)。通常按准确程度划分为以下三种:

  • 保守式
  • 半保守式
  • 准确式

保守式

当无法区分内存上某个位置的数据究竟是引用类型还是整数类型时(或其他类型),我们称它为保守式GC。当发生GC时,JVM从一些已知的位置开始扫描内存(比如栈,如果是堆就查找对象里面是否有引用类型的属性)并递归深入,如果满足下面的条件就认为它是一个引用类型:

  • 四字节对齐
  • 里面的数值在堆范围内(比如数值为100,堆的范围为0-1000,那么就认为是在堆里面)
  • The allocbit is set. The allocbit is a flag indicating whether the memory location corresponding to it is allocated or not.

缺点:

  • 部分应该死的对象没死;当内存需求量大时,这些本应该释放掉的内存没被释放,总归不是一件好事
  • 由于无法确切的知道一个变量究竟是基本类型还是引用类型,所以对象发生移动时,无法确切的得知哪些变量应该被修改。简单来说,保守式GC里的对象无法被移动;若是想要移动对象,必须增加一个中间层,即 句柄。所有指针都先指向句柄池,再从句柄里找到对象。这样要移动对象就只需要修改句柄表的内容即可。

半保守式

由于JVM要支持丰富的反射功能,本来对象就会带上一堆元数据,所以自己所有属性的类型信息都可以一目了然。
所以在扫描堆时,JVM可以直接根据对象带有的信息判断 该对象内什么位置是什么类型的变量
而在扫描栈时,仍然需要一个个扫描,且无法判断某个位置上具体是什么变量

改进:

  • 由于半保守式在堆内部的数据类型是准确可知的,所以大部分情况下可以采用 直接引用,也就可以支持部分对象的移动(对在栈上扫描出来的对象进行标记,表示 “不可移动对象”,而对象属性里的引用仍可以移动)

准确式

和保守式相反——能区分内存(包括栈和堆)上任意位置的数据类型。HotSpot里采用 从外部记录类型信息,存成映射表,在HotSpot里该映射表被叫做 OopMap。要实现这项功能,需要虚拟机里的解释器和JIT编译器共同的支持,由它们来生成足够的元数据信息。

使用这样的映射表一般有两种方式:
1、每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
2、为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。

对于堆里的对象,OopMap里记录了该类型的对象内什么偏移量上是什么类型的数据。这些数据是在类的加载过程中计算出来的。

对于栈来说就会比较麻烦。每个被JIT编译后的方法都会在一些 特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。特定位置主要在:

  • 循环的末尾
  • 方法临返回前/调用方法的call指令后
  • 可能抛出异常的位置

这些位置被成为 安全点。由于每个位置都记录 OopMap 很浪费空间,所以选择几个关键的位置就能有效缩减需要记录的数据量,但仍然能达到区分数据类型的作用。安全点数量既不能太多以至于增大运行时得负荷;也不能太少以至于GC间隔很长。

在解释器中执行的方法则可以通过解释器自动生成出 OopMap出来给GC用。

注意,这里分成了两部分,一部分是解释器的操作,一部分是JIT编译的操作。
两个什么区别可以看这篇文章

Q2. 如何让大家都停下来,拍个快照,获取某一个瞬间的引用链?

这个问题也可以理解为 GC如何来获取OopMap信息

停下来得方式分为两种:

  • 抢先式中断,发生GC时,让所有线程暂停,不在安全点上得线程恢复运行,直到运行到安全点。
  • 主动式中断,发生GC时,给所有线程一个标记,每个线程运行时不断去轮询该标记,当发现标记为真时,运行到安全点并暂停本线程

然而安全点还不够,因为如果线程没有分配到CPU时间,那么根本不会运行到安全点,典型得例子就是 Sleeping 或 Blocking 状态得线程,它们无法走到安全点上挂起。所有得线程也不可能已知等待这条线程走到安全点上,所以推出了一个 安全区域 的点。

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

在线程进入 安全区域时,会给自己打上一个标记。那样在这段时间里即使JVM发生GC,就不用管在 安全区域的线程了;当线程要离开 安全区域时,判断一下JVM是否正在GC,如果是,就等待直到收到 可以安全离开安全区域的信号为止;反之,线程继续执行。