概述
说起垃圾收集(Garbage Collection,GC),大部分人都把这项技术当做Java语言的伴生产物。事实上,GC的历史远远比Java久远,1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,人们就在思考:
GC需要完成的三件事情:
哪些内存需要回收?
什么时候回收?
如何回收?
经过半个世纪的发展,内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那为什么我们还要去了解GC和内存分配呢?答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
把时间从半个世纪以前拨回到现在,回到我们熟悉的Java语言。第2章介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多考虑回收的问题,因为方法结束或线程结束时,内存自然就跟随着回收了。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,本书后续讨论中的“内存”分配与回收也仅指这一部分内存。
回收策略
回收作用:
通过清除不用的对象来释放内存,同时垃圾收集的另外一个重要作用就是消除堆内存空间的碎片。
1. 引用计数
很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器都为0的对象就是不可能再被使用的。因此A的回收可能会导致连锁反应。
客观地说,引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软的COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言以及在游戏脚本领域中被广泛应用的Squirrel中都使用了引用计数算法进行内存管理。但是,Java语言中没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间的相互循环引用的问题。
优点:简单,快
缺点:无法检测循环引用,比如A的子类a引用了A,A又引用了a,因此A和a永远不会被回收。这个缺点是致命的,因此现在这种策略已经不用。
2. 根搜索算法
在主流的商用程序语言中(Java和C#,甚至包括前面提到的古老的Lisp),都是使用根搜索算法(GC Roots Tracing)判定对象是否存活的。这个算法的基本思路就是通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图3-1所示,对象object 5、object 6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
在Java语言里,可作为GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中的引用的对象。
方法区中的类静态属性引用的对象。
方法区中的常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)的引用的对象
跟踪收集器通常使用两种策略来实现:
1.压缩收集器:遍历的过程中如果发现对象有效,则立刻把对象越过空闲区滑动到堆的一端,这样堆的另一端就出现了一个大的连续空闲区,从而消除了堆碎片。
2.拷贝收集器:堆被分为大小相等的两个区域,任何时候都只使用其中一个区域。对象在同一个区域中分配,直到这个区域被耗尽。此时,程序执行被中止,堆被遍历,遍历时被标记为活动的对象被拷贝到另外一个区域。这种做法用称之为“停止并拷贝”。
这种做法的主要缺点是:太粗暴,要拷贝就都拷贝,粒度太大,整体性能不高。因此就有了更先进的“按代收集的收集器”
3. 按代收集的收集器
基于两个事实:
1)大多数程序创建的大部分对象都有很短的生命周期。
2)大多数程序都创建一些具有非常长生命周期的对象。
因此按代收集策略就是在“停止并拷贝”策略基础之上,把对象按照寿命分为三六九等,不是一视同仁。它把堆划分为多个子堆,每一个子堆为一“代”对象服务。最年幼的那一代进行最频繁的垃圾收集。没经过一次垃圾收集,存活下来的对象就会“成长”到更老的“代”,越是老的“代”对象数量应该越少,他们也越稳定,因此就可以采取很经济的策略来处理他们,简单“照顾”一下他们就行啦。这样整体的垃圾收集效率要比简单粗暴的“停止并拷贝”高。
4.火车算法
火车算法是用来替代按代收集策略的吗?不是的,可以说,火车算法是对按代收集策略的一个有力补充。我们知道按代收集策略把堆划分为多个”代“,每个代都可以指定最大size,但是”成熟对象空间“除外,”成熟对象空间“不能指定最大size,因为它是”最老“对象的最终也是唯一的归宿,除此之外,这些”老家伙“无处可去。而你无法确定一个系统最终会有多少老对象挤进”成熟对象空间“。
火车算法详细说明了按代收集的垃圾收集器的成熟对象空间的组织。火车算法的目的是为了在成熟对象空间提供限定时间的渐进收集。
火车算法把成熟对象空间划分为固定长度的内存块,算法每次在一个块中单独执行。为什么叫”火车算法“?这与算法组织这些块的方式有关。
每块数据相当于一节车厢
每一个数据块属于一个集合,集合内的所有数据块已经进行排序,因此集合就好比是一列火车
成熟对象空间又包含多个集合,因此就好比有多列火车,而成熟对象空间就好比是火车站。
图14中包含了若干个被按序标记的火车, 火车由随意多个同样被按序标记的车厢组成. 在这个例子中, 有两列火车. 每个车厢最多可以存储三个对象, 每列火车可以包含任意多个车厢.
火车的记忆集合是它所有车厢记忆集合的总和, 不包括那些来自其它火车的引用. 在图14中, 对象E是1.1车厢在引用集合中, 但是他不在1号火车的引用集合中. 因为垃圾回收算法总是从标记最小的车厢开始, 在更新引用集合的时候, 只有那些来自标记高的车厢的引用才被看作是. 因此, 对象E属于车厢1.1的记忆集合, 而对象C不在车厢1.2的记忆集合中.
当垃圾回收器收集第一个车厢, 对象A需要保留下来, 由于是根引用指向它, 所以它会被拷贝到一个完全新的火车中去. 由于对象B只有被A引用, 所以它会被拷贝到和A同一列火车中去. 这一点很重要, 因为通过这种方式, 自循环的垃圾对象结构最终被转移到同一列单独的火车中去了. 由于对象C被来自同一列火车的对象引用, 所以它被拷贝到了火车的最后去了. 现在第一个车厢空了, 可以被释放了. 通过第一遍回收, 火车站中的状况可以如图15所示
记忆集合将会相应地进行更新. 第一列火车已经没有从外面(这里的外面指的是第一列火车以外)指向的引用的,所以在下一次回收中, 整个火车空间将会被案例的释放. 如图16所示.
任何时候, 在第一列火车中自循环的垃圾对象结构不会被拷贝到另外火车中去. 当所有不在这个自循环结构中的对象被拷贝到其它火车中后, 这列火车将会被释放. 这很容易理解. 可是是否能保证每一个自循环的结构最终都会留在第一列火车中呢? 如果一个自循环结构分布在一些不同的火车中, 那么, 在一系列迭代之后, 原来第二列火车会成为这个自循环结构的第一列火车, 并且结构体中的所有对象, 都会被分配到其它火车中去.(这里的其它火车指的是刚才自循环结构所占据的一些火车, 但是除去第一列火车.). 因此包含这个自循环结构对象火车数量会减少一个. 当火车数目达到1时, 剩下的这个火车中包含了自循环结构的所有对象, 于是, 这个垃圾对象结构可以被正确的回收了.
图17反映一个由4个对象组成的自循环结构.
整体算法流程
1.选择标号最小的火车.
2.如果火车的记忆集合是空的, 释放整列火车并终止, 否则进行第三步操作.
3.选择火车中标号最小的车厢.
4.对于车厢记忆集合的每个元素:
如果它是一个被根引用引用的对象, 那么, 将拷贝到一列新的火车中去; 如果是一个被其它火车的对象指向的对象, 那么, 将它拷贝到这个指向它的火车中去.
假设有一些对象已经被保留下来了, 那么通过这些对象可以触及到的对象将会被拷贝到同一列火车中去.
在这个步骤中, 有必要对受影响的引用集合进行相应地更新. 如果一个对象被来自多个火车的对象引用, 那么它可以被拷贝到任意一个火车去.
释放车厢并且终止.
再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在JDK 1.2之前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(WeakReference)、虚引用(Phantom Reference)四种,这四种引用强度依次逐渐减弱。
强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
生存还是死亡?
在根搜索算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己—只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那它就真的离死不远了。从代码清单3-2中我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。
从代码清单3-2的运行结果可以看到,SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。
public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive(){ System.out.println("yes,i am still alive!"); } protected void finalize() throws Throwable { SAVE_HOOK = new FinalizeEscapeGC(); //对象第一次成功拯救自己 SAVE_HOOK = null; System.gc(); // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 Thread.sleep(500); if (SAVE_HOOK != null){ SAVE_HOOK.isAlive(); }else{ System.out.println("no,i am dead!"); } // 下面这段代码与上面的完全相同,但是这次自救却失败了 SAVE_HOOK = null; System.gc(); // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 Thread.sleep(500); if (SAVE_HOOK != null){ SAVE_HOOK.isAlive(); }else{ System.out.println("no,i am dead!"); } } }
运行结果:
1 finalize method executed! 2 yes, i am still alive! 3 no, i am dead!
另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。 需要特别说明的是,上面关于对象死亡时finalize()方法的描述可能带有悲情的艺术色彩,笔者并不鼓励大家使用这种方法来拯救对象。相反,笔者建议大家尽量避免使用它,因为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中提到它适合做“关闭外部资源”之类的工作,这完全是对这种方法的用途的一种自我安慰。finalize()能做的所有工作,使用try-finally或其他方式都可以做得更好、更及时,大家完全可以忘掉Java语言中还有这个方法的存在。
PS.
参考链接
http://www.cnblogs.com/gw811/archive/2012/10/19/2730258.html
http://www.cnblogs.com/wenfeng762/archive/2011/11/18/2137882.html
http://nileader.blog.51cto.com/1381108/402609