本章把"内存怎么分、对象怎么生、垃圾怎么判、收集器怎么停"串成一条因果链,是 Java 岗追问最深的一章。

学习目标

读完本章,你应当能够:

  1. 默画运行时数据区五部分并标出线程私有性,说出两类栈/堆异常各自的触发位置;
  2. 推导"为什么永久代必须被元空间替换",并解释 -XX:MetaspaceSize 为什么不是初始大小;
  3. 走完 new 的五步与 TLAB 分配路径,判断"对象一定在堆上"在什么条件下不成立;
  4. 背出 GC Roots 主要类别与四种引用的语义,用可达性分析解释循环引用为什么不会泄漏;
  5. 用赋值器/收集器双视角推导三色标记的两个漏标条件,说明增量更新与原始快照各破坏哪一个;
  6. 在给定堆大小、延迟与吞吐要求下选出收集器,并针对八种 OutOfMemoryError 给出定位手段。

前置知识检查

你需要知道在哪一节补齐
虚拟内存、分页与缺页见第 8 章 8.2 节(本层内存是 JVM 向 OS 申请的虚拟地址空间)
线程是内核调度实体见第 4 章 4.3 节(本层只讲 Java 线程私有什么)
对象头 Mark Word、锁升级、类加载过程见第 30 章 30.3 节与第 28 章 28.6 节(本层只用"对象头有 Klass Pointer"这一点)

32.1 运行时数据区与线程私有性

JVMS 2.5 只定义了五块区域。划分标准不是"放什么数据",而是这块内存的生命周期绑定谁:绑定线程的随线程生死,绑定进程的随 JVM 生死。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
                   一个 JVM 进程的地址空间
 线程私有(随线程创建/销毁)        线程共享(随 JVM 生死)
 ┌──────────────────────────────┬──────────────────────────────┐
 │ 程序计数器 PC Register:唯一不 │ 堆 Heap:对象实例、数组;     │
 │ 规定任何 OOM 的区域(只存字节码 │ 新生代 / 老年代               │
 │ 偏移量)                       │  · 超了抛 OutOfMemoryError    │
 ├──────────────────────────────┼──────────────────────────────┤
 │ 虚拟机栈 VM Stack:栈帧 = 局部 │ 方法区 Method Area:类元信息、 │
 │ 变量表 + 操作数栈 + 动态链接 + │ 运行时常量池(JDK 8+ 由元空间  │
 │ 返回地址                      │ 实现;字符串常量池在堆内)     │
 │ · -Xss 默认约 1 MB,随线程数线 ├──────────────────────────────┤
 │   性累加;深度超了抛           │ 直接内存 Direct Memory:非    │
 │   StackOverflowError          │ JVMS 区域,-Xmx 管不到;      │
 ├──────────────────────────────┤ ByteBuffer.allocateDirect 耗尽 │
 │ 本地方法栈 Native Stack:服务  │ 抛 Direct buffer memory       │
 │ native 方法,同为栈帧模型      │                              │
 └──────────────────────────────┴──────────────────────────────┘
  • 程序计数器是 JVMS 中唯一不规定任何 OutOfMemoryError 的区域 —— 它只存一个字节码偏移量,大小常量级。
  • 虚拟机栈默认大小由 -Xss 决定(HotSpot 64 位 Linux 约 1 MB),随线程数线性累加:这就是"-Xss 调大会让可创建线程数变少"的原因。栈是 JVM 向 OS 申请的,所以栈深度不够报 StackOverflowError,而创建线程失败报 OutOfMemoryError: unable to create new native thread。方法区是 JVMS 的规范概念,“永久代"与"元空间"都只是 HotSpot 的实现;直接内存不是运行时数据区,只受 -XX:MaxDirectMemorySize 与物理内存约束。

重要

第一个必背映射:栈深度不够 → StackOverflowError(线程仍可继续);要不到内存 → OutOfMemoryError;无法创建线程 → unable to create new native thread。判断看的是耗尽的资源类型,不是代码在哪个区域。

32.2 永久代到元空间:JDK 7 与 JDK 8 的两次搬家

最容易被"背错版本号"的考点:JDK 7 与 JDK 8 做的是两件事,不能合并成一句"JDK 8 把永久代换成了元空间”。

版本迁移时间线(一图记住两次搬家):

1
2
3
4
5
6
 JDK 6                        JDK 7                      JDK 8
 堆: —                        堆: ★字符串常量池          堆: ★字符串常量池
                                  ★静态变量                  ★静态变量
 永久代: 字符串常量池/静态变量/  永久代(掏空): 类的元信息    元空间(本地内存):
         类的元信息            -XX:MaxPermSize             类的元信息 + 运行时常量池
                                                          -XX:MaxMetaspaceSize

JDK 7 搬走了什么、为什么:字符串常量池(string constant pool)与静态变量(static field)移入堆 —— 这两类数据的生命周期与应用代码一致且会被大量创建(intern、反射生成类名),放在受 -XX:MaxPermSize 硬限制、且只能 Full GC 时才回收的永久代里,极易触发 OutOfMemoryError: PermGen space;搬进堆后它们跟着堆 GC 一起回收,效率立刻上升。

JDK 8 为什么彻底废掉永久代:

原因具体机制
容量上限不可控-XX:MaxPermSize 是启动时确定的固定上限(默认约 64 MB),类数量运行时不可预测,只能试错调参
回收效率低只在 Full GC 回收,且类卸载条件苛刻:该类的所有实例已回收、ClassLoader 已回收、Class 对象无引用且未被反射使用
与 JRockit 合并两个虚拟机要合并,JRockit 没有永久代概念,元空间是双方都能接受的抽象
便于分块管理元空间按 Chunk(块)分配,由 VirtualSpaceList 管理、块可归还 OS;永久代是一整块,归还粒度粗

-XX:MetaspaceSize 的真实语义(常被答错):它不是初始大小,而是首次触发元空间 GC 的阈值(HotSpot 里叫 initial_high_water_mark),64 位 Linux 默认约 20.8 MB。元空间启动时几乎不占承诺内存,随类加载增长;一旦触及这条线就触发一次 GC 并把它上调(按 MinMetaspaceFreeRatio / MaxMetaspaceFreeRatio 动态调整),代价是一次 Full GC,所以"启动期前几次 GC 由元空间扩容引起"是正常现象。

