浅谈现代自动垃圾回收

来源:互联网 发布:沙特驻华大使馆 知乎 编辑:程序博客网 时间:2024/05/02 01:09

1. 概述

在传统的C/C++程序中,程序员需要显示地申请内存,然后在不需要的时候释放它。这样做虽然有很高的效率,但风险也是很大的。由于程序员需要自己来进行内存管理,这也难免会降低程序的开发效率。而目前的大多数应用都对效率本身的要求不会太高,那么我们是否可以找到一个兼顾效率和开发效率的方法来减轻程序员的负担,减少人工内存管理带来的风险呢?这就是自动垃圾回收。所谓的垃圾,也就是无用的内存。在面向对象的世界里,所谓的垃圾指的是那些不可触及的对象,它们能够被安全地回收重新用于应用程序。在现代的编程语言里,这些对象大多都分配在堆中。所以现代所提到的垃圾回收指的也就是回收堆中分配的无用内存,本文将从这个角度来阐述垃圾回收的问题。那么如何来检测无用内存,如果合理而有效地释放这些垃圾,这也就是自动垃圾回收所要处理的两大问题。由于JDK.NET虚拟平台中都集成了自动垃圾回收,随着这两个平台日益被广大程序员所亲睐,对于自动垃圾回收的研究更是如火如荼。本文在前半部分阐述了自动垃圾回收用到的基本算法,在后半部分我们将针对这两个平台的自动垃圾回收机制进行简单的阐述,并提供实践方面的参考。

2. 基本概念

在进行垃圾回收的时候,我们往往需要处理两个问题,一个是如何检测垃圾,另外一个就是如何释放这些垃圾。下面我们通过下图来分别阐述现代垃圾算法中提到的一些基本概念:

图一 垃圾和存活对象

 

n  检测垃圾

在释放垃圾之前,我们需要通过一种有效的机制来区分垃圾和存活对象。当然,我们可以有两个策略来处理这个问题:找出垃圾和找出存活对象。现代的垃圾回收算法往往采用的是第二种策略。堆中所有的存活对象都被一个所谓的“根集”所直接或间接引用,反之则是垃圾。这里所提到的“根集”指的是那些由程序中的参数、局部变量以及全局变量组成的集合。

n  释放垃圾

通过垃圾检测,我们区分出了垃圾和存活对象。下一步我们要做的就是释放这些垃圾,同时我们还需要兼顾回收的效率、实时性以及内存碎片问题。仍旧可以有两种策略:抢救存活对象和清除垃圾。现代的垃圾算法大都采用的是第一种策略,因为这些策略灵活性很大而且可以兼顾上面提到的几点考虑。

n  根集

指的是那些由程序中的参数、局部变量以及全局变量组成的集合。上图中的根集为{R1,R2}

n  存活对象

指的是以根基中的某个变量为起点通过图的深度优先算法可触及的那些应用对象。上图中的存活对象为{A,B,F}

n  垃圾

指的是以根基中的某个变量为起点通过图的深度优先算法不可触及的那些引用对象。上图中的垃圾为{C,D,E}

3. 简单示例

在这节中,根据图一,我们将演示一个简单的垃圾处理流程。如下:

1.         找出存活对象和垃圾

下图中“红色背景”所标记出来的对象多为存活对象

 

图二 找出存活对象和垃圾

 

2.         采用拷贝策略抢救存活对象

将上图中的{A,B,F}拷贝到堆中的另外一个位置,如下图

 

图三 垃圾区域    

  

 图四 存活对象区域

 

3.         释放垃圾

内存状态如下图:

图五 垃圾区域                                            

图六 存活对象区域

4. 垃圾回收算法

垃圾回收多发生在当前应用程序不忙或者系统内存吃紧的时候。上面我们提到了垃圾回收解决了两个问题:检测垃圾和释放垃圾。检测垃圾的算法大致可分为两类:传统的引用计数和现代的引用跟踪。释放垃圾的算法大致可分为:即时清除,压缩堆栈,拷贝存活对象。

