垃圾回收器与内存分配策略
生存还是死亡?
即使在可达性分析算法中判定为不可达对象,也是“非死不可”的,这时候它们暂时还处于“缓行”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。加入对象没有override finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”;
- 如果这个对象被判定为有必要执行finalize() 方法,那么该对象将被放置在一个名为 F-Queue的队列中,并在稍后由一条虚拟机自动建立的、低调度等优先级的Finalizer线程去执行它们的finalize() 方法。这里所说的“执行”是指虚拟机会出发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的fianlize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。如果要在finalize() 方法中拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把this关键字赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合。
对象自救实例:
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed !");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
回收方法区
《Java虚拟机规范》中提到过可以不要求虚拟机在方法中实现垃圾回收,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在。
方法区的垃圾回收主要回收两部分内容:废弃的常量和不再使用的类型。
回收废弃的常量与回收Java堆中的对象非常类似,不再赘述。
判断类型可以回收需要同时满足的条件:
- 该类所有的实例都已经被回收(也就是Java堆中不存在该类及其任何派生类子类的实例)
- 加载该类的类加载器已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
垃圾回收算法
从如何判定对象消亡的角度出发,垃圾回收算法可以划分为“引用计数式垃圾收集器(Reference Counting GC)”和“追踪式垃圾收集器(Tracing GC)”两大类,这两类被称作“直接垃圾收集”和“间接垃圾收集”,主流的Java虚拟机中都没有涉及到“引用计数垃圾收集器”。
分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序实际运行情况的经验法则,它建立在两个分代假说上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象朝生夕灭
- 强分代假说(Strong Generational Hypothesis):熬过多次GC的对象越难以消亡
这两个分代假说共同奠定了多款常用的垃圾收集器的设计原则:收集器应该将堆划分出不同的区域,然后将回收对象根据其年龄分配到不同的区域中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保存少量存活而不是去标记大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那么把它们集中在一起,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存空间的有效利用。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因此才有了Minor GC、Major GC、Full GC这样的回收类型的划分;也才能够针对不同的区域设计不同的垃圾回收算法——因此发展出了标记-复制、标记-清除、标记-整理等针对性的垃圾回收算法。所有的这一切都始于分代收集理论。
第三个分代假说:
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
存在相互引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长后晋升到老年代中,这时跨代引用也随即被消除了。
标记-清除算法
这是最早出现也是最基础的垃圾回收算法,在1960年由Lisp之父 John McCarthy 所提出。首先标记出所有需要回收的对象,再统一回收掉被标记的对象,也可以反过来。
缺点:
- 执行效率不稳定:如果Java堆中存在大量需要被回收的对象(死亡对象),必须进行大量标记和清除动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低;
- 内存空间碎片化:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到连续内存而不得不提前触发另一次垃圾收集动作。
标记-复制算法
为了解决标记-清除算法面对大量可回收对象执行效率低,1969年Fenichel提出了一种称为半区复制的垃圾收集算法,它将可用内存划分为大小相等的两块。1989年Andrew Appel提出了更优化的半区复制分代策略,现在称为Appel式回收。Hotspot虚拟机中的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。Appel式回收的具体做法式把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外另一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也即每次新生代空间中可用内存空间为整个新生代容量的90%。
当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域进行分配担保(Handle Promotion)。通过分配担保机制直接进入老年代。
标记-整理算法
针对老年代对象的存亡特征,1974年Edward Lueders提出了另一种针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极其负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。
但是如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“内存空闲分配链表”来解决内存分配问题,内存的访问是用户程序最频繁的操作,甚至没有之一,假如在这个环节上增加了额外的负担,势必会直接影响用户程序的吞吐量。
是否移动对象各有优劣,HotSpot虚拟机里面关注用户吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记清除算法的,这也从侧面印证了这点。
HotSpot算法实现细节
根节点枚举
我们以可达性分析算法中从GC Roots集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在Java应用越做越大,光是方法区的大小就有数百上千兆,里面的类、常量等更是恒河沙数,要是逐个检查以这里为起源的引用肯定得消耗不少时间。
迄今为止,所有收集器在根节点枚举这一步骤时都必须暂停用户线程的,因此毫无疑问跟节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现在分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾回收过程必须停顿所有用户线程的一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根根节点时也是必须要停顿的。