垃圾回收 —— 分代垃圾回收算法

知识框架:


  1. 分代假说 ~ 弱分代(朝生夕灭)~ 强分代(老年难消亡)~ 跨代引用占极少数
  2. 分代收集 ~ Minor GC / Yound GC ~ Major GC / Old GC ~ Mixed GC ~ Full GC ~ 空间担保
  3. 方法区的回收 ~ Full GC ~ 废弃常量 ~ 废弃类型
  4. 垃圾回收算法 ~ 标记清除 ~ 标记复制(Appel 式) ~ 标记整理

1. 分代假说

分代收集理论建立在两个分代假说之上:
**1) 弱分代假说(Weak Generational Hypothesis):**绝大多数对象都是朝生夕灭的
**2) 强分代假说(Strong Generational Hypothesis):**熬过越多次垃圾收集过程的对象就越难以消亡

根据这两个假说,一般至少会把堆分为两个区域:**新生代(Young Generation)**和 **老年代(Old Generation)**不同的分区以不同的频率进行回收。

在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放

问题 1:跨代引用


这里存在一个明显的问题:对象不是孤立的,对象之间会存在跨代引用

如果要进行一次只局限于新生代区域内的收集,但新生代中的对象有可能被老年代引用,这时后就不得不额外遍历整个老年代的所有对象来确保可达性分析的正确性。反过来也是一样。为了解决这个问题,有添加了第三条经验法则:

**3)跨代引用假说(Intergenerational Reference Hypothesis):**跨代引用相对于同代引用来说仅占极少数

有了这条假说,们就不应再为了少量的跨代引用去扫描整个老年代
只需在新生代上建立一个全局的数据结构:记忆集(RememberedSet)
这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用

2. 分代收集

  1. **部分收集(Partial GC):**指目标不是完整收集整个 Java 堆的垃圾收集,分为

    1. 新生代收集(Minor GC / Young GC):

      只对新生代进行垃圾收集

      1. 在 Eden 区没有足够空间时触发
      2. 在有些垃圾回收器(如 Parallel Scavenge)会在 Full GC 之前执行一次 Young GC(不过可以使用参数取消掉)
    2. 老年代收集(Major GC / Old GC)

      只对老年代进行垃圾收集(目前只有 CMS 会)

      一般进行 Major GC 之前,都会伴随至少一次的 Minor GC
      所以对于 Major GC 需要按上下文区分到底是老年代收集,还是整堆收集

    3. 混合收集(Mixed GC)

      指目标是收集整个新生代以及部分老年代的垃圾收集(目前只有 G1 会)

  2. **整堆收集(Full GC):**收集整个 Java 堆和方法区 的垃圾收集

    会触发 Full GC 的有以下情况:

    1. 调用 System.gc() 时,系统建议执行 Full GC 但不是一定会执行

    2. 担保失败(见下面的担保策略)和并发失败

    3. 老年代空间不足

      1. 当大对象大于老年代的可用内存时(大对象会直接分配到老年代,比 Eden 大)
      2. 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存(担保失败)
      3. 由 Eden 区和 from 区向 to 区复制时,to 区空间不足,且多出的对象大于老年代的可用内存
    4. 方法区空间不足

    如果 Full GC 之后,老年代空间还是不足,就会报 OOM 了

2.1. 空间分配担保策略

在 JDK8 之后,默认的担保策略已经设置为 true 了
即每次进行 Minor GC 之前,虚拟机都会先检查老年代最大可用的连续空间是否大于:新生代所有对象的总空间 或者 历次晋升到老年代的对象的平均大小。如果是的话,则进行 Minor GC,否则担保失败,直接进行 Full GC

在 JDK8 之前,如果将担保策略设置为 false,则在第一个条件不成立时会直接进行 Full GC,因为第二个条件毕竟只是平均值,还是有放不下的风险的,如果没有担保的话就还是只能用 Full GC 了

2.2. 对方法区的回收

前面提到的各种 GC 基本上都是针对堆空间的,但是 HotSpot 在进行 Full GC 时也会对方法区进行回收,只不过方法区回收的条件很苛刻,主要有如下:

  1. 判定为废弃的常量

    相对简单,只需要要判断已经没有任何对象引用这个常量,那就可以被回收了

  2. 判定为不再使用的类型(类型卸载)

    条件苛刻,需要同时满足下面三个条件:

    1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例

    2. 加载该类的类加载器已经被回收

      这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的

    3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