通过组合上述的检测垃圾和释放垃圾的算法,衍生出了许多实用算法,如分代收集算法、自适应算法等。目前.NETJDK使用的都是分代收集算法的增强版本。

下面我们将对这些基本算法做简单介绍。

4.1. 垃圾检测算法

4.1.1.   引用计数

    引用计数法是唯一没有使用根集的垃圾回收算法,该算法使用引用计数器来区分存活对象和不再使用的对象。一般来说,堆中的每个对象对应一个引用计数器。当每一次创建一个对象并赋给一个变量时,引用计数器置为1。当对象被赋给任意变量时,引用计数器每次加1。当对象出了作用域后(该对象丢弃不再使用),引用计数器减1,一旦引用计数器为0,对象就满足了垃圾收集的条件。

虽然基于引用计数器的垃圾收集器运行较快,不会长时间中断程序执行,非常适宜实时运行的程序。但这种算法有很多致命的缺陷:

n  增加了程序执行的开销

因为每次对象赋给新的变量 ,计数器加1,而每次现有对象出了作用域生,计数器减1

n  这种算法无法检测到引用环

这个缺陷导致的最终结果是,当程序中存在引用环的时候,环中引用的对象将永远不会得到释放,从而会导致内存泄露。

4.1.2.   引用跟踪

引用跟踪算法是为了解决引用计数法的问题而提出,它使用了根集的概念。基于引用跟踪算法的垃圾收集器从根集开始扫描,识别出哪些对象可达,哪些对象不可达,并用某种方式标记可达对象,例如对每个可达对象设置一个或多个位。

4.2. 垃圾释放算法

垃圾释放算法在垃圾回收中处于最重要的位置。因为它决定垃圾回收算法的效率、安

全性和实时性。

如果在堆栈中分配了很多占用内存很小的对象,使用上面描述的方式进行自动垃圾回收回造成内存碎片,不但会影响到程序本身的运行效率,而且会造成内存浪费。因此我们需要有一种方式对这些内存碎片进行压缩和整理,最大程度地提高内存的使用率。

因为一个垃圾回收器往往都管理这一个或者多个应用程序的内存。因此,在进行内存碎片的压缩和整理的时候,我们就需要将垃圾回收涉及到的每个应用都挂起,以防止内存访问违例。当然,也有很多复杂的GC为了提高应用程序的效率,采取了与应用程序并发或者并行运行的测路。

4.2.1.   即时清除

扫描堆以寻找未标记对象并释放它们的内存,但并不对这些垃圾进行整理。这种策略很容易导致内存碎片。

4.2.2.   拷贝存活对象

把存活对象复制到堆栈的新域中以便压缩堆。这种策略需要中止当前正在运行的程序。

4.3. 常见垃圾收集器

4.3.1.   标记-清除收集器

这种收集器首先遍历对象图并标记可到达的对象,然后扫描堆栈以寻找未标记对象并释放它们的内存。这种收集器一般使用单线程工作并停止其他操作。

4.3.2.   标记-压缩收集器

有时也叫标记-清除-压缩收集器,与标记-清除收集器有相同的标记阶段。在第二阶段,则把标记对象复制到堆栈的新域中以便压缩堆栈。这种收集器也停止其他操作。

4.3.3.   复制收集器

这种收集器将堆栈分为两个域,常称为半空间。每次仅使用一半的空间,生成的新对象则放在另一半空间中。GC运行时,它把可到达对象复制到另一半空间,从而压缩了堆栈。这种方法适用于短生存期的对象,持续复制长生存期的对象则导致效率降低。

4.3.4.   增量收集器

增量收集器把堆栈分为多个域,每次仅从一个域收集垃圾。这会造成较小的应用程序中断。

4.3.5.   分代收集器 

.NET使用分代收集器。这种收集器把堆栈分为两个或多个域,用以存放不同寿命的对象。生成的新对象一般放在其中的某个域中。过一段时间,继续存在的对象将获得使用期并转入更长寿命的域中。分代收集器对不同的域使用不同的算法以优化性能