小心

答"-XX:MetaspaceSize 是初始大小"会被追问到崩:“那为什么把它调大能减少启动期 Full GC?” —— 只有答出"它是阈值/水位线,调大等于推迟首次元空间 GC"才自洽。-XX:MaxMetaspaceSize 不设则默认无上限,只受物理内存限制,这也是元空间耗尽报 Metaspace 而非 PermGen 的原因。

32.3 对象创建与内存分配

32.3.1 new 的五个步骤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 ① 类加载检查:常量池能否定位到类的符号引用?类是否已加载/解析/初始化?
        否 → 先走类加载流程(见第 28 章 28.6 节)
 ② 分配内存:方式取决于堆是否规整(是否带压缩)
        · 指针碰撞 Bump-the-Pointer:空闲|已用 之间一个指针平移
          → Serial / ParNew / Parallel Scavenge
        · 空闲列表 Free List:维护可用块列表挑一块够大的
          → CMS(标记-清除,不压缩)
        并发安全 → CAS 重试 / TLAB
 ③ 零值初始化:内存全部置 0(保证实例字段可直接读出默认值)
 ④ 设置对象头:Mark Word(哈希码/GC 分代年龄/锁状态)+ Klass Pointer
 ⑤ 执行 <init>:调用构造方法按代码赋值

关键区分:第 ③ 步后对象已"存在"(字段全零),第 ⑤ 步后才"可用"。所以构造方法里把 this 逸出给其他线程是危险的 —— 别人可能看到字段全为零的半成品(安全发布问题,见第 30 章 30.5 节)。数组对象的对象头额外多 4 字节 length。

32.3.2 TLAB:把分配从同步操作变成线程内操作

堆共享,“指针平移"本身不是原子操作,多线程同时分配必须同步;而 new 是最频繁的操作,一次 CAS(无竞争约 20 ns)乘以天文数字的分配次数不可接受。

TLAB(thread local allocation buffer)的解法:每个线程在 Eden 里预划一小块私有区域,线程内 new 只动自己 TLAB 里的指针,完全不需要同步;TLAB 用尽才去 Eden 申请新块(这一步才需要 CAS)。它由 -XX:+UseTLAB 控制、默认开启,单块默认占 Eden 的 1%(-XX:TLABWasteTargetPercent),大小按分配速率自适应;代价是 TLAB 内部碎片(TLABWastePercent 控制可容忍浪费),太大浪费空间、太小退化成频繁 CAS。大对象放不进 TLAB,退回 Eden 共享区用 CAS 分配。

32.3.3 逃逸分析与标量替换:为什么"对象都在堆上"不再严格

逃逸分析(escape analysis,-XX:+DoEscapeAnalysis,JDK 6u23 后默认开启)判断对象作用域:不逃逸(只在方法内用,未返回、未赋给静态字段、未传给其他线程)与方法/线程逃逸。据此 JIT 做两种优化:**标量替换(scalar replacement)**把不逃逸对象拆成若干基本类型局部变量(标量)放进寄存器或栈,效果等同于"栈上分配”;**锁消除(lock elision)**则判断不逃逸对象上的 synchronized 不可能有竞争,直接删掉 monitorenter/exit,省掉一次约 20 ns 的加解锁。需注意 HotSpot 并未实现纯粹的栈上分配(stack allocation),标量替换才是它的真实实现路径。

所以严格表述是:“绝大多数情况下对象在堆上分配”。开启逃逸分析后,不逃逸对象的字段可能被拆成栈上标量,堆上根本不存在这个对象。答"对象一定在堆上,这是规范规定的"是错的 —— JVMS 只说堆是"所有类实例和数组分配"的运行时区域,而 JIT 把不可观测的分配优化掉并不违反可观测语义。

提示

被追问"什么会破坏逃逸分析?“答:对象被返回、存入静态/成员字段、传给无法内联的多态调用点。再追问"标量替换和栈上分配是一回事吗?“答:概念上栈上分配是目标,标量替换是 HotSpot 的实现路径。

32.4 可达性分析与四种引用

32.4.1 为什么不用引用计数:GC Roots 清单

引用计数的致命缺陷是无法处理循环引用:a.next = b; b.next = a; 之后即使外部再无引用,两者计数都不为 0,永不回收;它还要在每次赋值时插桩,高并发下计数的原子更新也成瓶颈。因此主流 JVM 选择可达性分析(reachability analysis):从一组 GC Roots 出发沿引用链遍历,遍历不到的即为可回收。

类别具体内容
虚拟机栈中引用的对象各线程栈帧局部变量表(Local Variable Table)里的引用 —— 正在运行代码的活动变量
本地方法栈 JNI 引用native 方法持有的 jobject 句柄
方法区静态属性 / 常量引用类的 static 字段、运行时常量池里的 String/Class 等常量(JDK 7 后变量本体在堆,被引用对象同样视为根)
活跃线程与同步锁对象已启动未结束的 Thread 实例;被 synchronized 监视器(monitor)持有的对象
Class 对象与系统类加载器每个已加载类的 java.lang.Class;启动类加载器加载的类(类卸载条件极苛刻)
Unsafe / JVMTI / 其他内部Unsafe.allocateInstance 创建的对象;调试 agent 注册的引用;预分配的常驻异常对象

面试答出前四类加上"活跃线程、同步锁对象、Class 对象、系统类加载器” 八类即算完整。

32.4.2 两次标记、finalize() 的复活与四种引用

不可达对象要过两道关:第一次标记发现不可达后,若它未覆盖 finalize()(或该方法已被 JVM 调用过)就直接回收 —— 这是绝大多数对象的路径;只有覆盖了 finalize() 且从未被调用过的对象才被放入 F-Queue,由 Finalizer 线程执行,随后第二次标记:若它在 finalize() 里把 this 赋给某个 GC Root 可达的变量就复活(仅此一次机会),否则回收。finalize() 的执行时机与是否执行都不保证,这正是它被弃用的原因 —— JDK 9 起 deprecated,推荐 Cleaner 或 try-with-resources(见第 28 章 28.4 节)。

