探索JVM自动垃圾回收机制

 

田海立,系统分析师

2005 8 25

 

摘要

本文在分析堆空间管理容易出现的问题的基础上,探讨了Java虚拟机(JVM)的自动垃圾回收机制的各种算法。

关键词:堆,垃圾回收,JVM,内存碎片,内存泄漏,悬空引用

 

 

摘要... 1

目录... 1

一、堆(Heap)空间管理... 2

二、问题的提出——为何要实现自动垃圾回收?... 2

三、垃圾回收算法分析... 3

3.1 引用计数(Reference Counting)算法... 3

3.2 追踪(Tracing)回收算法... 3

3.3 压缩(Compacting)回收算法... 4

3.4 拷贝(Coping)回收算法... 4

3.5 分代(Generational)回收算法... 4

3.6 火车(Train)算法... 4

3.7 自适应(Adaptive)算法... 5

四、影响JVM的垃圾回收... 5

4.1 Object的finalize()方法... 5

4.2 System中相关的方法... 5

五、总结... 6

参考资料... 6

关于作者... 6

 

 

一、堆(Heap)空间管理

堆(Heap)是一个运行时的数据存储区,从它可以分配大小各异的空间。一般,运行时的数据存储区有堆(Heap)和堆栈(Stack),所以要先看它们里面可以分配哪些类型的对象实体,然后才知道如何均衡使用这两种存储区。一般,堆栈里存放的是非static的自动变量、函数参数、表达式的临时结果和函数返回值(如果他们没有被放到寄存器里)。堆栈里的这些实体的分配/释放是由编译系统自动完成的,堆里面存放的实体都是程序里面显式分配的,没有自动垃圾回收机制的系统里面也必须由程序代码显式的释放这些实体。在嵌入式系统中,这些资源都非常有限,尤其是堆栈,我见过的RTOS基本都是给每个Task/Process固定分配一定大小的Stack,因为对堆栈的操作不是由程序自身可以控制的,如果分配的实体如果超出堆栈区域,将会发生灾难性后果。避免堆栈溢出,一般只要遵循几个原则就行了,一、不要分配大的实体到堆栈上;二、避免过深的递归调用;三、注意类型转换(显式的和隐式的)。解决第二条需要算法上的考虑,而解决第一条只要做到把大的对象放到堆上就行了,也就是,如果局部自动变量或者函数参数是一个很大的变量,用一个引用/指针代替,把它分配到堆上,然后针对引用/指针操作,亦即用“引用模式”绕过“值模式”产生的大的实体复制的影响(关于这个话题,就不拓展了^_^)。

堆的管理,不同的语言/系统的实现是不同的。比如c语言就没有把堆的分配/释放做到语言的层次,它对堆空间对象的操作是通过库函数malloc/free来实现的;c的后代做的就比较彻底,c++直接把对堆空间里的对象的分配/释放做到语言层次的new/deleteJava就做得更彻底,应用开发者只要在需要用堆分配的时候创建就行了,何时释放如何释放,都有Java虚拟机(JVM)来做,而不需要程序代码来显式地释放。尽管JVMSpecification并未强求一定要有垃圾回收,也未对采用哪种回收技术做限定,但现在已由的JVM实现基本都实现了自动垃圾回收,有些还实现了很多种回收技术,具体采用哪种或哪几种可以通过配置来决定。

二、问题的提出——为何要实现自动垃圾回收?

没有实现自动垃圾回收机制的系统里,在堆里申请的实体所占的内存在程序运行的时候将一直被占用,在显式释放之前不能再分配给其它实体。对于全部的Task/Process来说,使用的堆都是同一块区域。看下面情形,有一段代码从堆里申请了一块内存M1;然后申请了一块M2,假定M2紧接着M1;现在释放M1,这样先前M1占着的空间就是空闲的,但是它与其它的空闲区域不是连续的,中间隔着M2。如果M1的大小加上其它的空闲区域的大小为count_free,如果再要申请大于M1,大于其它空闲区域,而小于count_free的空间,就会失败,因为虽然总的剩余空间满足大小要求,但却找不到那么大小的连续区域,这就是所谓的“内存碎片”问题。

