Java内存回收(2)——垃圾回收算法

时间:2022-12-27 21:36:53

如果还没看过第一篇的朋友请移步:JAVA内存回收(1)—深入浅出Java垃圾回收机制
 

任何垃圾收集算法必须完成两件事情。首先,它必须检测出垃圾对象。其次,它必须回收垃圾对象所占用的堆空间并使之对程序重新可用。

  垃圾检测通常通过定义一个根引用集并计算其可达对象集的方式来实现。一个对象,如果可以通过某条始于根引用的引用路径而被执行程序访问到的话,则称其为可达的(reachable)。对程序而言,根引用始终是可以访问的。一个对象如果是可达的,则称其为活动对象;否则就被称为垃圾,因为它对程序的未来执行不再有任何影响。
  根引用集的构成取决于JVM的具体实现,但总是包含所有栈帧中局部变量区和操作数栈所持有的引用以及保存在静态变量中的引用。根引用的另外一个来源是常量池,已装载类的常量池可能保存有对堆中一些字符串的引用,这些字符串往往是类名、父类名、父接口名、域名、域签名、方法名和方法签名等。根引用还有一个可能来源就是那些传递给本地方法(native method)但尚未被其释放的引用。根引用的另一个潜在来源是JVM的运行时数据区,因为某些实现会把JVM运行时数据区的一部分放在堆上,例如方法区中的类数据本身。
  可达对象集包含所有通过根引用可以直接或间接被程序访问到的对象,从技术角度讲,可达对象集是“指向”关系下根引用集的传递闭包。
  区分活动对象和垃圾的两个基本方法是引用计数和追踪。JDK中的标准垃圾收集器全部采用了追踪的办法,虽然具体形式各不相同。
  

一、引用计数法(Reference Counting Collector)

  引用计数是垃圾收集的早期策略。在这种方法中,堆中的每个对象都有一个引用计数。当对象被创建并且指向其引用被赋值给一个变量后,该对象的引用计数被设置为1。以后每当其引用被赋值给一个不同的变量时,该对象的引用计数就加1。当持有该对象引用的变量离开其作用域或者被赋给一个新值时,该对象的引用计数就减1。任一对象一旦其引用计数变为0,就成为垃圾。一个对象一旦被当作垃圾收集后,它所引用的所有其他对象的引用计数必须相应递减。这样,对一个对象的垃圾收集可能引发连续的对其他对象的垃圾收集。
  该方法的好处是,引用计数收集器算法简单,适于做增量收集,对于程序不能被长时间打断的实时环境特别适合,另外,收集过程也有助于改进引用局部性。坏处就是,引用计数无法检测出不可达的循环结构(两个或多个对象之间相互引用),因为它们的引用计数永远不会为0。另一个坏处就是每次增减引用计数都带来额外开销,而且该算法还需要编译器的高度配合。正是由于这些固有缺陷,引用计数算法在生产环境中很少使用。
 

二、 tracing算法(Tracing Collector)

  追踪收集器从根引用开始探寻并描画对象引用图。探寻过程中遇到的对象会以某种方式被打上标记。通常来说该标记既可保存在对象本身,也可保存在单独的位图中。探寻结束后未被标记的对象就是不可达的对象,可被当作垃圾收集。
  基本的追踪算法被称作“标记并清理”(mark and sweep)。该名字指出了垃圾收集过程的两个阶段。在标记阶段,垃圾收集器遍历引用树,标记每一个遇到的对象。在清理阶段,未被标记的对象被释放,相应内存被返还待用。在JVM中,清理阶段必须包括对象的了结(finalization)。
  标记并清理算法实现简单,可以轻易回收循环结构,而且不存在为维护引用计数而付出的额外开销和对编译器的依赖。但是它也有不足,其中最大的问题是,在清理阶段,堆中的所有对象,不论是否可达,都会被访问。一方面这对于可能有页面交换的堆所依赖的虚存系统有着非常负面的性能影响;另一方面,因为其中很大一部分对象可能是垃圾,这就意味着垃圾收集器把大量精力都花费在检查和处理垃圾上面了。无论从哪个角度来看,该算法都可能产生收集暂停时间过长、收集开销偏大的问题。标记并清理收集器的另一个不足是它容易导致堆的碎片化,从而引发引用局部性或者大对象分配失败等方面的问题。
在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图3-1所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

Java内存回收(2)——垃圾回收算法

在Java语言中,可作为GC Roots的对象包括下面几种:

虚拟机栈(栈帧中的本地变量表)中引用的对象。

方法区中类静态属性引用的对象。

方法区中常量引用的对象。

本地方法栈中JNI(即一般说的Native方法)引用的对象