引用类型类回收语义典型用途
强引用(strong)直接赋值只要引用链可达就绝不回收,宁可抛 OOM常规对象
软引用(soft)SoftReference<T>内存不足时回收(在 OOM 之前清空软引用)缓存:图片缓存、本地页面缓存
弱引用(weak)WeakReference<T>下一次 GC 必定回收,不管内存是否充足ThreadLocalMap 的 key、WeakHashMap
虚引用(phantom)PhantomReference<T>,必须传 ReferenceQueueget() 永远返回 null,仅在对象被回收时收到入队通知回收通知、DirectByteBuffer 的堆外内存释放
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;

public class ReferenceDemo {
    public static void main(String[] args) throws InterruptedException {
        // 用 ReferenceQueue 而不是轮询引用对象:引用本身被 GC 后我们无从观察,
        // 只有队列能可靠报告"被引用对象已被回收"这一事件已经发生
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
        // 刻意不建强引用:一旦存在强引用,弱引用形同虚设,GC 绝不会回收它
        WeakReference<byte[]> weak = new WeakReference<>(new byte[1024 * 1024 * 4], queue);

        System.gc();
        Thread.sleep(200); // 给引用处理线程留出入队时间,生产代码不应依赖 sleep
        // 断言"能从队列取出"而不是"get() == null":get() 为 null 只是必要条件,
        // 队列通知才是软/虚引用的真正用途
        System.out.println("入队: " + (queue.poll() == weak) + ", get()=" + weak.get());
    }
}

ThreadLocalMap 的 key 用弱引用是为解决一个具体问题:若线程长期存活(线程池)而 ThreadLocal 在外部被置空,强引用会让整个 Entry 的 value 也无法回收;但 value 仍是强引用,仍会泄漏 —— 用完必须 remove()(见第 30 章 30.6 节)。

32.5 GC 算法基础与分代假说

32.5.1 三种基础算法与各自的碎片问题

算法过程碎片问题适用场景移动对象
标记-清除(mark-sweep)标记存活 → 回收未标记块产生外部碎片;效率随对象数下降(分配要在空闲列表找块)老年代(CMS)否,因此不能指针碰撞分配
标记-复制(mark-copy)存活对象整体复制到另一块空闲区无碎片,但浪费一半空间,存活率越高复制越贵新生代(存活率通常 < 10%)是
标记-整理(mark-compact)标记 → 全部向一端移动 → 清边界外无碎片老年代是,必须 STW(移动中引用不稳定)

标记-复制的关键数字:新生代对象约 98% 朝生夕死,这正是 Eden : S0 : S1 = 8 : 1 : 1(浪费 1/10 而非 1/2)的依据。Appel 式回收把新生代切成一大两小,只在两个 Survivor 间复制,把空间浪费压到 10%。

32.5.2 分代假说与 Card Table

分代收集建立在两条弱假说上:弱分代假说(weak generational hypothesis) —— 绝大多数对象朝生夕死,决定了"新生代用复制、老年代用整理”;强分代假说(strong generational hypothesis) —— 熬过越多次 GC 的对象越难死,决定了"年龄计数与晋升"。

但分代带来新问题:跨代引用。Young GC 时若老年代对象引用了新生代对象,只扫新生代就会漏掉这些实际可达的对象。逐一扫描整个老年代代价太高,解法是 Card Table(卡表):把老年代按 512 字节(HotSpot 卡页)划分,每张卡对应一个字节标记"这页里有指向新生代的引用"。

1
2
3
4
5
6
7
8
 老年代内存(按 512 B 分卡)
 ┌────┬────┬────┬────┬────┬────┬────┐
 │卡0 │卡1 │卡2 │卡3 │卡4 │卡5 │卡6 │  ← 每张卡 512 字节
 │ 0  │ 1  │ 0  │ 0  │ 1  │ 0  │ 0  │  ← 0 干净 / 1 脏(dirty)
 └────┴────┴────┴────┴────┴────┴────┘
      ▲         ▲  这些卡里的对象引用了新生代
 Young GC 只扫脏卡,不扫整个老年代;置脏由写屏障在
 老年代 → 新生代 的引用赋值时插桩完成

写屏障是**赋值器(mutator,应用线程)**在写引用时插入的一小段代码(HotSpot 中由 oop_store 的 post_barrier 实现),成本是每次跨代引用赋值多一次内存写,换来 Young GC 不必扫描整个老年代。

32.6 Minor GC 的完整流程与对象晋升

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 ① Eden 满,触发 Young GC(Minor GC),【STW】
 ② 从 GC Roots 标记 Eden + 当前 From Survivor(S0) 的存活对象
        老年代的引用靠 Card Table 定位(见 32.5.2)
 ③ 复制存活对象到 To Survivor(S1),年龄 +1;以下任一命中则直接进老年代:
        · 年龄 ≥ MaxTenuringThreshold(默认 15)
        · 动态年龄判定命中
        · 对象大于 PretenureSizeThreshold(大对象路径)
        · To Survivor 放不下(空间分配担保)
 ④ 清空 Eden 与 From Survivor,From/To 角色互换
 ⑤ 若老年代空间不足(担保失败)──► 触发 Full GC

SurvivorRatio:-XX:SurvivorRatio=8 表示 Eden : 单个 Survivor = 8 : 1,即 Eden : S0 : S1 = 8 : 1 : 1。分母是"单个 Survivor"而不是两个之和 —— 最常见的算错点。设 -Xmn100m,则 Eden = 80 MB,S0 = S1 = 10 MB。

空间分配担保(handle promotion failure):Minor GC 前 JVM 预估"最坏情况有多少对象会晋升"(取历次晋升的平均大小),检查老年代剩余连续空间是否够。不够时,若允许冒险(JDK 6u24 后默认开启担保)仍试一次 Minor GC,让 Survivor 放不下的对象直接进老年代;冒险失败则退化为 Full GC。

32.6.1 对象晋升老年代的四种触发条件