4.3.6.   并发收集器

并发收集器与应用程序同时运行。这些收集器在某点上一般都不得不停止其他操作以完成特定的任务,但是因为其他应用程序可进行其他的后台操作,所以中断其他处理的实际时间大大降低。

4.3.7.   并行收集器

并行收集器使用某种传统的算法并使用多线程并行的执行它们的工作。在多CPU机器上使用多线程技术可以显著的提高应用程序的可扩展性。

5. 分代垃圾收集

相对标记-清除、标记-压缩等这些传统的垃圾收集器,分代垃圾收集具有更好的效率和灵活性,目前被广泛用于.NETJVM的垃圾回收。所以在这节里,我们对这种垃圾收集策略进行详细的描述。

5.1. 为什么?

在任何一个应用程序堆中,一些对象在创建后很快就成为垃圾,另一些则在程序的整个运行期间一直保持生存。经验分析表明,对于大多数面向对象的语言,包括 Java 语言,绝大多数对象――可以多达 98%(这取决于您对年轻对象的衡量标准)是在年轻的时候死亡的。可以用时钟秒数、对象分配以后内存管理子系统分配的总字节或者对象分配后经历的垃圾收集的次数来计算对象的寿命。但是不管您如何计量,分析表明了同一件事――大多数对象是在年轻的时候死亡的。大多数对象在年轻时死亡这一事实对于收集器的选择很有意义。特别是,当大多数对象在年轻时死亡时,复制收集器可以执行得相当好,因为复制收集器完全不访问死亡的对象,它们只是将活的对象复制到另一个堆区域中,然后一次性收回所有的剩余空间。那些经历过第一次垃圾收集后仍能生存的对象,很大部分会成为长寿的或者永久的对象。根据短寿对象和长寿对象的混合比例,不同垃圾收集策略的性能会有非常大的差别。当大多数对象在年轻时死亡时,复制收集器可以工作得很好,因为年轻时死亡的对象永远不需要复制。不过,复制收集器处理长寿对象却很糟糕,它要从一个半空间向另一个半空间反复来回复制这些对象。相反,标记-整理收集器对于长寿对象可以工作得很好,因为长寿对象趋向于沉在堆的底部,从而不用再复制。不过,标记-清除和标记-理整收集器要做很多额外的分析死亡对象的工作,因为在清除阶段它们必须分析堆中的每一个对象。

分代收集器将老对象和年轻对象按照存活时间的不同,分别放在不同的域中。在进行垃圾回收的时候,优先处理那些年轻的对象,而对于那些老对象基本不做回收,这样不但可以提高垃圾回收的效率,而且还可以提高垃圾回收的实时性。

5.2. 怎样处理?

分域

将堆分为几个固定或可变的区域用于分别存放年轻对象和老对象。如.NET将托管堆(在后面有提到)分为三个区域,每个域中方不同代(共3代)的对象。

 

跟踪代间引用

不像传统的标记-清除等垃圾收集器那样从根集开始扫描,遍历所有的对象间的引用,分代垃圾收集器也从根集开始扫描,但并不遍历指向更老一代的引用。虽然这样可以减少扫描的对象图的大小。但是这也带来一个问题――如果更老一代中的对象引用一个不能通过从根开始的所有其他引用链到达的更年轻的对象该怎么办?

为了解决这个问题,分代收集器必须显式地跟踪从老对象到年轻对象的引用并将这些老到年轻的引用加入到小的收集的根集中(这种根集称为RememberedSet)。

那么我们应该如何地区跟踪老对象到年轻对象的引用?

