一、根节点枚举
根据之前的博文,我们已经了解了可达性分析算法,那么这个算法具体是怎么在HotSpot中实现的呢?
首先,JVM需要标记直接与GC roots相连的对象,但这个过程必须要暂停其他用户进程,即stop the world(以下简称STW)。因为与GC roots直接相连的对象是可达性分析算法准确性的关键,关系着对象能否被正确地进行标记。
但用户线程停顿下来之后,并不需要检查完所有上下文和全局引用的位置来找到被GC roots直接引用的对象,这太费时间了。为了提高效率,JVM中会维护一个OopMap的数据结构来达到存储对象的。
二、安全点与安全区域
OopMap协助JVM记录了直接引用对象的存储地址,可以把这个地址理解为线程执行过程中的一个安全点,即线程没有执行到OopMap中对应的地址时,可以继续执行,当线程执行到对应的地址时,就中断以便JVM进行引用对象标记。
线程的中断有抢先式中断和主动式中断两种方案。前者的原理是:先把所有线程全部中断,若发现用户线程中断的地方不在安全点上,就恢复这条线程的执行,让它一会再重新中断。后者的思路是在需要中断的位置处(即安全点)设置一个中断标志符,线程在运行的过程中会轮询这个标志符,一旦发现标志符,便将线程挂起。
线程的中断采用自设陷阱的方法,即需要中断(即轮询到标志符)时,会将对应内存页中的数据置为不可读,此时一旦线程执行,就会因为无法获取相应的数据而抛出异常,预先注册的异常处理器中挂起线程实现等待,进而实现安全点的轮询和触发中断。
但事实上,安全点在实际应用中依然存在很多问题,就是某些线程处于sleep或blocked状态时,无法相应虚拟机的中断请求,无法实现走到安全点挂起线程。虚拟机也不可能等待线程被激活再给他分配处理器时间。此时就需要引入一个安全区域来解决。
线程在进入安全区的入口时,会首先声明自己已经进入了安全区,当它要离开安全区域时,它需要检查是否已经完成了根节点枚举,若完成,则继续执行,若没有完成,则一直等待,直到收到可以离开安全区域的信号为止。
三、记忆集
记忆集主要是解决JVM中的跨代引用问题,即老年代引用了新生代中的对象。如果出现这种情况,需要扫描老年代中的对象,以确定所引用的新生代对象是否需要被回收。但老年代多为大对象,且对象存活率较高,若遍历整个老年代,则效率太低。因此,JVM中在新生代中维护了一个记忆集,用于记录新生代中的对象被哪些老年代所引用。
记忆集中放置特定代表内存块大小的卡页(通常一个卡页代表512字节的内存块大小),只要卡页中有一个或多个字段存在着跨代指针,那就将这个卡页的元素标记为1,表示该卡页对应的内存地址范围内存在跨代引用。
卡表元素是通过写屏障来维护的,即在对象的每一个写操作后,会紧跟一条由写屏障生成的指令,将卡表的元素根据引用的情况来进行赋值。
四、并发的可达性分析
在并发情况下,可达性分析面临着线程安全问题,为了解释这个问题,我们引入了三色标记
白色:表示该对象尚未被垃圾收集器访问过。若在垃圾回收结束阶段,对象仍然为白色,则代表不可达。
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经被扫描过了。
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
并发的可达性分析中,最严重的问题就是可能会把原本存活的对象错误标记为死亡,引发程序错误。
比如,在遍历的过程中,某个灰色对象的引用改变了引用关系,与黑色对象建立了引用关系。
又或者在遍历的过程中某个黑色对象与白色对象建立了引用关系。
由于黑色对象时不会再次遍历的,这将导致这些这些本该存活的对象,被垃圾回收线程回收。
造成这个问题的原因可以总结为以下两点:
1.赋值器插入了一条或多条从黑色对象到白色对象的新引用;
2.赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
只要破坏这两个条件中的任意一个便可以解决线程安全问题。
解决方案有以下两种:
1.增量更新:当黑色对象要插入新的指向白色对象的引用关系时,就要将这个删除的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
2.原始快照:当灰色对象要删除白色对象的引用关系时,就将这个要删除的引用记录下来,等并发结束后,再将这些记录过的对象重新标记为根,重新扫描一次。