条件参数说明
年龄达到阈值-XX:MaxTenuringThreshold,默认 15每熬过一次 Minor GC 年龄 +1;15 是 4 bit 的极限(对象头年龄字段占 4 位),调大于 15 不生效
动态年龄判定(dynamic age determination)无独立参数Survivor 中相同年龄所有对象大小之和 > 单个 Survivor 的一半时,年龄 ≥ 该年龄的对象全部直接晋升,不必等 15
大对象直接进老年代-XX:PretenureSizeThreshold,默认 0(不启用)只对 Serial / ParNew 有效,Parallel Scavenge 不认这个参数;避免大对象在 Eden/Survivor 间反复复制
Survivor 空间不足无独立参数由空间分配担保接管,放不下的进入老年代

警告

动态年龄判定最常被答成"年龄超过 Survivor 一半就晋升"。正确表述是同类年龄对象的总大小超过 Survivor 空间的一半,判定的是"空间占用"不是"年龄数值"。推论:即使 MaxTenuringThreshold=15,只要一批同龄对象集体占了 Survivor 的一半以上,它们可能在第 2、3 次 GC 就全部晋升 —— 这就是"对象提前晋升把小对象填进老年代"的经典成因。

32.7 三色标记法:漏标的两条件与两类解法

三色标记是并发标记(标记与用户线程同时跑,不全程 STW)的理论基础。先立两个视角:收集器(collector)视角只做两件事 —— 扫描灰色对象的引用(扫完变黑)、发现白色对象;赋值器(mutator,用户线程)视角也只做两件事 —— 插入新引用、删除旧引用。并发标记期间这两组动作同时发生:白色是尚未被访问到的对象(标记结束时仍是白色即判定为垃圾);灰色是自己已被访问到、但它引用的对象还没扫描完(在待处理队列里);黑色是自己及它引用的所有对象都已扫描完、不会被重新扫描。初始时 GC Roots 直接引用的对象置灰、其余全白;过程是"取一个灰 → 扫描其引用并把白引用置灰 → 自己置黑",重复到灰色为空为止。

32.7.1 漏标(对象消失)的两个条件

并发标记期间赋值器可能"破坏"标记正确性,让一个实际存活的对象留在白色被误回收。漏标必须同时满足两条:① 赋值器插入了一条或多条从黑色对象到白色对象的新引用(A.ptr = C,而 A 已黑、收集器不会再扫它);② 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用(B.ptr = null,B 是 C 唯一的"灰→白"通路)。结果 C 既无"灰→白"通路、也不被黑对象扫描,收集器永远访问不到它 —— 它是存活的,却在标记结束时仍是白色,于是被回收,程序崩溃。两条件缺一不可:只满足 ①(A 插入指向 C 的新引用)时,C 仍被灰色 B 引用,扫到 B 会把 C 置灰,不漏标;只满足 ②(B 删掉指向 C 的引用)时,C 成了孤岛(既不被黑对象也不被灰对象引用),它本来就是垃圾,回收它是正确的。

32.7.2 两类解法:增量更新 vs 原始快照

思路只有两条:要么禁止 ① 发生,要么不许 ② 造成信息丢失。

方案破坏哪个条件手段写屏障收集器代价
增量更新(incremental update)破坏 ①:新增的"黑→白"引用被记录黑色对象插入指向白色的新引用时,把该黑色对象重新置灰,留到重新标记阶段重扫写后屏障(post-write barrier)CMS重新标记阶段需重扫被记录的黑色对象,STW 变长;记录集可能很大
原始快照(snapshot-at-the-beginning, SATB)破坏 ②:删除的"灰→白"引用被记录灰色对象删除指向白色的引用时,记录被删的旧引用,认为它在标记开始那一刻的快照里还活着,按快照继续标写前屏障(pre-write barrier)G1、Shenandoah产生浮动垃圾(floating garbage):标记期间死掉的对象被当作存活,本轮不回收,留到下一轮

一句话钉住两者:增量更新 = “你把新人介绍进来了,那就重新审你一遍”(记新增);SATB = “照片拍下时谁在就是谁,哪怕现在他走了”(记删除)。

重要

标准答法是三段:先给两条件(新增黑→白 + 删除灰→白的唯一通路),再说两解法各破坏哪一个,最后说屏障类型与代价(增量更新是写后屏障 + 重新标记变长;SATB 是写前屏障 + 浮动垃圾)。 只答"CMS 用增量更新、G1 用 SATB"而说不出破坏哪个条件,是最常见的失分位置。

32.8 CMS:第一款并发收集器

CMS(Concurrent Mark Sweep,-XX:+UseConcMarkSweepGC)基于标记-清除,目标是降低停顿。它的四个阶段是:① 初始标记(STW) 仅标记 GC Roots 直接关联的对象,极快;② 并发标记从①的结果出发遍历整个对象图,最耗时但与用户线程并发;③ 重新标记(STW) 修正②期间用户线程继续运行产生的变动,即增量更新记录下来的那些对象;④ 并发清除清除白色对象。只有 ① 与 ③ 会 STW,这就是 CMS 压低停顿的机制。

Concurrent Mode Failure(并发模式失败):阶段 ④ 并发清除期间应用线程仍在分配与晋升;若老年代被填满而清理未完成,CMS 无法再并发下去 —— 后果是退化为 Serial Old 做一次单线程标记-整理,停顿可能到秒级,这是 CMS 最严重的性能事故。相关参数:

  • -XX:CMSInitiatingOccupancyFraction:老年代占用达到该百分比时启动 CMS,默认约 92%(JDK 5 之前是 68%)。调大 → 启动晚、GC 次数少,但更容易 Concurrent Mode Failure;调小 → 更安全但更频繁、吞吐下降。纯权衡,无普适值。
  • -XX:+UseCMSInitiatingOccupancyOnly:只用固定值触发,不让 JVM 按历史自适应(默认自适应,常导致"明明设了 70% 却迟迟不 GC")。
  • -XX:+CMSScavengeBeforeRemark:重新标记前先做一次 Young GC,减少要扫描的对象。

三个缺点:内存碎片(标记-清除不压缩,碎片严重时大对象分配失败被迫 Full GC 整理)、浮动垃圾(并发清除期间新产生的垃圾本轮收不掉)、CPU 敏感(并发阶段占线程,默认回收线程数 (CPU 核数 + 3) / 4,2 核机器上占掉 1/4 以上算力)。