做到这一点的一种方法是跟踪老的代,但是这显然有很大的开销。更好的一种方法是线性扫描老的代以查找对年轻对象的引用。这种方法比跟踪更快并有更好的区域性(locality),但是仍然有很大的工作量。赋值函数(mutator)和垃圾收集器可以共同工作以在创建老到年轻的引用时维护它们的完整列表。当对象提升为更老一代时,垃圾收集器可以记录所有由于这种提升而创建的老到年轻的引用,这样就只需要跟踪由指针修改所创建的代间引用。垃圾收集器可以有几种方法跟踪由于修改现有对象中的引用而产生的老到年轻的引用。它可以使用在引用计数收集器中维护引用计数的同样方法(编译器可以生成围绕指针赋值的附加指令)跟踪它们,也可以在老一代堆上使用虚拟内存保护以捕获向老对象的写入。另一种可能更有效的虚拟内存方法是在老一代堆中使用页修改脏位(page modification dirty bit),以确定为找到包含老到年轻指针的对象时要扫描的块。

 

如何处理循环引用?

这是每个垃圾收集器都无法避免的问题。而且这种循环引用到处存在,所以我们需要有一种很有效率的方式来处理这个问题。当然目前有很多算法可以处理这个问题,其中就包括一个很著名的算法-火车算法(Train Algorithm)。

 

释放垃圾

目前的分代垃圾回收器中,广泛的采用的方式是拷贝策略。将存货的对象拷贝到更老的域中,如果所有的域都已经满的时候,垃圾回收器将会抛出一个内存异常。

6. 垃圾回收应用

在这节里,我们分别就目前广泛应用的两个垃圾收集器做简单的介绍。因为个人喜好的原因,我们将详细阐述.NET的垃圾回收。

6.1. JDK的垃圾回收

在默认情况下,JDK将堆分为两部分,一个年轻的代和一个老的代(实际上,还有第三部分――永久空间,它用于存储装载的类和方法对象)。借助于复制收集器,年轻的代又分为一个创建空间(通常称为 Eden)和两个生存半空间。老的代使用标记-整理收集器。对象在经历了几次复制后提升到老的代。小的收集将活的对象从 Eden 和一个生存半空间复制到另一个生存半空间,并可能提升一些对象到老的代。大的收集(major collection)既会收集年轻的代,也会收集老的代。 System.gc() 方法总是触发一个大的收集,这就是应该尽量少用(如果不能完全不用的话) System.gc() 的原因之一,因为大的收集要比小的收集花费长得多的时间。没有办法以编程方式触发小的收集。

6.2. .NET的垃圾回收

.NET为广大的程序员提供了很多灵活而且强大的功能,以帮助程序员提高开发效率。其中一个很重要的方面就是垃圾回收,.NET的垃圾回收策略采用的也是分代垃圾回收。

6.2.1.   基本原理

微软实现了一个分代垃圾回收器(Generational Garbage Collector)。分代垃圾回收器(Generational Garbage Collector)将内存分为很多个托管堆,每一个托管堆对应一种生存期等级。生存期垃圾回收器遵循着下面的原则:

新生成的对象,其生存期越短;而对象生成时间越长的对象,其生存期也就越长。对于垃圾回收器来说,回收一部分对象总是比回收全部对象要快,因此垃圾回收器对于那些生存期短的对象回收的频率要比生存期长的对象的回收频率高。

.NET中的垃圾回收器中目前有三个生存期等级:012012等级对应的托管堆的初始化大小分别是256K2M10M。垃圾回收器在发现改变大小能够提高性能的话,

会改变托管堆的大小。例如当应用程序初始化了许多小的对象,并且这些对象会被很快回收的话,垃圾回收器就会将0等级的托管堆变为128K,并且提高回收的频率。如果情况相反,垃圾回收器发现在0等级的托管堆中不能回收很多空间时,就会增加托管堆的大小。

在应用程序初始化的之前,所有等级的托管堆都是空的。当对象被初始化的时候,他们会按照初始化的先后顺序被放入等级为0的托管堆中。在托管堆中对象的存放是连续的,这样使得托管堆存取对象的速度很快,因为托管对不必对内存进行搜索。垃圾回收器中保存了一个指针指向托管堆中最后一个对象之后的内存空间。

