HotSpot虚拟机上实现具体的垃圾收集算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。常见垃圾收集算法可以看这篇文章:Java中的常见JVM垃圾收集算法。主要有三种方法用以分析垃圾:枚举根节点、安全点、安全区域。
1 枚举根节点
在正式的GC之前,要进行可达性分析来标记出将来可能要宣告死亡的对象。
从可达性分析中从GC Roots节点找引用链这个操作为例,垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference 类型。如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。但问题是,栈上的本地变量表里面只有一部分数据是 Reference 类型的(它们是我们所需要的),那些非 Reference 类型的数据对我们而言毫无用处,但我们还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。如果每次GC的时候都要遍历所有的引用,这样的工作量是非常大的。
可达性分析工作还必须在一个能确保一致性的快照中进行——这里的“一致性”的意思是指整个分析期间整个系统执行系统看起来就行被冻结在某个时间点,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果的准确性就无法得到保证。这点是导致GC进行时必须暂停所有Java执行线程的其中一个重要原因,即“Stop The World”。
所以,每次直接遍历整个栈空间肯定是不现实的。为了应对这种尴尬的问题,最早有保守式GC和后来的准确式GC。目前主流的Java虚拟机都是准确式GC,当执行系统停顿下来之后,并不需要一个不漏的检查执行上下文和全局的引用位置,虚拟机应当有办法得知哪些地方存放的是对象的引用。在HotSpot的实现中,是使用一组OopMap的数据结构来达到这个目的(OOP(Ordinary Object Pointer)指的是普通对象指针)。
在类加载完成的时候,HotSpot就把对象什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。
1.1 保守式GC
在进行GC的时候,会从一些已知的位置(GC Roots)开始扫描内存,扫描到一个数字就判断他是不是可能是指向GC堆中的一个指针(这里会涉及上下边界检查(GC堆的上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针),之类的。)。然后一直递归的扫描下去,最后完成可达性分析。这种模糊的判断方法因为无法准确判断一个位置上是否是真的指向GC堆中的指针,所以被命名为保守式GC。这种可达性分析的方式因为不需要准确的判断出一个指针,所以效率快,但是也正因为这种特点,他存在下面两个明显的缺点:
因为是模糊的检查,所以对于一些已经死掉的对象,很可能会被误认为仍有地方引用他们,GC也就自然不会回收他们,从而引起了无用的内存占用,造成资源浪费。
由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。有一种办法可以在使用保守式GC的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层“句柄”(handle)在中间,所有引用先指到一个句柄表里,再从句柄表找到实际对象。这样,要移动对象的话,只要修改句柄表里的内容即可。但是这样的话引用的访问速度就降低了。Sun JDK的Classic VM用过这种全handle的设计,但效果实在算不上好。
1.2 准确式GC
与保守式GC相对的就是准确式GC,何为准确式GC?就是我们准确的知道,某个位置上面是否是指针,对于java来说,就是知道对于某个位置上的数据是什么类型的,比如当前内存位置中的数据究竟是一个整型变量还是一个引用类型。这样就可以更快速的判断出所有的位置上的数据是不是指向GC堆的引用,包括栈和寄存器里的数据,从而更有针对性的进行GC roots枚举。
在java中实现的方式是:从我外部记录下类型信息,存成映射表,在HotSpot中把这种映射表称之为OopMap(OOP(Ordinary Object Pointer)指的是普通对象指针),不同的虚拟机名称可能不一样,JRockit里叫做livemap,J9里叫做GC map。Apache Harmony的DRLVM也把它叫GCMap。
总而言之,GC停顿的时候,虚拟机可以通过OopMap这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录着栈和寄存器中哪些位置是引用。
关于对象访问定位,可以看这篇文章:Java对象的内存布局和访问定位详解。
1.3 OopMap
OopMap 记录了栈上本地变量到堆上对象的引用关系。是一种以空间换时间的方法。在某个时候把栈上代表引用的位置全部记录下来,这样到真正 GC 的时候就可以直接读取,而不用再一点一点的扫描了。事实上,大部分主流的虚拟机也正是这么做的,比如 HotSpot。
在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。
实现这种功能,还需要虚拟机的解释器和JIT编译器支持,由他们来生成OopMap。生成这样的映射表一般有两种方式:
每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”; HotSpot是用“解释式”的方式来使用OopMap的。
为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。
2 安全点(Sefepoint)
在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
实际上,HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,每个被JIT编译过后的方法只会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用,这些位置称为安全点(Safepoint)。
程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点选定太少,GC等待时间就太长,选的太多,GC就过于频繁。选定原则是”具有让程序长时间执行的特征“,也就是在这个时刻现有的指令是可以复用的。
安全点的位置主要在:
循环的末尾。
方法临返回前 / 调用方法的call指令后。
可能抛异常的位置。
2.1 中断方式
现在的问题是在Safe Point让线程们以怎样的机制中断,方案有两种:抢先式中断、主动式中断。
抢先式中断:GC发生时,中断所有线程,如果发现有线程不再安全点上,就恢复线程让它运行到安全点上。现在几乎不用这种方案。
主动式中断:设置一个标志,和安全点重合,再加上创建对象分配内存的地方。各个线程主动轮询这个标志,发现中断标志为真就挂起自己。HotSpot使用主动式中断。
2 安全区域(Safe Region)
Safepoint保证了程序执行时,在不长的时间里就会遇到可进入GC的安全点,但如果线程没有分配cpu时间,必须线程处于sleep或blocked状态,就无法响应JVM的中断请求,走到安全点去挂起。此时就需要安全区域(Safe Region)解决了这一问题。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。