**JDK 14 移除 CMS(JEP 363)**的核心原因:CMS 是 G1 的前身,两套并发标记代码长期维护成本高;CMS 的并发清除依赖"整堆"假设,无法适应 G1 的 Region 化结构;G1/ZGC 已在延迟与可维护性上全面替代它。不含 CMS 的第一个版本是 JDK 14。

32.9 G1:分 Region 的增量式收集器

G1(Garbage-First)从 JDK 9 起成为服务端默认收集器(JEP 248),核心创新是把"物理连续的分代"改成"逻辑分代 + 物理分块":堆被切成约 2048 个 Region(-XX:G1HeapRegionSize 1~32 MB,必须是 2 的幂),每个 Region 动态扮演 Eden、Survivor、Old 或 Humongous 角色,回收时只选一部分组成 CSet(Collection Set)。

1
2
3
4
 约 2048 个 Region:E=Eden  S=Survivor  O=Old  H=Humongous
 ┌────┬────┬────┬────┬────┬────┐
 │ E  │ S  │ O  │ O  │ H  │ 空 │  Region 角色可动态改变;
 └────┴────┴────┴────┴────┴────┘  CSet = 本次要回收的 Region 集合
  • 逻辑分代:Eden/Survivor/Old 只是 Region 的角色标签,不需要物理连续,因此 G1 能"每次只回收一部分老年代 Region"。Humongous 对象(大小 ≥ 单个 Region 一半,≥ 一个 Region 则占连续多个)直接放入 Humongous Region,大对象多时是 G1 的常见痛点。
  • Remembered Set(RSet):每个 Region 一份,记录"谁引用了我"(points-into 结构)。这是与 Card Table 的关键差异:卡表是全局的,回答"哪些卡脏了";RSet 是 per-Region 的,回答"回收我这块时要去扫哪些别的 Region"。底层仍用卡表做粗粒度记录,RSet 是其上层的 per-Region 索引。G1 的并发标记用原始快照(见 32.7.2),因此会产生浮动垃圾 —— 这也解释了 -XX:G1HeapWastePercent(允许浪费的堆比例,默认 5%)的存在。

32.9.1 Young GC、Mixed GC 与 MaxGCPauseMillis

类型回收范围触发停顿特征
Young GC全部 Eden + 部分 SurvivorEden 的 Region 用尽几毫秒到几十毫秒
Mixed GC全部 Young Region + 并发标记选出的、回收价值最高的部分 Old Region并发标记周期完成,且老年代占用达 -XX:InitiatingHeapOccupancyPercent(IHOP,默认 45%)比 Young GC 长,由 MaxGCPauseMillis 约束
Full GC整堆见下文单/多线程整理,可能秒级 —— G1 下最需要避免

-XX:MaxGCPauseMillis(默认 200 ms)不是"设多少就停多少",而是软目标:G1 基于历史回收数据(每个 Region 的回收耗时、存活比例)估算,在暂停预算内挑选回收收益最高的 Region 组成 CSet —— 它求解的是"在时间预算内最大化收益"的选择问题,这就是 Garbage-First 名字的实质。

参数默认作用
-XX:G1MixedGCLiveThresholdPercent85(JDK 8 为 65)存活对象占比高于此值的 Old Region 不纳入 Mixed GC(复制成本划不来)
-XX:G1HeapWastePercent / -XX:G1MixedGCCountTarget5 / 8可容忍的浮动垃圾占比(低于此值停止 Mixed GC);一次并发标记周期最多分几次 Mixed GC 收完候选 Region
-XX:InitiatingHeapOccupancyPercent45老年代占用超此比例即启动并发标记周期

G1 下 Full GC 的触发条件与排查方向:① 并发标记来不及(分配速率远高于回收速率,日志出现 to-space exhausted);② Humongous 对象分配失败(需连续多个 Region,碎片化后找不到);③ 晋升失败(promotion failed);④ 显式 System.gc()(未开 -XX:+ExplicitGCInvokesConcurrent 时)。排查看 GC 日志中 to-space exhausted / Humongous / Pause Full 关键词,用 jmap -histo 找大对象来源(常见是超大数组、一次性加载的大集合);对策是调小 -XX:G1HeapRegionSize 让大对象不再 Humongous、降低 IHOP 让并发标记更早开始。

注意

面试官常追问的下一层是"G1 为什么还叫 Garbage-First?" —— 答案不在名字而在 MaxGCPauseMillis 的实现:它把"整堆回收"变成在停顿预算内求解最大收益子集的问题。这也解释了它为什么不能保证停顿(只能统计意义上收敛),以及为什么超高实时要求要交给 ZGC。

32.10 ZGC 与 Shenandoah:把停顿与堆大小解耦

ZGC(JEP 333 起实验,JDK 15 转正)与 Shenandoah(JDK 15 转正)的目标是 STW 停顿不超过 10 ms,且停顿时间与堆大小、存活对象数量无关。

着色指针(colored pointers)是 ZGC 的核心:64 位指针中实际寻址只用低 42 位(支持 4 TB 堆),高 4 位被借用来存标记状态(Marked0/Marked1 两套标记位交替以区分本轮与上轮标记、Remapped 指向重定位后的新地址、Finalizable 仅终结器可达)。读指针时先看状态位决定是否需要修复指针 —— 这一步由读屏障(load barrier)完成:它在每次从堆读引用时插入,检查指针状态,若指向"正在被转移"的旧地址就当场修正到新地址(“自愈”)。**写屏障(store barrier)**则在写引用时记录,用于维护并发转移所需的映射表。

为什么停顿与堆大小无关:对象转移(复制)是并发做的,STW 阶段只做极短的初始标记/再标记这类只与 GC Roots(而非堆大小)相关的操作;转移期间应用线程仍可能读到旧地址,而读屏障负责把"读旧地址"变正确,因此不需要停下所有线程。主要阶段:并发标记 → 并发预备重定位(选择要转移的 Region)→ 并发重定位 → 并发重映射,全程只有极短暂停点。分代 ZGC(JEP 439,JDK 21 引入,实验特性):此前 ZGC 非分代,每轮都要处理整个堆,分配速率高时频繁触发 GC、对短命对象不友好;引入 Young/Old 两代后降低 GC 频率与内存占用并提高分配吞吐。

