深入理解 JVM 垃圾回收(GC)

深入理解 JVM 垃圾回收(GC)

在 C/C++ 等编程语言中,程序员需要像管家一样,手动通过 malloc 申请内存,通过 free 释放内存。一旦忘记释放,就会导致内存泄漏;释放了还在使用的指针,就会产生悬挂指针。这种纯手工的管理方式极易出错。

而在 Java 的世界里,JVM 犹如一位不知疲倦的清洁工,帮我们全自动地打理内存。这位清洁工就是垃圾回收器(Garbage Collector,简称 GC)。

本文将从底层逻辑出发,详尽地回答五个核心问题,带你彻底吃透 Java 垃圾回收机制。


一、 为什么要有垃圾回收?

如果我们没有垃圾回收,Java 程序会面临怎样的灾难?

  1. 避免内存泄漏(Memory Leak) :
    程序中创建的对象在完成使命后,如果没有被及时清理,它们就会一直占用内存。随着时间推移,可用内存越来越少,最终导致系统崩溃。
  2. 防止内存溢出(Memory Overflow/OOM) :
    当堆内存被无用的"死亡对象"彻底填满,此时如果程序还需要创建新对象,JVM 无法再分配出哪怕一字节的空间,就会抛出 java.lang.OutOfMemoryError。
  3. 释放程序员的心智负担 :
    自动内存管理让开发者可以专注于业务逻辑。我们只需在需要时 new 对象,至于对象的"后事",完全交由 JVM 处理,极大地提高了开发效率和代码的安全性。

垃圾回收的作用:将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间,保证程序能够持久、稳定地运行。


二、 垃圾回收主要回收哪个内存区域?

很多人以为 JVM 负责管理所有的内存,但实际上,垃圾回收也是有"地盘划分"的。JVM 运行时数据区分为五大部分,但 GC 的关注点非常集中。

  • 不回收的区域(随线程生灭) :
    程序计数器、虚拟机栈、本地方法栈。这三个区域的生命周期与线程相同。当线程结束时,它们所占用的内存自然就被销毁了。而且这些区域的内存分配和回收具有确定性(比如方法执行完毕,对应的栈帧就出栈了),不需要 GC 介入。
  • 重点回收的区域(线程共享) :
    Java 堆(Heap) 和 方法区(Method Area) 。
    • Java 堆是重灾区,几乎所有的对象实例都在这里分配。它占用了 JVM 内存的绝大部分,是垃圾收集器的主战场。
    • 方法区(JDK 8 后称为元空间 Metaspace) 主要回收废弃的常量和不再使用的类信息(类卸载)。

分代收集理论(为什么要划分新生代和老年代?)

在堆中,对象的生命周期是两极分化的:

  • 绝大多数对象都是"朝生夕死"的(比如方法内的局部变量),存活时间极短。
  • 熬过多次 GC 的极少数对象,往往会存活很久(比如 Spring 容器中的 Bean,缓存的全局对象)。

因此,JVM 将堆划分为两个区域:

  1. 新生代(Young Generation) :存放新建的对象。又细分为一个 Eden 区 和两个 Survivor 区(From / To)。
  2. 老年代(Old Generation / Tenured):存放经历多次 GC 依然存活,或者占用内存极大的巨型对象。

三、 标记的过程:如何判断对象是"死"是"活"?

回收垃圾的前提是找出垃圾。JVM 怎么知道哪些对象是死去的?

1. 引用计数算法(Java 未采用)

原理 :给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,减 1。任何时刻计数器为 0 的对象就是可回收的。

致命缺点:无法解决循环引用问题。

java 复制代码
public class Test {
    public Object instance = null;
    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
        // 此时 a 和 b 互相引用,计数器都不为 0,但外界已无法访问它们,造成了内存泄漏。
    }
}

2. 可达性分析算法(Java 采用)

原理 :通过一系列被称为 "GC Roots" 的对象作为起始点,从这些节点开始向下搜索。搜索走过的路径称为"引用链(Reference Chain)"。

如果一个对象到 GC Roots 没有任何引用链相连(即不可达),则证明此对象是不可用的,会被判定为可回收对象。

