垃圾收集算法-分代理论
内容摘抄自《深入理解Java虚拟机 第三版》
3.3 垃圾收集算法
从如何判定对象消亡角度出发,垃圾收集算法可划分为引用计数式垃圾收集(Reference Counting GC)和追踪式垃圾收集(Tracing GC),这两类也被称为直接垃圾收集和间接垃圾收集。本文所有的算法都是追踪式垃圾收集
3.3.1 分代收集理论
当前的商业虚拟机的垃圾收集器,大多都遵循了“分代收集”(Generation Collection)的理论设计的,分代收集的著名理论,是指实质是一套符合大多数程序运行实际情况的经验法则,它建立的两个假说之上:
- 弱分代假说(Weak Generation Hypothesis):绝大部分对象都是朝生夕死的
- 强分代假说(String Generation Hypothesis):熬过越多次垃圾收集过程的对象就越难死亡
这两个假说共同奠定了多款常用的垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的内存区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域中存储。
显而易见,如果一个区域的大多数对象都朝生夕死,难以熬过垃圾收集过程的话,那么把它们集中在一起,每次垃圾收集的时候只关注如何保留少量的存活,而不是去标记那些大量将要被回收的对象,就能以极低的代价回收大量空间。
如果剩下的都是难以消亡的对象,把它们集中放在一块,虚拟机可以以极低的频率来回收这个区域,这就能用时兼顾了垃圾收集的时间开销和内存空间的有效利用
在Java堆划分出不同区域之后,垃圾收集器才可以每次只回收其中某一个区域或者某一部分区域——因而才有了Minor GC,Major GC,Full GC这样回收类型的划分;也才能针对不同区域安排与其里面存储的对象的存亡特征匹配的垃圾收集算法——因而发展处了标记-复制算法,标记-清除算法,标记-整理算法等针对性的垃圾收集算法。
把分代收集理论放到现在的商用Java虚拟机中,设计者一般至少把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。
顾名思义,在新生代中,每次垃圾收集时都会发现大批的对象死去,而每次回收后的存活的少量对象,将会逐步晋升到老年代中存放。大多数虚拟机,如HotSpot而言,虚拟机原码中包含一些名为*Generation的实现,如DefNewGeneration和ParNewGeneration等分代式垃圾收集框架。原本HotSpot是鼓励开发者在这个框架内开发新的垃圾收集器,但是除了最早期的两组四款收集器外,后来开发者并没有遵循这个框架。导致此事的原因很多,最根本原因是分代的垃圾收集理论仍在不断发展之中,如何实现也有很多细节可以改进,被既定的框架约数反而不便。其实,思考一下,就容易发现出分代的垃圾收并非简单的划分一下内存区域那么简单,它至少存在一个困难:对象不是孤立的,对象之间会存在跨代引用
假如要进行一次局限于新生代区域的内存收集(Minor GC),但新生代对象完全可能会被老年代引用,为了找到该区域的存活对象,不得不在固定的GC Roots之外,在遍历整个老年代中所有对象确保可达性分析的正确性,反之也是一样。遍历整个老年代理论上可行,但无疑会给内存回收带来负担。为了解决这个问题,就需要堆分代收集理论添加第三条经验法则:
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对同代引用来说占少数
这其实是根据前面两条假说逻辑推理出的隐含推论:存在相互引用关系的两个对象应该倾向于同时生存和死亡
举个例子来说:如果某个新生代对象存在跨年代引用,由于老年代难以消亡,那么该引用使得新生代对象在垃圾收集时得以生存,进而在年龄上增加,长久之后就会晋升到老年代,那么跨年代引用就消除了
依据这条假说,我们就不应该再为少量的跨年代引用而去扫描整个老年代,也不必浪费空间去专门记录每一个对象是否存在及存在那些跨年代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”, Remembered Set),这个结构会把老年代划分成若干个小块,标识出老年代那个块内存存在跨年代引用。此后,当发生Minor GC的时,只有包含跨代引用的小块内存里的对象才会被加入到GC Roots里进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或某个属性进行赋值)时维护数据的正确性,会增加一些运行时的开销,但比起扫描整个老年代还是划算的。
部分收集(Partial GC):指目标不是完整的收集整个Java堆的垃圾收集,其中分为
1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
2. 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独老年代收集
3. 混合收集(Mixed GC):指目标是收集整个新生代和部分老年代的垃圾收集。目前只有G1收集器会有这种行为
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
注: Major GC在不同的资料中可能有不同所指
2021-09-16
3.3.2 标记-清除算法
最早出现也是最基础的垃圾收集算法是标记-清除(Mark-Sweep),在1960年提出。如它的名字一样,算法分为标记和清除两个阶段:首先标记出所需要回收的对象,在标记完成后统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收掉未被标记的对象。
之所以说它是最基础的算法,是因为后续的垃圾收集算法大都是以标记-清除算法为基础,对其缺点改进得到的。
它的主要缺点有两个:
- 执行效率不太稳定,如果Java堆中包含大量的对象,其中大部分是要被回收的,这是必须要进行大量的标记与清除动作,导致标记和清除两个过程的执行效率都随对象数量的增长而降低
- 内存空间碎片化的问题,标记、清除后会产生大量的不连续的内存碎片,空间碎片太多会导致当以后程序运行过程中需要分配较大的对象无法找到足够的连续内存而不得不提前触发另一次垃圾收集
标记-清除算法的执行过程如下图3-2所示
3.3.3 标记-复制算法
标记-复制算法常被称之为复制算法,为解决标记-清除算法面对大量可回收对象时执行效率低下的问题,1969年提出了一种称为半区复制(Semispace Copying)的垃圾收集算法,它将内存按容量划分成大小相等的两块,每次只使用其中一块。当一块(A块)用完了,它将(A块)还存活的对象复制到另一块(B块)内存上,然后把(A块)已使用过的内存空间一次清除掉。如果内存中多数对象都是存活的,这种算法将产生大量的内存键复制开销,但对于多数对象都是可回收的情况,该算法就只需复制占少数的存活对象,而且每次只针对半区进行内存回收,分配对象时也不需要考虑空间碎片的复杂性,只要移动堆顶指针,按顺序分配即可。这样实现简单,高效,但是缺陷也是显而易见,这种复制回收算法的代价是将可用的内存缩小为原来一般,空间浪费未免太多了。标记-复制算法如下图3-3所示
现在商用的Java虚拟机大都采用了这种收集算法去回收新生代,IBM公司曾有一项研究显示:新生代中对象有98%熬不过第一轮回收。因此不需要按照1:1的比例来划分新生代内存空间
1989年,提出了一种更优化的半区复制分代策略,现在称为“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%会被浪费掉。当Survivor的内存不足以容纳一次Minor GC后存活的对象时,就需要依赖其他内存区域(实际上大多是老年代)进行分配担保(Handle Promotion)
2021-09-23
3.3.4 标记-整理算法
标记-复制算法在对象存活率比较高时需要进行较多的复制操作、效率会降低。更关键的是如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用内存中所有对象都100%存活的极端情况,所以老年代不适用这种算法。
针对老年代的对象存亡特征,1974年提出例外一种有针对性的标记-整理(Marked-Compact)算法,其中标记过程仍和标记-清除算法一样,但后续操作不是直接对可回收对象进行清理,而是让存活对象都向内存空间的一端移动,然后直接清除掉边界以外的内存,标记-整理算法示意图如下图3-4所示
标记-清除算法与标记-整理算法的本质差别在于前者是一种非移动式回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
-
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而这种移动操作必须暂停用户应用进程才能进行(最新的ZGC和Shenandosh收集器使用读屏障,实现了整理规程与用户线程并发执行),这就让使用者不得不权衡它的利弊了,想这样的停顿被最初的虚拟机设计者称为Stop The World
-
但如果像标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。内存访问是用户程序最为频繁的操作,甚至没有之一,假如在这个环节增加了额外负担,势必会直接影响应用程序的吞吐量
基于以上两点,是否移动都存在弊端,移动则内存回收时会更复杂,不移动内存分配时会更复杂。从垃圾收集器的停顿时间来看,不移动对象停顿时间会更短,甚至不需要停顿,但从整个应用程序的吞吐量来看,移动会更划算。此语境的吞吐量是指复制器(Mutator,可以理解为使用垃圾收集器的用户程序)与收集器的效率总和。即时不移动对象会是收集器的效率提升一些,但因内存分配和访问相比垃圾收集器要搞得多,这部分耗时增加,总的吞吐量仍然是下降。HotSpot虚拟机里面的最关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证了这一点。
另外,还有一种和稀泥式解决方案,解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机在平时大多是时间都采用标记-清除算法,暂时容忍内存碎片化的存在,知道内存碎片化程度已经大到影响内存分配时,再采用一次标记-整理算法进行收集一次,已获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片化过多时采用的就是这种处理方法。
2021-09-25 END