代价:吞吐量下降(读屏障在每次读引用时执行,是"逐次访问都要付"的成本,实测吞吐一般比 G1 低一档)、内存放大(memory amplification)(并发转移需同时保留新旧两份空间)、需要更大堆(大堆下才体现收益)。-XX:SoftMaxHeapSize 是软上限:正常情况下尽量不超此值(倾向更频繁 GC 而非扩堆),应急时才允许涨到 -Xmx,用于在延迟与内存占用间取舍。

32.11 GC 选型决策表与版本默认值

判断维度只有三个:堆大小、延迟要求、吞吐要求,三者不可能同时最优。

场景堆大小延迟要求吞吐要求选择理由
小内存服务/批处理/单机工具< 4 GB不敏感高Serial / Parallel少量线程、STW 短、无并发开销
主流后端服务(JDK 8)4~16 GB百毫秒级高Parallel Scavenge + Parallel Old(默认)或 CMSParallel 吞吐最高;CMS 换更短停顿但付碎片代价
主流后端服务(JDK 9+)4~32 GB200 ms 级较高G1(默认)停顿可预测、参数少
大堆 + 低延迟16~64 GB几十毫秒中G1 调优(MaxGCPauseMillis/IHOP)或 ZGC堆越大 G1 的并发标记越难跑赢分配速率,转 ZGC
超大堆 + 极低延迟≥ 32 GB(ZGC 优势区)< 10 ms可让步ZGC停顿与堆大小解耦,代价是吞吐与内存放大
低延迟且不便换版本中等< 10 ms中Shenandoah同样靠并发转移,屏障实现路径不同
版本服务端默认收集器备注
JDK 8Parallel Scavenge + Parallel Old永久代仍在;CMS 可用但需显式开启
JDK 9 ~ 13G1(JEP 248)元空间取代永久代;CMS 仍可用
JDK 14 / 15G1CMS 被移除(JEP 363);ZGC(JEP 377)与 Shenandoah(JEP 379)转正
JDK 21G1分代 ZGC 作为实验特性引入(JEP 439)

警告

常见错误回答:“JDK 8 默认 CMS”(错,默认 Parallel,CMS 要 -XX:+UseConcMarkSweepGC 打开)、“ZGC 在 JDK 11 就能上生产”(JDK 11 引入时为实验特性,JDK 15 才转正)、“JDK 17 默认 ZGC”(默认仍是 G1)。版本与默认值是硬事实,答错会被判定为背书而非理解。

32.12 八种 OutOfMemoryError 与 StackOverflowError

#错误信息触发位置首要排查方向
1Java heap space堆:对象太多或单个对象太大jmap -histo:live <pid> 看实例数 TOP;-XX:+HeapDumpOnOutOfMemoryError 抓 dump 用 MAT 看支配树
2GC overhead limit exceeded堆:GC 耗时占比过高默认 GC 时间超过 98% 且回收不到 2% 堆时抛出,本质是"堆太小/泄漏";查长生命周期集合是否持续增长
3Metaspace / PermGen space元空间(JDK 8+)/ 永久代(JDK 7 及以前)类加载器泄漏(动态生成类、热部署、CGLIB/Groovy 反复生成);jmap -clstats 看类加载器数量
4unable to create new native thread线程创建:栈内存或线程数上限ulimit -u(进程数)、cgroup pids.max、-Xss 过大导致每线程栈占用过多;jstack 数线程数
5Direct buffer memory直接内存(堆外)ByteBuffer.allocateDirect 未释放或未走 Cleaner;查 -XX:MaxDirectMemorySize;NMT 看 Internal/Other
6Requested array size exceeds VM limit数组分配:请求长度超 JVM 上限长度接近 Integer.MAX_VALUE;通常伴随整数溢出,是计算错误,先查业务算法
7Compressed class space元空间中存放类元数据指针的部分由 -XX:CompressedClassSpaceSize(默认 1 GB)限制,与 Metaspace 报错常成对出现

StackOverflowError 与它们的区别:前者是栈深度耗尽(一般是无限递归),发生在虚拟机栈;后者是内存无法满足分配。两者也可能互相引发:栈溢出时若栈可扩展而内存不足,HotSpot 报的是 OutOfMemoryError。

1
2
3
4
5
6
7
 症状                     第一手段                              第二手段
 堆 OOM                   -XX:+HeapDumpOnOutOfMemoryError      jmap -histo:live / MAT 支配树
 疑似泄漏但没 OOM         jmap -histo:live 隔 10 min 对比       jcmd <pid> GC.heap_info
 GC 频繁、吞吐下滑        -Xlog:gc*(JDK 9+)看频率与停顿       -XX:+PrintGCDetails(JDK 8)
 堆外 / 元空间增长        -XX:NativeMemoryTracking=detail      jcmd <pid> VM.native_memory summary
 线程创建失败             jstack <pid> | grep -c '^"'          ulimit -u / cat /proc/<pid>/limits
 类加载器泄漏             jmap -clstats <pid>                  jcmd <pid> GC.class_stats

提示

OOM 排查话术按"先分类(哪个区域)→ 再取证据(dump / NMT / jstack)→ 最后定性(泄漏 or 容量不足)“三步说,比罗列工具清单得分高。区分两者靠隔一段时间取两次 jmap -histo 对比增长:持续单向增长是泄漏,稳定在高位是容量不足。

本章小结