使用堆存储空间对编程来说是个便利,也是一个负担,因为使用堆不当可能引起的致命问题:内存泄漏(Leak)或悬空引用(Dangling Reference)。内存泄漏是指从堆上分配了空间,但在程序生命结束了还没有释放,而这些空间是被标记了被死掉的程序使用的,所以也不能再被其它程序使用,就成为了僵死的(zoombie),时间久了这些死尸没有回收“掩埋”,堆空间迟早会被耗尽,再有分配请求无法正常满足,结果必然导致系统不能正常工作。悬空引用就更危险了,悬空引用是指使用已经不再有效的实体,这些实体可能是使用之前自己把它释放掉了或者赋值给了别人,别人把它释放掉了。现在的系统基本都采取了存储空间的保护措施,悬空引用的也就是访问了分配给别人的空间或者是空闲的空间,结果就会导致系统崩溃。

JVM的自动垃圾回收机制解放了程序员,他们不再在对堆空间管理的泥潭里挣扎,同时也解决可能引起的内存碎片,内存泄漏和悬空引用等问题。但是这项技术的采用也是把双刃剑,自动垃圾回收对于何时进行不能准确把握,一次回收的粒度也不能精确定位,这必然影响系统性能,这些影响在嵌入式系统中尤为突出。早期采用的某些垃圾回收算法也不能保证100%收集到所有的废弃内存,不过随着垃圾收集算法的不断改进以及软硬件运行效率的不断提升,这些问题得以解决或缓解。

三、垃圾回收算法分析

任何一种垃圾收集算法都必须要做两件事情:(1)检测出垃圾信息;(2)回收检测出的垃圾所占用的内存空间,把这些空间返还给堆管理器,使之可以再次被程序使用。

3.1 引用计数(Reference Counting)算法

一般来说,引用计数法,堆中的每个对象对有一个引用计数器(Reference Counter)。计数器一般按如下规则赋初值和加减计数。

-         当创建了一个对象并把指向该对象的引用赋给一个变量时,这个对象的引用计数器就被置为1

-         当对象的引用被赋给任意变量时,该对象的引用计数器1

-         当对象的引用超出了生存期或者被设置为新的值的时候,其引用计数器1

-         如果引用计数器为0,该对象就满足了垃圾回收的条件

采用引用计数的垃圾回收器可以很快的运行,当前的程序不会因为JVM需要运行垃圾回收而被长期中断,比较适合嵌入式实时环境计算。但运行中对引用计数器的加减增加了程序执行的开销。最主要一点,它并不能处理两个或两个以上的对象的交叉引用情况。

3.2 追踪(Tracing)回收算法

追踪回收法会用到JVM维护的对象引用图,从跟节点开始追踪,遇到的对象打上印记。当整个追踪过程结束,未被标记的对象就是无法到达的,是可以被回收的,在某个适当的时候会被回收。因为标记和清楚是分开的过程,而Java暴露出finalize()方法,所以就有可能需要二次扫描,这种情况将在Finalize一节中讲述。

3.3 压缩(Compacting)回收算法

压缩回收算法把堆中的活动对象移动到堆的一端,这样就结果就是在堆的另外一端留出了一个大的空闲区,也就相当于压缩掉了垃圾对象和活动对象之间的碎片。压缩回收算法要求所有被移动对象的引用也要同步更新,指向新的移动后的位置。这一切都是在对程序员透明的情况下,由JVM来做的。

该算法不仅回收了堆里的垃圾,还整理了内存碎片。

3.4 拷贝(Coping)回收算法

拷贝回收算法一般把堆分成两个大小的区域,任何时刻只有一个区域在使用。对象在同一个区域内分配/释放,直到这个区域不能再被分配。此时当前正在运行的程序被中断,扫描使用中的的区域,遇到活动对象就把它拷贝到另外的哪个区域,知道没有活动对象,此时被中止的程序再次运行,而使用的堆是拷贝活动对象到的区域,完成两个区域的转换。拷贝回收算法也要求所有被拷贝对象的引用也要同步更新,指向新的拷贝后的位置。

拷贝算法也是实现了对垃圾的回收和碎片的整理。但是这个算法代价也很高,需要堆空间两倍大的内存;因为要停止当前运行程序进行内存整理,也需要耗费很长的时间,效率很低。分代回收算法可以减缓这种代价。

3.5 分代(Generational)回收算法

分代回收算法就是针对拷贝算法的效率问题做出的改进,它是基于下面两个经验规律的:

-         大部分对象的生存期都很短;

-         有些对象的生存期很长。

该算法把对象按他们的寿命进行分组,优先回收年轻的对象。把堆按代分组,每组称做一代。子代里的对象可以转到高一级的代里,如果一个对象经过多次收集仍然存活,把它移到高一级的代的堆里,减少再次扫描它的次数。

3.6 火车(Train)算法

