JVM详细分析(2)——垃圾回收篇

105 阅读8分钟

GC类别

image.png

总结:

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区(young GC 平均晋升大小比old gen剩余空间大或者perm gen空间不足)。

垃圾回收算法

标记-清除算法

标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:标记清除后会产生大量不连续的内存碎片。

标记-清除算法

标记-清除算法

关于具体是标记可回收对象还是不可回收对象,众说纷纭,两种说法其实都没问题,我个人更倾向于是前者。

如果按照前者的理解,整个标记-清除过程大致是这样的:

  1. 当一个对象被创建时,给一个标记位,假设为 0 (false);
  2. 在标记阶段,我们将所有可达对象(或用户可以引用的对象)的标记位设置为 1 (true);
  3. 扫描阶段清除的就是标记位为 0 (false)的对象。

复制算法

为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

复制算法

复制算法

虽然改进了标记-清除算法,但依然存在下面这些问题:

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。

标记-整理算法

标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

标记-整理算法

标记-整理算法

由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。

垃圾回收器

CMS(Concurrent Mark-Sweep)和 G1(Garbage-First)都是 Java 虚拟机中的垃圾回收器,它们有以下主要区别:

  1. 工作机制:

    • CMS:CMS是一种以低停顿时间为目标的垃圾回收器,它主要通过“标记-清除”算法实现垃圾回收。在 CMS 回收过程中,会尽量减少应用线程的停顿时间,允许部分并发执行来避免长时间的停顿。
    • G1:G1是一种面向服务端应用的垃圾回收器,它采用了分代算法,将堆内存划分为多个区域,并通过优先处理“垃圾优先”(Garbage-First)的机制来实现高效的垃圾回收。
  2. 回收方式:

    • CMS:CMS主要是通过并发标记和并发清理来实现垃圾回收,但在清理阶段仍然需要停止应用线程进行部分清理操作。
    • G1:G1将整个堆空间划分成多个区域,通过增量式的垃圾回收算法,在全局并发下处理垃圾回收,从而减少应用线程停顿的时间。
  3. 内存分配方式:

    • CMS:CMS主要关注降低停顿时间,因此在并发清理时可能会产生内存碎片,影响长时间运行的性能。
    • G1:G1采用了基于复制的收集算法,可以避免内存碎片问题,同时也更适合处理大堆内存的应用。
  4. 适用场景:

    • CMS:适用于需要短暂停顿时间、响应速度快的应用场景,但对处理器资源敏感,不适用于大内存应用或有大量长时间存活对象的场景。
    • G1:适用于大内存应用、需要更加稳定和可预测的垃圾回收表现的场景,能够同时在高吞吐量和低停顿时间之间取得平衡。

死亡对象判断

引用计数法

给对象中添加一个引用计数器:

  • 每当有一个地方引用它,计数器就加 1;
  • 当引用失效,计数器就减 1;
  • 任何时候计数器为 0 的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。

对象之间循环引用

对象之间循环引用

所谓对象之间的相互引用问题,如下面代码所示:除了对象 objAobjB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
    }
}

可达性分析算法

这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。

可达性分析算法

可达性分析算法

哪些对象可以作为 GC Roots 呢?

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象
  • JNI(Java Native Interface)引用的对象

对象可以被回收,就代表一定会被回收吗?

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

垃圾回收过程

G1为例,G1(Garbage-First)是Java虚拟机中的一种垃圾收集器,它在JDK 7中首次引入,主要用于取代CMS(Concurrent Mark-Sweep)收集器。G1收集器的设计目标是在有限的时间内实现可预测的停顿时间,以及高吞吐量的垃圾收集。下面是G1垃圾回收的基本流程:

  1. 初始标记(Initial Marking)

    • G1收集器会首先对根对象进行一次快速的标记,以确定初始的存活对象集合。
    • 这个阶段会短暂地停止所有应用线程,因此会产生较短的停顿时间。
  2. 并发标记(Concurrent Marking)

    • 在初始标记完成后,G1收集器会并发地标记整个堆中的存活对象。
    • 在标记的过程中,应用程序线程和标记线程可以同时执行,不需要全局停顿。
  3. 最终标记(Final Marking)

    • 在并发标记结束后,G1收集器会对在并发标记过程中有变化的对象进行一次最终的标记。
    • 这个阶段需要短暂地停止所有应用线程,但停顿时间通常比初始标记短。
  4. 筛选回收(Live Data Counting and Evacuation)

    • G1收集器会根据标记结果,确定哪些区域的存活对象较少,并优先回收这些区域,以最大化垃圾收集的效率。
    • 在回收时,G1收集器会将存活对象移动到空闲的区域,以便后续的对象分配。
  5. 清理(Cleanup)

    • 在对象移动完成后,G1收集器会执行一些必要的清理操作,例如更新引用、维护内部数据结构等。

总的来说,G1采取两次标记的形式,并发标记的思路,对存活对象标记。最后进行筛选回收和清理