跳过正文
  1. 博客/

JVM 垃圾回收机制

·167 字·1 分钟

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) 追求低停顿,用标记-清除算法,大致四步:

  1. 初始标记——STW,只标记 GC Roots 直接关联的对象,很快
  2. 并发标记——和用户线程一起跑,遍历整个引用链
  3. 重新标记——STW,修正并发标记期间的变动
  4. 并发清除——和用户线程一起跑

两次 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 参数有用得多。