【JVM速通】4. 垃圾回收机制
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 包括:
- 虚拟机栈中引用的对象:比如当前正在执行的方法里定义的局部变量对象。
- 元空间中静态属性引用的对象:比如类里定义的
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):年轻代由于对象死得快,采用的是改良版的标记 - 复制算法。
- 新对象都在 Eden 区诞生。
- 当 Eden 区满了,触发一次小规模的垃圾回收,叫做 Minor GC。
- 把 Eden 和正在使用的 Survivor 0 区中还活着的极少数对象,一次性复制到另一块干净的 Survivor 1 区。
- 清空 Eden 和 Survivor 0。此时 Survivor 0 和 1 角色对调,等待下一次回收。
- 对象每经历一次 Minor GC 还活下来,它的“年龄”就会加 1 岁。
2. 老年代(Old Generation)
-
存放什么:存放生命周期很长的对象。
-
对象怎么来的:
- 年轻代里熬过多次 GC(默认是 15 次)依然坚强活着的对象,会被“晋升”移交到老年代。
- 创建了体积非常巨大的对象(比如一个超大的全局字节数组),年轻代放不下,会直接放进老年代。
-
如何回收(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)”再到“养老(老年代)”以及被判定为垃圾并清除的整个生命周期。
掌握了内存的管理和回收,接下来我们将探讨另一个核心话题:类加载机制(即类文件是如何进入内存的)。