【JVM速通】4. 垃圾回收机制

罗文彬 64 阅读 后端

4. 垃圾回收机制

本文我们将深入探讨 JVM 的核心机制之一:垃圾回收机制(GC - Garbage Collection)

在了解了 JVM 运行时内存区域之后,我们知道所有 new 出来的对象都会被放在“堆(Heap)”内存里。后端服务器(比如一个 Spring Boot 应用)是需要 7×24 小时不停运行的。如果只往堆里塞对象,而从不清理,内存迟早会被占满,最终导致系统崩溃(也就是 OOM - Out Of Memory,内存溢出)。

垃圾回收机制,就是 JVM 自动帮你清理堆内存中“不再使用”的对象的过程。

1. 核心问题一:JVM 如何判断哪些对象是“垃圾”?

JVM 在清理内存前,必须先准确找出哪些对象已经彻底没人用了。目前主流的方法是可达性分析算法(Reachability Analysis)

什么是可达性分析?

JVM 会选定一些绝对不可能被当成垃圾的“核心对象”作为起点,这些起点被称为 GC Roots(垃圾回收根节点)

JVM 会从这些 GC Roots 开始向下搜索,寻找它们引用了哪些对象,被引用的对象又引用了哪些对象……这就形成了一条引用链

  • 存活对象:只要一个对象能够顺着引用链连到任何一个 GC Root,就说明它还在被使用,绝对不能杀。
  • 垃圾对象:如果一个对象到任何 GC Root 都没有任何引用链相连(即无法到达),说明程序已经不可能再访问到它了。哪怕它自己还连接着其他对象,它也是一堆垃圾,必须被回收。

哪些东西可以作为 GC Roots?

记住第三部分讲的内存区域,最典型的 GC Roots 包括:

  1. 虚拟机栈中引用的对象:比如当前正在执行的方法里定义的局部变量对象。
  2. 元空间中静态属性引用的对象:比如类里定义的 public static 全局变量对象。

2. 核心问题二:确定了垃圾,JVM 怎么清理?(三大基本算法)

找出垃圾后,如何把它们从堆内存里擦除?JVM 演进出了三种最基础的清理手段,它们各有优缺点:

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

  • 做法:先用可达性分析把所有垃圾对象标记出来,然后直接把它们在内存里清除掉。
  • 缺点:会导致内存碎片化。被清理掉的空间是零散的。这就好比一个停车场,虽然空出了 10 个分散的单车位,但是当一辆大客车(需要连续 5 个车位的大对象)开进来时,依然找不到地方停。

2. 标记 - 复制算法(Mark-Copy)

  • 做法:把可用的内存空间分成完全对等的两块。每次只使用其中一块。当这一块用完了,就触发 GC,把里面还活着的对象全部复制到另一块干净的内存上,然后把当前这一块整块格式化。
  • 优点:速度极快,而且复制过去后对象排列整齐,完全没有内存碎片。
  • 缺点:代价太高,直接浪费了一半的内存空间来做备用。

3. 标记 - 整理算法(Mark-Compact)

  • 做法:标记完垃圾后,不直接清除,而是让所有存活的对象都向内存的一端移动、排列紧凑(就像电脑磁盘碎片整理),然后直接清理掉端边界以外的所有垃圾空间。
  • 优点:既没有内存碎片,又不会浪费一半的空间。
  • 缺点:移动存活对象需要修改所有指向它们的指针,效率相对较低。

3. 终极方案:分代收集理论(Generational Collection)

在实际的 JVM 堆内存中,并没有单一使用某一种算法,而是结合了这三种算法的优势。因为商业系统的开发经验发现,Java 对象的生命周期有一个巨大的特征:绝大多数对象都是“短命”的,而少数对象是“长寿”的。

  • 短命对象:比如你在 Controller 接口里为了接收前端传参而 new 的一个 RequestDTO 对象,这个方法执行完(通常只要几毫秒),这个对象就彻底变成垃圾了。这种被称为“朝生夕死”。
  • 长寿对象:比如 Spring 容器管理的 Service Bean,或者是数据库连接池,它们在程序一启动就被创建,直到程序关闭才会销毁。

基于这个客观物理事实,JVM 把堆内存划分为两个大区域:年轻代(Young Generation)老年代(Old Generation)

1. 年轻代(Young Generation)

  • 存放什么:新创建的对象首先都会分配在这里。这里绝大多数对象都很短命。

  • 内部结构:年轻代被进一步拆分为三个部分:Eden 区(伊甸园)Survivor 0 区(幸存者 0)Survivor 1 区(幸存者 1)。它们的默认空间比例是 8:1:1

  • 如何回收(Minor GC):年轻代由于对象死得快,采用的是改良版的标记 - 复制算法

    1. 新对象都在 Eden 区诞生。
    2. 当 Eden 区满了,触发一次小规模的垃圾回收,叫做 Minor GC
    3. 把 Eden 和正在使用的 Survivor 0 区中还活着的极少数对象,一次性复制到另一块干净的 Survivor 1 区。
    4. 清空 Eden 和 Survivor 0。此时 Survivor 0 和 1 角色对调,等待下一次回收。
    5. 对象每经历一次 Minor GC 还活下来,它的“年龄”就会加 1 岁。

2. 老年代(Old Generation)

  • 存放什么:存放生命周期很长的对象。

  • 对象怎么来的

    1. 年轻代里熬过多次 GC(默认是 15 次)依然坚强活着的对象,会被“晋升”移交到老年代。
    2. 创建了体积非常巨大的对象(比如一个超大的全局字节数组),年轻代放不下,会直接放进老年代。
  • 如何回收(Full GC / Major GC):老年代的对象不容易死,空间也大。当老年代满了,会触发一次彻底的大回收,叫做 Full GC(或者 Major GC)。老年代清理通常使用标记 - 整理标记 - 清除算法。

后端开发必须注意的性能隐患:STW(Stop The World)

无论是 Minor GC 还是 Full GC,在清理内存的某些阶段,为了防止清理的同时程序还在继续产生新垃圾导致账目混乱,JVM 必须强制让所有用户线程暂停。这个暂停的状态就叫 STW(Stop The World)

Minor GC 的 STW 时间极短(通常几毫秒,无感知)。但 Full GC 的 STW 时间通常较长。如果你的后端服务器频繁发生 Full GC,前端用户就会感觉到明显的卡顿、接口响应超时,这是线上高并发系统的大忌。

现在,我们已经理解了对象在堆内存里从“出生(Eden)”到“挣扎生存(Survivor)”再到“养老(老年代)”以及被判定为垃圾并清除的整个生命周期。

掌握了内存的管理和回收,接下来我们将探讨另一个核心话题:类加载机制(即类文件是如何进入内存的)。