0等级托管堆被对象填满后,例如候程序初始化了新的对象,使0等级托管堆的大小超过了256K,垃圾回收器会检查托管堆中的所有对象,看是否有对象可以回收。当开始回收操作时,如前面提到的,垃圾回收器会找出根节点和根节点直接或间接引用了的对象,然

后将这些对象转移到1等级托管堆中,并将0等级托管堆的指针移到最开始的位置以清除所有的对象。同时垃圾回收器会压缩1等级托管堆以保证所有对象之间没有内存空隙。当1等级托管堆满了之后,会将对象转移到2等级的托管堆。

生存期垃圾回收器的原则也有例外的情况。当对象的大小超过84K时,对象会被放入"大对象区"。大对象区中的对象不会被垃圾回收器回收,也不会被压缩。这样做是为了强制垃圾回收器只能回收小对象以提高程序的性能。

6.2.2.   处理方式

.NET垃圾回收器设置了一个托管堆,托管堆中对象的存放是连续的。每次当开发人员使用 new 运算符创建对象时,运行库都从托管堆为该对象分配内存。新创建的对象被放在上次创建的对象之后。垃圾回收器保存了一个指针,该指针总是指向托管堆中最后一个对象之后的内存空间。当新的对象被产生时,运行库就知道应该将新的对象放在内存的什么地方。同时开发人员应该将相同类型的对象放在一起。它利用了程序局部性的原理,因此对于对象的存取具有比较高的效率。

当垃圾回收器的指针指向托管堆以外的内存空间时,就需要回收内存中的垃圾了。在这个过程中,垃圾回收器首先假设在托管堆中所有的对象都需要被回收。然后它在托管堆中寻找被根对象引用的对象(根对象就是全局,静态或处于活动中的局部变量以及寄存器指向的对象),找到后将它们加入一个有效对象的列表中,并在已经搜索过的对象中寻找是否有对象被新加入的有效对象引用。直到垃圾回收器检查完所有的对象后,就有一份根对象和根对象直接或间接引用了的对象的列表,而其它没有在表中的对象就被从内存中回收。

当对象被加入到托管堆中时,如果它实现了finalize()方法,垃圾回收器会在它的终结列表(Finalization List)中加入一个指向该对象的指针。当该对象被回收时,垃圾回收器会检查终结列表,看是否需要调用对象的finalize()方法。如果有的话,垃圾回收器将指向该对象的指针加入一个完成器队列中,该完成器队列保存了那些准备调用finalize()方法的对象。到了这一步对象还不是真正的垃圾对象。因此垃圾回收器还没有把他们从托管堆中回收。

当对象准备被终结时,另一个垃圾回收器线程会调用在完成器队列中每个对象的finalize()方法。当调用完成后,线程将指针从完成器队列中移出,这样垃圾回收器就知道在下一次回收对象时可以清除被终结的对象了。从上面可以看到垃圾回收机制带来的很大一部分额外工作就是调用finalize()方法,因此在实际编程中开发人员应该避免在类中实现finalize()方法。

对于finalize()方法的另一个问题是开发人员不知道什么时候它将被调用。为了解决这个问题,在.NET中提供了一个接口IDisposable。微软建议在实现带有fianlize()方法的类的时侯按照下面的模式定义对象:

public class Class1 : IDisposable {

 public Class1(){}

~Class1 (){

Dispose (false);

 }

public void Dispose (){

 //该方法由程序调用,在调用该方法之后对象将被终结。

 //因为我们不希望垃圾回收器再次终结对象,因此需要从终结列表中去除该对象。

GC.SuppressFinalize (this);

//因为是由程序调用该方法的,因此参数为true

Dispose (true);

 }

 //所有与回收相关的工作都由该方法完成

protected virtual void Dispose(bool disposing){

//处理竞态条件

             lock(this) {

 if (disposing){

             //需要程序员完成释放对象占用的资源。

      }

 //对象将被垃圾回收器终结。在这里添加其它和清除对象相关的代码。

      }

}

}

原创粉丝点击