垃圾回收的根和虚拟机运行时紧密结合,理解起来并不容易。需要回答两个问题:哪些是垃圾回收的根?如何实现标记?
以JVM为例,JVM为了能执行Java代码,实现了一套完整的编译、解释、执行框架,其中编译是一个独立的模块,执行是另一个模块。而GC的根既与执行框架相关,又与编译相关,除此之外,GC的根还与语言特性和JVM的实现相关。
在JVM中存在两种类型的根: 强根 和 弱根 。强根是GC的真正根,用于识别堆空间中的活跃对象;弱根并非用于识别活跃对象,只是为了支持语言特性(如Java的引用)或者JVM内部实现的优化而引入的。
强根这个概念相对容易理解,这里使用线程栈来演示这个概念。假设JVM执行一段Java程序,如下所示:
int a = 2; Object obj1 = new Object(); Object c = new Object(); { MyObject d = new MyObject(); //假设MyObject已经定义,且MyObject中有一个成员变量f指向Object d. f = c; // 地点一 } // 地点二
现在来模拟一下JVM执行过程中内存的使用情况,在代码的地点一,内存布局如图2-14所示。
图2-14 地点一内存布局
其中图2-14中栈空间的使用通常在编译时就可以确定,堆空间通常是在运行时才能确定。每一个局部变量a、b、c、d在栈中都有一个槽位(slot)与之对应,这样在程序中才能访问到它们指向的对象或者数值。
这里稍微提示一下,代码d.f = c并不是将栈中c的值赋值给d.f,而是将c指向的堆地址赋值给d.f。
当代码执行到地点二时,内存布局如图2-15所示。
图2-15 地点二内存布局
此时因为变量作用域,变量d在栈中将无法访问(实际上该槽位被其他的变量使用),变量d因为已经死亡,其对应堆中的内存(图中灰色空间)也应该可以被回收重用。
基于栈变量可以找到堆空间中所有活跃的对象。当然,如果变量d在GC执行时死亡,在活跃对象的遍历过程中并不能知道变量d是否存在过,也无法知道变量d指向的内存空间。整个GC结束后只能得到所有活跃对象所占用的内存空间,所以追踪的GC算法都是管理活跃对象(将活跃对象赋值到新的空间,即复制算法,或者从整个空间中剔除活跃对象后,采用列表的方式管理自由空间),从而达到内存重用的目的。
当然实现层面可能还有更多细节需要考虑,例如在栈中一个槽位存放的值到底是指向堆空间的变量(即指针)还是一个立即数(在上述代码中变量a就是一个立即数),对于立即数对象,GC并不需要遍历(因为没有在堆空间中分配内存)。但是GC执行时并不知道槽位到底是一个地址还是一个立即数,如果做 不精确 的GC,可以把立即数也“当作”指针,只要立即数在堆空间的访问范围内,也会把对应的内存空间进行标记;如果做 精确 的GC,则必须区分立即数和指针,所以通常需要额外的信息来保存指针信息(例如使用额外的位图来描述栈空间的哪些槽位是指针),在GC执行时借助额外的信息就可以进行精确的回收。
经研究发现,通常不精确的GC和精确的GC相比,性能会有15%~40%的差距。
从栈变量作为根的例子可以看出,如果缺少某一个根,则必然会遗漏一些活跃对象,从而导致GC会访问非法内存。所以必须找到所有的强根并且逐一遍历,才能保证垃圾回收的正确性。
Java语言中的引用主要指软引用(soft reference)、弱引用(weak reference)和虚引用(phantom reference)。另外,Java中的Finalize也是通过引用实现的,JDK定义了一种新的引用类型FinalReference,其处理和虚引用非常类似。
引用的处理和GC关系非常密切。在Java语言层面对于不同类型的引用有不同的定义,简单总结如下:
1)软引用: 声明为软引用的对象在垃圾回收时只有满足一些条件才会进行回收,这些条件程序员可以设置,比如通过参数SoftRefLRUPolicyMSPerMB设置软引用对象的存活时间。
2)弱引用: 在垃圾回收执行时,如果发现内存不足声明为弱引用的对象就会被回收。
3)虚引用: 使用虚引用需要定义一个引用队列,虚引用关联的对象在Java应用层面无法直接访问,而是通过引用线程(reference thread,这是一个Java应用的线程,JVM在启动时会生成该线程)处理引用队列来访问。所以虚引用对象的回收依赖于引用队列中的对象是否被执行,如果引用队列中的对象还没有被处理,则不能回收,否则就可以被GC回收。
4)Finalize: 如果Java的类重载了Finalize()函数,则需要通过Finalize线程(Finalizer Thread,这是一个Java应用的线程,JVM在启动时会生成该线程)处理。定义了Finalize()函数的对象类似于定义了虚引用,如果在GC执行过程中发现Finalize线程尚未执行对象的Finalize()函数,则对象不会被回收,否则对象就可以被回收。
可以发现Java语言中引用的处理和GC紧密相关。根据是否需要额外的线程执行额外的动作可以分为两类,对于这两类GC过程,处理方法有所不同:
1)软引用/弱引用: 在GC执行过程中,首先要通过强根扫描所有活跃对象,如果发现对象的元数据属于Java语言中的软引用/弱引用,则需要额外记录下来,在强根遍历结束后再根据GC的策略来决定是否回收引用对象占用的内存空间。
2)虚引用/Finalize引用: 在GC执行过程中,首先要通过强根扫描所有活跃对象,如果发现对象的元数据属于Java语言中的虚引用或者Finalize引用,则需要额外记录下来,然后将引用类型的对象单独保留起来,当GC结束后,引用线程处理过的对象就可以在下一次GC执行过程中进行回收。注意,定义了Finalize()函数的对象处理在对象生成期间就知道需要进行额外处理,所以生成的对象会自动添加到Finalize引用中。
从上面的描述中可以看出,当GC处理Java语言的引用特性时,需要额外地对引用对象进行处理,对于软引用/弱引用,在强根扫描结束以后就可以根据策略进行回收;对于虚引用/Finalize引用,在本次GC时不能进行回收,通常需要在后续的GC过程中才能真正进行回收,且能否执行回收依赖于引用线程/Finalizer线程是否处理过对象,只有处理过的对象才能在后续的GC中被回收,如果对象没有处理过,JVM需要继续记录这些对象,并保持这些对象活跃。而这些对象明显不属于GC回收时识别的活跃对象,但是为了支持引用特性又必须将其记录下来,保持程序运行语义的正确性,所以JVM内部引入了弱根来记录这些对象。
在Java语言的发展过程中,JVM的研究者发现在JVM内部可以优化实现,从而节约内存或者提高程序执行的效率。为了达到这样的目的,JVM内部也需要引入一些弱根来保证程序运行的正确性。
这里以字符串为例来演示JVM的一个弱根。Java类库中String类提供了一个intern()方法用于优化JVM内存字符串的存储,intern()方法用来返回常量池中的某字符串。其目的是当Java程序中存在多个相同的字符串时可以共用一个JVM的底层对象表示,从而节约空间。代码片段如下:
String str1 = new String("abc"); String str2 = new String("abc"); str1.intern(); str2.intern();
在示例中,str1和str2都执行了intern()方法,JVM在执行时会优化底层的存储,可以简单地理解intern()方法的功能是:在JVM里面使用一个StringTable(使用hash table实现)存储字符串对象,如果StringTable中已经存在该字符串,则直接返回常量池中该对象的引用;否则,在StringTable中加入该对象,然后返回引用。
str1.intern()执行后,在StringTable中使用hash table存储这个String对象。因为str1对应的字符数组对象并不在StringTable中,所以它会被加入StringTable中。如图2-16所示,图中用圆表示对象(这里我们忽略外部的引用根信息)。
图2-16 intern()方法执行前后的内存示意图
当执行str2.intern()时,首先计算str2的hash code,然后用hash code和str2的字符数组对象在StringTable查找是否已经存储了String对象,并且比较存储的String对象hash code与字符串数组是否相同,如果相同,则不需要再次把字符串放入StringTable中了,并且返回str1这个对象。
JVM在内部使用了StringTable来存储字符串intern的结果,其结构如图2-17所示。
图2-17 StringTable存储结构图
通过StringTable的方式方便共享字符串对象,但是会带来回收方面的问题。如果所有的共享变量都死亡,StringTable中的共享对象也应该释放。但什么时候可以回收或者释放StringTable占用的内存呢?在GC执行过程中,当强根遍历完成后,需要再次遍历StringTable,如果发现没有任何相关的引用,则StringTable中的共享对象可以释放,这个时候就可以回收了。可以看出,当GC的强根遍历完成后需要额外针对StringTable遍历来完成一些内存的释放,而StringTable和GC执行过程中对象的活跃性并无任何关系,仅仅是JVM内部设计带来的额外遍历,这样的根也称为弱根。
从上面的介绍可以看出,对于弱根,如果不进行遍历,则会导致一定程度的内存泄露,但是并不会影响Java程序正确地执行。为了保障GC执行的性能,在新生代回收中通常不回收这类弱根。当然由于JVM内存设计的复杂性,在一些新生代回收实现中也会处理这类弱根,其原因涉及对另外一些特性的支持的影响(例如类回收或者字符串去重等),这里不再展开介绍。
JVM中根的构成非常复杂,根据程序执行的语义、语言特性的支持及JVM内部优化实现,可以将根划分为Java根、JVM根和其他根。
Java根用于找到Java程序执行时产生的对象,包括两类,分别为:
JVM根主要指JVM为了运行Java程序所产生的一些对象,这些对象可以简单地被认为是全局对象。主要有:
其他根主要有:
这些根共同构成了GC根集合,实际上根的确定和虚拟机运行时密切相关,而运行时又非常复杂,限于篇幅,本书无法对根详细介绍,有兴趣的读者可以参考其他文献。
需要注意的是,对于弱根的处理在不同的GC实现中有所不同,主要原因是弱根通常涉及内部资源的释放,整个流程耗时较多,在一些回收中会把弱根当作强根对待(即不释放弱根相关的内部资源),以加快GC的执行。