说实话,JVM GC 这块我被问麻了。每次面试都能碰到相关问题,而且越来越刁钻。今天把 GC 彻底搞明白,写给自己,也写给正在备战秋招的你。

GC 到底是什么?

GC 的全称是 Garbage Collection,垃圾回收。听起来高大上,本质就是 JVM 自动清理"死了"的对象,释放内存。

为什么要自动?因为手动释放太容易出 bug。忘记 free 内存泄漏,free 两次直接崩溃。C++ 程序员天天和这个较劲,Java 直接帮你解决了。

GC 的两个核心指标:

指标含义你要关心吗
吞吐量CPU 实际执行业务代码的时间占比高并发场景必须关注
延迟(STW)GC 时业务线程暂停的时间低延迟场景必须关注(金融、游戏)

这两个指标是天平的两端,没有完美的方案,只有取舍。

什么样的对象算"垃圾"?

GC 之前得先知道哪些对象该回收。判断方法有两种。

第一种:引用计数法。

每个对象有个引用计数器,有地方引用就+1,引用失效就-1。计数器为0就是垃圾。

听起来没问题,但有个致命缺陷:循环引用。

对象 A 引用对象 B,对象 B 引用对象 A,但他俩都没被其他对象引用。计数器都是1,但已经是垃圾了。这种对象永远不会被回收。

JVM 没用这个方案。

第二种:可达性分析。

这个是 JVM 真正用的。

首先定义一批"gc root"对象,作为 GC 的起点。然后从这些根对象出发,顺着引用链往下走。能被走到的对象就是活的,走不到的就是垃圾。

gc root 包括:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即 native 方法)引用的对象

记住这个图:

gc roots
    │
    ├── 栈帧中的局部变量
    │       └── 指向堆中对象的引用
    │
    ├── 方法区静态属性
    │       └── 指向堆中对象的引用
    │
    └── 本地方法栈
            └── native 方法中的引用

一个对象是不是垃圾,就看他能不能被 gc roots 到达。

分代回收:JVM 的内存管理哲学

JVM 不是统一对待所有对象的,而是根据对象的"年龄"分而治之。这就是分代回收理论。

堆内存的划分:

┌─────────────────────────────────────┐
│                 堆                  │
├─────────────────┬───────────────────┤
│   Young Gen     │    Old Gen        │
│   (年轻代)      │    (老年代)        │
├─────┬─────┬─────┼───────────────────┤
│Eden │S0   │S1   │                   │
│     │Surv │Surv │                   │
└─────┴─────┴─────┴───────────────────┘

年轻代:新创建的对象在这里。分 Eden 区 + 两个 Survivor 区(S0、S1)。

老年代:经历了多次 GC 还存活的对象会挪到这里。大对象直接进老年代。

分代的理由是什么?

统计学告诉我们:大多数对象朝生夕死。

你 new 出来一个对象,可能下一秒就不用了。只有少数对象会活很久。如果每次 GC 都扫描全部对象,效率太低。

分代之后,年轻代 GC 频繁但快(对象多死得也快),老年代 GC 少但慢(对象少但活得久)。各取所需,效率最大化。

我踩过的坑:对象直接进老年代。

有一次线上 OOM,检查 dump 发现老年代占了 95%。排查半天,发现代码里有个逻辑:一次性从数据库加载了 50 万条记录,new 了一个巨大的 List 对象。

这个 List 直接触发了大对象直接进老年代的规则。结果年轻代根本没压力,老年代 GC 来不及回收,直接崩了。

教训:处理大集合数据要分批,别一口气全加载。

三大 GC 算法

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

最基础的算法,分两步:

  1. 标记:遍历所有对象,把活着的对象标记
  2. 清除:遍历所有对象,把没标记的干掉

问题:

  • 效率不高。两次遍历,对象多的时候很慢
  • 产生内存碎片。存活对象之间有空洞,下一个大对象可能放不下

2. 复制(Copying)

把内存分成两块,每次只用一块。GC 时把活着的对象复制到另一块,然后整体清空。

优点: 没有内存碎片,效率高。

缺点: 浪费一半内存。

JVM 用的是改良版:把年轻代分成一个 Eden + 两个 Survivor。默认比例是 8:1:1。

每次 GC 把 Eden + From Survivor 活着的对象复制到 To Survivor,然后清空其他区域。这样只浪费 10% 的空间。

Survivor 区为什么是两个?

为了解决内存碎片和分配效率的问题。From 和 To 角色每次 GC 互换。

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

老年代一般用这个。

  1. 标记:和之前一样,把活着的对象标记
  2. 整理:让存活对象向一端移动,然后清理边界外的内存

优点: 没有内存碎片。

缺点: 整理需要移动对象,耗时。

三种算法对比:

算法优点缺点适用场景
标记-清除不需要移动对象产生内存碎片老年代
复制无碎片,效率高浪费50%空间年轻代
标记-整理无碎片移动对象耗时老年代

常用垃圾回收器

Serial:最老最简单

单线程工作,GC 时必须 Stop The World(STW)。

