JVM 基础与 GC 垃圾回收详解|可达性分析、分代模型、垃圾收集器、STW

大家好,这里是后端基础拾光集。

前面两篇我们一起学习了多线程、锁、线程池。线程就是厨房里干活的厨师,厨师干活会不断产生各种食材、半成品,也就是 Java 对象(业务代码里 new User()、new ArrayList<>()、new HashMap<>()等)。 仓库空间(内存)是有限的。如果用过的食材一直堆在仓库不清理,仓库很快堆满,就会出现 OOM 内存溢出,程序崩溃。 这个时候就需要我们的保洁员来做相关的清理工作,GC 就是 JVM 自带的保洁员,专门负责识别废弃对象、清理垃圾,腾出内存空间。

更常见的情况是:GC 知识点背了不少,什么新生代、老年代、Minor GC、Full GC、CMS、G1,张口就能说几个词,但真问一句"GC 到底在干什么",脑子里又有点模糊。

这篇文章我们沿用厨房仓库类比,把 JVM 内存分区、GC Roots、可达性分析、分代回收、垃圾收集器串起来。最后再配几个开发踩坑案例、面试真题和线上 OOM 排查清单。

很多新手误区:不是对象不用了立刻被清理。保洁员有自己的工作规则,到合适时机才会来打扫。

一、JVM 内存五大区域(仓库分区类比)

JVM 启动之后,会划分出 5 块独立区域,就像厨房仓库划分不同储物隔间。

  1. 程序计数器(PC 寄存器)
    类比:厨师手上的工作小纸条,记录当前做到哪一步,下一步该干什么。
    特点:线程私有,每个厨师单独一张纸条;唯一没有 OOM 的内存区域 。
    作用:记录当前线程执行到哪一行代码。线程切换之后,回来可以接着继续干活。
  2. 虚拟机栈(Java 栈)
    类比:厨师手边临时操作台。每次开始一道菜,就在台面上放订单、临时用料;菜做完,台面直接清空。
    特点:每个线程私有,一个线程对应一个栈。存放局部变量、方法调用信息。 方法调用 → 压栈;方法执行结束 → 出栈释放。
    异常:栈深度超出限制,抛出StackOverflowError栈溢出。
  3. 本地方法栈
    类比:厨房里面对接外部设备(烤箱、蒸箱)的操作台。 专门用来执行 native 本地方法(C/C++ 写的底层方法),和虚拟机栈功能类似,只是服务本地方法。
  4. 堆(Heap)【GC 主战场!重点】
    类比:厨房大仓库+ 操作台。所有 new 出来的对象,全部放在堆里面。所有厨师(线程)共享这个大仓库。 堆是 GC 保洁员最主要的工作区域,绝大部分垃圾回收都发生在这里。 堆会继续划分为:新生代 + 老年代。
  • 新生代:刚创建的新对象,大部分对象生命周期很短,用完很快就变成垃圾。
  • 老年代:存活很久、多次打扫都没有被清理的对象,挪到老年代,GC 打扫频率更低。
  1. 方法区(元空间 Metaspace,JDK8 之后)
    类比:厨房的菜谱档案室。存放类的模板、静态变量、常量。 比如我们写的类 Class 信息,类里面 static 静态变量,都存在这里。

JDK1.8 之前叫永久代,容易 OOM;JDK8 移除永久代,替换成元空间,使用本地内存,不容易溢出。

  1. 直接内存
    类比:厨房外的临时货架。 NIO 等使用的堆外内存。

注意几个关键点:

  • 栈是线程私有的,每个厨师有自己的手边清单。
  • 堆是线程共享的,所有厨师都从大仓库拿食材、放半成品。
  • 方法区在 JDK 8 以后主要由元空间实现,用的是本地内存,不是堆。
  • 直接内存不属于 JVM 运行时数据区规范里的"堆" ,但 OOM 排查时经常绕不开它。