哪些对象可以作为 GC Roots?

  1. 虚拟机栈(栈帧中的局部变量表)中引用的对象(如方法内的局部变量)。
  2. 方法区中类静态属性(static)引用的对象。
  3. 方法区中常量(final)引用的对象。
  4. 本地方法栈中 JNI(Native 方法)引用的对象。
  5. Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,常驻的异常对象等。

3. 对象的"缓刑":四种引用类型

即使被标记为不可达,也不是马上就被"处死"。JDK 1.2 之后,Java 扩充了引用的概念,分为四种强度:

  • 强引用(Strong Reference) :类似 Object obj = new Object()。只要强引用还在,GC 绝不会回收。
  • 软引用(Soft Reference):发生 OOM 之前,GC 会把这些对象列入回收范围进行二次回收。如果回收后内存还不够,才会抛 OOM。适合做缓存。
  • 弱引用(Weak Reference) :只能生存到下一次 GC 发生之前。无论内存是否够用,只要 GC 发生,就会被回收。常用于 ThreadLocal 和 WeakHashMap。
  • 虚引用(Phantom Reference):形同虚设,唯一作用是能在对象被回收时收到一个系统通知。

四、 回收的过程:垃圾回收算法

找到垃圾后,如何高效清理?

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

  • 过程:先标记,后统一清除。
  • 缺点 :
    • 效率低。
    • 空间碎片化:清除后会产生大量不连续的内存碎片,导致程序在运行中需要分配较大对象时,无法找到足够连续内存,从而提前触发下一次 GC。

2. 复制算法(Copying)

  • 过程:将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块内存满了,将存活的对象复制到另一块,然后清空当前块。
  • 优点:实现简单、运行高效、没有内存碎片。
  • 缺点:内存利用率极低(通常只有一半)。
  • 实际应用(新生代优化) :
    新生代中 98% 的对象都会被回收,所以 HotSpot 不按 1:1 划分。而是分为 Eden : From Survivor : To Survivor = 8 : 1 : 1 。
    每次使用 Eden 和其中一块 Survivor(共 90%)。GC 时,将存活对象复制到另一块空的 Survivor,然后清理 Eden 和用过的 Survivor。

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

  • 过程:标记过程同"标记-清除",但后续不是直接清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
  • 优点:没有内存碎片。
  • 缺点:移动对象成本高,需要更新所有引用该对象的指针。
  • 应用场景:老年代对象存活率高,不适合复制算法,适合标记-整理。

4. 分代收集算法(Generational Collection)

现代虚拟机的标配。将堆分为新生代和老年代,分别采用不同的算法:

  • 新生代 :朝生夕死,存活率低,采用复制算法 。发生 GC 叫 Minor GC,速度极快。
  • 老年代 :存活率高,采用标记-清除 或标记-整理 算法。发生 GC 叫 Major GC / Full GC,速度比 Minor GC 慢十倍以上。

对象如何进入老年代?

  1. 熬过年龄 :对象在 Survivor 区每熬过一次 Minor GC,年龄加 1。当年龄达到阈值(默认 15,通过 -XX:MaxTenuringThreshold 设置),晋升老年代。
  2. 大对象直接进入:需要大量连续内存的 Java 对象(如长字符串、数组)。
  3. 动态年龄判定:如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象直接进入老年代。

五、 垃圾回收器有哪些典型实现?

算法只是方法论,真正的清洁工是垃圾收集器。垃圾回收器的发展史,就是一部不断缩短"Stop The World(STW,暂停用户线程)"时间的历史。

1. 经典收集器组合

收集器 区域 算法 特点与场景
Serial 新生代 复制 单线程。收集时会暂停所有用户线程(STW)。简单高效,适合单核 CPU 的 Client 模式。
ParNew 新生代 复制 Serial 的多线程版本。是许多运行在 Server 模式下 JVM 的首选。常与 CMS 搭配。
Parallel Scavenge 新生代 复制 关注吞吐量(吞吐量 = 运行用户代码时间 / CPU 总时间)。适合后台科学计算、批处理任务。
Serial Old 老年代 标记-整理 Serial 的老年代版本,单线程。
Parallel Old 老年代 标记-整理 Parallel Scavenge 的老年代版本。
CMS 老年代 标记-清除 关注最短停顿时间(并发收集)。适合 B/S 架构(如电商网站),响应速度快。