三、compacting算法(Compacting Collector)

  JVM的垃圾收集器很可能拥有一个对付堆碎片的策略。标记并清理收集器通常采用的两种策略是压缩或拷贝。这两种方法都是通过快速移动对象来减少堆碎片。压缩收集器把活动对象越过空闲内存区滑动到堆的一端,在这个过程中,堆的另一端就变成了一块大的连续空闲区。所有指向被移动对象的引用也被更新,指向新的位置。
  为了更好的理解压缩过程,可以将堆比作书架的一格,其中一部分放满了不同厚度的图书。空闲空间就是图书之间的空隙。压缩就是将所有图书朝一个方向推移,以弥合所有空隙。它从最靠近隔板的图书开始,将它推向隔板,然后将离隔板第二近的图书推向第一本图书,接着将第三本图书推向第二本图书,依此类推。最后,所有图书在一端,所有空闲空间在另一端。
  被移动对象的引用更新可以通过为对象引用添加一层间址而得到简化。对象引用不再直接指向堆中的对象,而是指向对象句柄表中的一个表项,该表项中的对象句柄才直接指向堆中的实际对象。这样,当对象被移动时,只需在对象句柄表中更新其句柄,执行程序中所有指向该对象的引用都不必再更新。这种方法简化了消除堆碎片的工作,但增加了每一次对象存取的开销。
  

四、copying算法(Coping Collector)

  拷贝收集器同样使用追踪技术,它把所有的活动对象移动到一个新的区域,而原有区域就全部变成了空闲空间。由于被移动对象在新的区域中被紧挨着放置,因而在原有区域时对象之间可能存在的空隙也被消除。对象的拷贝可以在追踪过程中即时进行,不必通过标记和清理两个单独的阶段来完成。对象被实时拷贝到新的区域后,它在原有区域中的副本被一个转向指针(forwarding pointer)所取代,该指针指向该对象在新的区域中的副本。转向指针让垃圾收集器检测出那些引用(其所指对象已经被移动到新的区域中)并以转向指针的值更新之,从而使它们指向对象的新位置。
  一个通用的拷贝收集器算法被称作“停止并拷贝”(stop and copy)。在这个方案中,堆被分成两个区域,任何时候都只使用其中的一个区域。对象在同一区域中分配,直到该区域的所有空间被耗尽。此时,程序执行被中止,堆被遍历,遍历过程中遇到的活动对象被拷贝到堆的另一个区域。当停止并拷贝过程完成后,程序恢复执行,对象的内存将从堆的这个新区域中分配,直到它也被用尽。那时程序将被再次中止,堆被遍历,活动对象被拷贝回原来的区域。该方案的代价是所需内存是指定堆空间的两倍,因为不论何时都只有一半的内存被使用。
  拷贝收集算法的优点是只访问活动对象,垃圾对象不会被检查,自然也无需被换页到内存或被缓存,收集过程所用时间只取决于活动对象的数量。这样既避免了不必要的收集开销,又最大限度地减少了收集暂停时间。不过,除了额外的内存消耗外,拷贝收集器还需要承担对象拷贝和引用更新所带来的成本增加。这一点在长寿对象较多时体现得更为明显,因为每次收集时它们都要被来回复制。
  

五、generation算法(Generational Collector)

  通过观察和实验,对多种语言所写的大多数应用程序而言,它们所创建的对象都具有如下特征:1)大部分对象的寿命都很短,但总有一些对象会活得足够长;2)年长对象很少引用年幼对象。以上事实也被称作“弱代假说”(weak generational hypothesis),它是分代收集算法的前提和基础。
  在这种方法中,对象按照年龄分成组(代),堆则被划分成两个或多个子堆,每个子堆服务于一代对象,因此子堆也常常被称作某某代。最年幼代被进行最频繁的垃圾收集。因为大部分对象的寿命都很短,所以只有很小一部分的最年幼对象经历首次收集后还能存活。如果一个最年幼对象在经历几次垃圾收集后依然存活,那么它将被提升到寿命更高的一代(被移动到另一个子堆中去)。与相对年幼代比较,相对年长代被垃圾收集的频率总会有所降低。随着对象在其当前代中的不断成熟(经历多次垃圾收集而不死),最终它们会被移动到较其当前代更为年长的一代中。
  分代收集技术既可应用于拷贝算法,以解决它在处理长寿对象时效率低下的问题,也可应用于标记并清理算法。不管在哪种情况下,把堆划分为对象代都有助于提高最基本的垃圾收集算法的效率。
 

六、adaptive算法(Adaptive Collector)

  自适应收集算法利用如下事实:一些收集算法在某些情况下工作的更好,而另一些则在其他情况下工作的更好。自适应收集器监控堆的当前状态并据此对其所用垃圾收集技术做相应调整。它可能只在程序运行的同时对单一收集算法的参数做出调整,也可能从一种算法快速切换到另一种算法,甚或还可能把堆划分为子堆并在不同的子堆上同时使用不同的算法。
  使用自适应方法,JVM实现的设计者无需再选择某种特定的垃圾收集技术。他们可以使用多种技术,为每种算法分配最适合它的工作。
  实际上,在现代JVM实现中,大部分垃圾收集子系统都具有某种程度的自适应能力,很多时候我们只须选择策略并设定目标便能得到满意的结果,至于具体的收集算法选择和参数配置则可交由垃圾收集子系统自行处理。Sun的HotSpot JVM中的垃圾处理子系统就是这样运作的,具体细节有时间再专门讨论。
参考:
1)Cheney’s algorithm
2)Garbage Collection - Chapter 9 of Inside the Java Virtual Machine
3)Java theory and practice: A brief history of garbage collection