一、分代收集算法
01当前主流的虚拟机都是采用分代收集算法,然而分代收集算法并不是一个具体的算法,而是一个分代收集的理论。
它是一套符合大多数程序运行实际情况的经验法则,并且是建立在两个分代假说之上:
弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
由这两个分代假说共同说共同奠定垃圾收集器一致的设计原则:
将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
那么下面就来看看Java堆的划分图
如果你们觉得分代收集就是这么简单的划分一下的话,那就是你们想的太过于简单了一些。
我们要知道一点:对象不是孤立的,对象之间会存在跨代引用。(年轻代,老年代)
现在我们假设一下只针对年轻代区域进行一次垃圾收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样(通常能单独发生收集行为的只是新生代,所以这里“反过来”的情况只是理论上允许,实际上除了CMS收集器,其他都不存在只针对老年代的收集)。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。
为了解决这个问题,就需要对分代收集理论再添加第三条经验法则:
跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
不知道你们是不是理解这句话:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的
举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以消灭而存活下来,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
依据跨代引用这条假说,我们就可以不必再为了少量的跨代引用而去扫描整个老年代,也不必浪费空间去专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
对于不同的内存区域,分代收集算法的各收集类别如下:
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
二、标记-清除算法
标记清楚算法是最早也是最基础的算法,在1960年由Lisp之父John McCarthy所提出。
之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
该算法分为“标记”和“清除”两个阶段:
标记:从引用根节点开始标记遍历所有的GC Roots, 先标记出要回收的对象。
清除:遍历整个堆,把标记的对象清除。
它的主要缺点有两个:
执行效率不稳定:如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
内存空间的碎片化问题:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
执行过程图:
三、标记-复制算法
标记-复制算法常被简称为复制算法。
上面说了,标记-清楚算法在面对大量可回收对象时执行效率低,为了解决这一问题1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
复制算法简单点理解就是,当使用的一块内存用完后,将存活的对象复制到另一块中,然后再一次性把使用过的内存空间全部清楚掉。
看上去,这个算法很好,但是如果存活的对象过多的话,复制存活的对象这一过程无疑会产生过多的开销,所以该算法只适合对象都是朝生夕死的内存区域(年轻代)。
这种算法现简单,运行高效,不过其缺陷也显而易见,复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。
执行过程图:
前面讲到过,在新生代中大部分对象都是朝生夕死的,根本熬不过第一轮垃圾收集,所以并不需要 1:1 的来划分新生代的内存空间
你们可以看上面分代收集算法的那张图,HotSpot虚拟机将年轻代分为 Eden 和 Survivor 两个区域,而 Survivor 又分 Survivor from 和 Survivor to 两个部分,它们的比例默认 8 : 1 : 1。
垃圾回收时,复制算法的执行过程:
新生对象分配到 Eden 区(也有直接分配到老年区,如大对象),该区内存不足时
回收 Eden 区域和 Survivor from 的对象,接着将存活对象复制到 Survivor to 区并给存活的对象年龄(存活次数,当到达15次后转移到老年代)加一
将原来 Survivor to 区变为原先的 Survivor from 区(以便于下次垃圾回收)原先的 Survivor from 变为 Survivor to
看我所画的图,复制算法时,每次垃圾回收之后,都会将 Eden 区域和 Survivor from 存活的对象直接复制到 Survivor to 区,那么现在有个问题,如果存活的对象过多, Survivor to 区的空间不足以容纳垃圾回收之后存活的对象这怎么办?
这时就要分配担保机制出来镇场子了。
分配担保:当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。
它就像我们去银行贷款,如果我们信誉很好且有能力偿还的情况下,银行会默认我们有能力偿还这次贷款,但还是要有一个人来担保这次贷款。保证我们在不能偿还的情况下,可以从担保人账户中扣钱,这样银行就没有什么风险。
内存的分配担保也一样,如果 Survivor to 空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。关于对新生代进行分配担保的内容,我会在后续博客中写出(任重道远!😎)
四、标记-整理算法
在年轻代,对象一般都是朝生夕死,所以复制算法适合用在该区域。
但是老年代不一样,对象大多都是饱经沧桑的老将了,被垃圾回收掉的对象很少,这时老年区就会有大量的存活对象,如果再用标记-复制算法就有点不太适合了,所以标记-整理算法横空出世。
1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
执行过程图:
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
而对于移动或是不移动存活的对象是一种优缺点并存的风险决策:
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(也有收集器可以并发执行),这就让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿我们形象地描述为“Stop The World”。
但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
吞吐量的实质是赋值器(Mutator,可以理解为使用垃圾收集的用户程序,本篇内容为便于理解,多数地方用“用户程序”或“用户线程”代替)与收集器的效率总和。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。
从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。
HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。
另外,还有一种“和稀泥式”解决方案(标记-清除-整理(Mark-Sweep-Compact))可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
五、总结
内存效率 | 复制算法 > 标记清除算法 > 标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此) |
内存整齐度 | 复制算法 = 标记整理算法 > 标记清除算法。 |
内存利用率 | 标记整理算法 = 标记清除算法 > 复制算法。 |
可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记-整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记-清除多了一个整理内存的过程。
难道就没有一种最优算法吗?
回答:无,没有最好的算法,只有最合适的算法。------------->分代收集算法(理论)。
对于年轻代,其特点是区域相对老年代较小,对像存活率低。
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对像大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
对于老年代,其特点是区域较大,对像存活率高。
这种情况,存在大量存活率高的对像,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现。
那么,今天的内容就到这里了,我们下期见咯!
结束语
- 由于博主才疏学浅,难免会有纰漏,假如你发现了错误或偏见的地方,还望留言给我指出来,我会对其加以修正。
- 如果你觉得文章还不错,你的转发、分享、点赞、留言就是对我最大的鼓励。
- 感谢您的阅读,十分欢迎并感谢您的关注。