所以,当我们说"对象什么时候销毁",大多数时候说的是:

堆里的对象,什么时候会被垃圾收集器清走?

二、GC 核心问题:怎么判断一个对象是垃圾?

保洁员 GC 要判断:仓库里哪个食材(对象)已经没人使用,可以清理。

1. 曾经的算法:引用计数法

给每个食材贴计数器标签,有厨师拿着它,计数 + 1;松手不用,计数 - 1。计数等于 0,就判定是垃圾。

❌致命缺陷:循环引用无法识别。A 里面持有 B,B 里面持有 A,两个对象互相引用,计数器永远不为 0,明明没人使用,但是永远无法回收。JVM 没有采用这个算法。

2. JVM 真正使用:可达性分析算法(重点!面试必考)

先纠正一个常见误区。

复制代码
public void test() {
    User user = new User();
    // 方法结束
}

方法结束后,局部变量 user 这个引用随着栈帧出栈就没了。但这不代表 new User() 这个对象立刻被销毁。

Java 没有 C++ 那种手动 delete。对象能不能被回收,取决于一件事:

从 GC Roots 出发,能不能沿着引用链找到它。

好比保洁员从几个固定起点(GC Roots),拿着手电筒顺着连接线寻找。能被手电筒照到、连通到的对象,判定还在被使用,保留;找不到、没有连通的,就是垃圾,可以清理。

GC Roots(根对象,手电筒起点)

  1. 当前正在执行的方法里,局部变量引用的对象(厨师手上正拿着的订单、食材清单)
  2. 静态变量引用的对象(菜谱档案里登记的食材)
  3. 本地方法栈 native 引用的对象(有没有外部供应商还锁着这批货)
  4. 活跃线程对象(正在干活的厨师本身)

这些"仍然有效的起点",就是 GC Roots。

常见 GC Roots 包括:

  1. 虚拟机栈中局部变量表里引用的对象
    比如方法参数、局部变量。
  2. 本地方法栈中 JNI 引用的对象。
  3. 方法区中静态变量引用的对象。
  4. 方法区中常量引用的对象,比如字符串常量池里的引用。
  5. 同步锁 synchronized 持有的对象。
  6. JVM 内部引用,比如基本数据类型对应的 Class 对象、常驻异常对象、系统类加载器。
  7. 活跃线程等。

通俗理解:GC Roots 就是后厨里仍然"有效"的订单、手边清单、长期规则和门锁 。只要 GC Roots 能顺着引用链找到这个对象,说明还有人在用,不能丢;找不到,就是垃圾。

循环引用在这里就解决了:A、B 互相引用,但是 GC Roots 找不到 A,那么 A 和 B 都会被判定垃圾。

JVM 判断对象是否存活,主流方法是 可达性分析过程总结:

  1. 从 GC Roots 出发;
  2. 沿着对象之间的引用关系往下遍历;
  3. 能走到的对象,标记为存活;
  4. 走不到的对象,判定为不可达,可以被回收。

比如:

复制代码
public class Demo {
    private static List<User> cache = new ArrayList<>();

    public void add() {
        cache.add(new User());
    }
}

如果 cache 是静态变量,它本身就是 GC Root 的一种来源。你不断往里 add,这些 User 从 GC Roots 出发一直可达,GC 就不会回收它们。

这就是很多"内存泄漏"的本质:

你以为对象没用了,但还有一条引用链从 GC Roots 牵着它。

所以排查 OOM 时,内存分析工具(MAT) 里最有用的一步就是看:

这个对象到 GC Roots 的引用路径是什么?谁在牵着它不放?

三、堆的分代模型:新生代 & 老年代

JVM 观察到一个规律:大部分对象生命周期很短,用完很快作废;少量对象可以存活很久。如果每次 GC 都把整个仓库翻一遍,成本太高。 所以堆划分新生代、老年代,保洁员采用不同打扫策略,提升打扫效率。

新生代(存放新对象)

