调用栈里的引用类型数据是GC的根集合(root set)的重要组成部分;找出栈上的引用是GC的根枚举(root enumeration)中不可或缺的一环。
JVM选择用什么方式会影响到GC的实现:
如果JVM选择不记录任何这种类型的数据,那么它就无法区分内存里某个位置上的数据到底应该解读为引用类型还是整型还是别的什么。这种条件下,实现出来的GC就会是“保守式GC(conservative GC)”。在进行GC的时候,JVM开始从一些已知位置(例如说JVM栈)开始扫描内存,扫描的时候每看到一个数字就看看它“像不像是一个指向GC堆中的指针”。这里会涉及上下边界检查(GC堆的上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针),之类的。然后递归的这么扫描出去。
保守式GC的好处是相对来说实现简单些,而且可以方便的用在对GC没有特别支持的编程语言里提供自动内存管理功能。Boehm-Demers-Weiser GC是保守式GC中的典型代表,可以嵌入到C或C++等语言写的程序中。
小历史故事:
微软的JScript和早期版VBScript也是用保守式GC的;微软的JVM也是。VBScript后来改回用引用计数了。而微软JVM的后代,也就是.NET里的CLR,则改用了完全准确式GC。
为了赶上在一个会议上发布消息,微软最初的JVM原型只有一个月左右的时间从开工到达到符合Java标准。所以只好先用简单的办法来实现,也就自然选用了保守式GC。
信息来源:Patrick Dussud在Channel 9的访谈,23分钟左右
保守式GC的缺点有:
1、会有部分对象本来应该已经死了,但有疑似指针指向它们,使它们逃过GC的收集。这对程序语义来说是安全的,因为所有应该活着的对象都会是活的;但对内存占用量来说就不是件好事,总会有一些已经不需要的数据还占用着GC堆空间。具体实现可以通过一些调节来让这种无用对象的比例少一些,可以缓解(但不能根治)内存占用量大的问题。
2、由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。有一种办法可以在使用保守式GC的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层“句柄”(handle)在中间,所有引用先指到一个句柄表里,再从句柄表找到实际对象。这样,要移动对象的话,只要修改句柄表里的内容即可。但是这样的话引用的访问速度就降低了。Sun JDK的Classic VM用过这种全handle的设计,但效果实在算不上好。
由于JVM要支持丰富的反射功能,本来就需要让对象能了解自身的结构,而这种信息GC也可以利用上,所以很少有JVM会用完全保守式的GC。除非真的是特别懒…
JVM可以选择在栈上不记录类型信息,而在对象上记录类型信息。这样的话,扫描栈的时候仍然会跟上面说的过程一样,但扫描到GC堆内的对象时因为对象带有足够类型信息了,JVM就能够判断出在该对象内什么位置的数据是引用类型了。这种是“半保守式GC”,也称为“根上保守(conservative with respect to the roots)”。
为了支持半保守式GC,运行时需要在对象上带有足够的元数据。如果是JVM的话,这些数据可能在类加载器或者对象模型的模块里计算得到,但不需要JIT编译器的特别支持。
前面提到了Boehm GC,实际上它不但支持完全保守的方式,也可以支持半保守的方式。GCJ和Mono都是以半保守方式使用Boehm GC的例子。
Google Android的Dalvik VM的早期版本也是使用半保守式GC的一个例子。不过到2009年中的时候Dalvik VM的内部版本就已经开始支持准确式GC了——代价是优化过的DEX文件的体积膨胀了约9%。
其实许多较老的JVM都选择这种实现方式。
由于半保守式GC在堆内部的数据是准确的,所以它可以在直接使用指针来实现引用的条件下支持部分对象的移动,方法是只将保守扫描能直接扫到的对象设置为不可移动(pinned),而从它们出发再扫描到的对象就可以移动了。
完全保守的GC通常使用不移动对象的算法,例如mark-sweep。半保守方式的GC既可以使用mark-sweep,也可以使用移动部分对象的算法,例如Bartlett风格的mostly-copying GC。
半保守式GC对JNI方法调用的支持会比较容易:管它是不是JNI方法调用,是栈都扫过去…完事了。不需要对引用做任何额外的处理。当然代价跟完全保守式一样,会有“疑似指针”的问题。
与保守式GC相对的是“准确式GC”,原文可以是precise GC、exact GC、accurate GC或者type accurate GC。外国人也挺麻烦的,“准确”都统一不到一个词上⋯
是什么东西“准确”呢?关键就是“类型”,也就是说给定某个位置上的某块数据,要能知道它的准确类型是什么,这样才可以合理地解读数据的含义;GC所关心的含义就是“这块数据是不是指针”。
要实现这样的GC,JVM就要能够判断出所有位置上的数据是不是指向GC堆里的引用,包括活动记录(栈+寄存器)里的数据。
有几种办法:
1、让数据自身带上标记(tag)。这种做法在JVM里不常见,但在别的一些语言实现里有体现。就不详细介绍了。打标记的方式在半保守式GC中倒是更常见一些,例如CRuby就是用打标记的半保守式GC。CLDC-HI比较有趣,栈上对每个slot都配对一个字长的tag来说明它的类型,通过这种方式来减少stack map的开销;类似的实现在别的地方没怎么见过,大家一般都不这么取舍。
2、让编译器为每个方法生成特别的扫描代码。我还没见过JVM实现里这么做的,虽说在别的语言实现里有见过。
3、从外部记录下类型信息,存成映射表。现在三种主流的高性能JVM实现,HotSpot、JRockit和J9都是这样做的。其中,HotSpot把这样的数据结构叫做OopMap,JRockit里叫做livemap,J9里叫做GC map。Apache Harmony的DRLVM也把它叫GCMap。
要实现这种功能,需要虚拟机里的解释器和JIT编译器都有相应的支持,由它们来生成足够的元数据提供给GC。
使用这样的映射表一般有两种方式:
1、每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
2、为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。
在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。
可以把oopMap简单理解成是调试信息。 在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。oopMap就是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。 这个信息是在JIT编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个oopMap,就是根据safepoint把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的oopMap就会包含多条记录。
每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。这些特定的位置主要在:
1、循环的末尾
2、方法临返回前 / 调用方法的call指令后
3、可能抛异常的位置
这种位置被称为“安全点”(safepoint)。之所以要选择一些特定的位置来记录OopMap,是因为如果对每条指令(的位置)都记录OopMap的话,这些记录就会比较大,那么空间开销会显得不值得。选用一些比较关键的点来记录就能有效的缩小需要记录的数据量,但仍然能达到区分引用的目的。因为这样,HotSpot中GC不是在任意位置都可以进入,而只能在safepoint处进入。
而仍然在解释器中执行的方法则可以通过解释器里的功能自动生成出OopMap出来给GC用。
平时这些OopMap都是压缩了存在内存里的;在GC的时候才按需解压出来使用。
HotSpot是用“解释式”的方式来使用OopMap的,每次都循环变量里面的项来扫描对应的偏移量。
对Java线程中的JNI方法,它们既不是由JVM里的解释器执行的,也不是由JVM的JIT编译器生成的,所以会缺少OopMap信息。那么GC碰到这样的栈帧该如何维持准确性呢?
HotSpot的解决方法是:所有经过JNI调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄”(handle)包装起来。JNI需要调用Java API的时候也必须自己用句柄包装指针。在这种实现中,JNI方法里写的“jobject”实际上不是直接指向对象的指针,而是先指向一个句柄,通过句柄才能间接访问到对象。这样在扫描到JNI方法的时候就不需要扫描它的栈帧了——只要扫描句柄表就可以得到所有从JNI方法能访问到的GC堆里的对象。
但这也就意味着调用JNI方法会有句柄的包装/拆包装的开销,是导致JNI方法的调用比较慢的原因之一。