关键结论一句话表述常见错误
元空间阈值-XX:MetaspaceSize 是首次 GC 阈值,不是初始大小;MaxMetaspaceSize 不设则无上限答成"元空间初始大小”
TLAB 的动机把"分配指针平移"从需要 CAS 的共享操作变成线程内操作答成"加快对象初始化"
对象位置开启逃逸分析后,不逃逸对象可被标量替换为栈上标量,“一定在堆上"不严格断言"对象一定在堆上”
GC Roots栈中引用、JNI 引用、静态属性、常量、活跃线程、锁对象、Class、系统类加载器只答"栈和静态变量"
四种引用强(不可回收)、软(内存不足时回收)、弱(下次 GC 必回收)、虚(仅回收通知)认为弱引用能解决 ThreadLocalMap 的 value 泄漏
三色标记漏标需同时满足"黑→白新增引用"与"灰→白引用全删";增量更新破坏前者,SATB 破坏后者只记结论说不出条件,或答"SATB 没有代价"
Concurrent Mode Failure并发清除期间老年代被填满 → 退化 Serial Old 单线程整理以为它只是"CMS 跑得慢"
动态年龄判定同年龄对象总大小 > 单个 Survivor 一半即整体晋升答成"年龄超过一半"
G1 的 Full GCto-space exhausted / Humongous 失败 / 晋升失败认为 G1 不会 Full GC

自测题

Q1(推导)某服务启动参数为 -Xmn100m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15,但线上观测到大量对象在第 2~3 次 Minor GC 就进入老年代。请给出至少两个可能原因,并说明如何验证。

答案与解析

原因一:动态年龄判定。此时 Eden = 80 MB、单个 Survivor = 10 MB,动态年龄判定只要求"Survivor 中同年龄对象总大小超过 5 MB(一半)",则年龄 ≥ 该年龄的对象整体晋升;在 10 MB 的小 Survivor 里 5 MB 极易达到,15 这个阈值根本轮不到生效。验证:-Xlog:gc+age=trace(JDK 9+)或 -XX:+PrintTenuringDistribution(JDK 8)看每次 GC 后各年龄分布,能直接看到"某年龄总字节数超阈值 → 全部晋升"。

原因二:Survivor 空间不足 + 空间分配担保。若某次 GC 存活对象超过 10 MB,To Survivor 放不下的部分直接进老年代。验证:GC 日志里 Desired survivor size 与实际晋升字节数对比。

错误答案的典型形态是"因为 Survivor 太小所以提前晋升" —— 只答了原因二,漏掉更隐蔽的动态年龄判定。面试官追问路径:先问"Eden、S0、S1 各多大"(考分母是单个 Survivor),再问"动态年龄判定的条件是什么"(考是空间总和不是年龄数值),最后问"怎么验证"。

Q2(推导)初始状态 A(黑,已被收集器扫描完)、B(灰,尚未扫描)、B → C(白)。赋值器依次执行 ① A.f = C;② B.f = null。判断是否漏标,并说明两类解法各自怎么救。

答案与解析

发生漏标,两条件同时满足:① 黑色对象 A 到白色对象 C 插入了新引用,而 A 已黑不会再被扫描;② 灰色对象 B 删除了到 C 的引用,且 B → C 是 C 唯一的"灰→白"通路。结果 C 既不被黑对象也不被灰对象扫描,标记结束时仍是白色被判为垃圾 —— 但 A.f 仍指向它,程序会读到已回收对象。

去掉任一条都不漏:只做 ① 时,收集器扫 B 会把 C 置灰;只做 ② 时,C 是真正的孤岛垃圾,回收正确。两类解法:增量更新破坏 ① —— 把 A 重新置灰留到重新标记阶段重扫(CMS,写后屏障);SATB 破坏 ② —— 在 B.f = null 之前把旧引用 B → C 记录下来,认为 C 在快照里存活(G1,写前屏障)。面试官会继续追问"记录下来的东西怎么用":增量更新把黑色对象重新置灰重扫(延长 STW),SATB 把被删的旧引用当作存活(产生浮动垃圾)。

Q3(辨析)同事说:“既然 ThreadLocalMap 的 Entry 的 key 是弱引用,ThreadLocal 用完不清 remove() 也不会泄漏。“请指出错在哪并画出引用链。

答案与解析

这句话只对了"key 会被回收”,错在 value 的引用链从未被切断:Entry 继承 WeakReference,弱引用只作用在 key 上,于是引用链是 Thread → ThreadLocalMap → Entry[] table,而 Entry.key --弱引用--> ThreadLocal(外部置空后下次 GC 即回收,key 变 null)、Entry.value --强引用--> 业务对象。ThreadLocal 被回收后 Entry 就变成"key 为 null 但 value 仍被强引用"的状态,value 一直被 table 数组引用、table 又被 Thread 引用。线程池场景下线程几乎不结束,value 永不回收 —— 这就是典型的 ThreadLocal 内存泄漏。ThreadLocalMap 的补偿机制(get/set 时顺带清理 key 为 null 的 Entry,即 expungeStaleEntry)只在恰好访问到该 ThreadLocal 时才触发,覆盖不了"它再也不被使用"的场景,所以必须在 finally 里 remove()。

面试官追问方向:为什么 key 用弱引用而不是强引用?(强引用下连 key 都无法回收,泄漏范围更大);ThreadLocalMap 的哈希冲突怎么解决?(开放地址法 + 线性探测,而非 HashMap 的链表/红黑树)。详见第 30 章 30.6 节。

Q4(设计)服务部署在 8 GB 堆、JDK 17,要求 P99 GC 停顿不超过 50 ms,吞吐不能明显下降。运维建议直接上 ZGC,你如何回应?给出方案与参数。

答案与解析

不应直接上 ZGC。 8 GB 属 G1 舒适区,而 ZGC 的读屏障给每次引用读增加成本(吞吐下降一档),并发转移还需额外空间(内存放大约 15%~20%),8 GB 上收益不明显。方案:先用 G1 调优,压不住再评估 ZGC。

  • -XX:MaxGCPauseMillis=50:把停顿目标写进预测模型。它是软目标,G1 会为此减少每次回收的 Region 数量,代价是 GC 更频繁 —— 这是显式的"吞吐换延迟”。
  • -XX:InitiatingHeapOccupancyPercent=35(默认 45):更早启动并发标记周期,避免 Mixed GC 来不及导致 to-space exhausted 进而 Full GC。
  • -XX:G1HeapRegionSize / -XX:+ParallelRefProcEnabled:把 Region 调大使大对象不再算 Humongous(默认由堆大小推导,8 GB 堆约 4 MB);并行处理引用入队以缩短重新标记。