2. 划时代的 CMS(Concurrent Mark Sweep)

CMS 是 HotSpot 第一款真正意义上的并发收集器 ,它让垃圾收集线程与用户线程同时工作。

工作原理(4个步骤):

  1. 初始标记(STW):仅仅标记一下 GC Roots 能直接关联到的对象。速度极快。
  2. 并发标记 :进行 GC Roots Tracing,耗时最长,但无需暂停用户线程。
  3. 重新标记(STW):修正并发标记期间,因用户程序继续运作而导致标记产生变动的对象。停顿时间比初始标记稍长,但远短于并发标记。
  4. 并发清除 :清理对象,无需暂停用户线程。

缺点:

  • 对 CPU 资源敏感(默认启动回收线程数 = (CPU数+3)/4)。
  • 无法处理浮动垃圾 (并发清理时用户线程产生的新垃圾),可能引发 Concurrent Mode Failure 而退化为 Serial Old。
  • 基于"标记-清除"算法,会产生大量空间碎片。

3. 未来趋势:G1(Garbage First)

G1 是 JDK 9 及以后的默认垃圾收集器,面向大内存、多核处理器的服务端应用。

核心理念 :不再物理上区分新生代和老年代,而是将堆划分为多个大小相等的独立 Region。

  • 每个 Region 动态扮演 Eden、Survivor 或 Old 区的角色。
  • 新增 Humongous 区域,专门存放大对象(超过 Region 大小的 50%)。
  • 可预测的停顿时间模型:G1 会跟踪各个 Region 的回收价值(获得的空间大小与回收所需时间的经验值),在有限的时间内,优先回收价值最大的 Region(这就是 Garbage First 名字的由来)。

六、 总结

垃圾回收是 JVM 最耀眼的明珠之一。我们可以这样串联记忆:

  1. 为什么回收:防止内存泄漏和溢出,把程序员从内存管理中解放出来。
  2. 回收哪里 :重点是共享的堆(新生代/老年代)和方法区。
  3. 怎么标记 :采用可达性分析算法 ,从 GC Roots 向下搜索,不可达的即判死刑。
  4. 怎么回收 :实行分代收集 。新生代存活率低,用复制算法 (Eden:S0:S1 = 8:1:1);老年代存活率高,用标记-清除/整理算法。
  5. 谁来回收:从早期的单线程 Serial,到多线程的 Parallel,再到重低延迟的 CMS,最终进化到目前的王者 G1 收集器。

理解这些底层细节,不仅能让你在面试中侃侃而谈,更能帮你在实际生产环境中,面对 Full GC 导致的系统卡顿时,能够从容地进行 JVM 调优。

相关推荐
铁链鞭策大师1 小时前
jvm基础
jvm
golang学习记1 小时前
IDEA 断点调试技巧:拖拽
java·ide·intellij-idea
Sayai1 小时前
Flink 日志解析任务性能优化实战:正则/Grok 预编译、Guava 缓存权重陷阱与 ES 写入隔离
java·大数据·性能优化·flink·实时计算
计算机毕设定制辅导-无忧学长1 小时前
《基于物品协同过滤的闲置书换购微信小程序设计开发》
java·vue.js·spring boot·微信小程序·uniapp·闲置书换购微信小程序·物品协同过滤算法
Best_Liu~1 小时前
SpringBoot全局日期格式化
java·spring boot·后端
少年的范儿1 小时前
IntelliJ IDEA 配置 JDK 21 与 Maven 教程(Windows):开发环境配置与验证
java·jdk·maven·intellij idea
RuoyiOffice1 小时前
SpringBoot3 + openGauss:RuoYi Office 登录、流程发布与用印填报适配实战
java·数据库·spring boot·opengauss·信创
Dwzun2 小时前
基于SpringBoot3+Vue3的油莎豆种植管理系统
java·前端·vue.js·spring boot·后端·python·计算机网络
学长毕设10 小时前
基于SpringBoot的实验室资产管理系统
java·spring boot·后端