▲ 需具备类型卸载的场景:

在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力

3. 分代垃圾回收算法

3.1. 标记-清除算法(Mark-Sweep)

这是最早出现的,最基础的垃圾收集算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象
也可以反过来,标记存活的对象,统一回收所有未被标记的对象

其中标记阶段就是对象是否属于垃圾的判定过程

主要缺点:


  1. 执行效率不稳定

    如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低

  2. 内存空间的碎片化

    标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

    需要通过更复杂的内存分配器和内存访问器来应对这些碎片化的空间
    比如使用 “分区空闲分配链表” 来解决内存分配问题

3.2. 标记-复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,就出现了复制算法(简称)

算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉

主要的优点:


  1. 对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象
  2. 每次都针对半区进行回收,也不用考虑有空间碎片的复杂情况

主要的缺点:


  1. 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销
  2. 付出了将可内存缩小为了原来的一半的代价,空间浪费比较多

针对优缺点,可以发现复制算法很适合用在 新生代 中,因为新生代每一轮都只有少量对象存活。现在商用的 Java 虚拟机大多都使用这种收集算法去 回收新生代(IBM 研究表示有 98% 的熬不过第一轮)

算法改进:Appel 式回收


**Appel 式回收:**针对具备 “朝生夕灭” 特点的对象提取的更优化的半区复制分代策略

具体做法是:把新生代分为一块较大的 Eden 空间 和两块较小的 Survivor 空间
Survivor 分为了 s0 区 和 s1 区,交替称为 from 区和 to 区

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8∶1(实际情况可能是 6:1)
每次分配内存只使用 Eden 和其中一块 Survivor(被选中的这块为 from 区)
也就是每次新生代可用的空间为整个新生代容量的 90%


当发生垃圾收集时(Eden 区满),将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间,这时候 from 区和 to 区的角色转换了,被清理的 Survivor 区为空了,就变成 to 区了

  1. Survivor 区满的时候,不会触发垃圾收集,剩下的会直接晋升到老年代

    Survivor 的空间不足以容纳一次垃圾收集后的存活对象,就需要依赖其他内存区域进行分配担保(大多数情况下是老年区),起到逃生门的作用

  2. Survivor 区中的对象变老时(默认为 15 岁,即熬过了 15 次垃圾回收)就会进入老年代

  3. Survivor 区中直接或满足要求晋升到老年代这两个动作,都是被动的,要等 Eden 区满触发垃圾回收时才会执行,而不会自己触发


动态对象年龄判断:

当 Survivor 区中 相同年龄 的对象大小中和大于 Survivor 区总空间的 一半,则大于或等于该年龄的对象都可以直接进入老年代,无需等到指定的年龄

3.3. 标记-整理算法

标记-复制算法在对象存活率较高时,就要进行较多的复制操作导致效率降低
所以根据老年代的存亡特性,一般不采用标记-复制算法,针对这种情况出现了标记-整理算法

其中的标记过程仍然与 “标记-清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉存活边界以外的内存

优缺点:


**优点:**内存分配变简单了
**缺点:**内存回收变复杂了

在老年区中,每一回收都有大量对象存活,移动并更新所有引用这些对象的地方消耗较大
而且这种对象移动操作必须全程暂停用户应用程序才能进行(STW,Stop The World)

  1. **抉择:**高吞吐 / 低延迟,只能选择一个

    不移动对象时(标记-清除算法)会使得收集器的效率提高一些,但内存分配和访问相比垃圾收集频率要高得多,所以不移动对象时,总体的吞吐率就会受到影响(分配和访问耗时较多)

    而移动对象时,会使得每次垃圾收集时产生延迟(收集的效率较低),但却可以保证平时在使用时,吞吐量高一些(内存连续,分配和访问耗时少)

    所以,如果关注高吞吐量(如 HotSpot 中的 Parallel Scavenge 收集器)就使用标记-整理,而如果关注低延迟(如 CMS 收集器)则使用标记-清除

  2. **折中:**CMS 收集器面临空间碎片过多时采用的就是这种方法

    折中的做法是:让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间