新生代分为:Eden 区 + Survivor0(S0) + Survivor1(S1),比例默认 8:1:1

类比:新食材全部放到 Eden 主储物区。Eden 满了,触发 MinorGC(新生代垃圾回收)

一次 Minor GC 大概是这样:

  1. 新对象优先在 Eden 分配
  2. Eden 区放满,触发 Minor GC;保洁员开始打扫新生代
  3. 从 GC Roots 出发标记 Eden 和 Survivor 中的存活对象
  4. Eden 存活的对象,赋值到Survivor0(S0)
  5. 下一次 MinorGC,Eden+S0 存活对象,复制到 Survivor1(S1)
  6. S0、S1 来回复制,对象每熬过一次 GC,年龄 + 1
  7. 对象年龄到达阈值(默认 15 左右,受参数和动态年龄判定影响),晋升到老年代
  8. 大对象可能直接进老年代,避免在 Survivor 来回复制。

老年代

熬过多次新生代 GC、年龄达标,晋升到老年代。 老年代对象存活时间长,很少变成垃圾。老年代满了,或者元空间不足、空间分配担保失败等,可能触发 MajorGC/FullGC,FullGC 开销很大,尽量减少 。

面试里常问:

为什么需要两个 Survivor?

简单答:两个 Survivor 区永远一个是空的,用来做复制存放。为了配合复制算法,让存活对象有地方搬,同时避免内存碎片。一个 Survivor 放本轮幸存者,另一个保持空闲作为下一次的周转区。

四、垃圾收集算法(保洁员打扫的 3 种方式)

  1. 标记 - 清除
    第一步标记垃圾,第二步直接清除。
    ✅简单;❌缺点:清理完内存碎片,空间零零碎碎,大对象找不到连续空间。
  2. 复制算法(新生代主要使用)
    把存活对象复制到一块空白区域,原来区域全部清空。
    ✅没有内存碎片;❌需要预留一块空白区域,占用额外内存。新生代用这个。
  3. 标记 - 整理(老年代主要使用)
    标记存活对象,把存活对象全部往一侧移动压缩,边界以外全部清理。
    ✅无内存碎片;❌移动对象,开销比较大。老年代用标记整理。

五、主流垃圾收集器(面试重点)

收集器就是不同版本的保洁工具,不同工具打扫效率、停顿时间不一样。

  1. Serial 收集器:单线程保洁,打扫期间厨房全部停工,简单,适合小内存、客户端。
  2. Parallel 收集器:多线程保洁,追求最大吞吐量,优先把打扫速度做快,关注吞吐,适合后台计算。
  3. CMS:边营业边打扫,并发标记清除,目标降低 STW 停顿;会产生内存碎片,JDK9 废弃。
  4. G1 收集器:JDK8 默认,分区打扫,可预测停顿,分区堆,可控制停顿时间,兼顾吞吐量和低停顿,大堆常用,按 Region 管理。
  5. ZGC:JDK11+,超低停顿保洁,低延迟,超大堆场景,几乎可以做到毫秒级停顿,现代高并发服务首选。

重点概念:STW(Stop The World) 保洁员打扫的时候,所有厨师(用户线程)全部暂停工作,停下手上所有操作 ,这个暂停就是 STW。 STW 时间越长,接口卡顿越明显。GC 优化核心目标:降低 STW 停顿时间。

我们不需要全部记住,但要知道:

  • Minor GC 主要清新生代;
  • Full GC 通常影响范围大、停顿长;
  • G1 之后,堆被分成多个 Region,不再要求物理连续的新生代/老年代;
  • ZGC 的目标是极低停顿,适合大堆低延迟场景。

线上一看到 Full GC 频繁,就要警惕:是不是老年代被大量长期对象占满?是不是有内存泄漏?是不是一次性加载了太多数据?

六、GC 触发场景? OOM 到底怎么产生?