分步验证:用 -Xlog:gc*:file=gc.log:time,uptime,level,tags 看停顿分布,确认超时的是 Young GC 还是 Mixed GC、有无 Pause Full。若调优后 P99 仍超 50 ms 且堆继续增长到 32 GB 以上,再评估 ZGC(-XX:+UseZGC,可配 -XX:SoftMaxHeapSize 留出余量)。

错误答案形态:① 直接说"用 ZGC,它停顿最低"—— 忽略吞吐与内存代价,也忽略 G1 在 8 GB 的适用性;② 只设 -XX:MaxGCPauseMillis=50 就以为能达标 —— 不懂它是软目标,G1 只能靠减少回收 Region 来逼近。

Q5(判断)逐条判断:① “-XX:MetaspaceSize=256m 会让元空间启动时就占 256 MB”;② “JDK 8 把字符串常量池从永久代移到了堆”;③ “标记-整理必须 STW,而标记-清除不需要”。

答案与解析

① 错。它设定的是首次触发元空间 GC 的高水位阈值,元空间仍从接近 0 按需分配、随类加载增长。实际效果是把首次元空间 GC 推迟到用量达 256 MB 时,减少启动期 Full GC。理解成初始大小会导致错误预期。

② 错,版本记错了。把字符串常量池与静态变量移入堆的是 JDK 7;JDK 8 做的是用元空间替换永久代(类的元信息搬到本地内存)。JDK 6 时字符串常量池确实还在永久代。这个"两次搬家"必须分清,因为面试官会接着问"JDK 7 为什么先搬字符串常量池?"(永久代容量受限且回收效率低,intern 字符串是最容易撑爆它的数据)。

③ 前半对、后半错。标记-整理要移动对象,移动中地址在变,赋值器不可能读到一致引用,所以必须 STW。标记-清除不移动对象,其清除阶段可以并发 —— 但 CMS 的初始标记与重新标记仍 STW(根扫描与增量更新修正必须停下),只有并发标记与并发清除阶段并发。正确说法是"清除阶段不需移动对象,因此可以并发"。

面试官追问方向:CMS 既然能并发清除,为什么还会有 Concurrent Mode Failure?(并发期间应用仍在分配与晋升,老年代可能先被填满,只能退化 Serial Old 做单线程整理)。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
-XX:MetaspaceSize 到底是什么意思?把它调大有什么效果?参数语义错误回答:“元空间的初始大小,调大就是启动时多占内存”。正确:它是首次元空间 GC 的阈值,调大等于推迟首次 GC、减少启动期 Full GC32.2
为什么 JDK 8 要用元空间替换永久代?JDK 7 又做了什么?版本迁移因果错误回答:只答"永久代容易 OOM"却说不出为什么容易(容量硬限制、只在 Full GC 回收、类卸载条件苛刻),且把 JDK 7 的字符串常量池搬家混进 JDK 832.2
分代收集为什么还需要 Card Table?它解决的是什么问题?跨代引用错误回答:“为了记录对象年龄”。正确:为了让 Young GC 不必扫描整个老年代,只扫脏卡;配套机制是写屏障置脏32.5.2
什么样的对象会直接进入老年代?晋升路径错误回答:只答"年龄到 15"。漏掉动态年龄判定、大对象(PretenureSizeThreshold,且只对 Serial/ParNew 有效)、Survivor 不足三种;且把动态年龄判定说成"年龄超过一半"32.6.1
为什么 CMS 会出现 Concurrent Mode Failure?后果是什么?并发收集器固有缺陷错误回答:“CMS 的 bug"或"内存不够了”。正确:并发清除期间应用继续分配晋升、老年代被填满 → 退化 Serial Old 单线程整理,停顿剧增;靠 CMSInitiatingOccupancyFraction 提前启动缓解32.8
三色标记为什么会漏标?增量更新和 SATB 各是怎么解决的?并发标记理论基础错误回答:“漏标是因为并发执行”(等于没答)。必须说出两个条件,并指出增量更新破坏"新增黑→白"、SATB 破坏"删除灰→白",以及写后/写前屏障与代价(重扫 vs 浮动垃圾)32.7
G1 什么时候会退化成 Full GC?怎么排查?G1 调优实战错误回答:“G1 不会 Full GC”。正确:to-space exhausted、Humongous 失败、晋升失败、显式 System.gc() 四种;排查看 GC 日志关键词与 jmap -histo 找大对象32.9.1
ZGC 凭什么做到停顿与堆大小无关?代价是什么?着色指针与屏障错误回答:“因为它是并发的”(所有并发收集器都并发,不足以解释)。必须提到着色指针 + 读屏障 + 并发转移,代价是吞吐下降与内存放大32.10
JDK 8 的默认垃圾收集器是什么?JDK 17 呢?版本默认值错误回答:“JDK 8 默认 CMS”(默认 Parallel Scavenge + Parallel Old,CMS 要显式开启);“ZGC 从 JDK 11 起可用于生产”(JDK 11 为实验,JDK 15 转正)32.11
遇到 OutOfMemoryError: unable to create new native thread 你怎么定位?OOM 分类排查错误回答:“堆内存不够,加 -Xmx"(与堆无关)。正确:查 ulimit -u、cgroup pids.max、-Xss 是否过大,用 jstack 数线程数32.12

延伸阅读

  • JVMS 第 2 章 The Structure of the Java Virtual Machine:2.5 运行时数据区、2.6 栈帧;JLS 第 17 章 Threads and Locks;
  • JEP 122(Remove the Permanent Generation)、JEP 248(Make G1 the Default GC)、JEP 363(Remove CMS)、JEP 333/377(ZGC)、JEP 439(Generational ZGC);
  • Oracle HotSpot Virtual Machine Garbage Collection Tuning Guide:G1/ZGC 调优章节与 NativeMemoryTracking 用法;
  • OpenJDK 源码:src/hotspot/share/gc/shared/cardTable.hpp(卡表)、gc/g1/g1RemSet.hpp(RSet)、gc/shared/markBitMap 与三色标记实现;
  • 《深入理解 Java 虚拟机》第 3 版第 2、3 章;另见第 12 章 12.4 节(从 Linux 侧用 pmap/smaps/NMT 对照观测 JVM 内存占用)。