第 32 章 JVM 内存结构与 GC
本章把"内存怎么分、对象怎么生、垃圾怎么判、收集器怎么停"串成一条因果链,是 Java 岗追问最深的一章。
学习目标
读完本章,你应当能够:
- 默画运行时数据区五部分并标出线程私有性,说出两类栈/堆异常各自的触发位置;
- 推导"为什么永久代必须被元空间替换",并解释
-XX:MetaspaceSize为什么不是初始大小; - 走完
new的五步与TLAB分配路径,判断"对象一定在堆上"在什么条件下不成立; - 背出
GC Roots主要类别与四种引用的语义,用可达性分析解释循环引用为什么不会泄漏; - 用赋值器/收集器双视角推导三色标记的两个漏标条件,说明增量更新与原始快照各破坏哪一个;
- 在给定堆大小、延迟与吞吐要求下选出收集器,并针对八种
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 生死。
| |
- 程序计数器是 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 把永久代换成了元空间”。
版本迁移时间线(一图记住两次搬家):
| |
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 的五个步骤
| |
关键区分:第 ③ 步后对象已"存在"(字段全零),第 ⑤ 步后才"可用"。所以构造方法里把 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>,必须传 ReferenceQueue | get() 永远返回 null,仅在对象被回收时收到入队通知 | 回收通知、DirectByteBuffer 的堆外内存释放 |
| |
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 卡页)划分,每张卡对应一个字节标记"这页里有指向新生代的引用"。
| |
写屏障是**赋值器(mutator,应用线程)**在写引用时插入的一小段代码(HotSpot 中由 oop_store 的 post_barrier 实现),成本是每次跨代引用赋值多一次内存写,换来 Young GC 不必扫描整个老年代。
32.6 Minor 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)。
| |
- 逻辑分代: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 + 部分 Survivor | Eden 的 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:G1MixedGCLiveThresholdPercent | 85(JDK 8 为 65) | 存活对象占比高于此值的 Old Region 不纳入 Mixed GC(复制成本划不来) |
-XX:G1HeapWastePercent / -XX:G1MixedGCCountTarget | 5 / 8 | 可容忍的浮动垃圾占比(低于此值停止 Mixed GC);一次并发标记周期最多分几次 Mixed GC 收完候选 Region |
-XX:InitiatingHeapOccupancyPercent | 45 | 老年代占用超此比例即启动并发标记周期 |
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(默认)或 CMS | Parallel 吞吐最高;CMS 换更短停顿但付碎片代价 |
| 主流后端服务(JDK 9+) | 4~32 GB | 200 ms 级 | 较高 | G1(默认) | 停顿可预测、参数少 |
| 大堆 + 低延迟 | 16~64 GB | 几十毫秒 | 中 | G1 调优(MaxGCPauseMillis/IHOP)或 ZGC | 堆越大 G1 的并发标记越难跑赢分配速率,转 ZGC |
| 超大堆 + 极低延迟 | ≥ 32 GB(ZGC 优势区) | < 10 ms | 可让步 | ZGC | 停顿与堆大小解耦,代价是吞吐与内存放大 |
| 低延迟且不便换版本 | 中等 | < 10 ms | 中 | Shenandoah | 同样靠并发转移,屏障实现路径不同 |
| 版本 | 服务端默认收集器 | 备注 |
|---|---|---|
| JDK 8 | Parallel Scavenge + Parallel Old | 永久代仍在;CMS 可用但需显式开启 |
| JDK 9 ~ 13 | G1(JEP 248) | 元空间取代永久代;CMS 仍可用 |
| JDK 14 / 15 | G1 | CMS 被移除(JEP 363);ZGC(JEP 377)与 Shenandoah(JEP 379)转正 |
| JDK 21 | G1 | 分代 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
| # | 错误信息 | 触发位置 | 首要排查方向 |
|---|---|---|---|
| 1 | Java heap space | 堆:对象太多或单个对象太大 | jmap -histo:live <pid> 看实例数 TOP;-XX:+HeapDumpOnOutOfMemoryError 抓 dump 用 MAT 看支配树 |
| 2 | GC overhead limit exceeded | 堆:GC 耗时占比过高 | 默认 GC 时间超过 98% 且回收不到 2% 堆时抛出,本质是"堆太小/泄漏";查长生命周期集合是否持续增长 |
| 3 | Metaspace / PermGen space | 元空间(JDK 8+)/ 永久代(JDK 7 及以前) | 类加载器泄漏(动态生成类、热部署、CGLIB/Groovy 反复生成);jmap -clstats 看类加载器数量 |
| 4 | unable to create new native thread | 线程创建:栈内存或线程数上限 | ulimit -u(进程数)、cgroup pids.max、-Xss 过大导致每线程栈占用过多;jstack 数线程数 |
| 5 | Direct buffer memory | 直接内存(堆外) | ByteBuffer.allocateDirect 未释放或未走 Cleaner;查 -XX:MaxDirectMemorySize;NMT 看 Internal/Other |
| 6 | Requested array size exceeds VM limit | 数组分配:请求长度超 JVM 上限 | 长度接近 Integer.MAX_VALUE;通常伴随整数溢出,是计算错误,先查业务算法 |
| 7 | Compressed class space | 元空间中存放类元数据指针的部分 | 由 -XX:CompressedClassSpaceSize(默认 1 GB)限制,与 Metaspace 报错常成对出现 |
StackOverflowError 与它们的区别:前者是栈深度耗尽(一般是无限递归),发生在虚拟机栈;后者是内存无法满足分配。两者也可能互相引发:栈溢出时若栈可扩展而内存不足,HotSpot 报的是 OutOfMemoryError。
| |
提示
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 GC | to-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 GC | 32.2 |
| 为什么 JDK 8 要用元空间替换永久代?JDK 7 又做了什么? | 版本迁移因果 | 错误回答:只答"永久代容易 OOM"却说不出为什么容易(容量硬限制、只在 Full GC 回收、类卸载条件苛刻),且把 JDK 7 的字符串常量池搬家混进 JDK 8 | 32.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 内存占用)。