GC 触发场景:

  1. MinorGC:Eden 空间满,触发新生代回收,STW 时间短。
  2. MajorGC:老年代空间不足,老年代回收。
  3. FullGC:整个堆 + 方法区全部回收,STW 时间很长,业务要尽量避免频繁 FullGC。

即便有GC,但还是会出现 OOM,全称 OutOfMemoryError,可以理解成:

后厨要放的东西太多,仓库、操作台、菜谱柜或者外部货架爆了,JVM 实在分配不出内存。

常见 OOM 类型不止一种:

OOM 类型 厨房类比 常见原因
Java heap space 仓库爆仓 堆对象太多、内存泄漏、一次查太多数据
Metaspace 菜谱柜塞满 类加载太多、动态代理/热部署导致类无法卸载
Direct buffer memory 厨房外临时货架满了 NIO 直接内存使用过多
unable to create native thread 厨师太多,排不开 线程数过多、系统资源限制
GC overhead limit exceeded 保洁一直在打扫但清不出空间 GC 频繁且回收效率极低

注意:StackOverflowError 是栈溢出,不是 OOM,但常和内存问题一起被问。递归太深就可能栈溢出。

OOM 的核心逻辑通常是:

  1. 对象不断创建;
  2. GC 后仍然有大量对象可达;
  3. 堆空间不够;
  4. JVM 尝试 GC,还是不够;
  5. 抛出 OutOfMemoryError。

所以 GC 不是万能的。GC 只能回收"不可达垃圾",回收不了"你以为没用但还被引用牵着"的对象。

七、线上 OOM 排查清单:按顺序看

真遇到 OOM,可以按这个顺序排查:

  1. 看日志里的 OOM 类型

    是 Java heap space、Metaspace 还是 Direct buffer memory?类型不同,方向不同。

  2. 保留堆转储

    启动参数加:

    复制代码
    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/path/heap.hprof
  3. 找进程

    复制代码
    jps -l
  4. 看 GC 情况

    复制代码
    jstat -gcutil <pid> 1000

    重点看老年代占用、Full GC 次数和耗时。

  5. 看对象分布

    复制代码
    jmap -histo:live <pid> | head -50

    看哪些对象数量异常多。

  6. 导出堆快照

    复制代码
    jmap -dump:live,format=b,file=heap.hprof <pid>
  7. 用 MAT / VisualVM 分析

    看支配树、大对象、对象到 GC Roots 的引用路径。重点找"谁在牵着它"。

  8. 看线程

    复制代码
    jstack <pid>

    排查线程数过多、线程池堆积、死锁等问题。

  9. 必要时用 Arthas

    看方法调用、对象来源、类加载情况。

排查 OOM 的核心不是"GC 参数怎么调",而是先回答:

到底是对象太多,还是对象该走却走不掉?

⚠️开发高频踩坑实录

坑 1:创建大量短期大对象,直接进入老年代,频繁触发 FullGC,服务卡顿。

✅解决:控制一次性创建超大对象,检查循环内不停 new 大对象。

坑 2:静态集合持续存放对象,GC Roots 一直持有引用,对象永远无法回收,慢慢堆积,最终 OOM。

✅解决:静态 List/Map 不用的时候,主动清空引用。

坑 3:线程池长时间保留大量任务,任务对象无法释放,内存持续上涨。

✅解决:合理设置线程池、有界队列,及时清理无用任务。

坑 4:忘记关闭流、连接,对象一直被引用,内存泄漏。

✅try-with-resources 自动关闭资源。

坑 5:误以为 System.gc () 可以立刻触发 GC。

❌只是建议 JVM 执行 GC,不保证马上执行,业务代码不要依赖。

🧪面试高频考点

Q1:简述 JVM 内存分区,哪些区域会 OOM?

A:程序计数器无 OOM;虚拟机栈、本地方法栈会栈溢出;堆、元空间会 OOM。堆是最常见 OOM 区域。

Q2:GC Roots 有哪些?可达性分析原理?

