垃圾回收

关于本笔记

本文件介绍的是JVM垃圾回收的难点知识

目录

垃圾回收简介

垃圾回收(Garbage Collection, GC)是自动管理内存的一种机制,它负责自动释放不再被程序引用的对象所占用的内存,这种机制减少了内存泄漏和内存管理错误的可能性。垃圾回收可以通过多种方式触发,具体如下:

内存不足时:当JVM检测到堆内存不足,无法为新的对象分配内存时,会自动触发垃圾回收。

手动请求:虽然垃圾回收是自动的,开发者可以通过调用 System.gc() 或 Runtime.getRuntime().gc() 建议 JVM 进行垃圾回收。不过这只是一个建议,并不能保证立即执行。

JVM参数:启动 Java 应用时可以通过 JVM 参数来调整垃圾回收的行为,比如:-Xmx(最大堆大小)、-Xms(初始堆大小)等。

分代触发条件:不同区域达到各自的触发条件时也会引发 GC。例如 Eden 区空间不足时触发 Minor GC;老年代空间不足、或 Minor GC 后晋升对象无法放入老年代时触发 Major GC / Full GC;元空间/方法区(Metaspace)达到阈值时也会触发 Full GC;在 G1 中,当堆占用率达到 -XX:InitiatingHeapOccupancyPercent(默认 45%)时会启动并发标记周期。

回收判断

在Java中,判断对象是否为垃圾(即不再被使用,可以被垃圾回收器回收)主要依据:

  1. 引用计数算法

给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。

正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法

1
2
3
4
5
6
7
8
9
10
11
public class ReferenceCountingGC {

public Object instance = null;

public static void main(String[] args) {
ReferenceCountingGC objectA = new ReferenceCountingGC();
ReferenceCountingGC objectB = new ReferenceCountingGC();
objectA.instance = objectB;
objectB.instance = objectA;
}
}
  1. 可达性分析算法(Reachability Analysis)

可达性分析

从一组称为**GC Roots(垃圾收集根)**的对象出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达),那么这个对象就被认为是不可达的,可以被回收。

Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象
  1. 方法区的回收

因为方法区主要存放永久代对象(来自堆与元空间),而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。

主要是对常量池的回收和对类的卸载

在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。

是否真正回收元空间取决于 GC 器的实现与触发条件。需要注意:元空间使用本地内存(Native Memory),并不在堆内,不能简单说”Full GC 会回收元空间”,更准确的说法是 Full GC 期间 JVM 可能对元空间中无用的类元数据进行卸载。

类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

  • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。

  1. finalize()

finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会调用 finalize() 方法。

引用类型

无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

Java 具有四种强度不同的引用类型。

  1. 强引用

被强引用关联的对象不会被回收。使用 new 一个新对象的方式来创建强引用。

1
Object obj = new Object();
  1. 软引用

被软引用关联的对象只有在内存不够的情况下才会被回收。

使用 SoftReference 类来创建软引用。

1
2
3
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
  1. 弱引用

被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

使用 WeakReference 类来实现弱引用。

1
2
3
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
  1. 虚引用

又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。

为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来实现虚引用。

1
2
3
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

回收算法

  1. 标记 - 清除

    标记-清除

    标记-清除算法分为”标记”和”清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。

    不足:

    • 标记和清除过程效率都不高;
    • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
  2. 标记 - 整理

    标记-整理

    标记-整理算法的”标记”过程与”标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。

  3. 复制

    复制

    将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

    主要不足是只使用了内存的一半。

    现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。

    HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

  4. 分代收集

    分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。

    新生代使用: 复制算法
    老年代使用: 标记 - 清除 或者 标记 - 整理 算法

回收器/收集器

垃圾收集器

垃圾收集器简述

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;

串行与并行: 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并形指的是垃圾收集器和用户程序同时执行。

