在 Java 后端开发中,JVM 的自动内存管理机制让我们彻底告别了 C/C++ 时代手动释放内存的繁琐与危险。但这并不意味着我们可以对内存放任不管——当系统出现卡顿、OOM(内存溢出)等性能瓶颈时,深入理解垃圾回收(GC)机制就成了解决问题的关键。
今天,让我们一步步剥丝抽茧,彻底搞懂 JVM 的垃圾回收机制。
一、如何判断一个对象是“垃圾”?
谈到垃圾回收,我们的第一个问题必然是:内存中这么多对象,谁该留,谁该走?
业界通常有两种算法来判定对象的存活状态:
1. 引用计数法
最直观的想法是给每个对象配一个“计数器”。有其他变量引用它,计数器加 1;引用断开,计数器减 1。计数器归零时,说明没人用它了,直接回收。
-
致命缺陷: 无法解决循环引用问题。假设对象 A 引用对象 B,对象 B 引用对象 A,但外界已经没有任何变量指向它们。这两个对象实际上已经是垃圾,但因为计数器互相撑着都不为 0,导致永远无法被回收。
2. 可达性分析
为了解决循环引用,Java 采用了可达性分析。 它的核心思想就像是“顺藤摸瓜”:以一系列被称为 GC Roots(根对象)的变量作为起点,向下遍历它们所引用的对象,形成一张有向图。
-
存活对象: 能够被这条藤蔓关联到的对象。
-
垃圾对象: 游离在图外,GC Roots 无法到达的对象。
二、遍历的艺术:三色标记法
可达性分析只是一个“思想”,在具体的并发执行过程中,JVM 是如何高效完成对象图遍历的呢?这就引入了三色标记法。
在这个过程中,我们将对象分为三种颜色:
-
白色:尚未被访问到的对象。如果遍历结束依然是白色,那就是要被清理的垃圾。
-
灰色:对象本身已被访问,但它内部引用的其他对象还没被全部扫描,属于“待办事项”。
-
黑色:对象本身及内部引用的对象都已扫描完毕,是绝对安全的存活对象。
标记的流转过程:
GC 开始时,所有对象都是白色;GC Roots 直接关联的对象被涂成灰色。
从灰色对象出发,把它引用的白色对象涂成灰色,它自己因为扫描完毕,变成黑色。
重复这个过程,直到场上再也没有灰色对象。最后剩下的白色对象,就是不可达的垃圾。
三色标记面临的挑战
由于现代 JVM 允许垃圾回收线程和用户线程并发执行,在标记的过程中,用户线程可能会偷偷改变引用关系,导致两个经典问题:
浮动垃圾(Floating Garbage): 某个对象本来被标记成了黑色(判定存活),但用户线程突然断开了对它的引用。它实际上成了垃圾,但本轮 GC 已经不会再管它了,只能等下一次 GC 兜底。这个问题无伤大雅,内存够用就行。
漏标(Missed Marks): 这是致命错误。存活的对象被当成垃圾清了!它必须同时满足两个极端条件:
-
黑色对象突然新增了对某个白色对象的引用。
-
所有灰色对象对该白色对象的引用被全部切断。
三、三大垃圾回收算法
查出了谁是垃圾,接下来就是“怎么回收”的问题。基础的垃圾回收算法主要有三种:
| 算法名称 | 核心思想 | 优点 | 缺点 | 适用场景 |
| 标记-清除 (Mark-Sweep) | 先标记存活对象,然后直接把不可达对象的内存抹掉。 | 实现简单。 | 会产生大量内存碎片,导致后续大对象无法分配。 | 存活对象多,垃圾少的区域。 |
| 标记-整理 (Mark-Compact) | 标记存活对象,然后让所有存活对象向内存一端移动,直接清理掉边界外的空间。 | 没有内存碎片。 | 需要移动对象并更新引用,速度慢,成本高。 | 存活对象多,垃圾少的区域。 |
| 复制 (Copying) | 将内存一分为二,每次只用一半。存活对象统一复制到另一半,然后直接清空当前半区。 | 效率极高,没有内存碎片。 | 内存利用率极低,永远有一半空间闲置。 | 存活对象少,垃圾极多的区域。 |
四、分代回收机制
通过对比发现,没有一种算法是完美的。于是,JVM 引入了分代收集理论——这并非一种新算法,而是一种内存管理策略。
JVM 将堆内存分为 新生代 (Young Gen) 和 老年代 (Old Gen),根据对象生命周期的长短,分配不同的算法,实现效率最大化。
1. 新生代(朝生夕死)
新生代的特点是对象产生快,死得也快(比如局部变量)。这里极其适合复制算法,因为真正存活的对象很少,复制成本极低。
新生代进一步被划分为一个 Eden 区和两个 Survivor 区(From 和 To)。
-
Minor GC(Young GC)流程: 新对象优先在 Eden 区分配。当 Eden 满了,触发 Minor GC。此时会发生 STW(Stop The World,暂停所有用户线程),回收器将 Eden 和 From 区依然存活的对象复制到 To 区,对象年龄 +1。随后清空 Eden 和 From,并交换 From 和 To 的角色。
2. 晋升老年代(老谋深算)
对象不会永远留在新生代,满足以下条件会晋升到老年代:
年龄达标: 在 Survivor 区熬过 15 次 GC(默认阈值)的老寿星。
大对象直通: 超大对象直接进入老年代,避免在新生代频繁来回复制。
动态年龄判定: 如果 Survivor 区中相同年龄的对象总大小超过空间的一半,大于等于该年龄的对象直接晋升。
空间担保: Survivor 区放不下本次存活的对象时,借用老年代空间存放。
3. 老年代与 Full GC
老年代存放的是生命周期长、存活率高的对象,极其适合标记-清除或标记-整理算法。 当老年代空间不足时,会触发 Full GC,对整个堆(新生代 + 老年代)进行彻底清理。Full GC 的 STW 时间极长,是系统卡顿的罪魁祸首之一。如果清理后依然没有空间,就会无情抛出 OutOfMemoryError。
五、垃圾回收器(CMS vs G1)
有了理论和策略,我们需要具体的干活工具。随着技术演进,垃圾回收器从单线程(Serial)走向多线程并行(Parallel),最终迎来了并发时代(用户线程与 GC 线程同时运行)。
CMS (Concurrent Mark Sweep)
低停顿:在进行垃圾回收时,尽可能缩短应用程序暂停的时间。让应用线程能够更快地恢复执行。
CMS是一款以低停顿为目标的老年代垃圾收集器,基于“标记-清除”算法。
工作流程:
初始标记(STW):仅标记 GC Roots 直接关联的对象。因为范围小,所以速度极快。
并发标记:对初始标记的对象进行整个引用链的扫描。这是最耗时的阶段,但允许与用户线程并发执行,从而大幅降低停顿。
重新标记(STW):修正并发标记期间因用户线程运行导致的引用变动(漏标问题)。虽然需要暂停,但耗时远短于并发标记。
并发清除:将标记为垃圾的对象进行清除。
CMS为啥可以做到低停顿呢?
CMS将原本冗长的引用链扫描进行切分。通过 GC 线程与用户线程并发执行,加上重新标记校正的方式,减少了垃圾回收的时间
缺点:
-
使用了标记清除算法,会有内存碎片;
-
垃圾回收和用户线程同时进行,会产生浮动垃圾
G1 (Garbage-First)
G1 是面向整个堆的垃圾回收器,同时管理新生代和老年代。
G1 最大的特点是把整个堆划分成大量 Region,每个 Region 可以动态扮演 Eden、Survivor 或 Old 区的角色。回收时不再以整个代作为单位,而是以 Region 作为回收单位。
工作流程:
初始标记(STW):标记 GC Roots 直接关联的对象,耗时极短。
并发标记:从初始标记的根对象出发, 遍历全堆所有 Region, 标记存活对象
重新标记(STW):修正并发标记期间因用户线程运行导致的引用变动(漏标问题)。虽然需要暂停,但耗时远短于并发标记。
筛选回收(STW):这是 G1 的核心。它会统计各 Region 的垃圾价值并排序,根据用户设定的最大停顿时间目标(-XX:MaxGCPauseMillis),优先回收“性价比”最高(垃圾最多)的 Region。回收时采用复制算法,宏观上实现了标记-整理,彻底解决了内存碎片问题。】
CMS 与 G1 的区别
第一,CMS 只负责老年代回收,而 G1 管理整个堆。
第二,CMS 采用标记清除算法,会产生内存碎片;G1 采用复制整理思想,基本不会产生碎片。
第三,CMS 是对整个老年代进行回收;G1 是以 Region 为单位进行选择性回收。