A:可达性分析:从 GC Roots 出发遍历引用链,不可达的对象判定垃圾。GC Roots 包含活跃线程、局部变量引用、静态变量、native 引用。

Q3:Minor GC 什么时候触发?

A:新生代 Eden 区空间不足时常见。存活对象进入 Survivor,年龄增长后晋升老年代。

Q4:MinorGC、MajorGC、FullGC 区别?

A:MinorGC 只回收新生代,频率高、速度快;MajorGC 回收老年代;FullGC 全堆回收,STW 时间很长,尽量避免。

Q5:什么是 STW?G1 和 CMS 区别?

A:STW 是 GC 时用户线程暂停。CMS 并发标记清除,有碎片;G1 分区化,可控制停顿时间,JDK8 默认。

Q6:为什么循环引用的对象可以被 GC 回收?

A:JVM 用可达性分析,不是引用计数。循环引用互相持有,但没有 GC Roots 可达,判定垃圾,被回收。

Q7:OOM 和 StackOverflowError 有什么区别?

A:OOM 是内存分配不足;StackOverflowError 是线程栈深度超限,常见于递归过深。

✅总结避坑清单

  1. 绝大多数 OOM 都发生在堆内存,优先排查堆里大对象、长期存活集合。
  2. 静态集合持有对象引用极易造成内存泄漏,用完及时清空。
  3. FullGC 代价极高,线上监控重点关注 FullGC 频次。
  4. System.gc () 只是提醒,不能强制触发垃圾回收,业务不要依赖。
  5. STW 会造成业务卡顿,高并发项目优先选择低延迟收集器如 ZGC。

最后总结:GC 到底在干什么?

再用厨房仓库收个尾:

  • JVM 内存分区:后厨不同区域,仓库、清单、菜谱柜各司其职;
  • GC Roots:还在生效的订单、手边清单、长期规则;
  • 可达性分析:从根出发走一遍,走不到的就是垃圾;
  • 分代回收:新货区、周转筐、老库存分开管理;
  • 垃圾收集器:不同保洁团队,停顿和吞吐取舍不同;
  • OOM:不是 GC 不干活,而是仓库爆了,或者东西被引用牵着清不掉。

所以,下次再被问"Java 对象什么时候销毁",别只答"GC 回收"。可以补一句:

对象是否存活看可达性,是否回收看 GC 时机;OOM 排查先看 OOM 类型,再看 GC 日志和堆快照,最后找到那条从 GC Roots 牵过来的引用链。

搞懂这条主线,面试能答,线上排查也能用。


📚系列专栏:后端基础拾光集|Java 后端踩坑实录,持续更新 Java 面试、源码、线上故障复盘。欢迎关注评论区交流学习。

相关推荐
估值探索者1 小时前
【Python量化选股流水线 #03】候选股怎么排序?动量+低波+流动性三因子打分取 Top-N
开发语言·python·接口·api接口·股票数据接口·数据接口·股票数据api接口
骑士雄师1 小时前
supervisor 中关于上下文多轮信息维护常用到的python语法
开发语言·python
苏三说技术1 小时前
为什么越来越多人用MinerU?
后端
曹牧1 小时前
C#:字符串占位符与值数量不一致
开发语言·数据库·c#
2301_32241428041 小时前
基于SSM框架的在线考试管理系统的设计与实现14750-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·redis·mysql·tomcat·maven·mybatis·ssm
SimonKing1 小时前
别再找笔记了,试试这款轻量级的开源软件:local-notes
后端·程序员·开源
狂师1 小时前
太强了!GitHub上开源了一款AI自动化测试框架,0代码搞定前后端测试!
前端·后端·测试
clz13145211 小时前
RuoYi-Cloud 新建微服务并实现服务间调用(OpenFeign 实战)
java·ruoyi
迅猛龙办公室1 小时前
Python函数实现反转一个只有3位数的整数
开发语言·前端·python