JVM 垃圾回收看这篇就够了#
JVM 垃圾回收是面试八股文里的重头戏,也是我觉得最难啃的一块。概念多、算法多、收集器也多,第一次看的时候感觉脑子不够用。这篇把我学到的整理一下,尽量讲人话。

堆内存长什么样#
JVM 的堆分两大块:新生代(Young Generation)和老年代(Old Generation)。
新生代又分三块:Eden 区和两个 Survivor 区(S0、S1),默认比例 8:1:1。
为什么这么分?大部分对象都是"朝生夕死"的,活不过一次 GC。放在新生代用快速算法回收就行,少数长寿的晋升到老年代。
对象一般在 Eden 区创建。Eden 满了触发 Minor GC,存活对象搬到 Survivor 区。每熬过一次 GC 年龄加 1,到阈值(默认 15)就晋升老年代。
两个 Survivor 同一时刻只有一个在用。GC 时把 Eden 和正在用的 Survivor 里的存活对象一起复制到空的那个 Survivor,然后清空原来的区域。这是复制算法在新生代的应用。
怎么判断对象该不该回收#
主流方案是可达性分析。从一组叫 GC Roots 的对象出发,沿引用链往下找,找不到的就是垃圾。
GC Roots 包括:
- 虚拟机栈中引用的对象(局部变量)
- 类的静态变量引用的对象
- 常量引用的对象
- JNI 引用的对象
- 同步锁持有的对象
你可能听过引用计数法——被引用加 1,引用没了减 1,归零就回收。听着简单,但有个致命问题:循环引用。A 引用 B、B 引用 A,计数永远不为 0,但都是垃圾。JVM 没用这个方案。
三种回收算法#
标记-清除(Mark-Sweep)
先标记要回收的对象,然后清除。问题是清完之后碎片一堆,大对象找不到连续空间。
复制算法(Copying)
内存分两半,只用一半。GC 时把活的复制到另一半,这一半整个清空。没碎片,但浪费一半空间。新生代用的就是这个思路——因为大部分对象活不久,实际要复制的量很少,效率很高。
标记-整理(Mark-Compact)
先标记,然后把存活对象往一端挪,清掉边界外的内存。没碎片也不浪费空间,但移动对象有额外开销。老年代一般用这个。
CMS 和 G1 怎么选#
这两个是面试高频考点。
CMS(Concurrent Mark Sweep) 追求低停顿,用标记-清除算法,大致四步:
- 初始标记——STW,只标记 GC Roots 直接关联的对象,很快
- 并发标记——和用户线程一起跑,遍历整个引用链
- 重新标记——STW,修正并发标记期间的变动
- 并发清除——和用户线程一起跑
两次 STW 都很短,大部分工作并发完成,停顿时间低。
但 CMS 有明显缺点:
- 标记-清除会产生碎片
- 并发阶段占 CPU,吞吐量下降
- “concurrent mode failure”:并发清除时老年代空间不够,退化成 Serial Old 做 Full GC,一停就很久
G1(Garbage First) 是 JDK9 后的默认收集器,思路跟 CMS 很不一样。
G1 把堆切成很多大小相等的 Region(默认 2048 个),每个 Region 可以充当 Eden、Survivor 或 Old。不再是物理连续的分代,而是逻辑分代。
G1 的核心思想:每次 GC 不回收所有垃圾,优先回收垃圾最多的 Region。“Garbage First"就是这个意思。在有限时间内回收尽可能多的空间。
G1 的优势:
- 可以设停顿时间目标(
-XX:MaxGCPauseMillis),G1 会尽量满足 - 整体上是标记-整理,不产生碎片
- 大对象有专门的 Humongous Region
实际怎么选?JDK8 堆不大(几个 G 以内)用 CMS 还行。JDK9 以上直接 G1,基本不用纠结。堆特别大(几十 G)可以了解一下 ZGC。
实际碰到的一件事#
上学期课设的项目跑着跑着就卡一下,频率还挺高。一开始以为是网络问题,后来用 jstat -gcutil 看了下,发现 Full GC 特别频繁,每次停顿几百毫秒。
原因是代码里有个循环不停创建大 byte 数组,直接分配到老年代了,老年代很快就满。
解决方案也简单:byte 数组改成对象池复用,Full GC 频率立刻降下来了。
这件事让我意识到,GC 问题往往不是调参能解决的,根源通常在代码层面。写代码的时候注意对象生命周期,比调一堆 JVM 参数有用得多。