年轻代用复制,老年代用标记-整理。

适合:单核机器,或者对延迟不敏感的批处理任务。

Parallel(吞吐量优先)

多个线程并行 GC,目标是最大化吞吐量(业务代码执行时间 / 总时间)。

JDK 8 默认的年轻代收集器就是这个。

特点:吞吐量高,但 STW 时间长。

CMS(Concurrent Mark Sweep)

这是第一个真正并发的收集器。GC 线程和业务线程可以一起跑。

工作流程:

  1. 初始标记:STW,标记 gc roots 直接引用的对象(很快)
  2. 并发标记:业务线程跑,同时 GC 线程顺着引用链标记
  3. 重新标记:STW,修正并发标记期间产生的变动
  4. 并发清除:和业务线程一起跑,清除垃圾

优点:延迟低,大部分时间并发。

缺点:

  • CPU 敏感,多核才有效
  • 产生内存碎片
  • 浮动垃圾(并发标记期间新产生的垃圾只能等下次 GC)

JDK 14 已经废弃了。

G1(Garbage First)

现代 JVM 的主力收集器。JDK 9+ 默认。

核心思想:把堆分成多个大小相等的 Region,每个 Region 可以是 Eden、Survivor 或 Old。

GC 时根据"回收价值"优先回收垃圾最多的 Region。这就是"Garbage First"的含义。

工作流程:

  1. 初始标记:STW,标记 gc roots 直接引用的对象
  2. 并发标记:业务线程跑,GC 线程标记存活对象
  3. 最终标记:STW,处理并发标记期间的变动
  4. 筛选回收:STW,根据回收价值和成本选择Region回收

优点:

  • 可以设置期望的停顿时间(-XX:MaxGCPauseMillis=200)
  • 整体看是并发收集,延迟可控
  • 不产生内存碎片

适合:大内存、低延迟场景。4GB+ 堆内存推荐用 G1。

ZGC

革命性的收集器,JDK 11 引入了。

核心特性:STW 时间不会超过 10ms,且停顿时间不随堆大小增加而增加。

原理是用了读屏障和着色指针,实现了并发标记、并发压缩,整个过程几乎不需要 STW。

缺点:吞吐量比 G1 低一些,延迟极低但不是零。

适合:超大内存(TB级别)、要求极低延迟的场景。

各收集器对比:

收集器线程STW适用场景
Serial单线程长单核、批处理
Parallel多线程长吞吐量优先
CMS并发短低延迟(已废弃)
G1并发可控大内存、低延迟
ZGC并发<10ms超大内存、极低延迟

面试高频问题

Q1:为什么新生代用复制算法,老年代用标记-整理?

新生代对象存活率低,复制算法浪费的空间少(只用10%的 Survivor 区)。老年代对象存活率高,复制代价太大,用标记-整理更划算。

Q2:对象在 GC 后一定被回收吗?

不一定。可达性分析中,如果对象重写了 finalize() 方法且没被调用过,对象会被放入一个 F-Queue。GC 线程会触发一次 finalize() 调用,对象可以在这时自救(把自己的引用赋值给某个类变量)。但 finalize() 只执行一次,第二次 GC 就没了。

Q3:Minor GC 和 Full GC 有什么区别?

Minor GC 只清理年轻代,频率高但快。Full GC 清理整个堆,耗时很长。Full GC 触发条件包括:老年代空间不足、永久代/元数据区空间不足、System.gc() 调用等。

Q4:G1 和 ZGC 怎么选?

看数据量。几十 GB 选 G1,TB 级别选 ZGC。G1 调优复杂,ZGC 对吞吐量有一定牺牲。

我踩过的坑

第一个坑:CMS 导致频繁 Full GC。

线上 CMS 收集器,老年代使用率达到 70% 就触发 GC。但并发标记期间业务还在跑,产生新的垃圾叫"浮动垃圾"。如果浮动垃圾增长太快,CMS 还没清理完老年代就满了,只能触发 Full GC。

解决:调低 -XX:CMSInitiatingOccupancyFraction,提前触发回收。我设的是 65%。

第二个坑:G1 的 Mixed GC 没配好。

G1 的 Mixed GC 会回收年轻代+老年代的 Region。有一次年轻代设太大(50%),Mixed GC 回收老年代时没有足够时间,只能继续触发 Full GC。

解决:合理设置 -XX:G1NewSizePercent 和 -XX:G1MaxNewSizePercent,不要让年轻代占太多。

记忆口诀

GC 判断靠可达,引用计数有循环

分代理论是核心,Eden 复制效率高

老年代用标记整理,CMS 并发延迟低

G1 分区回收快,ZGC 十毫秒极低

总结

JVM GC 这块,核心就三件事:

第一:判断垃圾。 可达性分析 + gc roots。

第二:分代回收。 年轻代频繁快,老年代慢但少。

第三:选对收集器。 吞吐量优先用 Parallel,低延迟用 G1 或 ZGC。

面试问到 GC,能把这个链条讲清楚,能结合自己踩过的坑,基本就稳了。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