目录
垃圾回收GC(Garbage Collection),是java逃不开的一个话题,垃圾回收主要需要完成三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
一、哪些内存需要回收
在堆中几乎存放着所有的对象实例,垃圾收集器在对堆进行回收前,
第一件事情就是要确定哪些对象还“存活”着,哪些对象已经没有什么用了,可以进行垃圾回收了,下面我们依次介绍几种算法:
1.1引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;
任何时刻计数器为零的对象就是不可能再被使用的。
引用计数算法虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也高,
在大多数情况下都是不错的算法,但是在主流的Java虚拟机中都没有选用引用计数废来管理内存,
主要原因是这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确工作,譬如单纯的引用几乎就很难解决对象之间相互循环引用的问题。例如:
public static class ReferenceCountingGC{
public Object instance=null;
private static final int _1MB=1024*1024;
private byte[] bigSize=new byte[2*_1MB];
public static void testGC(){
ReferenceCountingGC objA=new ReferenceCountingGC();
ReferenceCountingGC objB=new ReferenceCountingGC();
objA.instance=objB;
objB.instance=objA;
objA=null;
objB=null;
System.gc();
}
}
如果使用引用计数算法,虚拟机就不会回收它们,但是实际上这两个对象被JVM回收。
1.2可达性分析算法
这个算法的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,
从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,
如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
如图所示,对象object5、object6和object7虽然互有关联,但它们到GC Roots是不可达的,因此它们会被判定为可回收对象。
在Java中,固定作为GC Roots的对象包括以下几种:
- 虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
- 方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
- 本地方法栈中JNI(即通常所说的Native方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,常驻的异常对象(NullPointException、OutOfMemoryError)等
- 所有被同步锁(synchronized关键字)持有的对象
1.3引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,
判定对象是否存活都和“引用”离不开关系,在JDK1.2版本之后,Java将引用分为强引用、软引用、弱引用和虚引用,引用强度依次减弱。
- 强引用:是最传统的引用的定义,指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”的引用关系。只要强引用的关系还在,垃圾收集器就不会回收被引用的对象
- 软引用:描述一些还有用,但非必须的对象。只要被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列入回收范围中进行二次会后,如果这次回收还是没有足够的内存,才会抛出内存溢出异常。JDK1.2版之后提供了SoftReference类来实现软应用
- 弱引用:描述那些非必须对象,强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。JDK1.2版之后提供WeakReference类来实现弱引用
- 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时回收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用
二、什么时候回收
即使在可达性分析算法中判定为不可达的对象,也不是”非死不可“的,它们暂时还处于”缓刑“阶段,
要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析之后发现没有与GC Roots相连接的引用链,那么它会将被第一次标记,
这是会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,满足以下任意情况都视作没有必要执行,
- 对象没有覆盖finalize()方法
- finalize()方法已经被虚拟机调用过
如果这个对象被认定有必要执行finalize()方法,那么该对象会放置在一个叫做F-Queue的队列中,并在稍后由一条虚拟机自动建立的,低调度优先级的Finalizer线程去执行它们的finalize()方法。
这里所说的执行是指虚拟机会触发这个方法开始运行,但hi不承诺一定会等待它运行结束。
这么做的原因是,如果某个对象的finalize()方法执行缓慢或者死循环,F-Queue队列中其他对象永远处于等待,导致整个内存回收子系统崩溃。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么第二次标记时它将被移出”即将回收“的集合;如果对象这时候还没有逃脱,那么基本上它就要真的被回收了。
三、如何回收
从如何判定对象消亡的角度触发,垃圾回收算法可以分为”引用计数式垃圾回收“和”追踪式垃圾回收“两大类,
这两类也常被称作”直接垃圾收集“和”间接垃圾收集“,由于引用计数式垃圾收集算法使用较少,所以我们这里仅讨论追踪式垃圾回收算法
3.1分代收集理论
当代行业虚拟机的垃圾收集器,大多数都遵循了”分代收集“的理论进行设计,它建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
这两个分代假说共同奠定了多款常用的垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(即熬过垃圾收集过程的次数)分配到不同的区域中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那么它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,因而才有了"Minor GC"、”Major GC“和”Full GC“这样的回收类型的划分,也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法。
把分代收集理论具体放在现在的商用Java虚拟机里,一般至少会把Java堆划分为新生代和老年代两个区域,新生代即每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
3.2标记-清除算法
最早出现也是最基础的垃圾收集算法是标记-清除算法,算法分为标记和清除两个阶段,
- 首先标记处所有需要回收的对象
- 在标记完成后,统一回收掉所有被标记的对象
标记的过程就是对象是否属于垃圾的判定过程,该算法主要有了两个缺点:
- 执行效率不稳定:如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量的标记和清除
- 内存空间的碎片化:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致分配较大对象时无法找到足够的连续内存而触发垃圾收集
标记-清除的垃圾回收流程如图所示:
3.3标记-复制算法
标记-复制算法简称为复制算法,是用于解决标记-清除算法的效率问题,
其主要思路为:将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,就将还存活着的对象复制到另一块绗棉,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,那么这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也不用考虑有空间碎片的影响,只需要移动堆顶指针,按顺序分配即可。
这样实现简单,运行高效,但是这种算法的代价是将可用内存缩小为了原来的一半,空间浪费较大。
标记-复制垃圾回收流程如图所示:
3.4标记-整理算法
标记-复制算法在对象存活率较高时需要消耗大量的复制时间,效率会降低,特别是对老年代这种存活率较高的内存区域时,我们一半不采取这种算法,取而代之的是标记-整理算法。
标记-整理算法的标记过程和标记-清除算法相同,但是后续不是直接对可回收对象进行清理,而是将所有存活对象都向内存空间的一端移动,然后直接清理掉边界外的内存。
标记-清除和标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
是否移动回收后的存活对象是一项优缺点并存的风险决策:
- 移动存活对象:在老年代这种存活率较高的区域,移动存储对象并更新所有引用这些对象的地方将会是十分耗时的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行
- 不移动存活对象:通过不断的清理存储会导致很多的空间碎片,导致程序的运行速度下降