1.如何确定回收
一般来说,一个对象如果需要回收,第一件事就是要确定这个对象是否已经“死去”,那么这种“死去”的状态怎么来判断呢?
1.1可达性分析算法
在主流商用程序语言(Java、C#等)的主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的,基本思路就是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则说明对象是不可达的。如下图,对象5、6、7到GC Roots是不可达的,它们将会被判定为可回收的对象:
可达性算法
在Java语言中,可作为GC Roots的对象包括以下几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI(Native方法)引用的对象。
这里扩展说明另外一个判断对象是否存活的算法-引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;引用失效时,计数器减1;任何时刻计数器为0的对象就是不能在被使用的。可观来说,引用计数算法实现简单,判定效率也高。但是在主流的Java虚拟机中没有选择这个算法,主要原因是它很难解决对象之间相互循环引用的问题。
无论是通过可达性分析算法还是引用计数算法,判断对象是否存活都与“引用”有关。Java对“引用”的概念定义了四种(从强到弱):
强引用(Strong Reference):程序代码中普遍存在在,类似Object o = new Object这种,只要强引用存在,GC就永远不会回收被引用的对象。
软引用(Soft Reference):用来描述一些还有用但非必需的对象。当内存足够使用时,不会被回收。在将要发生内存溢出之前,将会把这些软引用对象列入回收范围之内进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常,使用SoftReference类来实现软引用。
弱引用(Weak Reference):也用来描述非必需对象,被弱引用关联的对象只能存活到下次垃圾收集之前。使用WeakReference实现。
虚引用(Phantom Refernce):虚引用是最弱的一种引用关系,一个对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用获得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被回收时收到一个系统通知。使用PhantomRefernce实现。
1.2 对象真正“死亡”
即使在可达性分析算法中不可达的对象,也不是立即“死亡”的,要真正宣告一个对象的死亡,至少要经历两次标记过程:
如果对象在可达性分析之后发现没有GC Roots的引用链,那么它首先会被第一次标记并进行第一次筛选,筛选的条件是**此对象是否有必要执行finalize()方法。
**当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况定为“没有必要执行”。
如果一个对象被判定有必要执行finalize()方法,那么这个对象将会被放置在F-Queue队列中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟是会触发这个方法,但并不承诺会等待它运行结束。如果对象在finalize()中成功拯救了自己-重新与引用链关联上,例如this赋值给某个类变量或成员变量,那么在第二次标记时它就会被移除出“即将回收”的集合。需要特么说明的是,finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,它所能做的工作,使用try-finally或其他方式都可以做的更好,所以笔者建议大家可以忘记这个方法的存在。
2.永久代的回收
在HotSpot中,我们常称方法区为“永久代”。它的垃圾收集主要回收两部分内容:废弃常量和无用的类。
废弃常量:以常量池中字面量为例,加入一个字符串“abc”已经在常量池中,但是系统中没有任何一个String对象引用常量池中的“abc”常量,如果这时发生内存回收,这个“abc”常量就会被清理出去。常量池中的其他类(接口)、方法、字段的符号引用也与之类似。
无用的类:类需要满足以下三个条件才会被判定为“无用的类”:
该类所有的实例都已经被回收,也就说Java堆中不存在该类的任何实例。
加载该类的ClassLoader已经被回收。
该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
3. 垃圾收集算法
3.1 标记-清除算法
“标记-清除”(Mark-Sweep)算法是最基础的收集算法,分为“标记”和“清除”两个部分:首先标记出所有需要回收的对象,在标记完成后同意回收所有被标记的对象,它的标记过程就是上文中所讲述的可达性分析算法。现在虚拟机的收集算法大多都是基于“标记-清除”改进而得到的。这个算法的主要不足有两个:一是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。,标记-清除算法的执行过程如下图:
标记-清除算法
3.2 复制算法
“复制”(Copying)算法解决了“标记-清除”算法的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另一块内存上,然后再把已使用过的内存空间一次清理掉。这种算法的代价是将内存缩小为原来的一半。复制算法执行过程如下:
复制算法
IBM研究表明,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor控件,每次使用Eden和其中一块Survivor(HotSpot就是这种布局)。当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存为整个新生代容量的90%(80%+10%),只有10%的空间会浪费。当然98%的可回收对象只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代的内存进行分配担保(Handle Promotion)。如果另一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入到老年代中。
3.3 标记-整理算法
“标记-整理(Mark-Compact)算法”主要针对老年代,标记过程仍然同“标记-清除”算法一样,但后续步骤不是直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法执行过程如下:
标记-整理算法
3.4 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集(Generational Collection)”算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。例如Java堆的新生代和老年代,这样就可以针对各个代的特点采用最合适的算法。新生代中对象朝生夕死,就选择复制算法,主要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对其进行分配担保,就必须使用“标记-清理”或“标记-整理”算法进行回收。
4. HotSpot的算法实现
在HotSpot中实现对象存活判定算法和垃圾收集算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。下面我们来详细看一下这些算法的实现。
4.1 枚举根节点
从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多引用仅仅方法区就有数百兆,如果要逐个检查这些引用,必然会消耗很多时间。
可达性分析工作必须在一个能确保一致性的快照中进行-这里“一致性”的意思是指在分析期间整个执行系统看起来就像被冻结在某个时间点上,不能出现分析过程中对象引用关系还在不断变化的情况。这点也是导致GC进行时必须停顿所有Java执行线程(Sun称之为“Stop The World”)的一个重要原因。
目前主流Java虚拟机使用的都是准确式GC,即虚拟机可以知道内存中某个位置的数据具体是什么类型。譬如有一个32位的整数123456,它到底是一个reference类型指向123456的内存地址还是一个数值为123456的整数,虚拟机将有能里分辨出来,这样才能在GC的时候准确判断对上的数据是否还可能被使用。所以当执行系统停顿下来后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置。在HotSpot实现中,使用一组OopMap的数据结构来得知哪些地方存放着对象引用。在类加载完之后,HotSpot就把对象内偏移量对应的数据类型计算出来,在JIT编译时也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,在GC扫描时就可以直接得知这些信息了。
4.2 安全点和安全区域
在OopMap的协助下,HotSpot可以快速准确的完成GC Root枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
上面已近提到,HotSpot只是在“特定的位置”记录了这些信息,这些位置称为“安全点(Safepoint)”,即程序执行时只有在到达安全点时才能暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的-因为每条执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,这些指定的位置主要在:循环的末尾 、方法临返回前 / 调用方法的call指令后 、可能抛异常的位置 。确定了安全点,那么如何在GC发生时让所有线程都“跑”到最近的安全点再停顿下来呢?现在多数虚拟机都采用主动式中断,当GC需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。
另外一种情况,如果在程序“不执行”的时候如何进入GC的安全点呢,例如线程sleep或blocked,这时候线程无法响应JVM的中断请求,也不太可能等待线程重新被分配CPU执行时间,这种情况就需要安全区域(safe region)解决。安全区域是指在一段代码片段之中,引用关系不会发生变化,在这个区域的任何地方GC都是安全的。在线程执行到safe region中的代码时,首先标识自己已经进入了safe region,这样,当在这段时间JVM发起GC时,就不用管标识自己为safe region状态的线程了。