除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

  1. Serial 收集器(复制算法)

    新生代单线程收集器,标记和清理都是单线程,优点是简单高效,适合客户端模式或单核环境。

  2. ParNew 收集器(复制算法)

    新生代并行收集器,实际上是 Serial 的多线程版本,历史上主要用来与 CMS 配合使用。

    注意:ParNew 在 JDK 9 已被 deprecated,JDK 14 随 CMS 移除后基本退出历史舞台。

  3. Parallel Scavenge 收集器(复制算法)

    新生代并行收集器,追求高吞吐量(吞吐量 = 用户线程时间 /(用户线程时间 + GC 线程时间))。适合后台计算等对交互响应要求不高的场景。

  4. Serial Old 收集器(标记-整理算法)

    老年代单线程收集器,Serial 的老年代版本。

  5. Parallel Old 收集器(标记-整理算法)

    老年代并行收集器,吞吐量优先,Parallel Scavenge 的老年代版本。

  6. CMS(Concurrent Mark Sweep)收集器(标记-清除算法)

    老年代低延迟收集器,目标是最短回收停顿时间,大部分阶段与用户线程并发执行。

    CMS 已在 JDK 9(JEP 291)被 deprecated,JDK 14(JEP 363)正式从 HotSpot 中移除,线上 JDK 14+ 已无法再使用 CMS,下面对 CMS 的讨论仅作为历史知识点。

    CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

    分为以下四个流程:

    • 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
    • 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
    • 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
    • 并发清除: 不需要停顿。在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

    具有以下缺点:

    • 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
    • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
    • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
  7. G1(Garbage First)收集器(整体基于标记-整理,局部采用复制)

    JDK 7 引入,JDK 9 起成为服务端默认 GC,面向大堆、兼顾吞吐与停顿时间。

    堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

    G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离,每次只选择回收收益最高的若干 Region(Collection Set),不会产生物理意义上的内存碎片。

    每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

    如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

    • 初始标记
    • 并发标记
    • 最终标记: 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
    • 筛选回收: 首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

    具备如下特点:

    • 空间整合: 整体来看是基于”标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于”复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
    • 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
  8. ZGC(Z Garbage Collector)

    JDK 11 作为实验特性引入,JDK 15 正式可用,JDK 21(JEP 439)引入分代 ZGC。基于染色指针 + Load Barrier,停顿时间可稳定控制在 1ms 以内(与堆大小无关),适用于超大堆(TB 级)和低延迟场景。

  9. Shenandoah 收集器

    由 Red Hat 开发,JDK 12 引入、JDK 15 正式。和 ZGC 类似同样追求低停顿,采用 Brooks Pointer 实现并发整理,也适合大堆低延迟场景。

内存分配与回收策略

回收策略

Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:

  1. 部分收集(Partial GC)

不是完整收集整个 Java 堆的垃圾收集。

其中又分为:

  • 新生代收集(Minor GC/Young GC):
    • 作用范围:只针对年轻代进行回收,包括Eden区和两个Survivor区(S0和S1)。
    • 触发条件:当Eden区空间不足时,JVM会触发一次Minor GC,将Eden区和一个Survivor区中的存活对象移动到另一个Survivor区或老年代(Old Generation)。
    • 特点:通常发生得非常频繁,因为年轻代中对象的生命周期较短,回收效率高,暂停时间相对较短。
  • 老年代收集(Major GC/Old GC):
    • 作用范围:主要针对老年代进行回收,但不一定只回收老年代。
    • 触发条件:当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快,可能会触发Major GC。
    • 特点:相比Minor GC,Major GC发生的频率较低,但每次回收可能需要更长的时间,因为老年代中的对象存活率较高。
    • 注意: 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
  • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集 目前只有 G1 GC 会有这种行为
  1. 整堆收集(Full GC)部分收集

对整个 Java 堆(年轻代 + 老年代)进行回收,并且通常会伴随方法区/元空间的类卸载(是否真正回收元空间取决于 GC 器的实现与触发条件)。

需要注意:元空间使用本地内存(Native Memory),并不在堆内,不能简单说”Full GC 会回收元空间”,更准确的说法是 Full GC 期间 JVM 可能对元空间中无用的类元数据进行卸载。

Full GC是最昂贵的操作,因为它需要停止所有的工作线程(Stop The World),遍历整个堆内存来查找和回收不再使用的对象,因此应尽量减少Full GC的触发。

Full GC 的触发条件

相对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。

而 Full GC 则相对复杂,有以下条件:

  1. 调用 System.gc():

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

  1. 老年代空间不足:

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

  1. 空间分配担保失败:

使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第五小节。

  1. JDK 1.7 及以前的永久代空间不足:

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.
OutOfMemoryError。为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

  1. Concurrent Mode Failur:

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

内存分配策略

  1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

  1. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

  1. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

  1. 动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

  1. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

总结

JVM 垃圾回收的核心可以概括为以下几点:

  1. 判断对象生死:Java 虚拟机使用可达性分析算法,从 GC Roots 出发判定对象是否存活,而非引用计数法(无法解决循环引用)。

  2. 四种引用强度:强引用 > 软引用 > 弱引用 > 虚引用,强度递减决定了对象在不同内存压力下的回收优先级。

  3. 三代回收算法:新生代用复制算法(Eden:S0:S1 = 8:1:1),老年代用标记-清除或标记-整理,分代收集是兼顾效率与吞吐量的工程权衡。

  4. 收集器演进路线:从 Serial(单线程串行)→ Parallel Scavenge(高吞吐)→ CMS(低延迟)→ G1(Region 分治 + 可预测停顿)→ ZGC/Shenandoah(亚毫秒停顿时,堆大小无关),体现的是 GC 技术从”能用”到”好用到极致”的演进逻辑。

  5. Full GC 是敌人:触发条件多(老年代满、担保失败、System.gc()、Concurrent Mode Failure 等),停顿时间长,日常开发应通过合理配置堆参数、避免大对象直接进入老年代、控制晋升速率来减少 Full GC。

理解这些知识,不仅能帮你写出更优质的 Java 代码,也是排查线上内存问题、调优 JVM 性能的基础。