Skip to content
Charles Shao
Go back

JVM 深入(二)· 核心篇:垃圾回收全解 —— 从可达性分析到 ZGC

views

上一篇我们看着一个对象在堆里安了家。但堆是有限的,对象迟早要死。写过 C++ 的同学都体验过 mallocfree 的痛苦 —— 忘了 free 就内存泄漏,free 错了就段错误。Java 程序员几乎不用操心这些,因为 JVM 会自动帮你回收没用的对象。这个机制就是垃圾回收(Garbage Collection,简称 GC)。

这一篇是整个系列最硬核的一篇,我们会一路从「怎么判断一个对象是垃圾」讲到「工业级回收器 G1 / ZGC 凭什么又快又不卡顿」。

TL;DR

Table of contents

Open Table of contents

一、什么样的对象算「垃圾」

一句话:没有任何引用指向它的对象,就是垃圾。但怎么判断「没有引用」?有两种思路。

思路一:引用计数

每个对象内部记一个数:被引用一次就 +1,引用解除就 -1,归零就回收。简单粗暴,但有个致命问题 —— 循环引用

A a = new A();
B b = new B();
a.ref = b;
b.ref = a;
a = null;
b = null;

按引用计数,a 和 b 互相引用,计数都是 1,永远不会被回收,但实际上外部已经没人用它们了。所以 HotSpot JVM 没用 这个方案。

思路二:可达性分析

JVM 选了一个更聪明的办法:

  1. 定义一组「肯定还活着的对象」,叫做 GC Roots(比如当前栈帧里引用的对象、静态变量引用的对象等);
  2. 从 GC Roots 出发,沿着引用链一路走下去,所有能走到的对象都是「活的」;
  3. 走不到的对象,就是垃圾。

一个直观类比:拎起一串葡萄

桌上有一串葡萄,你的手抓住的是 葡萄梗(GC Roots),梗连着枝、枝上挂着一颗颗葡萄。

  • 你一提起梗,所有 还连在枝上 的葡萄都被一起拎起来 → 这就是「可达」,活的
  • 桌上几颗 掉落的葡萄,哪怕两颗还黏在一起,但已脱离主枝 → 提梗时根本带不起它们 → 垃圾

GC Roots 就是那只「手抓住的梗」—— 它一定还在被使用,所以从它出发能连到的对象都还有用。

那「手抓住的梗」具体是谁?就是这些 明确还在被使用 的引用:

GC Roots 来源对应你代码里的什么
虚拟机栈里的引用正在执行的方法里的 局部变量
静态变量类里的 static 字段
常量引用常量池里引用的对象
本地方法栈引用native 方法用到的对象

回到上面那段循环引用的代码,执行完 a = null; b = null; 后是这样的画面 —— 那只「手」已经松开:

循环引用被判为垃圾的示意:GC Roots(局部变量 a / b)在 a=null·b=null 后已松手,与对象 A 断开;对象 A 指向对象 B、对象 B 又指回对象 A,两者互相引用但都已脱离 GC Roots,被判定为垃圾 a、b 置 null 后整串葡萄从手上脱落,A、B 内部再互相引用也无济于事,双双判为垃圾。

为什么可达性分析更优

它看的不是「有没有人指着我」,而是「能不能从根一路连到我」。所以孤岛对象就算内部互相引用,但跟 GC Roots 断了线,照样会被判死刑 —— 循环引用的难题就这么被天然化解了。

补充:引用其实分四种强度

前面说「没有引用就是垃圾」,其实「引用」本身还分强弱,这决定了对象在什么时机被回收:

引用类型回收时机典型用途
强引用只要还可达就 永不回收Object o = new Object()平时写的绝大多数引用
软引用 SoftReference内存不够 时才回收内存敏感的缓存(如图片缓存)
弱引用 WeakReference下次 GC 必回收,撑不过一轮ThreadLocalWeakHashMap 的 key
虚引用 PhantomReference形同虚设,仅用于对象被回收时收到 通知管理堆外内存(如 DirectByteBuffer

一句话记忆

强引用是「亲儿子」(绝不抛弃)、软引用是「内存紧张才忍痛割爱」、弱引用是「下轮必清」、虚引用是「只为收尸通知」。

二、GC 怎么「停下世界」:Safepoint 与 OopMap

上面说「从 GC Roots 出发」,但有个绕不开的现实问题:你的业务线程一直在跑,引用关系时刻在变。GC 怎么可能在一个奔跑的靶子上精确数清有哪些 Roots?

答案是:GC 需要先让所有业务线程 停在一个「可以安全被检查」的位置,这个位置叫 安全点(Safepoint)。这就是大名鼎鼎的 Stop-The-World(STW) 真正发生的地方。

Safepoint 停顿枚举 GC Roots 流程:GC 线程发起收集请求后,各线程插入的 Safepoint 轮询点生效,业务线程跑到轮询点就挂起、已阻塞的线程则处于安全区域;所有线程到齐后借助 OopMap 精确枚举 GC Roots,枚举完再唤醒所有业务线程 GC 先让所有线程在安全点集合,再借 OopMap 精确枚举 GC Roots,枚举完才放行。

拆开看:

一句话本质

STW 不是「GC 想卡你」,而是「要在一张一致的快照上数清活对象,就必须先按下暂停键」。后面所有并发回收器的努力,本质都是 把这个暂停键按得尽可能短

三、怎么把垃圾扔掉:三种基础算法

判断完垃圾,下一步是回收。回收算法有三种:

算法思路优点缺点
标记-清除标记活对象 → 清除没标记的实现简单留下大量 内存碎片
标记-复制内存分两半,把活对象复制到另一半没有碎片永远浪费一半空间
标记-整理标记活对象 → 全挪到一端 → 清空边界外没碎片、不浪费移动对象成本高

把同一块内存(=活对象,=垃圾,·=空闲)丢给三种算法,回收后的布局一目了然:

原始内存:   ■ □ ■ □ □ ■ □ ■        (4 个活对象,4 个垃圾)

① 标记-清除  只清垃圾,活对象原地不动
  回收后:   ■ · ■ · · ■ · ■        ← 空闲被切碎,留下内存碎片

② 标记-复制  内存分两半,活对象搬到另一半并紧凑排列
  回收前:   左半[ ■ □ ■ □ □ ■ □ ■ ]  右半[ · · · · · · · · ]
  回收后:   左半[ · · · · · · · · ]  右半[ ■ ■ ■ ■ · · · · ]   ← 整段连续,但永远空着一半

③ 标记-整理  活对象全部推到左端,边界右侧整片清空
  回收后:   ■ ■ ■ ■ ┊ · · · ·       ← 无碎片,也不浪费空间(┊ 是边界)

一句话区分三者

都要先「标记」活对象,区别只在第二步:清除=原地删垃圾(留碎片);复制=活对象搬家到空白区(费空间);整理=活对象就地挪到一端再清边界外(费搬运)。

标记-复制为什么不亏

听起来浪费一半空间很奢侈,但有一个观察让它变得划算 —— 绝大多数对象都是「朝生夕灭」的(用完就扔),活下来的少数派复制起来开销很小。

四、分代收集:把三种算法组合起来

JVM 工程师们发现一个规律 —— 对象的生命周期高度两极化

既然如此,为什么不针对这两类对象用不同的策略呢?这就是 分代收集 的思想。JVM 把堆分成两部分:

区域特点采用算法
年轻代新对象诞生地,死得多活得少标记-复制(损失空间小)
老年代长寿对象养老院,死得少标记-整理(避免反复移动)

分成两代之后,「GC」也就随之分出了不同的范围,这几个高频术语先一次性厘清:

先厘清:Minor GC / Major GC / Full GC

  • Minor GC(Young GC):只清理 年轻代,发生最频繁、速度最快(下面要演示的就是它);
  • Major GC(Old GC):只清理 老年代(注意:这个词有歧义,有人也用它指 Full GC,看上下文);
  • Full GC:清理 整个堆 + 方法区,最慢、停顿最长。线上要尽量压低 Full GC 频率 —— 它往往是性能问题的元凶。

年轻代内部又细分成三块:Eden + 两个 Survivor(S0、S1),默认比例 8 : 1 : 1。新对象在 Eden 出生,GC 时把 Eden 和其中一个 Survivor 里的活对象复制到另一个 Survivor。一个对象熬过足够多次 GC 后,就被「晋升」到老年代。

分代收集流程:年轻代(采用标记-复制)里,新对象在 Eden 诞生,Minor GC 存活后复制到 Survivor 0,再来回复制到 Survivor 1;对象熬过约 15 次 GC 后晋升到老年代(采用标记-整理) 年轻代用标记-复制(Eden ↔ Survivor 来回搬运),熬过足够多次 GC 的对象晋升到用标记-整理的老年代。

这里有几个常见疑问,一次性讲清。

8 : 1 : 1 是空间大小比例。 年轻代被切成 10 份:Eden 占 8 份,两个 Survivor 各占 1 份。关键在于 任何时刻总有一个 Survivor 是空的,专门留作「复制的目的地」,所以实际能装对象的是 Eden + 一个 Survivor(9 份)。

② 复制放得下吗? 绝大多数情况放得下 —— 一次 Minor GC 后 Eden 里 90%+ 的对象都死了,活下来的少数派一个 Survivor 通常够装。万一这次存活特别多装不下,JVM 有 分配担保:超出部分 直接晋升老年代。此外,一出生就很大的对象(如大数组)会跳过 Eden 直接进老年代。

③「熬过多次 GC」是次数概念。 每个对象的对象头里有个 年龄计数器,每被复制一次(熬过一次 Minor GC)就 +1,达到阈值(默认 15,由 -XX:MaxTenuringThreshold 控制)就晋升。

④ 不到 15 也可能提前晋升:动态年龄判定。 很多人误以为「非得熬满 15 次」,其实还有一条更实际的规则:如果 Survivor 中某个年龄及以下的对象总大小超过了 Survivor 的一半,那么年龄 ≥ 这个值的对象直接晋升,不必等到 15。这是为了避免 Survivor 被塞爆。

下面用内存格子走一遍(·=空闲,上标数字=对象年龄):

① 新对象在 Eden 出生(S0、S1 都空)
  Eden:[ a b c d e f · · ]   S0:[ · ]   S1:[ · ]

② 第 1 次 Minor GC:只有 a、c 存活 → 复制到空的 S0,年龄+1,清空 Eden
  Eden:[ · · · · · · · · ]   S0:[ a¹ c¹ ]   S1:[ · ]   ← S1 仍空着备用

③ 程序继续跑,Eden 又生出新对象 g、h、i
  Eden:[ g h i · · · · · ]   S0:[ a¹ c¹ ]   S1:[ · ]

④ 第 2 次 Minor GC:扫 Eden + 在用的 S0,存活 a、c、g → 复制到空的 S1,年龄+1
  Eden:[ · · · · · · · · ]   S0:[ · ]   S1:[ a² c² g¹ ]   ← S0/S1 角色对调

⑤ 一直循环……a 每熬过一次 GC 年龄就 +1:a¹ → a² → … → a¹⁵

⑥ a 年龄达到阈值 → 判定为长寿对象,晋升老年代,不再来回搬
  Eden:[ … ]   S0:[ … ]   S1:[ … ]   老年代:[ a ]

眼见为实:把 -Xlog:gc(JDK 9+ 统一日志)打开,一次年轻代 GC 大致长这样:

[2.145s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 512M->48M(1024M) 6.114ms

读法:第 12 次 GC、年轻代回收,堆从 512M 降到 48M(总 1024M),停顿 6.1ms —— 绝大多数对象当场死掉、少数存活被复制或晋升,正是上面格子演示的真实写照。(第三篇 §6.4 会逐字段拆解 GC 日志。)

为什么必须留一个空的 Survivor

标记-复制算法需要一块空白区域当「搬家目的地」。所以 S0、S1 的角色 每次 GC 都对调:谁空着,谁就是这次的复制目的地,复制完原来那块就清空 —— 两块 Survivor 永远「一个在用、一个待命」。

形象类比

这套机制就像公司里的员工流动 —— 新员工先放试用期(Eden),表现好的转正去 Survivor,几年后核心骨干(老员工)就进了高级管理层(老年代)。

围绕年轻代,还有两个常被追问的细节,一并补齐。

4.1 为什么在 Eden 分配对象这么快:TLAB

多个线程同时 new 对象,如果都往同一个 Eden 抢地盘,就得加锁排队,很慢。JVM 的办法是给 每个线程在 Eden 里预先圈一小块私有地,叫 TLAB(Thread Local Allocation Buffer)。线程在自己的一亩三分地里分配,互不打扰、无需加锁,只有 TLAB 用完了才去申请新的。这就是上一篇「对象创建 → 分配内存」那步能近乎「指针一碰」般飞快的原因。

4.2 Minor GC 凭什么不用扫整个老年代:记忆集 + 卡表

先想明白问题卡在哪。Minor GC 靠「从 GC Roots 出发能否到达」判断年轻代对象的死活,但有一种情况会漏 —— 一个老年代对象引用了一个年轻代对象

class Cache {
    static Cache INSTANCE = new Cache(); // 长寿,在老年代
    Object data;
}
Cache.INSTANCE.data = new Object();      // 老年代的 INSTANCE 指向了刚 new 的年轻代对象

这个 new Object() 只被老年代的 INSTANCE 引用着,栈和静态变量都没直接指它。如果 Minor GC 只扫年轻代自己的 Roots,就会误判它「没人要」而删掉 —— 可它明明还活着。所以「老年代里指向年轻代的引用」也必须算作 GC Roots。

问题来了:怎么找出这些引用?

记忆集的具体实现叫 卡表(Card Table):把老年代切成一块块固定大小的 卡页(通常 512 字节),用一个字节数组,一个字节对应一张卡页;某张卡页里只要出现「指向年轻代」的引用,就把对应字节标记为 脏(dirty)

老年代被切成若干卡页,每页对应卡表里的一个字节:

老年代:  [卡页0][卡页1][卡页2][卡页3][卡页4][卡页5] ...
              │     │     │     │     │     │
卡表:    [  0 ][  1 ][  0 ][  0 ][  1 ][  0 ] ...   ← 1=脏,0=干净
              ↑                 ↑
        卡页1里有对象指向年轻代   卡页4里也有

Minor GC:只扫描【脏卡页 1、4】里的对象,其余干净卡页全部跳过

那卡表由谁来标脏?靠的是一种叫 写屏障(Write Barrier) 的机制(下一章还会再用它解决并发标记问题):每当执行 老对象.字段 = 某对象 这类引用赋值时,JVM 顺手插一段代码检查「这次是不是让老年代指向了年轻代」,是就把对应卡页标脏。记账在赋值当下顺手完成,GC 时直接查现成的卡表即可。

一句话类比

老年代是有几百个书架的大图书馆,Minor GC 要找「馆里有没有书写着『参见年轻代某本书』」。笨办法是每次翻遍所有书架;卡表办法是门口挂块白板(卡表),谁往书里写了「参见年轻代」的便签(写屏障)就在对应格子打红叉(标脏),下次只翻打红叉的那几个书架。用一点平时记账的成本,换 GC 时的大幅提速。

五、并发 GC 的核心难题:三色标记与写屏障

前面的 Minor GC 是「停下来扫」。但老年代一大,停顿就受不了,于是有了 并发回收器(GC 线程和业务线程同时跑)。这带来一个棘手问题:我一边标记,你一边改引用,会不会标错?

GC 用 三色标记法 描述标记过程:

标记从 GC Roots 出发,把对象逐步从白 → 灰 → 黑推进。扫描结束后,仍是白色的就是垃圾。

三色标记法:从 GC Roots 出发,对象逐步从白色(尚未访问)推进到灰色(自己扫完但引用待扫)再到黑色(自己和引用都扫完,确认存活);标记结束后仍为白色的对象被判定为垃圾 三色标记:对象从白 → 灰 → 黑推进,标记结束仍为白色的即为垃圾。

5.1 漏标:并发标记会「误杀活对象」

并发标记最怕的事故是 漏标:一个本该存活的白对象,被错判成垃圾回收掉。研究表明,漏标 当且仅当同时满足两个条件

  1. 业务线程 插入 了一条「黑对象 → 白对象」的新引用;
  2. 业务线程 删除 了「所有从灰对象通往该白对象」的引用。

直觉理解:黑对象已经被认定「扫完了、不会再回头看」,此时它突然指向一个白对象;而原本还能通过灰对象走到这个白对象的路又被切断了 —— 于是这个白对象成了「黑对象偷偷抓着、但标记流程再也走不到」的漏网之鱼,被冤杀。

只要 破坏其中任意一个条件,漏标就不会发生。这正好对应两种主流解法:

解法破坏哪个条件做法谁在用
增量更新(Incremental Update)破坏条件 1记录「黑 → 白」的新增引用,标记快结束时把这些黑对象 变回灰色重新扫一遍CMS
原始快照(SATB, Snapshot-At-The-Beginning)破坏条件 2引用 被删除前 先把旧引用记下来,相当于「以标记开始那一刻的快照为准」,被删的也当作还活着G1

而实现这个「记录」动作的机制,还是 写屏障 —— 跟 4.2 卡表标脏是同一套东西,只是这里换了个用途:在「修改引用」前后插一小段 GC 钩子,把受影响的对象补记下来。

一句话收束

卡表用写屏障解决「跨代引用」,并发标记用写屏障解决「漏标」。写屏障 = JVM 在你每次改引用时顺手记的一笔账,是几乎所有现代 GC 的隐形地基。SATB 会带来一点「浮动垃圾」(本该死的当轮没清),但换来了正确性与低停顿,非常划算。

六、垃圾回收器的演进

回收算法是理论,垃圾回收器 才是工程实现。JVM 历史上的回收器演进,本质上是一部 「如何让 GC 不卡顿用户线程」 的奋斗史。

世代代表回收器关键特征
第一代:串行Serial / Serial Old单线程 GC,全程 Stop-The-World,停顿长
第二代:并行Parallel多线程一起 GC,缩短 STW,但仍需全员暂停业务(JDK 8 默认)
第三代:并发CMS、G1GC 线程与业务线程同时运行,把 STW 压到最低
第四代:低延迟ZGC、ShenandoahSTW 压到 10ms 以内,且几乎与堆大小无关

垃圾回收器演进:第一代串行(Serial,STW 长)→ 第二代并行(Parallel,多线程 GC)→ 第三代并发(CMS / G1,STW 最低)→ 第四代低延迟(ZGC / Shenandoah,STW 小于 10ms) 回收器演进是一部「如何让 GC 不卡顿用户线程」的奋斗史:从串行到并行、并发,再到低延迟。

其中第三、四代最值得细看:

下面把当前的默认选择 G1 和低延迟标杆 ZGC 分别讲透。

七、重点看看 G1(默认回收器)

既然 G1 是 JDK 9 起的默认选择,值得单独讲透。要理解它,先看它解决了前几代的什么痛点:传统分代 GC 把堆分成 一整块连续的年轻代 + 一整块连续的老年代,堆一大(比如 16GB),回收老年代就得 整块扫一遍,停顿长且不可控,你也没法跟它说「我最多只能接受停 200ms」。

7.1 两个核心创新

① 把堆切成一堆大小相等的小方块(Region)

G1 不再固定「一大块年轻代 / 一大块老年代」,而是把整个堆切成 上千个等大的 Region(通常 1~32MB)。关键在于 每个 Region 的角色是动态的 —— 这一刻是 Eden,回收完下一轮可能就变成 Old 或 Survivor:

传统:[========年轻代========][============老年代============]   连续大块

G1: [E][O][E][S][O][O][E][H][O][E][S][O][E][O][O][H] ...   一堆小方块
      E=Eden  S=Survivor  O=Old  H=Humongous(大对象专用)

② Garbage-First:哪块垃圾最多就先收哪块

这就是「Garbage-First」名字的由来。G1 会统计每个 Region 的垃圾占比,优先回收垃圾最多、性价比最高的那几块,而不是傻乎乎全扫 —— 用最小代价回收最多空间。正因为是「挑着几块回收」,你设定 -XX:MaxGCPauseMillis=200,G1 会自己算「这次清几块刚好不超时」,只清那么多,从而做到 可预测停顿

7.2 G1 的一轮回收长什么样

G1 的老年代回收不是一锤子买卖,而是一个 并发标记周期,分成四个相位(前面 5.1 的 SATB 就用在这里):

G1 回收相位:初始标记(标 GC Roots 直达对象,短暂 STW,搭 Young GC 一起做)→ 并发标记(与业务线程并跑,遍历整个对象图)→ 重新标记(用 SATB 补扫漏标,短暂 STW)→ 筛选回收(按性价比挑 Region,复制存活对象,STW),然后进入下一轮 G1 并发标记周期:初始标记 → 并发标记 → 重新标记 → 筛选回收,只有首尾几步短暂 STW。

两个还会被追问的细节:

形象类比

老办法是大扫除 —— 每次必须把整个房子(老年代)从头擦到尾,房子越大停得越久;G1 则把家划成很多小格子,管家 每次只挑最脏的几格 打扫,而且你能规定「这次别超过 10 分钟」,他就挑差不多 10 分钟能扫完的格子先扫。

一句话记住 G1

把大堆切成上千个小方块,优先扫垃圾最多的那几块(Garbage-First),用「初始标记 → 并发标记 → 重新标记 → 筛选回收」四步、只在首尾短暂 STW,从而在几个 GB 的大堆上做到「你定停顿目标、它尽量满足」。

八、低延迟标杆 ZGC:染色指针 + 读屏障

G1 已经很强,但它的停顿仍随堆里存活对象增多而变长(复制存活对象要 STW)。当你需要 几十上百 GB 的堆、停顿还得稳定在个位数毫秒 时,就轮到第四代的 ZGC 出场了。ZGC 的目标很激进:停顿 < 10ms(现在实测常在亚毫秒),且几乎与堆大小无关。

它凭什么做到?前面 G1 停顿的大头是「复制存活对象」这一步的 STW。ZGC 的杀手锏是把 对象的复制(转移)也变成并发的 —— 业务线程和 GC 一边转移一边跑。要做到这点,靠两个核心机制:

① 染色指针(Colored Pointers)

在 64 位系统里,一个对象指针用不满 64 位。ZGC 借用指针里几个空闲的高位,存上「这个对象的 GC 状态」——比如是否已标记、是否已被转移(remapped)。GC 的状态信息不再单独存表,而是直接『染』在指针上,读指针就知道对象处于哪个阶段。

② 读屏障(Load Barrier)

既然状态在指针上,那每次 从堆里读出一个对象引用 时,ZGC 都插一小段 读屏障 代码检查这个指针的染色位:

ZGC 读屏障流程:业务线程读取一个对象引用时,读屏障检查指针的染色位是否最新 —— 干净则直接使用;带标记位则顺手完成标记再返回;发现对象已被移动则查转发表并 self-heal 把指针修正到新地址,再使用 ZGC 读屏障:每次读引用都顺手检查染色位,必要时就地完成标记或修正到对象的新地址(self-heal)。

正因为「转移」被摊进了每次读操作里并发完成,ZGC 才把需要 STW 的工作压缩到只剩「扫描 GC Roots」这一小步,于是停顿几乎不随堆变大而变长。

对照记忆

  • G1 靠 写屏障 维护 SATB 和跨代引用,停顿主要花在「复制存活对象」;
  • ZGC 靠 读屏障 + 染色指针 把复制也并发化,停顿几乎只剩「扫 Roots」。
  • Shenandoah 思路类似(用 Brooks 转发指针 + 读屏障),是另一条低延迟路线。

那什么时候该换 ZGC

G1 是「平衡型」默认选择,普通在线服务用它即可。只有当你要 极致低延迟(停顿 < 10ms,且堆开到几十 GB 停顿也不变长)时,才是 ZGC 的主场。代价是它吞吐通常略低于 G1、且读屏障有一定开销。

一句话记住 ZGC

把「对象搬到哪了」这条信息塞进 指针本身的几个空闲位(染色指针),每次读引用都过一道 读屏障 顺手把旧指针修正到新地址(自愈)—— 于是连「搬对象」都能和业务线程并发进行,STW 只剩扫 Roots,停顿几乎与堆多大无关。

九、怎么选 GC:取舍三角

GC 调优永远在三个目标之间做取舍:

  1. 吞吐量:业务代码占总运行时间的比例;
  2. 延迟:单次 GC 停顿的时长;
  3. 内存占用:GC 算法本身要额外用多少内存。

用「边办公边打扫」理解三者

把程序想象成在家办公:业务线程 = 你办公,GC = 打扫卫生,屋子(堆)用着用着就脏了(产生垃圾),得时不时停下来扫。

  • 吞吐量 = 一天里真正用来办公的时间占比。公式:业务时间 / (业务时间 + GC 时间)。一天 480 分钟里只花 8 分钟打扫,吞吐量就有 98.3%;
  • 延迟 = 每次打扫时被迫停工的那一下卡顿(即 Stop-The-World,所有业务线程冻结)。指的是 单次 停顿有多长;
  • 内存占用 = 为了扫得高效额外占的空间。还是办公室的例子:你得 空出一间储物房专门周转(标记-复制永远空着一半)、边办公边打扫时要 留出空地暂放杂物(G1/ZGC 预留缓冲,否则新垃圾没处堆)、还要一本 记账本记录东西放在哪(记忆集,本身也占地方)。办公室越大,垃圾堆很久才满、很久才扫一次(GC 从容);办公室越小,一会儿就塞满、得频繁打扫(GC 频繁)。

这里有个反直觉的点 —— 吞吐量高 ≠ 用户体验好

方案总 GC 时间停顿方式吞吐量延迟体验
A8 分钟1 次停 8 分钟极差(一次卡 8 分钟)
B10 分钟1200 次,每次 0.5 秒略低好(几乎无感)

一个网页请求平时 10ms 返回,一旦撞上 A 那种长 STW,这次请求直接卡几百毫秒,用户立刻感知到顿挫。所以 在线服务/金融/游戏看重延迟,离线批处理看重吞吐量

三者不可兼得

  • 想要 低延迟 → 让 GC 与业务线程 并发 同时跑,但会抢走 CPU、还要加读/写屏障 → 吞吐量下降
  • 想要 省内存 → 堆开小,垃圾很快堆满 → 只能 更频繁地 GC → 停顿更多、吞吐也受影响。

GC 调优铁三角:吞吐量(干活时间占比高)、延迟(单次停顿短)、内存占用(额外内存少)三者互相牵制 —— 低延迟要并发会抢 CPU 从而拉低吞吐,省内存要更频繁 GC 从而增加停顿 吞吐量、延迟、内存占用构成铁三角,三者互相牵制,无法同时最优。

正因为不可兼得,所以没有「最好的 GC」,只有「最适合你场景的 GC」。实际选型可以简化成:

场景推荐 GC
离线批处理、看重总吞吐Parallel
普通在线服务G1(默认)
极致低延迟(金融、游戏)ZGC

对象怎么生、怎么死,两篇下来都讲透了:可达性分析找垃圾、Safepoint + OopMap 停顿枚举 Roots、分代收集配三种算法、三色标记 + 读写屏障保住并发正确性,直到 G1 与 ZGC 把停顿压到毫秒级。但还有一个问题没答:既然 Java 要经过 JVM 翻译,它到底慢不慢?为什么大厂敢用 Java 扛高并发? 这正是下一篇 进阶篇 · JIT、内存模型与调优前沿 的主题 —— 即时编译、Java 内存模型,以及一套从「玄学」到「科学」的调优方法论。

延伸阅读


views
Share this post on:

Previous Post
JVM 深入(三)· 进阶篇:JIT、内存模型与调优前沿
Next Post
JVM 深入(一)· 入门篇:内存架构与类加载 —— 从字节码到对象的一生