浅谈JVM中的OopMap

2,112 阅读6分钟

目录

•写在前面

•保守式GC

•准确式GC

•补充

•半保守式GC

•JNI方法


•写在前面

JVM在进行正式GC之前总是需要进行可达性分析来查找内存中所有存活对象,以便能够正确的回收已经死亡的对象,如果有了解JVM的GC机制的话(不了解可以看一下我的另一篇文章,JVM如何判断对象能否回收),我们就会知道,调用栈里的引用类型数据时GC的根集合的重要组成部分,找出栈上的引用是GC的根枚举中不可或缺的一环。而对于一个十分复杂的程序系统,每次进行GC的时候,都要遍历所有的引用肯定是不现实的,因为在可达性分析中,需要进行Stop The World,程序中的线程需要停止来配合可达性分析。所以,每次直接遍历整个引用链肯定是不现实的。 为了应对这种尴尬的问题,最早有保守式GC和后来的准确式GC。这里准确式GC就会提到一个OopMap,用来保存类型的映射表。

•保守式GC

在进行GC的时候,会从一些已知的位置(也就是GC Roots)开始扫描内存,扫描到一个数字就判断他是不是可能是指向GC堆中的一个指针(这里会涉及上下边界检查,GC堆的上下界是已知的、对齐检查,通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针)。然后一直递归的扫描下去,最后完成可达性分析。这种模糊的判断方法因为无法准确判断一个位置上是否是真的指向GC堆中的指针,所以被命名为保守式GC。这种可达性分析的方式因为不需要准确的判断出一个指针,所以效率快,但是也正因为这种特点,它存在下面两个明显的缺点:

  • 因为是模糊的检查,所以对于一些已经死掉的对象,很可能会被误认为仍有地方引用他们,GC也就自然不会回收他们,从而引起了无用的内存占用,造成资源浪费。
  • 由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。有一种办法可以在使用保守式GC的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层“句柄”(handle)在中间,所有引用先指到一个句柄表里,再从句柄表找到实际对象。这样,要移动对象的话,只要修改句柄表里的内容即可。但是这样的话引用的访问速度就降低了。

•准确式GC

与保守式GC相对的就是准确式GC,何为准确式GC?就是我们准确的知道,某个位置上面是否是指针,对于java来说,就是知道对于某个位置上的数据是什么类型的,这样就可以判断出所有的位置上的数据是不是指向GC堆的引用,包括栈和寄存器里的数据。而实现这种要求的方法有很多种,不过,在java中实现的方式是:从外部记录下类型信息,存成映射表,在HotSpot中把这种映射表称之为OopMap,不同的虚拟机名称可能不一样,简而言之,OopMap就是存着一系列信息的数据结构。实现这种功能,需要虚拟机的解释器和JIT编译器支持,由他们来生成OopMap。生成这样的映射表一般有两种方式:

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

总而言之,GC开始的时候,就通过OopMap这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中哪些位置是引用。

•补充

准确式GC中,记录并判断指针是否指向堆另外的几种实现方式

  • 让数据自身带上标记(tag)。这种做法在JVM里不常见,但在别的一些语言实现里有体现。打标记的方式在半保守式GC中倒是更常见一些,例如CRuby就是用打标记的半保守式GC。
  • 让编译器为每个方法生成特别的扫描代码。

•半保守式GC

JVM可以选择在栈上不记录类型信息,而在对象上记录类型信息。这样的话,扫描栈的时候仍然会跟上面说的过程一样,但扫描到GC堆内的对象时因为对象带有足够类型信息了,JVM就能够判断出在该对象内什么位置的数据是引用类型了。这种是“半保守式GC”,也称为“根上保守。

要说明一下的是,每个方法可能会有好几个oopMap,就是根据safepoint(安全点,见我另一篇文章)把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置,那这段代码的oopMap就会包含多条记录。

•JNI方法

对Java线程中的JNI方法,它们既不是由JVM里的解释器执行的,也不是由JVM的JIT编译器生成的,所以会缺少OopMap信息。那么GC碰到这样的栈帧该如何维持准确性呢? HotSpot的解决方法是:所有经过JNI调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄”(handle)包装起来。JNI需要调用Java API的时候也必须自己用句柄包装指针。在这种实现中,JNI方法里写的“jobject”实际上不是直接指向对象的指针,而是先指向一个句柄,通过句柄才能间接访问到对象。这样在扫描到JNI方法的时候就不需要扫描它的栈帧了——只要扫描句柄表就可以得到所有从JNI方法能访问到的GC堆里的对象。但这也就意味着调用JNI方法会有句柄的包装/拆包装的开销,是导致JNI方法的调用比较慢的原因之一。