Train算法在堆中老的代和年轻的代之间创建一个新区域。这些堆区域划分为“火车”,每个火车又分为一系列的车厢。每个火车车厢组成单独的一代,这意味着不但要跟踪老到年轻的引用,而且还要跟踪从老的火车到年轻的火车以及老的车厢到年轻的车厢的引用。

回收可以针对车也可以针对车厢。回收的时候会把被回收的火车/车厢内被引用的对象都转到其它车/车厢中,剩下的就是垃圾,可以整个火车/车厢的回收了。这个算法提供了对堆空间限定时间内的渐进式回收,可以用在嵌入式实时环境。

火车算法还解决了有交叉引用情形的垃圾回收问题。如果正被回收的车厢内有被来自所管理的火车之外的对象所引用,则把它移到其它特殊的火车里;如果正被回收的车厢内有被所管理的火车之内的其它火车里的对象所引用,则把它移到那列火车;正在被回收的车厢的外部引用都来自同一列火车的其它车厢,则把这样的对象都移到这列火车的最后一列去。重复这个过程,结果就是没有引用指向这节车厢,这节车厢可以被回收了。

3.7 自适应(Adaptive)算法

垃圾回收算法在不同的情况下,工作表现也不同。自适应算法通过监视堆的情形,相应的调整垃圾回收算法。

四、影响JVM的垃圾回收

Java中的垃圾回收是由JVM实现的,如何回收,何时开始回收对程序员来说都是透明的(Transparent,但是程序员可能会影响到垃圾回收工作,也可以请求垃圾回收器开始工作。

4.1 Objectfinalize()方法

Java里所有类的父类Object里有个finalize()方法,它在垃圾回收器认为这个对象是垃圾的之后,真正回收之前执行。所以你在实现这个方法的时候有可能再次使一些对象成为被引用对象,也就影响到了垃圾回收工作。

finalize()的原型如下:

protected void finalize() throws Throwable

一般在调用这个方法之前,垃圾回收器已经检测出不再被引用的对象;然后如果这些对象声明了finalize方法,就要处理该方法;因为finalize方法可能使某些对象“复活”,回收器必须再次检测不再被引用的对象,两次都不再使用的对象才被回收。

4.2 System中相关的方法

程序员可以通过System类的某些方法来请求开始垃圾回收器或者开始运行不再被引用对象的finalize方法。

它们的原型如下:

public static void gc();

请求回收器回收不再被引用对象。

public static void runFinalization();

请求回收器回收不再被引用对象的finalize方法。

通过Runtime同样有System类中的那两个方法的效果。Runtime是个Singleton,通过public static Runtime getRuntime()获得Runtime的实例,所以Runtime.getRuntime().gc()System的类方法gc()的效果一样;Runtime.getRuntime().runFinalization()System的类方法runFinalization()的效果一样。

另外在写代码的时候,把不再使用的引用赋为null也可以方便JVM回收器的工作,当然这并不是必须的。

五、总结

垃圾自动回收技术仍是目前研究活跃的领域,这篇文章只是分析别人的研究和应用的成果,分析的目的并不是要设计一个垃圾回收器,只是要吸收其中的设计思想,在工作中无形的起到帮助作用。

本来采用那种回收算法,甚至采用回收机制与否的取舍也并没有绝对的标准,思考的角度,评价的标准不同,采取的设计决策当然也就不同。这已经是从哲学角度看问题了——怪不得现在《xxx艺术》(计算机程序设计艺术、UNIX程序设计艺术、汇编程序设计艺术、c++程序设计艺术、Java程序设计艺术、软件架构的艺术,)这么热卖!^_^

参考资料

1.        Bill Venners, Inside the Java Virtual Machine, 2nd edition

2.        B.K & D.R, The c Programming Language, 2nd edition

3.        Bjarne Stroustrup, The c++ Programming Language, 3rd special Edition, Addison Wesley

4.        Ken Arnold, James Gosling, David Holmes, The Java Programming Language, 3rd Edition

5.        Michael L. Scott, Programming Language Pragmatics

关于作者

田海立(IT小混混),系统分析师,2004年硕士毕业于南京大学(软件架构方向),主要兴趣:嵌入式系统应用架构,Java/EclipseLinux技术,攻读硕士之前和目前都从事嵌入式系统研发。您可以通过haili.tian@gmail.com 与他联系,到 http://blog.csdn.net/thl789/ 或 http://spaces.msn.com/members/thl789 看他的文章。

 

(本文可自由转载,但请给出原文链接: http://blog.csdn.net/thl789/archive/2005/08/29/467412.aspx)。


Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