JVM垃圾收集器与内存分配策略(二)—— 垃圾收集算法

1、垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可划分为 “引用计数式垃圾收集”(Reference Counting GC)和 “追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作 “直接垃圾收集” 和 “间接垃圾收集”。

1.1、分代收集理论

当前商业虚拟机的垃圾收集器,大多数遵循了 “分代收集”(Generational Collection)的理论进行设计,它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
    这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分区域——因而才有了“Minor GC”、“Major GC”、“Full GC”这样的回收类型划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”、“标记-清楚算法”、“标记-整理算法”等针对性的垃圾收集算法。

1.2、标记-清除算法

最早出现也是最基础的垃圾收集算法——“标记-清除算法”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。也可以反过来,标记存活的对象,统一回收所有未被标记的对象。它的主要缺点有两个:

  1. 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行的大量标记和清除动作,导致标记和清除的两个过程的执行效率都随着对象数量增长而降低;
  2. 内存空间碎片化问题,标记、清除之后会产生大量不连续内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存空间而不得不提前触发另一次垃圾收集动作。
    标记-清除算法执行过程如图:
    “标记-清除”算法示意图

1.3、标记-复制算法

标记-复制算法常被称为复制算法。为了解决标记-清除面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半复制区”(Semispace Copying)的垃圾收集算法,它将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,将还存活的对象复制到另一块上面,然后再把自己使用过的内存空间一次清理掉。如果内存中的多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制算法的代价是将可用内存缩小了原来的一半,空间资源浪费。标记-复制算法执行过程如图:
标记-复制算法示意图
现在的商用虚拟机大都优先采用了这种收集算法回收新生代,IBM曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此不需要按照1 :1的比例来划分新生代的内存空间。
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为”Appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略会设计新生代的内存布局。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中任然存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉Eden和已经用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8 : 1,也就是每次新生代可用的空间为整个新生代容量的90%,(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没办法百分百保证每次回收都只有不超过10%的对象存活,一次Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后村后的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

1.4、标记-整理算法

针对老年代的对象存亡特征,1974年Eward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Sweep)算法,其中标记过程任然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向着内存空间一端移动,然后直接清理掉边界意外的内存。“标记-整理”算法示意图:
“标记-整理”算法示意图
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的会后算法,而后者是移动式的。
是否移动回收后的存活对象是一项优缺点并存的风险决策:
如果移动存活对象,尤其是老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这对象的地方将会是一种几位负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,像这样的停顿被最初的设计者形象地描述为“Stop The World”。
但如果跟标记-清除算法一样完全不考虑移动和整理存活对象的话,弥散与堆中的存活对象导致的空间碎片化问题只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至没有之一,加入这个环节增加额外负担,势必会直接影响用户应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间更短,甚至可以不需要停顿,但是从整个应用程序的吞吐量来看,移动对象会更划算。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章