大家好,这里是后端基础拾光集。
前面两篇我们一起学习了多线程、锁、线程池。线程就是厨房里干活的厨师,厨师干活会不断产生各种食材、半成品,也就是 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 块独立区域,就像厨房仓库划分不同储物隔间。
- 程序计数器(PC 寄存器)
类比:厨师手上的工作小纸条,记录当前做到哪一步,下一步该干什么。
特点:线程私有,每个厨师单独一张纸条;唯一没有 OOM 的内存区域 。
作用:记录当前线程执行到哪一行代码。线程切换之后,回来可以接着继续干活。 - 虚拟机栈(Java 栈)
类比:厨师手边临时操作台。每次开始一道菜,就在台面上放订单、临时用料;菜做完,台面直接清空。
特点:每个线程私有,一个线程对应一个栈。存放局部变量、方法调用信息。 方法调用 → 压栈;方法执行结束 → 出栈释放。
异常:栈深度超出限制,抛出StackOverflowError栈溢出。 - 本地方法栈
类比:厨房里面对接外部设备(烤箱、蒸箱)的操作台。 专门用来执行 native 本地方法(C/C++ 写的底层方法),和虚拟机栈功能类似,只是服务本地方法。 - 堆(Heap)【GC 主战场!重点】
类比:厨房大仓库+ 操作台。所有 new 出来的对象,全部放在堆里面。所有厨师(线程)共享这个大仓库。 堆是 GC 保洁员最主要的工作区域,绝大部分垃圾回收都发生在这里。 堆会继续划分为:新生代 + 老年代。
- 新生代:刚创建的新对象,大部分对象生命周期很短,用完很快就变成垃圾。
- 老年代:存活很久、多次打扫都没有被清理的对象,挪到老年代,GC 打扫频率更低。
- 方法区(元空间 Metaspace,JDK8 之后)
类比:厨房的菜谱档案室。存放类的模板、静态变量、常量。 比如我们写的类 Class 信息,类里面 static 静态变量,都存在这里。
JDK1.8 之前叫永久代,容易 OOM;JDK8 移除永久代,替换成元空间,使用本地内存,不容易溢出。
- 直接内存
类比:厨房外的临时货架。 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(根对象,手电筒起点)
- 当前正在执行的方法里,局部变量引用的对象(厨师手上正拿着的订单、食材清单)
- 静态变量引用的对象(菜谱档案里登记的食材)
- 本地方法栈 native 引用的对象(有没有外部供应商还锁着这批货)
- 活跃线程对象(正在干活的厨师本身)
这些"仍然有效的起点",就是 GC Roots。
常见 GC Roots 包括:
- 虚拟机栈中局部变量表里引用的对象
比如方法参数、局部变量。 - 本地方法栈中 JNI 引用的对象。
- 方法区中静态变量引用的对象。
- 方法区中常量引用的对象,比如字符串常量池里的引用。
- 同步锁
synchronized持有的对象。 - JVM 内部引用,比如基本数据类型对应的 Class 对象、常驻异常对象、系统类加载器。
- 活跃线程等。
通俗理解:GC Roots 就是后厨里仍然"有效"的订单、手边清单、长期规则和门锁 。只要 GC Roots 能顺着引用链找到这个对象,说明还有人在用,不能丢;找不到,就是垃圾。
循环引用在这里就解决了:A、B 互相引用,但是 GC Roots 找不到 A,那么 A 和 B 都会被判定垃圾。
JVM 判断对象是否存活,主流方法是 可达性分析过程总结:
- 从 GC Roots 出发;
- 沿着对象之间的引用关系往下遍历;
- 能走到的对象,标记为存活;
- 走不到的对象,判定为不可达,可以被回收。
比如:
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 大概是这样:
- 新对象优先在 Eden 分配
- Eden 区放满,触发 Minor GC;保洁员开始打扫新生代
- 从 GC Roots 出发标记 Eden 和 Survivor 中的存活对象
- Eden 存活的对象,赋值到Survivor0(S0)
- 下一次 MinorGC,Eden+S0 存活对象,复制到 Survivor1(S1)
- S0、S1 来回复制,对象每熬过一次 GC,年龄 + 1
- 对象年龄到达阈值(默认 15 左右,受参数和动态年龄判定影响),晋升到老年代
- 大对象可能直接进老年代,避免在 Survivor 来回复制。
老年代
熬过多次新生代 GC、年龄达标,晋升到老年代。 老年代对象存活时间长,很少变成垃圾。老年代满了,或者元空间不足、空间分配担保失败等,可能触发 MajorGC/FullGC,FullGC 开销很大,尽量减少 。
面试里常问:
为什么需要两个 Survivor?
简单答:两个 Survivor 区永远一个是空的,用来做复制存放。为了配合复制算法,让存活对象有地方搬,同时避免内存碎片。一个 Survivor 放本轮幸存者,另一个保持空闲作为下一次的周转区。
四、垃圾收集算法(保洁员打扫的 3 种方式)
- 标记 - 清除
第一步标记垃圾,第二步直接清除。
✅简单;❌缺点:清理完内存碎片,空间零零碎碎,大对象找不到连续空间。 - 复制算法(新生代主要使用)
把存活对象复制到一块空白区域,原来区域全部清空。
✅没有内存碎片;❌需要预留一块空白区域,占用额外内存。新生代用这个。 - 标记 - 整理(老年代主要使用)
标记存活对象,把存活对象全部往一侧移动压缩,边界以外全部清理。
✅无内存碎片;❌移动对象,开销比较大。老年代用标记整理。
五、主流垃圾收集器(面试重点)
收集器就是不同版本的保洁工具,不同工具打扫效率、停顿时间不一样。
- Serial 收集器:单线程保洁,打扫期间厨房全部停工,简单,适合小内存、客户端。
- Parallel 收集器:多线程保洁,追求最大吞吐量,优先把打扫速度做快,关注吞吐,适合后台计算。
- CMS:边营业边打扫,并发标记清除,目标降低 STW 停顿;会产生内存碎片,JDK9 废弃。
- G1 收集器:JDK8 默认,分区打扫,可预测停顿,分区堆,可控制停顿时间,兼顾吞吐量和低停顿,大堆常用,按 Region 管理。
- ZGC:JDK11+,超低停顿保洁,低延迟,超大堆场景,几乎可以做到毫秒级停顿,现代高并发服务首选。
重点概念:STW(Stop The World) 保洁员打扫的时候,所有厨师(用户线程)全部暂停工作,停下手上所有操作 ,这个暂停就是 STW。 STW 时间越长,接口卡顿越明显。GC 优化核心目标:降低 STW 停顿时间。
我们不需要全部记住,但要知道:
- Minor GC 主要清新生代;
- Full GC 通常影响范围大、停顿长;
- G1 之后,堆被分成多个 Region,不再要求物理连续的新生代/老年代;
- ZGC 的目标是极低停顿,适合大堆低延迟场景。
线上一看到 Full GC 频繁,就要警惕:是不是老年代被大量长期对象占满?是不是有内存泄漏?是不是一次性加载了太多数据?
六、GC 触发场景? OOM 到底怎么产生?
GC 触发场景:
- MinorGC:Eden 空间满,触发新生代回收,STW 时间短。
- MajorGC:老年代空间不足,老年代回收。
- 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 的核心逻辑通常是:
- 对象不断创建;
- GC 后仍然有大量对象可达;
- 堆空间不够;
- JVM 尝试 GC,还是不够;
- 抛出
OutOfMemoryError。
所以 GC 不是万能的。GC 只能回收"不可达垃圾",回收不了"你以为没用但还被引用牵着"的对象。
七、线上 OOM 排查清单:按顺序看
真遇到 OOM,可以按这个顺序排查:
-
看日志里的 OOM 类型
是
Java heap space、Metaspace还是Direct buffer memory?类型不同,方向不同。 -
保留堆转储
启动参数加:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/heap.hprof -
找进程
jps -l -
看 GC 情况
jstat -gcutil <pid> 1000重点看老年代占用、Full GC 次数和耗时。
-
看对象分布
jmap -histo:live <pid> | head -50看哪些对象数量异常多。
-
导出堆快照
jmap -dump:live,format=b,file=heap.hprof <pid> -
用 MAT / VisualVM 分析
看支配树、大对象、对象到 GC Roots 的引用路径。重点找"谁在牵着它"。
-
看线程
jstack <pid>排查线程数过多、线程池堆积、死锁等问题。
-
必要时用 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 是线程栈深度超限,常见于递归过深。
✅总结避坑清单
- 绝大多数 OOM 都发生在堆内存,优先排查堆里大对象、长期存活集合。
- 静态集合持有对象引用极易造成内存泄漏,用完及时清空。
- FullGC 代价极高,线上监控重点关注 FullGC 频次。
- System.gc () 只是提醒,不能强制触发垃圾回收,业务不要依赖。
- STW 会造成业务卡顿,高并发项目优先选择低延迟收集器如 ZGC。
最后总结:GC 到底在干什么?
再用厨房仓库收个尾:
- JVM 内存分区:后厨不同区域,仓库、清单、菜谱柜各司其职;
- GC Roots:还在生效的订单、手边清单、长期规则;
- 可达性分析:从根出发走一遍,走不到的就是垃圾;
- 分代回收:新货区、周转筐、老库存分开管理;
- 垃圾收集器:不同保洁团队,停顿和吞吐取舍不同;
- OOM:不是 GC 不干活,而是仓库爆了,或者东西被引用牵着清不掉。
所以,下次再被问"Java 对象什么时候销毁",别只答"GC 回收"。可以补一句:
对象是否存活看可达性,是否回收看 GC 时机;OOM 排查先看 OOM 类型,再看 GC 日志和堆快照,最后找到那条从 GC Roots 牵过来的引用链。
搞懂这条主线,面试能答,线上排查也能用。
📚系列专栏:后端基础拾光集|Java 后端踩坑实录,持续更新 Java 面试、源码、线上故障复盘。欢迎关注评论区交流学习。