常见对象存活判定算法
引用计数法
具体方法是,在对象中添加一个引用计数器,每当有一个地方引用它时,计数器+1,当引用失效时,计数器-1,如果计数器为零,就说明没有地方在引用它。这个方法看似原理很简单,效率也高,但其实需要很多额外的考虑才能保证这个判定过程正确执行,比如单纯的引用计数很难解决对象间的循环引用的问题,举个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class ReferenceCountingGC {
private Object instance=null;
private static final int _1MB=1024*1024;
private byte[] bigSize=new byte[1024*1024];//1MB的堆空间
public static void main(String[] args) {
ReferenceCountingGC objA=new ReferenceCountingGC(); // step1
ReferenceCountingGC objB=new ReferenceCountingGC(); // step2
objA.instance=objB; // step3
objB.instance=objA; // step4
objA=null; // step5
objB=null; // step6
}
}
分析一下这段代码,首先经过了step1和step2之后,虚拟机栈的局部变量表里有两个reference类型变量,objA和objB,他们分别指向堆上的两个ReferenceCountingGC对象实例,我们用实例A和实例B表示,实例A因为objA引用它,所以计数器加1,cntA = 1,同理cntB = 1;step3阶段,实例B被实例A的instance成员引用,cntB = 2,同理step4阶段cntA = 2。step5阶段,栈帧中的objA不再指向实例A,cntA = 1,step6阶段,栈帧中的objB不再指向实例B,cntB = 1。到此,实例A和实例B的引用计数器均不为0,如果只是单纯的引用计数法,这便产生了内存泄漏
可达性分析法
可达性分析法就是通过一系列的GC Root根对象作为起始节点,用类似二叉树遍历的方法向下搜索,如果GC Root到某个对象不可达,这时的对象就是不可能再被引用的,会被回收掉。
可作为GC Root的对象包含下面几种:
- 虚拟机栈本地变量表中引用的对象
- 方法区中常量引用的对象、静态属性引用的变量
- 本地方法区中JNI引用的变量
- Java虚拟机内部的引用,比如异常对象
NullPointerException - 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码的缓存等…
除了这些固定的对象之外,根据垃圾收集的区域不同,还可以有其他对象临时加入,因为根据虚拟机自己的实现细节,某个区域里的对象完全有可能被其他区域对象所引用
对象的引用分类(强度依次递减)
- 强引用:例如
Object a = new Object()只要强引用关系存在,对象就不会被回收 - 软引用:还有用,但非必须的对象,这部分对象会在系统将要内存溢出前进行回收
- 弱引用:一旦发起垃圾回收,就会回收弱引用的对象
- 虚引用:“形同虚设”,一个对象是否有虚引用,完全不影响它的生命周期。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。
finalize()方法
要真正宣告一个对象的死亡,至少要经过两次标记过程:第一次是可达性分析后发现不可达,被第一次标记;第二次是一次筛选,筛选条件是此对象是否有必要执行finalize()方法,如果对象未覆盖finalize()方法,或者finalize()方法已经被对象调用过,那就没必要执行,假如被判定为有必要执行,那么该对象会被放在一个名为F-Queue的队列中,稍后由一条虚拟机自动建立的、低调度优先级的Finalize()线程执行队列中对象的finalize()方法。执行是指仅仅触发,并不会等待一个对象执行完,因为假如一个对象的finalize()方法运行缓慢,如果等待的话会影响其他对象的回收。
finalize()有点类似C/C++的析构函数,这个语法当初就是为了迎合C/C++程序员而做的一项妥协,但这个语法不合适当成析构函数用,因为它运行代价高昂,不确定性大,无法保证各个对象的调用顺序,已经被官方声明为不推荐的语法。
finalize()方法是对象最后的抢救机会,假如对象在此方法中又与GC Root建立了联系,那么这个对象实例就会逃过这次GC,但一个实例的finalize方法只会被执行一次,所以在下一次GC发起时,这个对象不会再因为finalize方法而再逃一次。
方法区的回收
方法区的垃圾回收收益远不及堆区,所以一般不要求虚拟机在方法区中实现垃圾收集,但有些情况下,方法区也会有一些较大的内存压力,那么方法区如何垃圾回收呢?
从上一篇博客我们知道,方法区主要有两部分数据,类型信息和常量池,常量池中常量的回收比较简单,类似于对象,但判断一个类型是否属于不再被使用的类条件就比较苛刻了,需要同时满足下面三个条件:
- 该类所有实例已经被回收,堆中不存在该类或者任何派生类的实例
- 加载该类的类加载器已经被回收
- 该类对应的
java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射获取这个类的方法
如果同时满足了上述三个条件,那么该类型便允许被回收,而并不是像对象那样,必然被回收
垃圾收集算法
上文说,对象判活有两种算法,引用计数和可达性分析,从这两个算法出发,垃圾收集算法可以分为引用计数式垃圾收集和追踪式垃圾收集。主流的是追踪式垃圾收集。
分代收集理论
在垃圾回收时,虚拟机要关注的对象体量是非常庞大的,假如每次都要遍历一边,无疑会造成很多性能上的损失,于是提出了分代收集理论,将Java堆划分出不同区域,然后将对象按照年龄(年龄即熬过垃圾收集过程的次数)分配到不同区域中,这个理论是建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭
- 强分代假说:熬过越多次垃圾收集的对象越难消亡
根据这两个假说,大部分虚拟机将Java堆划分成新生代和老年代,新生代中存放朝生夕灭的对象,每次垃圾回收时都有大批量对象死去,而每次回收后存活的少量对象,将晋升到老年代中存放,老年代对象因为很难消亡,所以只需很低的频次对这块区域进行回收即可,这样就比原先每次遍历整个堆效率提升了很多。
但仔细思考一下,分代收集真的只是划分一下区域这么简单吗?这里其实存在一个问题,对象不是孤立的,而是存在跨代引用。假如现在要进行一次只针对新生代的收集(Minor GC),但新生代中的对象完全有可能被老年代中对象引用,这样的话,GC Root就得再加上老年代对象,来进行可达性分析,这种方案理论可行,但也会为内存增大很大负担,所以垃圾分代理论有了第三条经验法则:
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
有了这条假说,我们就不必为了少量的跨代引用去扫描整个老年代,只需要在新生代上建立一个记忆集(Remember Set),这个结构作用是将老年代划分成若干小块,标示出哪一块会存在跨代引用,此后每次进行Minor GC时,只需将跨代引用的那一小块加入GC Root中,这个做法会增加一些运行时常数复杂度的开销,但相比于扫描整个老年代仍是划算的。
三种垃圾收集算法
标记-清除算法
扫描一遍对象之后,标记出哪些可回收哪些不可回收,接着保留不可回收的,清除可回收的,这个算法最为简单,也是后面两个算法的基础,它有几个显而易见的缺点:
- 执行效率不稳定,假如Java堆中对象数量很大,并且大部分都是需要回收的,那么要进行大量的标记清除工作,执行效率就会降低
- 直接原地清除的话会产生大量不连续的内存碎片,如果内存碎片太多,程序运行也需要分配较大对象的话,可能会出现无法找到足够的连续内存而提前出发GC
标记-复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,有人提出了半区复制算法,将可用内存划为大小相等的两块,当一块内存用完时,将存活的对象复制到另一块空间上,并将自身已经使用的内存空间一次性清理掉。如果内存中大量的对象都是存活的,那么这个算法将产生大量的复制开销,但如果大多数内存都是可回收的,那么算法每次复制的就是少量的存活对象,并且这种算法也不会出现空间碎片的情况。但这个算法还有点不足,内存缩小为原先的二分之一,空间浪费太大。
因为这种算法适用大多数内存可回收的情况,所以很多商用JVM用这个算法来回收新生代,根据新生代朝生夕灭的特点,有人对这个算法做了改进,弥补了空间浪费这个缺点,被称为Appel式回收。
Appel式回收的具体做法是将新生代划为一块较大的Eden区,两块较小的survivor区,内存大小比例是8:1:1,每次分配内存只在Eden区和一块survivor区上进行,发生垃圾收集时,将Eden区和survivor区上的存活对象复制到另一块survivor区上,然后对Eden区和已用的survivor区清理内存,这样的话,每次新生代的空间利用率可以达到90%,假如存活对象太多,一块survivor区不足以承载怎么办呢?Appel式回收提供了一个逃生门的设计,如果survivor区空间不够,就会依赖别的内存区域(多数是老年代)进行内存担保,借一块内存。
标记-整理算法
这种算法是针对老年代的特点提出的,其标记过程仍与“标记-清除”算法一样,但后续步骤不是对对象进行清理,而是将所有存活对象都向内存空间一端移动,然后直接清除掉边界外的内存。因为老年代中存活的对象很多,所以移动对象并更新对象引用是一个极为负重的操作,并且对象的移动操作必须要全程暂停用户应用线程才能进行,这无疑是增加延迟的。但如果和标记-清除算法一样不考虑移动整理存活对象的话,空间碎片化问题只能依赖更为复杂的内存分配器/内存访问器解决(比如像计算机硬盘存储大文件那样),两者均有利弊,那到底是整理内存还是不整理呢?从整个程序的吞吐量来看,因为内存分配和访问相比于垃圾收集频率要大得多,所以权衡利弊,应该用垃圾收集时的短暂延迟换取内存分配访问所耗费的时间复杂度。
另外,也可以两者兼顾,让虚拟机平时多数时间采用标记-清除算法,当内存碎片化程度高于一定阈值时,再采用标记-整理算法收集一次,换取规整的内存空间。