JVM垃圾收集算法

发布时间 2023-05-27 15:11:40作者: 踢足球的程序猿康康

JVM垃圾收集算法

当前商业虚拟机的垃圾收集器,大多数都遵循了 “分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,分代收集理论建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象 就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:垃圾收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。顾名思义,在新生代中,每次垃圾收集时都会发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

JVM GC(Java Virtual Machine Garbage Collection)指的是Java虚拟机的垃圾回收机制。

Minor GC(年轻代)、Major GC(老年代)、Full GC(全局)

Minor GC:回收程序运行过程中产生的临时对象和无用的对象,通常只回收新生代(Young Generation)中的对象。新生代又分为Eden区和两个Survivor区(通常为S0和S1)。当新生代中的Eden区满时,触发Minor GC。在Minor GC过程中,首先会对Eden区进行垃圾回收,回收掉那些不再被引用的对象。幸存下来的对象将被移动到其中一个Survivor区。如果Survivor区无法容纳这些对象,或者某些对象已经经历过多次回收仍然存活,那么这些对象将被晋升到老年代中。

Major GC & Full GC:Major GC(Major Garbage Collection)和Full GC(Full Garbage Collection)实际上是指同一种垃圾回收操作。它们用来描述在垃圾回收算法中对整个堆内存进行标记和回收的操作。在某些情况下,有人会将Minor GC(新生代垃圾回收)和Major GC(老年代垃圾回收)区分开来,将Full GC用于表示同时回收新生代和老年代的操作。这种使用方式有助于更明确地描述垃圾回收的执行范围。

分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。假如现在要进行一次只局限于新生代区域内的垃圾收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有的对象来确保可达性分析结果的正确性,反过来也是一样。

遍历整个老年代中所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

OK,既然内存划分了不同的区域,那怎样清除它们呢?

根据先辈们的经验,大致有了以下三种垃圾收集思想:“标记-清除”(Mark-Sweep)算法、“标记-复制” 算法和“标记-整理”(Mark-Compact)算法。那我们依次来简单了解一下这三种垃圾收集算法。

标记-清除

“标记-清除”(Mark-Sweep)算法是最早出现也是最基础的垃圾收集算法,“标记-清除” 算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。也可以反过来,标记出所有存活的对象,在标记完成后,统一回收掉所有未被标记的对象。

IMG_256

该方法简单快速,但是缺点也很明显,一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制

为了解决 “标记-清除” 算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出了一种被称为 “半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完时,就将还存活着的对象复制到另外一块内存上,然后再将已使用过的内存空间一次清理掉。

IMG_256

“标记-复制” 算法的优劣局限:

如果内存中多数对象都是存活的,“标记-复制” 算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

“标记-复制” 算法的实现简单,运行高效,不过其缺陷也显而易见,“标记-复制” 算法的代价是将可用内存缩小为了原来的一半,空间浪费有点多。

标记-整理

“标记-复制” 算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用 “标记-复制” 算法。

针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了一种有针对性的 “标记-整理”(Mark-Compact)算法, “标记-整理” 算法的标记过程仍然与 “标记-清除” 算法一样,但后续的步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。

IMG_256

是否移动回收后的存活对象是一项优缺点并存的风险决策:

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了

但如果跟 “标记-清除” 算法那样完全不考虑移动和整理存活对象的话,弥散于 Java 堆中的存活对象导致的内存碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在内存访问这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动对象则内存回收时会更复杂,不移动对象则内存分配时会更复杂。