Appearance
JVM 是 Java 工程师进阶的必经之路。从内存结构、垃圾回收到类加载、性能调优,理解 JVM 才能写出高效稳定的 Java 程序。
Q1: JVM 内存结构(运行时数据区)是怎样的? 「🟢 校招/初级」
考察点:考察候选人对 JVM 最基础的内存模型的掌握程度,能否清晰区分线程私有和共享区域,以及各区域的作用。这是 JVM 的入门题,筛掉那些只会写 CRUD 对底层完全没概念的人。
参考答案:
JVM 运行时数据区整体分为 线程私有 和 线程共享 两大类,另外还有一块 直接内存 不属于运行时数据区但也被频繁使用。
整体架构(文字描述)
┌─────────────────────────────────────────────────────────────┐
│ JVM 运行时数据区 │
├──────────────────┬──────────────────────────────────────────┤
│ │ ┌────────────────────────────────────┐ │
│ │ │ 方法区 / 元空间 │ │
│ 线程共享区域 │ │ (类信息、常量、静态变量、JIT代码) │ │
│ (所有线程共享) │ └────────────────────────────────────┘ │
│ │ ┌────────────────────────────────────┐ │
│ │ │ 堆 (Heap) │ │
│ │ │ (对象实例、数组) │ │
│ │ └────────────────────────────────────┘ │
├──────────────────┼──────────────────────────────────────────┤
│ │ ┌─────────┐ 每个线程一份 │
│ │ │程序计数器│ (PC Register) │
│ 线程私有区域 │ ├─────────┤ │
│ (每条线程独立) │ │虚拟机栈 │ (VM Stack) │
│ │ ├─────────┤ │
│ │ │本地方法栈│ (Native Method Stack) │
│ │ └─────────┘ │
└──────────────────┴──────────────────────────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ 直接内存 (Direct Memory) —— 不在 JVM 中 │
│ (NIO 使用,堆外内存) │
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─各区域详解
| 区域 | 线程归属 | 作用 | 可能的 OOM/异常 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 记录当前线程执行的字节码行号指示器,是唯一不会 OOM 的区域 | 无 |
| 虚拟机栈 | 线程私有 | 每个方法执行时创建一个栈帧,存储局部变量表、操作数栈等 | StackOverflowError / OutOfMemoryError |
| 本地方法栈 | 线程私有 | 为 Native 方法服务,作用与虚拟机栈类似 | StackOverflowError / OutOfMemoryError |
| 堆 | 线程共享 | 存放对象实例和数组,是 GC 的主要区域 | Java heap space |
| 方法区/元空间 | 线程共享 | 存储类信息、常量、静态变量、即时编译器编译后的代码等 | Metaspace / PermGen space |
| 直接内存 | 不在 JVM 中 | NIO 通过 DirectByteBuffer 操作的堆外内存 | Direct buffer memory |
补充说明
- 程序计数器:如果执行的是 Native 方法,计数器值为空(Undefined)。
- 虚拟机栈:每个方法从调用到执行完成,对应一个栈帧在虚拟机栈中入栈到出栈。
- 堆:是 JVM 内存中最大的一块,也是垃圾收集器管理的主要区域。
- 方法区:JDK 7 及之前叫永久代(PermGen),JDK 8 之后被元空间(Metaspace)取代。
- 直接内存:不受 JVM 堆大小限制,但受本机总内存限制,NIO 框架会使用。
追问延伸:
- 为什么程序计数器是线程私有的?(线程切换后能恢复到正确的执行位置)
- 哪些区域会发生 OOM?分别在什么场景下?
- 直接内存和堆内存有什么区别?什么时候用直接内存?
- 运行时常量池在哪个区域?(JDK 7 之前在永久代,JDK 7 移到堆中,JDK 8 元空间中包含类常量池但运行时常量池在堆)
Q2: 虚拟机栈是什么?栈帧包含哪些内容? 「🟢 校招/初级」
考察点:考察对 JVM 栈结构的细节理解,特别是栈帧内部组成。这道题能看出候选人是否真的读过《深入理解 Java 虚拟机》这类书籍,还是只背了八股文的标题。
参考答案:
虚拟机栈概述
虚拟机栈(VM Stack)是 线程私有 的,生命周期与线程相同。每个 Java 方法在执行的同时都会创建一个 栈帧(Stack Frame),方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈的特点是 先进后出(FILO),栈顶元素就是当前正在执行的方法。
栈帧的结构
每个栈帧包含以下 5 个部分:
┌──────────────────────────────────┐
│ 栈帧 (Stack Frame) │
├──────────────────────────────────┤
│ 1. 局部变量表 (Local Variable Table) │
│ - 存放方法参数和局部变量 │
│ - 基本类型/对象引用/returnAddress│
│ - 以 slot 为单位,32位为一个slot│
├──────────────────────────────────┤
│ 2. 操作数栈 (Operand Stack) │
│ - 方法执行的"工作区" │
│ - 字节码指令在操作数栈上计算 │
│ - 比如 iadd 指令弹出两个数相加 │
├──────────────────────────────────┤
│ 3. 动态链接 (Dynamic Linking) │
│ - 指向运行时常量池的方法引用 │
│ - 支持多态的动态分派 │
├──────────────────────────────────┤
│ 4. 方法返回地址 (Return Address) │
│ - 方法退出后回到调用点的位置 │
│ - 正常返回 vs 异常返回 │
├──────────────────────────────────┤
│ 5. 附加信息 (Extra Info) │
│ - 调试信息等 │
└──────────────────────────────────┘1. 局部变量表(Local Variable Table)
- 存放方法参数和方法内部定义的局部变量
- 容量以 变量槽(Slot) 为最小单位,一个 Slot 占 32 位
- 64 位的 long 和 double 占 2 个连续的 Slot
- 第 0 位 Slot 默认存放方法所属对象实例的引用(
this) - Slot 可以重用,当局部变量超出作用域时,其 Slot 可以被其他变量复用
2. 操作数栈(Operand Stack)
- 方法执行过程中,各种字节码指令会往操作数栈中写入和提取内容
- 例如执行
iadd指令时,会将栈顶两个 int 弹出相加,再将结果压栈 - 栈的深度在编译期就确定了,存在 Code 属性的 max_stacks 中
3. 动态链接(Dynamic Linking)
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用
- 作用是支持方法调用过程中的动态连接
- 静态解析(类加载阶段就确定)vs 动态链接(运行期才确定)
4. 方法返回地址
- 方法执行完后,需要返回到方法被调用的位置
- 正常完成出口:正常返回,返回值压入调用者的操作数栈
- 异常完成出口:抛出异常,返回地址通过异常处理器表确定
StackOverflowError 和 OutOfMemoryError 的区别
| 异常 | 触发原因 | 说明 |
|---|---|---|
| StackOverflowError | 线程请求的栈深度大于虚拟机允许的深度 | 比如递归过深、方法调用链太长 |
| OutOfMemoryError | 虚拟机栈扩展时无法申请到足够内存 | 栈空间不足,一般出现在创建大量线程时 |
栈大小设置
- 通过
-Xss参数设置每个线程的栈大小,例如-Xss256k - 栈大小不是越大越好,栈越大,能创建的线程数越少
- 默认值:不同平台不同,一般 1024K(1MB)左右
追问延伸:
- 递归深度过深会抛出什么异常?怎么解决?(StackOverflowError,增大 -Xss 或改为迭代)
- 局部变量表中的 Slot 可以复用吗?这对 GC 有什么影响?(可以复用,可能影响对象可达性判断)
- 操作数栈和局部变量表有什么区别和联系?
- 为什么 long 和 double 类型占两个 Slot?
- 方法调用时参数是怎么传递的?(从调用者操作数栈弹出,压入被调用者的局部变量表)
Q3: 堆内存的结构?新生代和老年代的比例? 「🟡 中级」
考察点:考察对堆内存分代模型的理解,包括为什么分代、各代比例、对象晋升机制等。这道题能看出候选人对 GC 分代收集理论的理解深度。
参考答案:
堆内存分代结构
Java 堆是垃圾收集器管理的主要区域,基于 分代收集理论,堆被划分为不同的区域:
┌──────────────────────────────────────────────────────────────┐
│ Java 堆 (Heap) │
├──────────────────────────────┬───────────────────────────────┤
│ 新生代 (Young) │ 老年代 (Old/Tenured) │
│ (对象创建和消亡频繁的区域) │ (长期存活对象、大对象) │
│ │ │
│ ┌──────┬────────┬────────┐ │ │
│ │ Eden │ Survivor│ Survivor│ │ │
│ │ │ S0 │ S1 │ │ │
│ └──────┴────────┴────────┘ │ │
│ │ │
│ 默认比例 1 : 2 │ │
└──────────────────────────────┴───────────────────────────────┘默认比例
| 区域 | 默认比例 | 说明 |
|---|---|---|
| 新生代 : 老年代 | 1 : 2 | 新生代占堆的 1/3,老年代占 2/3 |
| Eden : S0 : S1 | 8 : 1 : 1 | Eden 占新生代的 80%,两个 Survivor 各占 10% |
参数调整:
-Xmn:设置新生代大小-XX:SurvivorRatio=8:设置 Eden 与 Survivor 的比例(默认 8)-XX:NewRatio=2:设置老年代与新生代的比例(默认 2,表示老年代:新生代 = 2:1)
为什么要分代?
分代的核心思想是 弱分代假说 和 强分代假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集的对象就越难以消亡
基于这两个假说,不同代使用不同的 GC 算法:
| 分代 | 特点 | 适用算法 | 原因 |
|---|---|---|---|
| 新生代 | 对象存活率低,每次 GC 回收大量对象 | 复制算法 | 只需复制少量存活对象,效率高 |
| 老年代 | 对象存活率高,没有额外空间担保 | 标记-清除 / 标记-整理 | 没有多余空间做复制,只能标记后清理/整理 |
对象晋升老年代的条件
年龄阈值(默认 15)
- 对象在 Survivor 区每熬过一次 Minor GC,年龄 +1
- 达到阈值(默认 15,CMS 默认 6)后晋升到老年代
- 参数:
-XX:MaxTenuringThreshold
大对象直接进入老年代
- 需要大量连续内存空间的对象(如大数组、长字符串)
- 参数:
-XX:PretenureSizeThreshold(只对 Serial 和 ParNew 有效) - 目的:避免大对象在 Eden 和 Survivor 之间来回复制
Survivor 区放不下
- 当 Survivor 空间不足以容纳一次 Minor GC 后存活的对象时,通过 分配担保机制 直接进入老年代
- 老年代会承担空间担保,如果老年代剩余空间不足,会触发 Full GC
动态年龄判定
- 如果 Survivor 空间中相同年龄的所有对象大小总和大于 Survivor 空间的一半
- 年龄大于等于该年龄的对象直接进入老年代,无需等到 MaxTenuringThreshold
补充:分代收集的 GC 类型
| GC 类型 | 作用区域 | 触发条件 | 速度 |
|---|---|---|---|
| Minor GC / Young GC | 新生代 | Eden 满了 | 快,停顿短 |
| Major GC / Old GC | 老年代 | 老年代空间不足 | 慢,停顿长 |
| Full GC | 整个堆(新生代+老年代+方法区) | 老年代不足、System.gc() 等 | 最慢,停顿最长 |
追问延伸:
- 为什么 Survivor 需要两个区?一个不行吗?(解决碎片问题,复制算法需要两块区域)
- 动态年龄判定是怎么工作的?
- 什么是分配担保?老年代空间不够怎么办?
- 如果设置了很大的新生代,会有什么影响?(Minor GC 频率降低,但每次时间变长;老年代变小可能更频繁 Full GC)
- G1 收集器也是分代的吗?和传统分代有什么不同?(G1 逻辑上分代,物理上不分,是 Region 划分)
Q4: 方法区、永久代、元空间的区别和关系? 「🟡 中级」
考察点:考察对 JVM 方法区演进历史的理解,特别是永久代到元空间的变化。这道题能区分出是否有 JDK 7/8 迁移经验,以及对 JVM 内存模型演进的理解。
参考答案:
三者的关系
方法区(Method Area) 是 JVM 规范中定义的一个逻辑区域,是所有 JVM 实现都必须遵守的概念。而 永久代(PermGen) 和 元空间(Metaspace) 是方法区在不同 JDK 版本中的具体实现。
JVM 规范
└── 方法区 (Method Area) ←—— 逻辑概念,JVM 规范定义
├── JDK 1.6/1.7 → 永久代 (PermGen) ←—— 具体实现,在堆中
└── JDK 1.8+ → 元空间 (Metaspace) ←—— 具体实现,在本地内存永久代 vs 元空间对比
| 对比项 | 永久代 (PermGen) | 元空间 (Metaspace) |
|---|---|---|
| JDK 版本 | JDK 7 及之前 | JDK 8 及之后 |
| 内存位置 | JVM 堆内(和老年代连续) | 本地内存(Native Memory),不在 JVM 堆中 |
| 大小限制 | 受 JVM 堆大小限制,-XX:MaxPermSize | 受本机物理内存限制,默认几乎无上限 |
| 默认大小 | 64MB(32位)/ 82MB(64位) | 受系统内存限制,初始约 21MB |
| GC 方式 | Full GC 时一起回收,回收条件苛刻 | 元空间不足时触发 GC,可设置阈值 |
| 存储内容 | 类信息、常量池、静态变量、JIT 编译代码 | 类的元数据(类结构、字段、方法等),常量池和静态变量移到堆中 |
| OOM 类型 | java.lang.OutOfMemoryError: PermGen space | java.lang.OutOfMemoryError: Metaspace |
为什么用元空间替代永久代?
大小难以确定
- 永久代大小需要提前设置,容易出现 OOM
- 设置太大浪费空间,太小容易溢出
- 元空间使用本地内存,只受系统内存限制,更灵活
Full GC 开销大
- 永久代的 GC 是和老年代绑定的 Full GC,停顿时间长
- 元空间有独立的 GC 机制,可以单独回收
与本地内存整合
- JRockit 等 JVM 没有永久代的概念,Oracle 收购后统一
- 便于和其他 JVM 实现保持一致
类的元数据信息不易确定大小
- 类的元数据可能很大,放在永久代容易导致 OOM
- 尤其是在动态生成类较多的场景(如 Spring、Hibernate、CGLib)
元空间的重要参数
| 参数 | 作用 | 默认值 |
|---|---|---|
-XX:MetaspaceSize | 元空间初始大小,达到该值触发 Full GC | 约 21MB |
-XX:MaxMetaspaceSize | 元空间最大大小 | 无限制(受系统内存限制) |
-XX:MinMetaspaceFreeRatio | GC 后最小空闲空间比例 | 40% |
-XX:MaxMetaspaceFreeRatio | GC 后最大空闲空间比例 | 70% |
补充:字符串常量池的迁移
- JDK 6 及之前:字符串常量池在永久代中
- JDK 7:字符串常量池移到了堆中(永久代还在,但常量池搬走了)
- JDK 8:永久代完全移除,字符串常量池在堆中,类的元数据在元空间
迁移原因:永久代空间有限,大量字符串容易导致 OOM;堆中 GC 更频繁,回收更及时。
追问延伸:
- JDK 8 为什么要把元空间放在本地内存?有什么好处和坏处?
String.intern()在 JDK 6 和 JDK 7/8 中有什么区别?- 什么场景下会出现 Metaspace OOM?怎么解决?
- 元空间也会发生 GC 吗?什么时候触发?
- 静态变量存在哪里?(JDK 7 之前在永久代,JDK 7 之后移到堆中)
- 运行时常量池和字符串常量池有什么区别?分别在哪个区域?
Q5: 判断对象是否存活的算法?什么是 GC Roots? 「🟡 中级」
考察点:考察对垃圾回收最基础的对象存活判定算法的理解,以及 GC Roots 的具体含义。这道题能看出候选人是否真的理解 GC 的本质,还是只停留在表面概念。
参考答案:
两种对象存活判定算法
1. 引用计数法(Reference Counting)
原理:每个对象都有一个引用计数器,每当有一个地方引用它时,计数器 +1;引用失效时,计数器 -1。计数器为 0 的对象就是不可能再被使用的。
优点:实现简单,判定效率高
缺点:无法解决循环引用问题
java
// 循环引用示例
public class MyObject {
public MyObject instance;
}
MyObject a = new MyObject();
MyObject b = new MyObject();
a.instance = b; // a 引用 b
b.instance = a; // b 引用 a
a = null;
b = null;
// 此时两个对象互相引用,引用计数都不为 0
// 但实际上它们已经无法被访问到了由于循环引用的存在,Java 虚拟机没有使用引用计数法。
2. 可达性分析算法(Reachability Analysis)
原理:从一组称为 GC Roots 的根对象出发,向下搜索,走过的路径称为 引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时,证明此对象是不可达的,可以被回收。
GC Roots
│
┌─────┴─────┐
▼ ▼
对象A 对象B
│ │
▼ ▼
对象C 对象D
│
▼
对象E ←—— 对象F (不可达,可被回收)Java 虚拟机使用的是可达性分析算法。
什么是 GC Roots?
GC Roots 是一组必须存活的"根对象",它们是引用链的起点。可以作为 GC Roots 的对象包括:
| 类别 | 具体内容 | 说明 |
|---|---|---|
| 虚拟机栈中引用的对象 | 各个线程栈帧中的局部变量表引用的对象 | 方法参数、局部变量等 |
| 本地方法栈中引用的对象 | JNI(Native 方法)引用的对象 | |
| 方法区中类静态属性引用的对象 | static 修饰的引用类型变量 | 全局静态变量 |
| 方法区中常量引用的对象 | 字符串常量池(String Table)中的引用 | |
| 所有被同步锁持有的对象 | synchronized 持有的对象 | 锁对象不能被回收 |
| JVM 内部的引用 | Class 对象、常驻异常对象、系统类加载器等 |
四种引用类型
Java 中有四种引用类型,强度从强到弱依次为:
| 引用类型 | 关键字 | 回收时机 | 用途 |
|---|---|---|---|
| 强引用 | 默认(普通引用) | 永远不回收,OOM 也不回收 | 普通对象引用 |
| 软引用 | SoftReference | 内存不足(OOM 之前)时回收 | 缓存、图片缓存 |
| 弱引用 | WeakReference | 下一次 GC 时一定回收 | ThreadLocal、WeakHashMap |
| 虚引用 | PhantomReference | 任何时候都可能被回收 | 管理堆外内存,跟踪对象回收 |
软引用示例
java
// 软引用:内存不足时才会被回收
SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = softRef.get(); // 获取引用,可能为 null弱引用示例
java
// 弱引用:只要 GC 就会被回收
WeakReference<Object> weakRef = new WeakReference<>(new Object());
System.gc();
// weakRef.get() 大概率返回 null虚引用示例
java
// 虚引用:必须配合引用队列使用,用于跟踪对象回收
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);
// phantomRef.get() 永远返回 null补充:对象的自救——finalize()
即使在可达性分析中不可达的对象,也不是"非死不可",它们还有一次自救机会:
- 第一次标记:发现对象没有到 GC Roots 的引用链,进行第一次标记和筛选
- 筛选条件:对象是否有必要执行
finalize()方法- 如果对象没有覆盖
finalize()或已经执行过一次,视为没必要执行
- 如果对象没有覆盖
- 如果有必要执行,对象会被放入 F-Queue 队列
- 稍后由一个低优先级的 Finalizer 线程去执行它们的
finalize()方法 - 第二次标记:GC 对 F-Queue 中的对象进行第二次小规模标记
- 如果对象在
finalize()中重新与引用链上的任何一个对象建立关联,就能逃脱回收
注意:
finalize()方法只能被系统自动调用一次,不推荐使用,运行代价高,不确定性大。
追问延伸:
- 为什么 Java 不使用引用计数法?(循环引用问题,维护计数器也有开销)
- GC Roots 有哪些?说几个具体的例子。
- 软引用和弱引用的区别?各自的使用场景?
- ThreadLocal 为什么会有内存泄漏?和弱引用有什么关系?
- finalize() 方法有什么用?为什么不推荐使用?
- 一个对象真正死亡需要经历几次标记?
- 什么是引用队列(ReferenceQueue)?有什么用?
Q6: 常见的垃圾回收算法? 「🟡 中级」
考察点:考察对经典 GC 算法的理解,以及分代收集理论的底层逻辑。能说清各算法的优缺点和适用场景,说明候选人对 GC 有系统性的理解,而不是零散记忆。
参考答案:
三种经典 GC 算法
1. 标记-清除算法(Mark-Sweep)
原理:分为"标记"和"清除"两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
标记前: 标记后: 清除后:
┌───┬───┬───┐ ┌───┬───┬───┐ ┌───┬───┬───┐
│ A │ B │ C │ │ ✓ │ │ ✓ │ │ │ B │ │
├───┼───┼───┤ ├───┼───┼───┤ ├───┼───┼───┤
│ D │ E │ F │ │ │ ✓ │ │ │ D │ │ F │
└───┴───┴───┘ └───┴───┴───┘ └───┴───┴───┘
(✓表示存活) (✓表示要回收)优点:
- 实现简单,是最基础的算法
- 不需要移动对象
缺点:
- 效率问题:标记和清除两个过程效率都不高
- 空间问题:清除后会产生大量不连续的内存碎片,碎片太多可能导致分配大对象时提前触发 GC
适用场景:老年代(对象存活率高,移动对象代价大)
2. 复制算法(Copying)
原理:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
使用 From 区: GC 时复制到 To 区: 清空 From,交换身份:
┌───┬───┬───┐ ┌───┬───┬───┐ ┌───┬───┬───┐
│ A │ │ B │ 存活 → │ A │ B │ │ 清空后 → │ │ │ │ (旧 From)
├───┼───┼───┤ ├───┼───┼───┤ ├───┼───┼───┤
│ │ C │ │ │ C │ │ │ │ A │ B │ C │ (新 To)
└───┴───┴───┘ └───┴───┴───┘ └───┴───┴───┘
(From 区) (To 区)优点:
- 实现简单,运行高效
- 不会产生内存碎片(复制过去后顺序排列)
- 只需要移动堆顶指针,按顺序分配内存即可
缺点:
- 浪费空间:可用内存缩小为原来的一半
- 对象存活率高时,复制操作开销大
适用场景:新生代(对象存活率低,每次 GC 只有少量对象存活)
实际应用:HotSpot 新生代的 Eden + Survivor 布局就是复制算法的改进版,不是 1:1 划分,而是 8:1:1,只浪费 10% 空间。
3. 标记-整理算法(Mark-Compact)
原理:标记过程与"标记-清除"一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记前: 标记后: 整理后:
┌───┬───┬───┐ ┌───┬───┬───┐ ┌───┬───┬───┐
│ A │ │ B │ │ A │ │ B │ │ A │ B │ D │
├───┼───┼───┤ ├───┼───┼───┤ ├───┼───┼───┤
│ │ D │ │ │ │ D │ │ │ │ │ │
└───┴───┴───┘ └───┴───┴───┘ └───┴───┴───┘
(存活对象左移) (右侧全部回收)优点:
- 没有内存碎片
- 分配新对象时简单,指针碰撞即可
缺点:
- 需要移动大量存活对象,成本高
- 移动对象过程中需要 STW(Stop The World)
适用场景:老年代(对象存活率高,空间不能浪费)
三种算法对比
| 算法 | 标记-清除 | 复制算法 | 标记-整理 |
|---|---|---|---|
| 速度 | 中等 | 快 | 慢 |
| 空间开销 | 少(有碎片) | 多(浪费一半) | 少(无碎片) |
| 移动对象 | 否 | 是 | 是 |
| 内存碎片 | 有 | 无 | 无 |
| 适用场景 | 老年代 | 新生代 | 老年代 |
分代收集理论
分代收集不是一种具体的算法,而是一种思想,基于两个分代假说:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的
- 强分代假说(Strong Generational Hypothesis):熬过越多次 GC 的对象就越难以消亡
基于这两个假说,我们将堆分为新生代和老年代,不同代使用不同算法:
| 分代 | 对象存活率 | 适用算法 | 原因 |
|---|---|---|---|
| 新生代 | 很低(约 90% 以上死亡) | 复制算法 | 只需复制少量存活对象,效率高 |
| 老年代 | 很高 | 标记-清除 / 标记-整理 | 没有多余空间做复制,移动对象代价大 |
跨代引用问题与卡表
问题:新生代的对象可能被老年代引用(跨代引用),如果只扫描新生代,可能漏掉这些引用,导致存活对象被误回收。
解决方案:使用 卡表(Card Table)
老年代被划分为一个个卡(Card,通常 512 字节):
┌────┬────┬────┬────┬────┬────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ ← 卡表
└────┴────┴────┴────┴────┴────┘
↑ ↑
脏 脏
卡 卡
(表示这张卡对应的老年代内存区域中有对象引用了新生代)- 卡表是一个字节数组,每个元素对应老年代的一块内存区域(称为"卡",通常 512 字节)
- 当老年代某个卡中的对象引用了新生代对象时,将该卡标记为"脏"(dirty)
- Minor GC 时,只需扫描脏卡中的老年代对象,不需要扫描整个老年代
- 写屏障(Write Barrier)负责维护卡表的状态
追问延伸:
- 为什么新生代用复制算法,老年代不用?
- 标记-清除和标记-整理的区别?各自的优缺点?
- 什么是分代收集?为什么要分代?
- 跨代引用怎么处理?卡表是什么?
- 写屏障(Write Barrier)是什么?和读屏障有什么区别?
- 复制算法浪费 50% 空间,为什么 HotSpot 新生代只浪费 10%?
- 标记-整理算法移动对象的时候,用户线程还能运行吗?(需要 STW)
Q7: 常见的垃圾收集器有哪些?各有什么特点? 「🟡 中级」
考察点:考察对垃圾收集器家族的整体认识,能否按代、按并发/串行、按特点分类讨论。这道题能看出候选人的知识体系是否完整,以及是否关注 JDK 版本演进带来的变化。
参考答案:
垃圾收集器总览
垃圾收集器是内存回收的具体实现,不同的收集器有不同的特点和适用场景。按代划分和特点分类如下:
新生代收集器 老年代收集器
│ │
┌────────────────────┼────────────────────────┼───────────────────┐
│ │ │ │
串行: 并行: 并发: 其他:
Serial ParNew CMS G1
Serial Old Parallel Scavenge (已废弃) ZGC
Parallel Old Shenandoah按代和特点分类
| 收集器 | 代 | 算法 | 特点 | 适用场景 | JDK 版本 |
|---|---|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程,简单高效 | 客户端模式、内存小 | JDK 1.3.1 起 |
| Serial Old | 老年代 | 标记-整理 | 单线程,CMS 的后备方案 | 客户端模式、CMS 失败降级 | - |
| ParNew | 新生代 | 复制 | Serial 的多线程版本 | 配合 CMS 使用 | - |
| Parallel Scavenge | 新生代 | 复制 | 吞吐量优先,可自适应调节 | 后台计算、吞吐量优先 | JDK 1.4 起 |
| Parallel Old | 老年代 | 标记-整理 | Parallel Scavenge 的老年代版本 | 吞吐量优先组合 | JDK 1.6 起 |
| CMS | 老年代 | 标记-清除 | 低停顿,并发执行 | 响应时间优先的 B/S 系统 | JDK 1.5,JDK 9 废弃 |
| G1 | 全堆 | 复制 + 标记-整理 | 可预测停顿,Region 划分 | 服务端大内存应用 | JDK 7u4,JDK 9 默认 |
| ZGC | 全堆 | 标记-整理(着色指针) | 极低停顿(<10ms),TB 级堆 | 超大内存、极低延迟要求 | JDK 11 实验,JDK 15 生产 |
串行收集器
Serial / Serial Old
- Serial:新生代收集器,单线程复制算法
- Serial Old:老年代收集器,单线程标记-整理算法
- 整个 GC 过程只有一条线程,且必须 Stop The World(暂停所有用户线程)
- 优点:简单而高效,对于限定单个 CPU 的环境,没有线程交互开销,专心做 GC 反而最高效
- 缺点:停顿时间长
- 适用:客户端模式(Client VM)、内存较小的场景
并行收集器
ParNew
- Serial 收集器的 多线程版本,除了使用多线程进行 GC,其余行为和 Serial 完全一致
- 是许多运行在 Server 模式下虚拟机的首选新生代收集器
- 重要原因:只有它能和 CMS 收集器配合工作
- 参数:
-XX:ParallelGCThreads限制 GC 线程数
Parallel Scavenge(吞吐量优先)
- 新生代收集器,复制算法,并行多线程
- 关注点是 吞吐量(Throughput) = 运行用户代码时间 / (运行用户代码时间 + GC 时间)
- 高吞吐量可以最高效率地利用 CPU 时间,尽快完成程序的运算任务
- 适合在后台运算而不需要太多交互的任务
- 自适应调节策略:
-XX:+UseAdaptiveSizePolicy,开启后不需要手动指定新生代大小、Eden/Survivor 比例等参数,JVM 会根据系统运行情况动态调整 - 也被称为"吞吐量优先"收集器
Parallel Old
- Parallel Scavenge 的老年代版本,多线程,标记-整理算法
- JDK 1.6 才提供,在此之前 Parallel Scavenge 只能和 Serial Old 配合
- Parallel Scavenge + Parallel Old 是吞吐量优先的组合
并发(低停顿)收集器
CMS(Concurrent Mark Sweep)
- 老年代收集器,标记-清除算法
- 目标是获取最短回收停顿时间,重视服务响应速度
- 是第一个真正意义上的并发收集器,实现了让用户线程和 GC 线程同时工作
- 详细工作流程见下一题
- JDK 9 中被标记为废弃(Deprecated),JDK 14 中被移除
G1(Garbage-First)
- 面向服务端应用,JDK 9 成为默认收集器
- 最大特点:可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒
- 将堆划分为多个大小相等的 Region,新生代和老年代不再物理隔离
- 整体上是标记-整理,局部(两个 Region 之间)是复制算法,不会产生内存碎片
- 详细原理见下一题
ZGC(Z Garbage Collector)
- JDK 11 作为实验特性引入,JDK 15 正式生产可用
- 目标:在 TB 级堆大小下,停顿时间不超过 10ms,且吞吐量不低于 Parallel Scavenge 的 50%
- 核心技术:着色指针(Colored Pointers)、读屏障(Load Barrier)
- 几乎所有工作都是并发的,只有标记开始和结束时有短暂 STW
- 停顿时间不随堆大小增加而增加
关注的两个核心指标
| 指标 | 含义 | 代表收集器 | 适用场景 |
|---|---|---|---|
| 吞吐量 | 用户代码时间 / (用户代码时间 + GC 时间) | Parallel Scavenge | 后台计算、批处理、数据分析 |
| 停顿时间 | GC 时用户线程暂停的时间 | CMS、G1、ZGC | Web 服务、游戏、实时系统 |
这两个指标通常是矛盾的:追求高吞吐量意味着 GC 频率低但每次时间长,停顿就长;追求低停顿意味着 GC 更频繁,吞吐量会下降。需要根据业务场景权衡。
常见组合
| 组合 | 新生代 | 老年代 | 特点 |
|---|---|---|---|
| 串行组合 | Serial | Serial Old | 客户端、小内存 |
| 吞吐量组合 | Parallel Scavenge | Parallel Old | 吞吐量优先 |
| CMS 组合 | ParNew | CMS | 低停顿(JDK 9 前) |
| G1 | G1(整堆) | G1(整堆) | 可预测停顿(JDK 9+ 默认) |
| ZGC | ZGC(整堆) | ZGC(整堆) | 极低停顿、大内存 |
追问延伸:
- 吞吐量和停顿时间有什么区别和关系?
- Parallel Scavenge 和 ParNew 有什么区别? (Parallel Scavenge 关注吞吐量、有自适应调节;ParNew 关注和 CMS 配合)
- 为什么 G1 可以做到可预测的停顿时间?
- JDK 8 默认的垃圾收集器是什么?JDK 9 呢? (JDK 8: Parallel Scavenge + Parallel Old;JDK 9+: G1)
- 你们线上用的什么收集器?为什么这么选?
- CMS 为什么被废弃了?
- ZGC 和 G1 有什么区别?ZGC 的优势是什么?
Q8: CMS 收集器的工作流程?有什么优缺点? 「🟡 中级」
考察点:考察对 CMS 这个经典并发收集器的理解深度。虽然 CMS 已被废弃,但它是理解并发收集器的基础,很多概念(如三色标记、浮动垃圾)都是通用的。
参考答案:
CMS 概述
CMS(Concurrent Mark Sweep)是一种以获取 最短回收停顿时间 为目标的老年代收集器。它使用 标记-清除 算法,是 HotSpot 虚拟机中第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
参数开启:-XX:+UseConcMarkSweepGC
CMS 的工作流程
CMS 整个过程分为 4 个阶段:
时间轴 →
┌──────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────┐
│初始标记│ │ 并发标记 │ │ 重新标记 │ │ 并发清除 │
│ STW │───▶│ (并发) │───▶│ STW │───▶│ (并发) │
└──────┘ └──────────────┘ └──────────┘ └──────────────┘
│ │ │ │
│ │ │ └── 与用户线程并发
│ │ └── 停顿比初始标记长一点
│ └── 耗时最长,但与用户线程并发
└── 停顿很短,只标记 GC Roots 直接关联的对象1. 初始标记(Initial Mark)—— STW
- 仅仅标记一下 GC Roots 直接关联 的对象
- 速度很快,停顿时间很短
- 需要 Stop The World
2. 并发标记(Concurrent Mark)—— 并发执行
- 从 GC Roots 直接关联的对象开始,进行 可达性分析遍历
- 这个过程耗时最长,但 与用户线程并发执行
- 用户程序继续运行,GC 线程在后台标记
3. 重新标记(Remark)—— STW
- 为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
- 这个阶段的停顿时间一般比初始标记稍长,但远比并发标记短
- 需要 Stop The World
这里涉及到 三色标记法 和 增量更新 的概念,见下文补充。
4. 并发清除(Concurrent Sweep)—— 并发执行
- 清除删除掉那些标记为死亡的对象
- 释放内存空间
- 与用户线程并发执行
CMS 的优点
- 低停顿:大部分工作与用户线程并发执行,只有初始标记和重新标记需要 STW,停顿时间短
- 响应速度快:适合对响应时间要求高的应用(如 B/S 架构的服务端)
- 并发执行:GC 线程和用户线程可以同时工作
CMS 的缺点
对 CPU 资源敏感
- 并发阶段会占用一部分 CPU 资源,导致用户程序变慢
- 默认回收线程数 = (CPU 数量 + 3) / 4
- CPU 数量少时(如 2 核),对用户程序影响较大
无法处理浮动垃圾(Floating Garbage)
- 并发清除阶段,用户线程还在运行,会产生新的垃圾
- 这些新垃圾在本次 GC 中无法被回收,只能等下一次 GC
- 因此 CMS 不能等到老年代快满了才 GC,需要预留空间
- 参数
-XX:CMSInitiatingOccupancyFraction设置触发百分比(JDK 5 默认 68%,JDK 6+ 默认 92%)
空间碎片问题
- CMS 使用 标记-清除 算法,会产生内存碎片
- 碎片太多时,分配大对象可能找不到连续空间,提前触发 Full GC
- CMS 提供了
-XX:+UseCMSCompactAtFullCollection(默认开启)参数,在 Full GC 时进行碎片整理,但整理时需要 STW,会增加停顿时间 -XX:CMSFullGCsBeforeCompaction设置多少次 Full GC 后进行一次压缩整理
并发模式失败(Concurrent Mode Failure)
- 并发清理阶段,如果用户线程需要分配内存,但老年代剩余空间不足
- 此时 CMS 不得不 退化为 Serial Old,进行一次 Full GC
- 这会导致很长的停顿时间
- 这是 CMS 最严重的问题之一
补充:三色标记法与增量更新
并发标记阶段,用户线程和 GC 线程同时运行,可能出现"对象引用变化"的问题。CMS 使用 三色标记法 来追踪对象的标记状态:
| 颜色 | 含义 |
|---|---|
| 白色 | 未被访问过,可达性分析完成后白色对象就是不可达的 |
| 灰色 | 已被访问过,但至少有一个引用还没被扫描 |
| 黑色 | 已被访问过,且所有引用都已扫描完毕 |
问题:并发标记时,可能出现 白色对象被黑色对象引用,而灰色对象对它的引用被删除的情况,导致白色对象被误判为垃圾(漏标)。
CMS 的解决方案:增量更新(Incremental Update)
- 当黑色对象插入新的白色对象引用时,将这个黑色对象变回灰色
- 重新标记阶段再扫描这些变灰的对象
对比:G1 和 Shenandoah 使用的是 原始快照(SATB, Snapshot At The Beginning) 方案。
为什么 JDK 9 废弃了 CMS?
- 内存碎片问题 无法根本解决,长期运行后必然触发 Full GC
- 并发模式失败 退化为 Serial Old,停顿不可控
- G1 更加成熟,JDK 9 起 G1 成为默认收集器,功能上完全可以替代 CMS
- 维护成本高,CMS 代码复杂,且有大量遗留问题
- 后续 ZGC、Shenandoah 等更先进的低延迟收集器出现
追问延伸:
- CMS 的四个阶段分别做什么?哪些阶段需要 STW?
- 什么是浮动垃圾?为什么 CMS 会有浮动垃圾?
- 并发模式失败是什么?怎么解决? (增大老年代空间、调大 CMSInitiatingOccupancyFraction、换 G1)
- CMS 为什么用标记-清除而不用标记-整理? (并发阶段移动对象的话,用户线程的引用地址会变,需要复杂的屏障机制)
- 三色标记法的漏标问题怎么解决?增量更新和原始快照的区别?
- CMS Full GC 的时候用的什么收集器?(Serial Old,单线程标记-整理)
- 什么是增量更新?什么是 SATB?
Q9: G1 收集器的原理?和 CMS 有什么区别? 「🔴 高级」
考察点:考察对 G1 这个当前主流收集器的深入理解。Region 划分、可预测停顿模型、记忆集等都是高频考点。能答清楚 G1 和 CMS 的区别,说明候选人对现代 GC 有较深的认识。
参考答案:
G1 概述
G1(Garbage-First)是一款面向服务端应用的垃圾收集器,JDK 7u4 正式发布,JDK 9 成为默认收集器,取代了 CMS。
G1 的设计目标:在延迟可控的情况下获得尽可能高的吞吐量。
G1 的核心特点:
- Region 分区:将堆划分为多个大小相等的 Region
- 逻辑分代,物理不分代:新生代和老年代不再是物理隔离的
- 可预测的停顿时间模型:可以指定 GC 最大停顿时间
- 整体标记-整理,局部复制算法:不会产生内存碎片
G1 的内存布局
G1 不再遵循传统的分代物理划分,而是将整个 Java 堆划分为 多个大小相等的独立区域(Region):
┌─────┬─────┬─────┬─────┬─────┐
│ Eden│ Eden│ Old │ Hum │Survi│ ← 每个 Region 大小相同
├─────┼─────┼─────┼─────┼─────┤
│ Old │ Eden│ Old │ Old │Survi│
├─────┼─────┼─────┼─────┼─────┤
│ Eden│ Old │ Hum │ Old │ Eden│
├─────┼─────┼─────┼─────┼─────┤
│ Old │ Old │ Eden│ Old │ Old │
└─────┴─────┴─────┴─────┴─────┘
每个 Region 可以是 Eden、Survivor、Old、Humongous 中的一种
Region 大小:1M ~ 32M,且都是 2 的幂,默认根据堆大小自动计算关键特性:
- Region 大小:通过
-XX:G1HeapRegionSize设置,范围 1MB~32MB,默认堆大小 / 2048 - 逻辑分代:
- 新生代:一组 Eden Region + 一组 Survivor Region
- 老年代:一组 Old Region
- 新生代和老年代只是逻辑概念,不要求物理连续
- Humongous Region:大对象区域
- 超过 Region 容量一半的对象称为大对象
- 大对象直接分配在 Humongous Region
- Humongous Region 被视为老年代的一部分
可预测的停顿时间模型
G1 最突出的特点是可以建立 可预测的停顿时间模型,通过参数 -XX:MaxGCPauseMillis(默认 200ms)指定最大 GC 停顿时间。
实现原理:
- G1 跟踪每个 Region 中垃圾堆积的"价值"(回收能释放多少空间、需要多少时间)
- 在后台维护一个 优先级列表
- 每次 GC 时,根据允许的停顿时间,优先回收那些"价值最高"的 Region
- 这就是"Garbage-First"名称的由来——垃圾优先,优先回收垃圾多的 Region
G1 的 GC 模式
| GC 模式 | 触发条件 | 回收范围 | 说明 |
|---|---|---|---|
| Young GC | Eden 区满 | 所有 Eden + Survivor Region | 复制算法,存活对象复制到 Survivor 或晋升 Old |
| Mixed GC | 老年代占用达到阈值(默认 45%) | 所有 Young Region + 部分 Old Region | G1 独有的模式,回收部分老年代控制停顿 |
| Full GC | Mixed GC 跟不上分配速度,堆空间不足 | 整个堆 | 单线程标记-整理,停顿很长,要尽量避免 |
G1 的工作流程(以 Mixed GC 为例)
G1 的工作过程和 CMS 类似,也分为几个阶段:
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│初始标记 │ │ 并发标记 │ │ 最终标记 │ │ 筛选回收 │ │ ... │
│ STW │──▶│ (并发) │──▶│ STW │──▶│ STW │──▶│ │
└─────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │
│ │ │ └── 选择价值最高的 Region 回收
│ │ └── 修正并发标记期间的变动
│ └── 遍历整个堆,时间较长但并发
└── 标记 GC Roots 直接关联对象,停顿短- 初始标记(Initial Marking):STW,标记 GC Roots 直接关联的对象,修改 TAMS 指针
- 并发标记(Concurrent Marking):与用户线程并发,从 GC Roots 开始做可达性分析
- 最终标记(Final Marking):STW,处理并发标记期间遗留的 SATB 记录
- 筛选回收(Live Data Counting and Evacuation):STW,根据用户期望的停顿时间制定回收计划,选择部分 Region 进行回收,使用复制算法
记忆集(Remembered Set)解决跨 Region 引用
问题:和跨代引用类似,G1 中每个 Region 都可能被其他 Region 引用,如果 Young GC 时扫描所有 Region,那停顿时间就不可控了。
解决方案:每个 Region 维护一个 记忆集(Remembered Set),记录其他 Region 引用本 Region 的情况。
Region A Region B Region C
┌────────┐ ┌────────┐ ┌────────┐
│ obj1 │───────▶│ obj2 │ │ obj3 │
│ │ │ │ │ │
│ RSet │◀───────│ RSet │ │ RSet │
│(记录谁 │ │(A引用了│ │ │
│ 引用我) │ │ B的obj)│ │ │
└────────┘ └────────┘ └────────┘- 每个 Region 有自己的 Remembered Set
- 当其他 Region 的对象引用了本 Region 的对象时,会通过 写屏障 把引用信息记录到本 Region 的 RSet 中
- GC 时,只要扫描 RSet 就能知道哪些外部对象引用了本 Region 的对象,不需要扫描整个堆
- 记忆集本质上是一种"空间换时间"的策略
对比:卡表(Card Table)是"我引用了谁"的记录(出向),而 Remembered Set 是"谁引用了我"的记录(入向)。G1 的 Remembered Set 实际上是一种更复杂的卡表实现。
G1 vs CMS 对比
| 对比项 | CMS | G1 |
|---|---|---|
| 内存布局 | 传统分代,物理连续 | Region 划分,逻辑分代 |
| 算法 | 标记-清除(整体) | 标记-整理(整体)+ 复制(局部) |
| 内存碎片 | 有碎片 | 无碎片 |
| 停顿预测 | 不支持 | 支持,可设置 MaxGCPauseMillis |
| 适用内存 | 中小堆 | 大堆(推荐 4GB 以上) |
| 停顿时间 | 较短,但不可控 | 可控,可预测 |
| 回收范围 | 老年代整代回收 | 按 Region 回收,可回收部分老年代 |
| 维护成本 | 较低(卡表) | 较高(每个 Region 一个 RSet) |
| 吞吐量 | 较高 | 稍低(维护 RSet 有开销) |
| 并发标记方案 | 增量更新 | SATB(原始快照) |
| JDK 版本 | JDK 5 起,JDK 9 废弃 | JDK 7u4 起,JDK 9 默认 |
G1 的常用参数
| 参数 | 作用 | 默认值 |
|---|---|---|
-XX:+UseG1GC | 开启 G1 收集器 | JDK 9+ 默认 |
-XX:MaxGCPauseMillis | 最大目标停顿时间 | 200ms |
-XX:G1HeapRegionSize | Region 大小 | 1~32MB,自动计算 |
-XX:G1NewSizePercent | 新生代最小比例 | 5% |
-XX:G1MaxNewSizePercent | 新生代最大比例 | 60% |
-XX:InitiatingHeapOccupancyPercent | 触发并发标记的堆占用比例 | 45% |
追问延伸:
- G1 的 Region 是什么?为什么要这样设计?
- G1 怎么实现可预测的停顿时间?
- G1 的 Humongous Region 是什么?
- G1 的记忆集和卡表有什么区别?
- G1 有哪几种 GC 模式?分别什么时候触发?
- G1 和 CMS 的并发标记有什么区别?(SATB vs 增量更新)
- 什么是 TAMS 指针?(Top at Mark Start,G1 用于并发标记时的对象分配)
- G1 什么情况下会触发 Full GC?怎么避免?
- 什么场景适合用 G1?什么场景不适合?
- G1 的 SATB 是什么?和 CMS 的增量更新相比有什么优劣?
Q10: ZGC 是什么?有什么特点? 「🔴 高级」
考察点:考察对最新一代低延迟垃圾收集器的了解。ZGC 是目前最前沿的生产级 GC 之一,能说清楚 ZGC 的核心技术(着色指针、读屏障),说明候选人紧跟技术发展,对 JVM 有深度兴趣。
参考答案:
ZGC 概述
ZGC(Z Garbage Collector)是 JDK 11 中作为实验特性引入的低延迟垃圾收集器,JDK 15 正式成为生产特性。它是一款以 极低停顿时间 为目标的新一代 GC。
设计目标:
- 支持 TB 级 堆大小(从 8MB 到 16TB+)
- 最大停顿时间 不超过 10ms(且不随堆大小或对象数量增加而增加)
- 吞吐量不低于 Parallel Scavenge 的 50%
简单来说,ZGC 追求的是 在超大堆下依然保持极低停顿。
ZGC 的核心技术
ZGC 能做到几乎全并发、极低停顿,依赖两个核心技术:
1. 着色指针(Colored Pointers)
这是 ZGC 最核心的创新。ZGC 将对象指针的 某些位 用来存储元数据信息(标记信息),而不是只存储内存地址。
在 64 位系统中,ZGC 使用 64 位指针中的几位来存储颜色信息:
64 位指针结构(ZGC 用 42 位表示地址,剩余位存储元数据):
┌──────┬──────┬──────┬──────────────────────────────────────────┐
│ 标 │ 标 │ 标 │ 对象地址 │
│ 记0 │ 记1 │ 记2 │ (42 位,支持 4TB 内存) │
└──────┴──────┴──────┴──────────────────────────────────────────┘
4bit 1bit 1bit
颜色位的含义:
- Marked0 / Marked1:标记状态位(三色标记的颜色信息)
- Remapped:重映射状态(对象是否已被移动到新地址)为什么叫"着色指针"?
- 因为指针本身就带有对象的标记状态信息(颜色信息)
- 不需要像传统 GC 那样额外维护一个标记位图
- GC 标记时直接修改指针的颜色位即可
优点:
- 标记信息直接存在指针上,访问对象时就能知道它的标记状态
- 转移(移动)对象后,指针可以直接指向新地址,不需要额外的句柄或转发指针
- 使得几乎所有 GC 操作都可以并发执行
2. 读屏障(Load Barrier)
读屏障是一小段代码,当应用线程 读取对象引用 时触发(注意是"读"屏障,不是"写"屏障)。
作用:
- 当读取一个对象指针时,如果指针的颜色位显示该对象已经被移动了,读屏障会触发"自愈"过程
- 也就是将指针更新为对象的新地址(重映射)
- 这样用户线程始终拿到的是正确的对象地址
应用线程读取对象引用:
obj.field
│
▼
┌─────────┐
│ 读屏障 │
│ 检查指针 │
│ 颜色位 │
└────┬────┘
│
┌────┴────┐
│ │
正常 对象已移动
返回 自愈:更新指针到新地址
指针 返回新地址对比:
- CMS / G1 用的是 写屏障(写操作时触发)
- ZGC 用的是 读屏障(读操作时触发)
- 读屏障的好处是可以在 GC 转移对象时完全并发,不需要 STW
ZGC 的内存布局
ZGC 也使用 Region 划分,但和 G1 不同,ZGC 的 Region 大小是动态的:
┌──────┐ ┌──────┐ ┌──────────────┐ ┌──────┐ ┌──────┐
│ 小 │ │ 小 │ │ 中 │ │ 大 │ │ 小 │
│ 256K │ │ 256K │ │ 4MB │ │ 32MB │ │ 256K │ ...
└──────┘ └──────┘ └──────────────┘ └──────┘ └──────┘
ZGC 的 Region 分为三类:
- 小型 Region(Small):256KB,存放 < 256KB 的对象
- 中型 Region(Medium):4MB,存放 256KB ~ 4MB 的对象
- 大型 Region(Large):N × 2MB,存放 >= 4MB 的对象(每个 Large Region 只放一个对象)ZGC 也是 逻辑分代 的(但不分新生代老年代,所有 Region 一视同仁)。
注意:JDK 21 之前 ZGC 是不分代的(单代),JDK 21 引入了分代 ZGC(Generational ZGC)。
ZGC 的工作流程
ZGC 的 GC 周期大致分为以下阶段,绝大部分都是并发的:
┌───────┐ ┌──────────┐ ┌───────┐ ┌──────────┐ ┌───────┐
│ 标记开始│ │ 并发标记 │ │ 标记结束│ │ 并发转移 │ │ 转移结束│
│ STW │──▶│ │──▶│ STW │──▶│ │──▶│ STW │
│ 很短 │ │ 主要耗时 │ │ 很短 │ │ 主要耗时 │ │ 很短 │
└───────┘ └──────────┘ └───────┘ └──────────┘ └───────┘
停顿时间通常在 1ms 级别,不随堆大小增长- 标记开始(Mark Start):STW,很短,找 GC Roots
- 并发标记(Concurrent Mark):并发,遍历对象图,标记存活对象(通过着色指针)
- 标记结束(Mark End):STW,很短,处理收尾
- 并发转移(Concurrent Relocate):并发,将存活对象复制到新 Region,整理内存
- 转移结束(Relocate End):STW,很短,切换视图
关键:对象转移(移动)过程也是并发的!用户线程和转移线程同时运行。当用户线程访问一个正在被转移的对象时,读屏障会自动处理(要么等待转移完成,要么自己帮着转移)。
ZGC 的特点总结
| 特点 | 说明 |
|---|---|
| 极低停顿 | 几乎所有阶段并发,STW 时间通常 < 1ms |
| 停顿时间不随堆增长 | 堆从 GB 到 TB,停顿时间基本不变 |
| 着色指针 | 指针本身携带元数据,是 ZGC 最核心的创新 |
| 读屏障 | 读操作时触发,支持并发转移对象 |
| 无内存碎片 | 基于复制算法,转移过程中完成整理 |
| 支持超大堆 | 可支持 16TB 级别的堆 |
| 吞吐量较低 | 读屏障有一定开销,吞吐量比 Parallel Scavenge 低 |
ZGC vs G1 对比
| 对比项 | G1 | ZGC |
|---|---|---|
| 停顿时间 | 几十 ms ~ 几百 ms(可配置) | < 10ms(通常 < 1ms) |
| 停顿是否随堆增长 | 有一定增长 | 几乎不增长 |
| 核心技术 | Region + Remembered Set | 着色指针 + 读屏障 |
| 内存碎片 | 无(复制算法) | 无(复制算法) |
| 分代 | 分代(新生代+老年代) | JDK 21 前不分代,JDK 21+ 分代 |
| 适用堆大小 | 4GB ~ 几十 GB | 几十 GB ~ TB 级 |
| 吞吐量 | 较高 | 稍低(读屏障开销) |
| JDK 版本 | JDK 7u4 起,JDK 9 默认 | JDK 11 实验,JDK 15 生产 |
| 并发转移 | 转移时需要 STW(筛选回收阶段) | 转移完全并发 |
适用场景
- 大内存应用:堆内存几十 GB 甚至 TB 级别
- 低延迟要求高:对响应时间要求极高的场景(如交易系统、实时推荐)
- STW 敏感:长时间 STW 会严重影响业务的场景
开启参数:-XX:+UseZGC
追问延伸:
- ZGC 的着色指针是什么?为什么叫着色指针?
- ZGC 的读屏障和 G1 的写屏障有什么区别?
- ZGC 的停顿时间为什么不随堆大小增加而增加?
- ZGC 有分代吗?(JDK 21 之前不分代,JDK 21 引入分代 ZGC)
- ZGC 怎么做到并发转移对象的?(读屏障 + 着色指针的自愈机制)
- ZGC 和 Shenandoah 有什么区别? (都是低延迟 GC,Shenandoah 用 Brooks 指针,ZGC 用着色指针)
- 什么场景下应该使用 ZGC 而不是 G1?
- ZGC 的缺点是什么?(吞吐量稍低、对内存有额外开销、JDK 版本要求高)
Q11: 类加载过程是怎样的? 「🟡 中级」
考察点:考察对 Java 类加载机制全过程的理解,包括每个阶段的具体工作和类初始化时机。这道题是 JVM 类加载子系统的基础题,能看出候选人对 Java 程序运行底层机制的掌握程度。
参考答案:
类加载的五个阶段
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。其中前五个阶段合称为 类加载过程。
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐
│ 加载 │─▶│ 验证 │─▶│ 准备 │─▶│ 解析 │─▶│ 初始化 │
└────────┘ └────────┘ └────────┘ └────────┘ └──────────┘
│ │ │ │ │
│ └──── 连接 (Linking) ────┘ │
│ │
└───────────────── 类加载过程 ────────────────────┘注意:解析阶段在某些情况下可以在初始化之后再开始,这是为了支持 Java 的运行时绑定(动态连接)。
各阶段详解
1. 加载(Loading)
"加载"是"类加载"过程的一个阶段,注意不要混淆。
加载阶段虚拟机需要完成以下 3 件事:
- 通过一个类的全限定名获取定义此类的 二进制字节流
- 将这个字节流所代表的静态存储结构转化为 方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class对象,作为方法区这个类的各种数据的访问入口
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。
获取字节流的方式(不一定从 Class 文件来):
- 从 ZIP 包读取(JAR、WAR)
- 从网络获取(Applet)
- 运行时计算生成(动态代理、CGLib)
- 由其他文件生成(JSP 编译成 Class)
- 从数据库读取
2. 验证(Verification)
验证是连接阶段的第一步,目的是确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致完成 4 个阶段的检验:
| 验证阶段 | 检查内容 |
|---|---|
| 文件格式验证 | 魔数(0xCAFEBABE)、版本号、常量池类型等 |
| 元数据验证 | 类的继承关系、字段方法合法性、抽象类是否实现了所有抽象方法等 |
| 字节码验证 | 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的 |
| 符号引用验证 | 符号引用能否找到对应的类、字段、方法,访问权限是否足够等 |
验证阶段很重要,但不是必须的。如果代码已经被反复验证过,可以使用
-Xverify:none关闭大部分类验证,以缩短类加载时间。
3. 准备(Preparation)
准备阶段是正式为 类变量(静态变量)分配内存并设置类变量初始值 的阶段。
- 内存分配位置:方法区(JDK 7 之前)/ 堆(JDK 7 及以后,静态变量移到堆中)
- 初始值:通常是数据类型的 零值,而不是代码中显式赋的值
java
public static int value = 123;
// 准备阶段 value = 0(零值)
// 初始化阶段 value = 123(执行 <clinit> 方法)特殊情况——常量:如果字段是 static final 类型(即常量),并且它的值是编译期可知的字面量,那么在准备阶段就会被赋值为程序中定义的值。
java
public static final int value = 123;
// 准备阶段 value = 123(因为是 static final 且值是字面量)零值表:
| 数据类型 | 零值 |
|---|---|
| int | 0 |
| long | 0L |
| short | (short)0 |
| char | '\u0000' |
| byte | (byte)0 |
| boolean | false |
| float | 0.0f |
| double | 0.0d |
| reference | null |
4. 解析(Resolution)
解析阶段是虚拟机将常量池内的 符号引用替换为直接引用 的过程。
| 引用类型 | 说明 |
|---|---|
| 符号引用(Symbolic Reference) | 用一组符号来描述所引用的目标,符号可以是任何形式的字面量,与虚拟机内存布局无关 |
| 直接引用(Direct Reference) | 可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,与内存布局相关 |
解析动作主要针对 7 类符号引用:
- 类或接口
- 字段
- 类方法
- 接口方法
- 方法类型
- 方法句柄
- 调用点限定符
5. 初始化(Initialization)
类初始化阶段是类加载过程的最后一步,到了这个阶段,才真正开始执行类中定义的 Java 程序代码(字节码)。
初始化阶段就是执行 类构造器 <clinit>() 方法 的过程。
<clinit>() 方法的特点:
- 由编译器自动收集类中所有 类变量的赋值动作 和 静态语句块(static{}) 中的语句合并产生
- 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问
<clinit>()与类构造函数(<init>())不同,它不需要显式调用父类构造器,虚拟机会保证在子类的<clinit>()执行之前,父类的<clinit>()已经执行完毕- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,接口的
<clinit>()不需要先执行父接口的<clinit>() - 虚拟机保证一个类的
<clinit>()方法在多线程环境中被正确地加锁、同步
类初始化时机(主动引用)
虚拟机规范严格规定了 有且只有 以下 6 种情况必须对类进行 初始化(主动引用):
遇到
new、getstatic、putstatic、invokestatic这 4 条字节码指令时- 即:new 一个对象、读取/设置静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、调用静态方法
使用
java.lang.reflect包的方法对类进行反射调用时- 如
Class.forName("xxx")
- 如
初始化一个类时,如果其父类还没初始化,则先触发父类的初始化
虚拟机启动时,用户指定一个要执行的主类(包含 main() 方法的类),虚拟机会先初始化这个主类
使用 JDK 7 的动态语言支持时,如果一个
java.lang.invoke.MethodHandle实例最后的解析结果是...(略复杂,一般不考)JDK 8 接口中定义了 default 方法时,如果该接口的实现类初始化,接口要在其之前初始化
被动引用(不会触发初始化)
以下情况称为"被动引用",不会触发类的初始化:
通过子类引用父类的静态字段,不会导致子类初始化
javaSystem.out.println(SubClass.value); // value 是父类的静态变量,子类不会初始化通过数组定义来引用类,不会触发此类的初始化
javaSuperClass[] sca = new SuperClass[10]; // 不会触发 SuperClass 初始化常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
javaSystem.out.println(ConstClass.HELLO); // HELLO 是 static final 常量,不会触发 ConstClass 初始化
追问延伸:
- 类加载的五个阶段分别做什么?
- 准备阶段和初始化阶段的区别?静态变量在哪个阶段赋值?
- 什么是符号引用?什么是直接引用?
<clinit>()和<init>()的区别?- 类初始化的时机有哪些?什么是主动引用和被动引用?
- 子类初始化时父类会初始化吗?父接口呢?
- 静态代码块和静态变量的执行顺序是怎样的?
- 接口的初始化和类的初始化有什么不同?
Class.forName("xxx")和ClassLoader.loadClass("xxx")的区别? (forName 会完成初始化,loadClass 只完成加载)
Q12: 什么是双亲委派模型?为什么要这么设计? 「🟡 中级」
考察点:考察对类加载器体系和双亲委派机制的深入理解。这是 Java 类加载的核心概念,不仅要能说清工作流程,还要能说出设计意图和破坏场景。这道题能区分出候选人是背八股还是真的理解。
参考答案:
类加载器的层次结构
从 JDK 1.2 开始,Java 引入了双亲委派模型(Parents Delegation Model)。在这个模型下,类加载器按层次分为:
┌─────────────────────────────┐
│ Bootstrap ClassLoader │
│ (启动类加载器) │
│ 加载 JAVA_HOME/lib 下的类 │
│ C++ 实现,无 Java 对象 │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Extension/Platform │
│ ClassLoader │
│ (扩展类加载器) │
│ 加载 lib/ext 下的类 │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Application ClassLoader │
│ (应用程序类加载器) │
│ 加载用户 classpath 下的类 │
│ 也叫系统类加载器 │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Custom ClassLoader │
│ (自定义类加载器) │
│ 用户自己实现 │
└─────────────────────────────┘各层类加载器说明:
| 类加载器 | 加载范围 | 实现语言 |
|---|---|---|
| 启动类加载器(Bootstrap) | JAVA_HOME/lib 下的核心类库(如 rt.jar) | C++ |
| 扩展类加载器(Extension) | JAVA_HOME/lib/ext 目录下的类 | Java |
| 应用程序类加载器(Application) | 用户 classpath 下的类 | Java |
| 自定义类加载器 | 用户自定义路径 | Java |
JDK 9 引入模块系统后,扩展类加载器被平台类加载器(Platform ClassLoader)取代,但双亲委派模型的基本思想不变。
双亲委派模型的工作过程
双亲委派:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求 委派给父类加载器 去完成。
收到类加载请求
│
▼
先查缓存 ── 已加载?─── 是 ──▶ 直接返回
│ 否
▼
委派给父加载器
│
▼
父加载器加载 ── 成功?─── 是 ──▶ 返回结果
│ 否(父加载器找不到)
▼
自己尝试加载
│
▼
成功?── 是 ──▶ 返回
│ 否
▼
抛出 ClassNotFoundException工作流程总结:
- 当前类加载器收到加载请求
- 先检查该类是否已经被加载过,如果已经加载则直接返回
- 如果没有加载过,把请求委派给父加载器(递归向上)
- 如果父加载器加载成功,返回结果
- 如果父加载器加载失败(抛出 ClassNotFoundException),才自己尝试加载
- 自己也加载失败,则抛出异常
为什么要设计双亲委派模型?
1. 避免类的重复加载
- 如果没有双亲委派,用户自己写一个
java.lang.String类,放在 classpath 下 - 那么系统中就会出现多个不同的 String 类,互相之间类型不兼容
- 有了双亲委派,String 类始终由启动类加载器加载,保证全应用只有一份
2. 保证核心类的安全性(沙箱安全)
- 防止核心 API 被随意篡改
- 例如用户不能通过自定义
java.lang.String来替换系统的 String 类 - 因为加载请求最终会委派给启动类加载器,它加载的是 JDK 中的 String,而不是用户定义的
这就是 Java 安全模型中"沙箱"的基础保障之一。
破坏双亲委派模型
双亲委派模型并不是一个具有强制性约束力的模型,而是 Java 设计者推荐给开发者的一种类加载器实现方式。历史上出现过几次"破坏":
第一次破坏:JDK 1.2 之前
- JDK 1.2 之前还没有双亲委派模型
- 用户可以重写
loadClass()方法来实现自己的加载逻辑 - JDK 1.2 之后,不提倡用户重写
loadClass(),而是把自定义加载逻辑写在findClass()中
第二次破坏:SPI(Service Provider Interface)机制
典型例子:JDBC
java.sql.Driver接口定义在 rt.jar 中,由启动类加载器加载- 但 Driver 的实现(如 MySQL 的
com.mysql.jdbc.Driver)在第三方 jar 包中 - 启动类加载器不可能认识这些第三方 jar 包中的类
- 怎么办?—— 线程上下文类加载器(Thread Context ClassLoader)
启动类加载器加载 DriverManager
│
▼
DriverManager 要加载 Driver 实现类
│
▼
使用线程上下文类加载器(通常是 Application ClassLoader)
│
▼
去 classpath 中加载 MySQL/PostgreSQL 等驱动- 线程上下文类加载器可以通过
Thread.setContextClassLoader()设置 - 默认就是应用程序类加载器
- 这是一种"父加载器请求子加载器去完成类加载"的行为,打破了双亲委派的向上委派方向
其他使用 SPI 的场景:JNDI、JCE、JAXB、JBI 等。
第三次破坏:容器类 / 热部署
典型代表:Tomcat、OSGi、热部署
Tomcat 的类加载器结构:
- 每个 Web 应用有自己的类加载器(WebAppClassLoader)
- Web 应用之间相互隔离(每个应用的类互不影响)
- 同一个 Tomcat 下可以部署多个不同版本的同一类库
Tomcat 的类加载顺序(与双亲委派相反):
- 先自己加载(在 WEB-INF/classes 和 WEB-INF/lib 中找)
- 自己加载不到,再委派给父加载器
当然,Tomcat 也不是完全打破,对于
java.*等核心类还是要委派给启动类加载器的,保证安全。
OSGi:
- 更复杂的类加载器网络,每个模块(Bundle)有自己的类加载器
- 可以实现热插拔、模块化
- 类加载器之间是网状结构,不是树状结构
自定义类加载器
自定义类加载器的方式:
- 继承
java.lang.ClassLoader - 重写
findClass()方法(推荐,不破坏双亲委派) - 或者重写
loadClass()方法(不推荐,会破坏双亲委派)
java
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 从自定义路径读取字节码
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
return defineClass(name, classData, 0, classData.length);
}
private byte[] loadClass(String name) {
// 自定义加载逻辑,比如从加密文件、网络、数据库读取
}
}追问延伸:
- 什么是双亲委派模型?工作流程是怎样的?
- 双亲委派模型有什么好处? (避免重复加载、保证核心类安全、沙箱机制)
- 双亲委派模型可以被破坏吗?有哪些破坏场景?
- 为什么 JDBC 要破坏双亲委派?怎么破坏的? (Driver 接口在 rt.jar,实现在第三方 jar,用线程上下文类加载器)
- Tomcat 的类加载器是怎样的?为什么要这么设计? (每个 Web 应用独立的类加载器,实现应用隔离、支持不同版本)
- 自定义类加载器怎么实现?需要重写哪个方法?
- 怎么判断两个类是否相等? (类的全限定名相同 + 加载它的类加载器相同,两个条件都满足才相等)
- 线程上下文类加载器是什么?有什么用?
- JDK 9 的模块化对类加载器有什么影响?
Q13: 对象的创建过程是怎样的? 「🟡 中级」
考察点:考察对 Java 对象从创建到内存布局的全过程理解。这道题涵盖了类加载检查、内存分配、对象初始化、对象头结构等多个知识点,能比较全面地考察候选人对 JVM 对象模型的掌握程度。
参考答案:
对象创建的 5 个步骤
当虚拟机遇到一条 new 指令时,对象创建的过程如下:
new 指令
│
▼
┌─────────────┐
│ 1. 类加载检查 │ → 检查类是否已加载、解析、初始化
│ │ 未加载则先执行类加载过程
└──────┬──────┘
│
▼
┌─────────────┐
│ 2. 分配内存 │ → 为新生对象在堆中分配内存
│ │ 方式:指针碰撞 / 空闲列表
└──────┬──────┘
│
▼
┌─────────────┐
│ 3. 初始化零值│ → 将分配到的内存空间都初始化为零值
│ │ 保证对象字段不赋值也能使用
└──────┬──────┘
│
▼
┌─────────────┐
│ 4. 设置对象头│ → 设置对象的 Mark Word、类型指针等
│ │
└──────┬──────┘
│
▼
┌─────────────┐
│ 5. 执行<init>│ → 执行构造函数,初始化对象
│ │ 这一步才是 Java 代码层面的初始化
└──────┬──────┘
│
▼
对象创建完成1. 类加载检查
- 虚拟机遇到
new指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用 - 检查这个符号引用代表的类是否已被加载、解析和初始化过
- 如果没有,那必须先执行相应的类加载过程
2. 分配内存
类加载检查通过后,虚拟机为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。
分配方式有两种,取决于堆内存是否规整(而堆是否规整又由 GC 算法决定):
| 分配方式 | 适用场景 | 原理 | 对应 GC 算法 |
|---|---|---|---|
| 指针碰撞(Bump the Pointer) | 堆内存规整 | 有一个指针作为分界点,分配时指针向空闲方向移动一段等于对象大小的距离 | 标记-整理、复制算法 |
| 空闲列表(Free List) | 堆内存不规整 | 维护一个列表,记录哪些内存块是可用的,分配时找一块足够大的空间划分给对象 | 标记-清除 |
并发安全问题:
对象创建在虚拟机中非常频繁,需要保证线程安全。有两种解决方案:
- CAS + 失败重试:对分配内存空间的动作进行同步处理(虚拟机采用的方式)
- 本地线程分配缓冲(TLAB, Thread Local Allocation Buffer):每个线程在 Eden 区预先分配一小块内存,哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定
- 参数:
-XX:+UseTLAB(默认开启)
- 参数:
3. 初始化零值
- 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为 零值(不包括对象头)
- 这一步保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用
- 程序能访问到这些字段的数据类型对应的零值(和类加载准备阶段的零值表一样)
4. 设置对象头
初始化零值后,虚拟机要对对象进行必要的设置,这些信息存放在 对象头(Object Header) 中。对象头包含:
- Mark Word:对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等)
- 类型指针(Klass Pointer):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- 数组长度:如果对象是数组,对象头中还会有一块用于记录数组长度的数据
5. 执行 <init> 方法
- 上面的步骤完成后,从虚拟机的视角来看,一个新的对象已经产生了
- 但从 Java 程序的视角来看,对象创建才刚开始——
<init>方法还没执行 - 执行
new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化 - 这样一个真正可用的对象才算完全产生出来
对象的内存布局
在 HotSpot 虚拟机中,对象在内存中的布局可以分为 3 块区域:
┌──────────────────────────────────────────┐
│ 对象 (Object) │
├──────────────────────────────────────────┤
│ 对象头 (Header) │
│ ┌────────────────────────────────────┐ │
│ │ Mark Word(运行时数据) │ │
│ │ 哈希码、GC年龄、锁状态、偏向线程ID等 │ │
│ ├────────────────────────────────────┤ │
│ │ 类型指针(Klass Pointer) │ │
│ │ 指向方法区中的类元数据 │ │
│ ├────────────────────────────────────┤ │
│ │ 数组长度(仅数组对象有) │ │
│ └────────────────────────────────────┘ │
├──────────────────────────────────────────┤
│ 实例数据 (Instance Data) │
│ 对象真正存储的有效信息 │
│ 包括从父类继承下来的和子类中定义的字段 │
├──────────────────────────────────────────┤
│ 对齐填充 (Padding) │
│ 占位符,不一定存在,起对齐作用 │
│ HotSpot 要求对象起始地址必须是 8 字节的整数倍│
└──────────────────────────────────────────┘1. 对象头(Header)
Mark Word:用于存储对象自身的运行时数据,是一个 32 位或 64 位的结构(根据 JVM 位数)。
Mark Word 在不同状态下存储的内容不同:
| 锁状态 | 存储内容 |
|---|---|
| 无锁状态 | 对象哈希码、对象分代年龄 |
| 轻量级锁 | 指向栈中锁记录的指针 |
| 重量级锁 | 指向重量级锁的指针 |
| GC 标记 | 空(不需要记录信息) |
| 偏向锁 | 偏向线程 ID、偏向时间戳、对象分代年龄 |
类型指针:
- 对象指向它的类元数据的指针
- 虚拟机通过这个指针来确定这个对象是哪个类的实例
- 并不是所有虚拟机实现都必须在对象数据上保留类型指针(如果使用句柄访问方式就不需要)
数组长度:
- 只有数组对象才有
- 因为虚拟机可以通过普通 Java 对象的元数据信息确定对象大小
- 但从数组的元数据中无法确定数组的大小
2. 实例数据(Instance Data)
- 对象真正存储的有效信息
- 包括从父类继承下来的,以及在子类中定义的字段
- 存储顺序受虚拟机分配策略参数和字段在 Java 源码中定义顺序的影响
- HotSpot 默认的分配策略:longs/doubles → ints → shorts/chars → bytes/booleans → oops(Ordinary Object Pointers)
- 相同宽度的字段总是被分配到一起
3. 对齐填充(Padding)
- 不是必然存在的,仅仅起着占位符的作用
- HotSpot VM 要求对象起始地址必须是 8 字节的整数倍
- 如果对象实例数据部分没有对齐,就需要通过对齐填充来补全
对象的访问方式
Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象访问方式由虚拟机实现而定,主流的有两种:
1. 句柄访问
栈上的 reference 堆中的句柄池 堆中的对象
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 句柄地址 │─────────▶│ 实例数据指针│─────────▶│ 实例数据 │
└────────────┘ │ 类型数据指针│────┐ └────────────┘
└────────────┘ │
│ 方法区
▼ ┌────────────┐
│ 对象类型数据 │
└────────────┘- reference 中存储的是对象的 句柄地址
- 句柄中包含了对象实例数据和类型数据各自的具体地址信息
- 优点:reference 中存储的是稳定的句柄地址,对象被移动(GC 时很常见)时只会改变句柄中的实例数据指针,reference 本身不需要修改
2. 直接指针访问(HotSpot 使用)
栈上的 reference 堆中的对象
┌────────────┐ ┌────────────┐
│ 对象地址 │───────────────────▶│ 实例数据 │
└────────────┘ │ 类型指针 │────┐
└────────────┘ │
│ 方法区
▼ ┌────────────┐
│ 对象类型数据 │
└────────────┘- reference 中直接存储的就是 对象地址
- 对象头中有类型指针,指向方法区中的类型数据
- 优点:速度快,节省了一次指针定位的时间开销
- HotSpot 使用这种方式
追问延伸:
- 对象创建的过程分几步?每步做什么?
- 对象内存分配方式有哪两种?分别适用于什么场景?
- 对象在内存中的布局是怎样的?对象头包含什么内容?
- Mark Word 里存了什么?为什么要这么设计? (复用存储空间,不同状态存储不同信息,节省空间)
- 对象访问方式有哪两种?HotSpot 用的哪种?
- 什么是 TLAB?为什么需要 TLAB? (Thread Local Allocation Buffer,线程本地分配缓冲,解决并发分配内存的安全问题)
- 指针碰撞和空闲列表分别在什么情况下使用?
- 为什么需要对齐填充?对齐规则是什么?
- 数组对象和普通对象的对象头有什么区别?
Q14: JVM 调优有哪些常用参数和工具? 「🔴 高级」
考察点:考察候选人的实战经验和问题排查能力。理论知识谁都能背,但有没有真正调过优、用过工具,从这道题能看出来。能说出具体参数的使用场景、工具的使用方法和分析思路,说明有实战经验。
参考答案:
常用 JVM 参数
1. 堆内存相关
| 参数 | 含义 | 示例 | 说明 |
|---|---|---|---|
-Xms | 初始堆大小 | -Xms2g | 等价于 -XX:InitialHeapSize |
-Xmx | 最大堆大小 | -Xmx4g | 等价于 -XX:MaxHeapSize |
-Xmn | 新生代大小 | -Xmn1g | 等价于 -XX:NewSize + -XX:MaxNewSize |
-XX:SurvivorRatio | Eden 与 Survivor 比例 | -XX:SurvivorRatio=8 | 默认 8,即 Eden:S0:S1 = 8:1:1 |
-XX:NewRatio | 老年代与新生代比例 | -XX:NewRatio=2 | 默认 2,即老年代:新生代 = 2:1 |
-XX:MaxTenuringThreshold | 晋升老年代年龄阈值 | -XX:MaxTenuringThreshold=15 | 默认 15(CMS 默认 6) |
-XX:PretenureSizeThreshold | 大对象直接进老年代阈值 | -XX:PretenureSizeThreshold=1m | 仅对 Serial 和 ParNew 有效 |
生产环境建议
-Xms和-Xmx设置为相同值,避免堆自动扩展带来的性能抖动。
2. 元空间相关
| 参数 | 含义 | 说明 |
|---|---|---|
-XX:MetaspaceSize | 元空间初始大小 | 达到该值触发 Full GC,默认约 21MB |
-XX:MaxMetaspaceSize | 元空间最大大小 | 默认无限制(受系统内存限制) |
-XX:MinMetaspaceFreeRatio | GC 后最小空闲比例 | 默认 40% |
-XX:MaxMetaspaceFreeRatio | GC 后最大空闲比例 | 默认 70% |
3. GC 收集器相关
| 参数 | 含义 | 适用版本 |
|---|---|---|
-XX:+UseSerialGC | 使用 Serial + Serial Old | 客户端模式 |
-XX:+UseParallelGC | 使用 Parallel Scavenge + Serial Old | JDK 7 之前默认 |
-XX:+UseParallelOldGC | 使用 Parallel Scavenge + Parallel Old | - |
-XX:+UseConcMarkSweepGC | 使用 ParNew + CMS | JDK 9 废弃 |
-XX:+UseG1GC | 使用 G1 收集器 | JDK 9+ 默认 |
-XX:+UseZGC | 使用 ZGC 收集器 | JDK 15+ 生产可用 |
4. G1 相关参数
| 参数 | 含义 | 默认值 |
|---|---|---|
-XX:MaxGCPauseMillis | 最大目标停顿时间 | 200ms |
-XX:G1HeapRegionSize | Region 大小 | 1~32MB,自动计算 |
-XX:G1NewSizePercent | 新生代最小占比 | 5% |
-XX:G1MaxNewSizePercent | 新生代最大占比 | 60% |
-XX:InitiatingHeapOccupancyPercent | 触发并发标记的堆占用比例 | 45% |
5. 辅助调试参数
| 参数 | 含义 | 用途 |
|---|---|---|
-XX:+HeapDumpOnOutOfMemoryError | OOM 时自动 dump 堆快照 | 排查 OOM 必备 |
-XX:HeapDumpPath | dump 文件路径 | 配合上面参数使用 |
-XX:+PrintGCDetails | 打印 GC 详细日志 | 观察 GC 情况 |
-XX:+PrintGCDateStamps | 打印 GC 时间戳 | 配合 GC 日志使用 |
-Xloggc:<file> | GC 日志输出到文件 | 生产环境使用 |
-XX:+PrintGCApplicationStoppedTime | 打印 GC 停顿时间 | 精确测量停顿 |
JDK 9 之后 GC 日志参数统一使用
-Xlog语法,例如-Xlog:gc*=info:file=gc.log。
常用调优工具
1. 命令行工具(JDK 自带)
| 工具 | 作用 | 常用场景 |
|---|---|---|
| jps | 查看 Java 进程列表 | 快速找到进程 ID |
| jstat | 监视虚拟机运行时状态信息 | 查看 GC 统计、类加载、编译情况 |
| jmap | 生成堆转储快照(dump)、查看堆信息 | OOM 分析、查看对象分布 |
| jstack | 生成线程快照(thread dump) | 死锁排查、CPU 高占用分析 |
| jinfo | 实时查看和调整虚拟机参数 | 查看 JVM 参数、动态修改参数 |
| jhat | 分析 heap dump 文件 | 简易堆分析(基本被 MAT 替代) |
jstat 常用示例:
bash
# 查看 GC 情况,每 1 秒输出一次,共 10 次
jstat -gc <pid> 1000 10
# 输出列说明:
# S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
# Survivor0容量/使用量 Eden容量/使用量 老年代容量/使用量 元空间容量/使用量
# YGC: Young GC 次数 YGCT: Young GC 总时间
# FGC: Full GC 次数 FGCT: Full GC 总时间jmap 常用示例:
bash
# 生成堆 dump 文件
jmap -dump:format=b,file=heap.hprof <pid>
# 查看堆内存使用情况
jmap -heap <pid>
# 查看对象统计信息(按类统计实例数和大小)
jmap -histo <pid>jstack 常用示例:
bash
# 生成线程快照
jstack <pid> > thread.txt
# 常用分析:死锁、线程状态、调用栈2. 可视化工具
| 工具 | 特点 | 适用场景 |
|---|---|---|
| JConsole | JDK 自带,简单易用 | 基本监控、入门使用 |
| VisualVM | JDK 自带,功能强大,支持插件 | 性能分析、内存分析、线程分析 |
| MAT (Memory Analyzer Tool) | Eclipse 出品,专业堆内存分析 | OOM 排查、内存泄漏分析 |
| JProfiler | 商业软件,功能全面 | 深度性能分析、CPU 热点分析 |
| GCViewer / GCEasy | GC 日志分析工具 | 可视化分析 GC 日志 |
MAT 核心功能:
- 支配树(Dominator Tree):找出占用内存最大的对象
- 泄露 suspects:自动检测内存泄漏嫌疑
- 路径到 GC Roots:查看对象为什么还活着
- 直方图(Histogram):按类统计对象数量和大小
3. 在线诊断工具
| 工具 | 特点 | 适用场景 |
|---|---|---|
| Arthas | 阿里开源,命令行交互 | 线上问题诊断,不需要重启 |
| BTrace | 动态追踪工具 | 线上无侵入式调试 |
| Greys | 类似 Arthas 的诊断工具 | 线上问题排查 |
Arthas 常用命令:
bash
# 查看最忙的线程
thread -n 3
# 查看方法调用耗时
trace com.example.Service methodName
# 监视方法调用
watch com.example.Service methodName '{params,returnObj,throwExp}'
# 查看 JVM 信息
jvm
# 生成火焰图
profiler start
profiler stopJVM 调优的基本思路
调优不是盲目调参数,而是有方法论的:
┌──────────────┐
│ 1. 定位问题 │ → 先搞清楚问题是什么
│ │ 是内存溢出?GC 频繁?响应慢?
└──────┬───────┘
│
▼
┌──────────────┐
│ 2. 分析原因 │ → 用工具收集数据,分析根因
│ │ GC 日志、堆 dump、线程 dump、监控数据
└──────┬───────┘
│
▼
┌──────────────┐
│ 3. 制定方案 │ → 根据原因制定优化策略
│ │ 调参数?改代码?换收集器?加机器?
└──────┬───────┘
│
▼
┌──────────────┐
│ 4. 验证效果 │ → 上线后观察效果,对比指标
│ │ 不达标则回到第 2 步继续分析
└──────┬───────┘
│
▼
调优完成常见调优场景和思路
| 问题现象 | 可能原因 | 优化方向 |
|---|---|---|
| 频繁 Full GC | 内存不足、内存泄漏、大对象过多、元空间不足 | 增大堆内存、排查泄漏、优化代码、增大元空间 |
| GC 停顿时间过长 | 堆太大、对象存活率高、GC 算法不合适 | 换 G1/ZGC、调整 MaxGCPauseMillis、减小新生代 |
| YGC 频繁 | 新生代太小、对象创建太多 | 增大新生代、优化代码减少对象创建 |
| YGC 耗时过长 | 新生代太大、存活对象太多 | 减小新生代、调整 Survivor 比例 |
| Metaspace OOM | 动态生成类太多、元空间太小 | 增大元空间、排查类泄漏(如 CGLib 代理过多) |
| CPU 高占用 | 死循环、GC 频繁、热点方法 | jstack 找热点线程、Arthas trace、Profile 分析 |
追问延伸:
- 你们线上用的什么 JVM 参数?为什么这么设置?
- JVM 调优的一般步骤是什么?
- 怎么排查频繁 Full GC?
- OOM 了怎么排查?说一下你的排查流程。
- jmap 和 jstack 分别是干嘛的?怎么用?
- MAT 怎么分析内存泄漏?你关注哪些指标? (支配树、Histogram、Leak Suspects、到 GC Roots 的路径)
- Arthas 用过吗?常用哪些命令?
- 怎么查看 JVM 的默认参数? (
java -XX:+PrintFlagsFinal -version、jinfo) - G1 调优有哪些经验?MaxGCPauseMillis 设置多少合适? (不是越小越好,太小会导致 GC 频繁、吞吐量下降)
- 怎么判断有没有内存泄漏? (多次 Full GC 后老年代使用率持续上涨、dump 后看对象存活情况)
Q15: 什么是 OOM?常见的 OOM 类型及排查方法? 「🔴 高级」
考察点:考察候选人的线上问题排查能力。OOM 是 Java 工程师最常遇到的线上问题之一,能否清晰说出各种 OOM 类型的区别和对应的排查思路,直接反映了候选人的实战经验和问题解决能力。
参考答案:
OOM 概述
OOM(OutOfMemoryError)是 Java 虚拟机内存不足时抛出的错误。注意它是 Error 而不是 Exception,表示这是程序无法处理的严重问题。
Java 虚拟机规范中,除了程序计数器,其他内存区域都可能抛出 OOM。不同区域的 OOM 有不同的表现形式和原因。
常见的 OOM 类型
| OOM 类型 | 所属区域 | 触发原因 | 典型场景 |
|---|---|---|---|
| Java heap space | 堆内存 | 对象太多/内存泄漏,堆不够用 | 大集合、缓存未清理、内存泄漏 |
| Metaspace | 元空间 | 类的元数据太多,元空间不足 | 动态生成类过多、CGLib 代理 |
| GC overhead limit exceeded | 堆内存 | GC 时间占比过高,回收效果差 | 内存不足,GC 频繁但回收少 |
| Direct buffer memory | 直接内存 | NIO 直接内存分配过多 | Netty、使用 ByteBuffer.allocateDirect |
| unable to create new native thread | 栈内存/操作系统 | 创建线程数超过系统限制 | 线程池滥用、创建大量线程 |
| StackOverflowError | 虚拟机栈 | 栈深度超过限制(严格说不是 OOM,但常一起讨论) | 递归过深、方法调用链过长 |
各类型详解
1. Java heap space(堆溢出)
错误信息:
java.lang.OutOfMemoryError: Java heap space原因:
- 堆中对象数量太多,达到最大堆容量限制
- 可能是 内存溢出(内存真的不够),也可能是 内存泄漏(对象本该回收但没回收)
常见场景:
- 大对象(大数组、大集合)
- 缓存使用不当,无限增长(如 HashMap 做缓存没有淘汰机制)
- 内存泄漏:对象被生命周期更长的对象持有,无法回收
- 一次加载太多数据到内存(如全表查询)
排查方法:
- 配置
-XX:+HeapDumpOnOutOfMemoryError,OOM 时自动 dump 堆快照 - 用 MAT / VisualVM 分析 dump 文件
- 查看支配树,找出占用内存最大的对象
- 查看这些对象到 GC Roots 的引用链,确定为什么没有被回收
- 判断是内存溢出还是内存泄漏
2. Metaspace(元空间溢出)
错误信息:
java.lang.OutOfMemoryError: Metaspace(JDK 7 及之前是 PermGen space)
原因:
- 方法区中存储的类元数据太多
- 元空间大小不够,或者类加载器泄漏
常见场景:
- 动态生成大量类(CGLib、Javassist、动态代理)
- 应用中类的数量非常多(如微服务中大量接口)
- 类加载器泄漏(热部署时旧的类加载器没有被回收)
- JSP 应用(JSP 会被编译成类)
排查方法:
- 先调大
-XX:MaxMetaspaceSize看是否解决 - 用
jmap -permstat(JDK 7)或查看元空间使用情况 - 用 Arthas 的
classloader命令分析类加载器 - 检查是否有大量动态生成的类
- 检查热部署/热加载场景下类加载器是否泄漏
3. GC overhead limit exceeded
错误信息:
java.lang.OutOfMemoryError: GC overhead limit exceeded原因:
- GC 花费了大量时间但回收的内存很少
- JDK 虚拟机的默认策略:如果 GC 花费的时间超过 98%,且回收的内存不到 2%,就抛出这个错误
- 相当于"GC 已经在尽力了,但内存还是不够用"
特点:
- 这是一种"预告性"的 OOM,说明内存已经快满了,GC 频繁但效果差
- 程序可能还能跑,但已经接近崩溃边缘
- 抛出这个错误前,通常已经经历了多次 Full GC
排查方法:
- 和 Java heap space 类似,但更偏向于"内存即将耗尽"
- 查看 GC 日志,确认 GC 频率和耗时
- dump 堆快照分析内存占用
4. Direct buffer memory(直接内存溢出)
错误信息:
java.lang.OutOfMemoryError: Direct buffer memory原因:
- 直接内存(堆外内存)分配超过了限制
- 直接内存不受 JVM 堆大小限制,但受操作系统内存限制
- NIO 使用
ByteBuffer.allocateDirect()分配直接内存
常见场景:
- 使用 Netty、Mina 等 NIO 框架
- 大量使用 DirectByteBuffer
- 直接内存泄漏(DirectByteBuffer 回收依赖 Full GC 或 System.gc())
排查方法:
- 检查是否使用了大量 NIO 直接内存
- 查看参数
-XX:MaxDirectMemorySize(默认等于 -Xmx) - 用
jmap -histo查看 DirectByteBuffer 数量 - 用 NMT(Native Memory Tracking)追踪堆外内存
- 检查是否有 DirectByteBuffer 泄漏(比如被静态变量持有)
5. unable to create new native thread
错误信息:
java.lang.OutOfMemoryError: unable to create new native thread原因:
- 应用创建的线程数太多,超过了操作系统限制
- 每个线程栈都占用一定内存,线程太多导致内存不够
- 或者系统限制了进程的最大线程数
常见场景:
- 线程池配置不当,核心线程数/最大线程数太大
- 代码中不断创建线程没有复用
- 操作系统 ulimit 限制
排查方法:
- 用
jstack查看线程数和线程状态 - 检查线程池配置是否合理
- 检查是否有线程泄漏(线程用完没有回收)
- 查看系统 ulimit 限制:
ulimit -u - 适当调小
-Xss(每个线程栈大小)可以创建更多线程,但这不是根本解法
6. StackOverflowError(栈溢出)
错误信息:
java.lang.StackOverflowError严格来说这不是 OOM,而是 StackOverflowError,但通常和 OOM 一起讨论。
原因:
- 线程请求的栈深度大于虚拟机允许的最大深度
- 每个方法调用都会创建一个栈帧,方法调用链太深栈就会溢出
常见场景:
- 递归调用没有正确的退出条件
- 递归深度太大
- 方法调用链过长
- 栈帧太大(局部变量太多)
排查方法:
- 查看异常堆栈,找到循环调用的方法
- 检查递归逻辑,确保有正确的终止条件
- 如果递归深度确实很大,考虑改为迭代方式
- 适当调大
-Xss(但这通常治标不治本)
内存泄漏 vs 内存溢出
| 概念 | 含义 | 关系 |
|---|---|---|
| 内存泄漏(Memory Leak) | 对象已经不再使用了,但因为某些原因没有被 GC 回收 | 内存泄漏 → 内存占用持续增长 → 最终内存溢出 |
| 内存溢出(Memory Overflow) | 内存不够用了,无法分配新的对象 | 内存溢出可能是泄漏导致的,也可能是真的内存不够 |
内存泄漏的常见原因:
- 静态集合类:
static的 Map/List 无限增长 - 各种连接:数据库连接、网络连接、IO 流没有关闭
- 监听器:注册了监听器但没有取消注册
- 单例模式:单例对象持有外部对象的引用
- ThreadLocal:ThreadLocal 使用不当导致内存泄漏
- 缓存:缓存没有淘汰策略,无限增长
- 内部类:非静态内部类持有外部类引用
OOM 排查通用流程
发现 OOM
│
▼
┌───────────────┐
│ 1. 保存现场 │ → 堆 dump、GC 日志、线程 dump、错误日志
│ │ 最重要的是堆转储快照
└───────┬───────┘
│
▼
┌───────────────┐
│ 2. 分析类型 │ → 看错误信息确定是哪种 OOM
│ │ heap? metaspace? direct?
└───────┬───────┘
│
▼
┌───────────────┐
│ 3. 定位根因 │ → 根据类型用不同工具分析
│ │ 堆溢出:MAT 分析 dump
│ │ 元空间:分析类加载器
│ │ 直接内存:NMT / 检查 DirectByteBuffer
└───────┬───────┘
│
▼
┌───────────────┐
│ 4. 解决问题 │ → 调参数 / 改代码 / 加机器
│ │
└───────┬───────┘
│
▼
┌───────────────┐
│ 5. 验证 + 预防 │ → 上线观察、加监控告警
│ │ HeapDumpOnOutOfMemoryError 常开
└───────────────┘常用排查工具清单
| 工具 | 用途 | 适用 OOM 类型 |
|---|---|---|
| jmap -dump | 生成堆快照 | 堆溢出、GC overhead |
| MAT | 分析堆快照 | 堆溢出、GC overhead |
| jstat -gc | 查看 GC 统计 | 所有 GC 相关 |
| jstack | 查看线程堆栈 | 线程数过多 OOM |
| Arthas | 在线诊断 | 所有类型 |
| GC 日志 | 分析 GC 行为 | 所有 GC 相关 |
| NMT | 追踪本机内存 | 直接内存 OOM |
| VisualVM | 可视化监控 | 所有类型 |
追问延伸:
- 遇到过哪些 OOM?怎么排查的?
- Java heap space OOM 怎么排查?说一下你的排查思路。
- 内存泄漏和内存溢出有什么区别和联系?
- 常见的内存泄漏场景有哪些?
- Metaspace OOM 是什么原因?怎么排查?
- 直接内存溢出怎么排查?
- ThreadLocal 为什么会有内存泄漏?怎么避免? (ThreadLocalMap 的 key 是弱引用,value 是强引用,key 被回收后 value 还在)
- 线上 OOM 了怎么办?第一时间做什么? (先保存现场:dump 堆、保存日志;再考虑重启恢复)
- 怎么预防 OOM?有哪些手段? (合理设置参数、代码 review、加监控告警、压测、HeapDumpOnOutOfMemoryError)
- GC overhead limit exceeded 是什么意思?和 Java heap space 有什么区别?
Q16: 引用类型有哪些?各有什么区别? 「🟡 中级」
考察点:引用类型的全面理解。
参考答案:
Java 有四种引用类型(从强到弱):
| 类型 | 类 | GC 行为 | 应用场景 |
|---|---|---|---|
| 强引用 | 普通赋值 Object o = new Object() | 只要强引用在就不回收 | 日常对象 |
| 软引用 | SoftReference | 内存不足时回收 | 缓存(如图片缓存) |
| 弱引用 | WeakReference | 下次 GC 就回收 | ThreadLocalMap 的 key、WeakHashMap |
| 虚引用 | PhantomReference | 随时可回收,get() 永远返回 null | 跟踪对象被回收的时机(配合 ReferenceQueue) |
java
// 强引用
Object strong = new Object();
// 软引用(内存不足时回收)
SoftReference<Object> soft = new SoftReference<>(new Object());
// 弱引用(下次 GC 回收)
WeakReference<Object> weak = new WeakReference<>(new Object());
// 虚引用(必须配合 ReferenceQueue 使用)
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), queue);实际应用:
- SoftReference:内存敏感的缓存(如 Bitmap 缓存,内存不足时自动释放)
- WeakReference:ThreadLocalMap 的 key(防止 ThreadLocal 对象无法回收)
- WeakHashMap:key 是弱引用,key 被 GC 后 Entry 自动清除
- 虚引用:NIO DirectByteBuffer 的清理(Cleaner 机制)
追问延伸:
- WeakHashMap 的 key 被 GC 后 Entry 会立刻消失吗?(不会,要等下一次 GC 时 ReferenceQueue 处理)
- 虚引用和 finalize 的区别?(虚引用更安全,不阻止 GC,Cleaner 是替代 finalize 的机制)
Q17: 内存泄漏和内存溢出(OOM)有什么区别? 「🟡 中级」
考察点:内存问题的概念辨析。
参考答案:
| 概念 | 说明 | 表现 |
|---|---|---|
| 内存泄漏 | 对象不再使用但无法被 GC 回收 | 内存逐渐增长,最终 OOM |
| 内存溢出 | 内存不够分配新对象 | OutOfMemoryError |
内存泄漏常见场景:
- 静态集合:
static List<Object>持有对象引用无法回收 - ThreadLocal 未 remove:线程池场景下 ThreadLocal 持有对象
- 监听器未注销:事件监听器持有目标对象
- 内部类持有外部类:非静态内部类隐式持有外部类引用
- 缓存未清理:Map 做缓存但从不清理过期项
- 连接未关闭:IO/数据库连接泄漏
内存溢出的常见类型:
java.lang.OutOfMemoryError: Java heap space:堆溢出(对象太多或内存泄漏)java.lang.OutOfMemoryError: GC overhead limit exceeded:GC 占用超过 98% 时间但回收不到 2% 内存java.lang.OutOfMemoryError: Metaspace:元空间溢出(加载类太多,如 CGLIB 动态代理)java.lang.OutOfMemoryError: Direct buffer memory:直接内存溢出(NIO ByteBuffer.allocateDirect)java.lang.StackOverflowError:栈溢出(递归太深)
排查流程:
-XX:+HeapDumpOnOutOfMemoryError:OOM 时自动 dumpjmap -dump:format=b,file=heap.hprof <pid>:手动 dump- 用 MAT/JProfiler 分析 hprof 文件
- 找到占用最大的对象 → 查看引用链 → 定位泄漏源
追问延伸:
- 一次 OOM 后 JVM 还能继续运行吗?(通常不能保证稳定,建议重启)
- Metaspace OOM 一般是什么原因?(动态代理类生成太多、CGLIB/反射过度使用)
Q18: 类加载器有哪些?双亲委派模型可以打破吗? 「🟡 中级」
考察点:类加载机制。
参考答案:
JVM 三层类加载器:
| 加载器 | 负责加载 | 实现 |
|---|---|---|
| Bootstrap ClassLoader | rt.jar/java.*(核心类库) | C++ 实现,JVM 内部 |
| Extension ClassLoader | ext/*.jar(扩展类库) | Java 实现 |
| Application ClassLoader | classpath(应用类) | Java 实现 |
| Custom ClassLoader | 自定义加载路径 | 继承 ClassLoader |
双亲委派模型:
- 收到类加载请求 → 先委托父加载器加载
- 父加载器加载不了 → 才自己加载
- 保证核心类不被篡改(如自定义
java.lang.String会被 Bootstrap 加载真正的 String)
打破双亲委派的场景:
- SPI(如 JDBC):Bootstrap 加载接口,但实现在 classpath → 用
Thread Context ClassLoader - Tomcat:每个 Web 应用一个 ClassLoader → 应用间类隔离
- 热部署:自定义 ClassLoader 重新加载类
- OSGi:模块化,每个 bundle 独立 ClassLoader
Tomcat 的类加载模型:
Bootstrap → Extension → Application
↓
CatalinaClassLoader(Tomcat 自身类)
↓
WebAppClassLoader(每个应用一个)
↓
JasperLoader(JSP 热加载)追问延伸:
- 怎么自定义 ClassLoader?(继承 ClassLoader,重写 findClass 方法,defineClass 定义类)
- 为什么 Tomcat 要打破双亲委派?(Web 应用间类隔离、JSP 热部署需要重新加载)
Q19: 垃圾回收的 STW 是什么?哪些阶段会 STW? 「🔴 高级」
考察点:GC 停顿的理解。
参考答案:
STW(Stop-The-World):GC 时暂停所有应用线程,保证对象引用关系不变。
为什么要 STW:
- GC 过程中如果应用线程还在修改对象引用 → 标记/转移不准确
- 保证 GC Root 一致性
各阶段 STW 情况(以 G1 为例):
| 阶段 | 是否 STW | 说明 |
|---|---|---|
| 初始标记 | 是(很短) | 标记 GC Root 直接引用,借用了 Minor GC 的 STW |
| 并发标记 | 否 | 从 GC Root 遍历对象图 |
| 最终标记 | 是(很短) | 处理并发标记期间的引用变化 |
| 筛选回收 | 是 | 统计回收集合,选择回收效率最高的 Region |
| 复制/转移 | 是(主要停顿) | 存活对象复制到空 Region |
减少 STW 的技术:
- 并发标记:标记阶段和应用线程并发执行
- 分区回收:只回收部分 Region,不用全堆扫描
- 读屏障/写屏障:记录引用变化,减少最终标记时间
- ZGC/Shenandoah:几乎全并发,STW < 1ms
各收集器 STW 时长对比:
| 收集器 | STW 时长 | 适用场景 |
|---|---|---|
| Serial | 最长 | 单核、小堆 |
| Parallel | 长 | 吞吐量优先 |
| CMS | 中(4 阶段 2 次 STW) | 低延迟 |
| G1 | 较短(可预测停顿) | 大堆、低延迟 |
| ZGC | 极短(< 1ms) | 超大堆、极低延迟 |
追问延伸:
- CMS 的 Concurrent Mode Failure 是什么?(并发标记时老年代满了 → 退化为 Serial Old 全堆 STW → 大停顿)
- ZGC 怎么做到 < 1ms STW?(读屏障 + 染色指针 + 并发转移)
Q20: String s = new String("abc") 创建了几个对象? 「🟢 校招/初级」
考察点:String 的内存理解。
参考答案:
java
String s = new String("abc");创建了 1 个或 2 个对象:
- 如果字符串常量池中没有 "abc":
- 在堆中创建
new String("abc")对象 - 在字符串常量池中创建 "abc"(JDK 1.7+ 常量池在堆中)
- 在堆中创建
- 如果字符串常量池中已有 "abc":
- 只创建 1 个
new String("abc")对象
- 只创建 1 个
但变量 s 本身不是对象,它是栈上的引用。
String 的存储位置:
- JDK 1.6 及之前:字符串常量池在方法区(永久代)
- JDK 1.7+:字符串常量池在堆中
new String()创建的对象在堆中- 字面量
"abc"在字符串常量池中
intern() 方法:
java
String s1 = new String("abc");
String s2 = s1.intern(); // 如果常量池有则返回引用,没有则放入并返回
String s3 = "abc";
System.out.println(s2 == s3); // true(都是常量池中的引用)
System.out.println(s1 == s3); // false(s1 是堆中的 new 对象)追问延伸:
String s1 = "a" + "b"和String s2 = "ab"是同一个对象吗?(是,编译期常量折叠)String s3 = s1 + "b"呢?(不是,变量拼接在运行时用 StringBuilder)
Q21: 程序计数器为什么是私有的?方法区存什么? 「🟢 校招/初级」
考察点:JVM 运行时数据区的基础。
参考答案:
JVM 运行时数据区(5 个区域):
| 区域 | 私有/共享 | 存储内容 | OOM |
|---|---|---|---|
| 程序计数器 | 私有 | 当前线程执行的字节码行号 | 不会 |
| 虚拟机栈 | 私有 | 栈帧(局部变量、操作数栈、返回地址) | StackOverflowError |
| 本地方法栈 | 私有 | native 方法的栈帧 | StackOverflowError |
| 堆 | 共享 | 对象实例、数组 | OutOfMemoryError |
| 方法区 | 共享 | 类元数据、常量池、静态变量 | OutOfMemoryError: Metaspace |
程序计数器为什么私有:
- 多线程通过时间片轮转切换执行
- 每个线程需要记录自己执行到了哪一行字节码
- 切换回来后能从正确位置继续
- 如果共享 → 线程间会互相覆盖行号
程序计数器的特点:
- 唯一不会 OOM 的区域
- 执行 native 方法时 PC 值为 undefined
- 随线程创建而创建,随线程销毁而销毁
方法区(元空间)存储的内容:
- 类元数据:类的结构信息(字段、方法、构造器等)
- 运行时常量池:编译期常量 + 运行时
String.intern()的字符串 - 静态变量:
static修饰的变量(JDK 1.7 后移到堆中) - 方法字节码:方法的编译后指令
JDK 版本变化:
| 版本 | 方法区实现 | 存储位置 |
|---|---|---|
| JDK 1.6 | 永久代 | 堆外内存 |
| JDK 1.7 | 永久代(部分移出) | 堆外内存 |
| JDK 1.8+ | 元空间 | 本地内存 |
追问延伸:
- 为什么 JDK 1.8 把永久代改成元空间?(永久代大小固定容易 OOM,元空间用本地内存不受 JVM 堆限制)
- 静态变量存在哪里?(JDK 1.7 前在方法区,JDK 1.7 后在堆)
Q22: 对象的内存布局是怎样的?怎么计算对象大小? 「🟡 中级」
考察点:对象在内存中的表示。
参考答案:
对象在内存中的三部分:
+------------------+
| 对象头 | 12-16 字节(64位 JVM)
| + Mark Word | 8 字节(锁信息、GC 年龄、hashCode)
| + 类型指针 | 4 字节(压缩指针)/ 8 字节
| + 数组长度 | 4 字节(仅数组对象有)
+------------------+
| 实例数据 | 实际字段值(对齐填充)
+------------------+
| 对齐填充 | 补齐到 8 字节的倍数
+------------------+对象头(Object Header):
- Mark Word(8字节):存储 hashCode、GC 分代年龄、锁状态(无锁/偏向锁/轻量级锁/重量级锁)
- 类型指针(4/8字节):指向类元数据(Klass Pointer)
- 数组长度(4字节):只有数组对象有
实例数据:
- 按类型大小排列:longs/doubles → ints/floats → shorts/chars → bytes/booleans → references
- 父类字段在前,子类字段在后
对齐填充:
- 对象大小必须是 8 字节的整数倍
- 不够则填充空白
计算对象大小:
java
// 使用 JOL(Java Object Layout)查看
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
</dependency>
// 打印对象布局
System.out.println(ClassLayout.parseInstance(obj).toPrintable());示例:
java
// 一个空对象
class Empty {}
// Mark Word: 8B + Klass Pointer: 4B = 12B → 对齐到 16B
// 一个含 int 的对象
class MyObj { int value; }
// Mark Word: 8B + Klass: 4B + int: 4B = 16B(刚好对齐)压缩指针:
-XX:+UseCompressedOops(默认开启,堆 < 32GB)- 对象引用从 8 字节压缩为 4 字节
- 节省大量内存(引用很多时效果显著)
追问延伸:
- 为什么对象要 8 字节对齐?(CPU 读取对齐数据更快,内存管理以 8 字节为单位)
- 怎么用 Unsafe 修改对象的值?(直接操作内存偏移量,绕过访问控制)
Q23: GC 调优的目标是什么?有哪些常用参数? 「🔴 高级」
考察点:GC 调优实践。
参考答案:
GC 调优目标:
- 降低停顿时间:减少 STW 时长(低延迟,如 Web 服务)
- 提高吞吐量:减少 GC 总时间占比(CPU 密集型)
- 避免 Full GC:Full GC 停顿长,应该避免
常用 JVM 参数:
| 参数 | 说明 |
|---|---|
-Xms2g | 初始堆大小 |
-Xmx4g | 最大堆大小 |
-Xmn1g | 新生代大小 |
-XX:MetaspaceSize=256m | 元空间初始大小 |
-XX:MaxMetaspaceSize=512m | 元空间最大大小 |
-XX:SurvivorRatio=8 | Eden:Survivor = 8:1:1 |
-XX:NewRatio=2 | 新生代:老年代 = 1:2 |
-XX:+UseG1GC | 使用 G1 收集器 |
-XX:MaxGCPauseMillis=200 | G1 目标停顿时间 |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时自动 dump |
-XX:+PrintGCDetails | 打印 GC 详情 |
调优经验:
- Xms 和 Xmx 设成一样:避免堆动态伸缩导致性能抖动
- 新生代不宜太大:太大导致 Minor GC 频率降低但停顿长
- G1 适合大堆:4GB 以上堆建议用 G1
- SurvivorRatio:8 表示 Eden:S0:S1 = 8:1:1
- ParallelGCThreads:GC 线程数,通常等于 CPU 核数
GC 日志分析工具:
- GCViewer(开源)
- GCEasy(在线)
- JFR(JDK Flight Recorder)
常见问题排查:
- Minor GC 频繁 → 增大新生代或 Survivor
- Full GC 频繁 → 检查老年代大小、内存泄漏
- STW 太长 → 换 G1/ZGC、减小停顿目标
- Metaspace OOM → 增大 MaxMetaspaceSize
追问延伸:
- 为什么 Xms 和 Xmx 建议设成一样?(避免 JVM 动态调整堆大小时的性能开销)
- G1 的 MaxGCPauseMillis 设得太小会怎样?(GC 会频繁触发,反而降低吞吐量)
Q24: CMS 和 G1 的回收过程对比? 「🔴 高级」
考察点:垃圾收集器的深度对比。
参考答案:
CMS(Concurrent Mark Sweep,JDK 9 后废弃):
初始标记(STW) → 并发标记 → 预清理 → 重新标记(STW) → 并发清除 → 并发重置| 阶段 | STW | 说明 |
|---|---|---|
| 初始标记 | 是(短) | 标记 GC Root 直接引用 |
| 并发标记 | 否 | 从 Root 遍历对象图 |
| 预清理 | 否 | 减少重新标记的停顿 |
| 重新标记 | 是(短) | 修正并发标记期间的引用变化 |
| 并发清除 | 否 | 清除未标记的对象 |
| 并发重置 | 否 | 重置数据结构 |
G1(Garbage First,JDK 9 后默认):
初始标记(STW) → 并发标记 → 最终标记(STW) → 筛选回收(STW)| 阶段 | STW | 说明 |
|---|---|---|
| 初始标记 | 是(短,借 Minor GC) | 标记 GC Root 直接引用 |
| 并发标记 | 否 | 遍历对象图,记录存活对象 |
| 最终标记 | 是(短) | 处理 STAB(Snapshot-At-The-Beginning)引用变化 |
| 筛选回收 | 是(可控) | 选择回收价值最高的 Region,复制存活对象 |
CMS vs G1 对比:
| 维度 | CMS | G1 |
|---|---|---|
| 内存布局 | 分代(固定) | Region(逻辑分代) |
| 回收算法 | 标记-清除 | 标记-复制(Region 间) |
| 碎片 | 有(清除算法) | 无(复制算法) |
| 停顿可控 | 否 | 是(MaxGCPauseMillis) |
| 大堆支持 | 一般(> 8GB 效果差) | 好(4-64GB) |
| Mixed GC | 不支持 | 支持(新老混合回收) |
| Floating GC | 可能 | 不可能 |
CMS 的缺点:
- 内存碎片(标记-清除算法,不压缩)
- Concurrent Mode Failure(并发标记时老年代满了 → 退化为 Serial Old)
- Floating Garbage(并发标记期间的垃圾本次不回收)
G1 的改进:
- Region 化内存布局(每个 Region 1-32MB)
- 停顿时间可控(选回收价值最高的 Region)
- 无碎片(复制算法)
- Mixed GC(老年代和新生代混合回收)
追问延伸:
- CMS 为什么用标记-清除而不是标记-复制?(老年代存活对象多,复制成本高)
- G1 怎么选择回收哪些 Region?(根据回收价值和预期停顿时间排序)
Q25: JVM 怎么判断对象可以被回收?可达性分析的过程? 「🟡 中级」
考察点:GC 判活算法。
参考答案:
两种判断方法:
引用计数法(JVM 不用):
- 对象被引用 +1,取消引用 -1
- 为 0 时可回收
- 缺点:无法处理循环引用(A→B, B→A)
- Python 用引用计数 + 分代回收补充
可达性分析法(JVM 使用):
- 从 GC Roots 出发,遍历引用链
- 不可达的对象可回收
GC Roots(哪些对象可以作为起点):
- 虚拟机栈中的引用(方法中的局部变量、参数)
- 方法区中的静态变量(static 字段引用的对象)
- 方法区中的常量(final 引用的对象)
- 本地方法栈中的引用(JNI 中的 Java 对象引用)
- Java 虚拟机内部的引用(系统类加载器、异常对象等)
- 同步锁持有的对象(synchronized 持有的对象)
- JMXBean、JVMTI 中的引用
可达性分析过程:
1. 从所有 GC Roots 开始
2. 遍历每个 Root 的引用链
3. 标记所有可达对象
4. 未被标记的对象 = 不可达 = 可回收对象的三种命运:
- 可达:不需要回收
- 不可达但未调用 finalize():放入 F-Queue,执行 finalize(只执行一次)
- 不可达且 finalize 已执行:回收
finalize 的影响:
- 对象可以在 finalize 中"自救"(重新建立引用)
- 但 finalize 只执行一次,下次 GC 直接回收
- finalize 已废弃(Java 9+ 用 Cleaner)
实际 GC 中的标记过程:
- 标记存活对象(白色:未标记 → 灰色:已标记但子节点未处理 → 黑色:已标记且子节点已处理)
- 并发标记用三色标记法 + 写屏障(SATB/CMS)或 STAB(G1)
追问延伸:
- 为什么 Java 不用引用计数法?(循环引用问题,内存泄漏)
- 三色标记法中的"漏标"问题怎么解决?(写屏障记录并发标记期间的引用变化)
Q26: 栈中存的是指针还是对象?大对象分配在哪个区域? 「🟢 校招/初级」
考察点:栈与堆的区别、大对象分配策略。
参考答案:
栈中存的是引用(指针),不是对象本身:
栈(Stack) 堆(Heap)
┌──────────────┐ ┌──────────────────┐
│ obj(引用) │──────────────>│ MyObject 实例 │
│ 8字节地址 │ │ - 字段1 │
├──────────────┤ │ - 字段2 │
│ int x = 10 │ │ - 方法... │
│ 基本类型直接 │ └──────────────────┘
└──────────────┘- 基本类型(int、double 等):值直接存在栈中
- 引用类型:栈中只存引用(固定大小,64 位系统上 8 字节),对象实例存在堆中
java
MyObject obj = new MyObject();
// obj 在栈中,是一个引用(指针)
// new MyObject() 创建的对象实例在堆中
// obj 指向堆中的对象地址为什么这样设计:
| 原因 | 说明 |
|---|---|
| 栈大小有限 | 栈帧空间小,不适合存大对象 |
| 栈随方法出栈销毁 | 对象生命周期可能超出方法范围 |
| 堆方便 GC 管理 | 统一管理对象生命周期 |
| 引用大小固定 | 栈中操作效率高,不依赖对象大小 |
大对象的分配区域:
大对象(如大数组、长字符串)通常直接分配到老年代:
对象分配流程:
小对象 → Eden 区 → Survivor 区 → 晋升老年代
大对象 → 直接进老年代(跳过新生代)为什么大对象直接进老年代:
- 避免频繁 Minor GC:新生代空间小,大对象很快填满 Eden,频繁触发 GC
- 避免内存碎片:新生代用复制算法,大对象在 Survivor 间复制开销大
- 减少复制开销:Minor GC 时存活对象要复制,大对象复制成本高
-XX:PretenureSizeThreshold=4M
→ 大于 4MB 的对象直接分配到老年代
→ 只对 Serial 和 ParNew 收集器有效G1 的大对象处理:
G1 引入了 Humongous 区域:
- 大于 Region 一半的对象(默认 > 1.5MB)→ Humongous Object
- 存在独立的 Humongous Region 中
- 不进入老年代,独立管理追问延伸:
- 逃逸分析了解吗?逃逸分析后的对象会分配在哪里?(栈上分配)
- G1 的 Humongous Region 和老年代 Region 的区别?
Q27: 对象的生命周期是怎样的? 「🟡 中级」
考察点:对象从创建到销毁的完整过程。
参考答案:
对象的生命周期包括 创建、使用、销毁 三个阶段,但每个阶段都有深入细节:
1. 创建阶段:
类加载检查 → 分配内存 → 初始化零值 → 设置对象头 → 执行<init>| 步骤 | 说明 |
|---|---|
| 类加载检查 | 检查类是否已加载,没有则先加载、验证、准备、解析、初始化 |
| 分配内存 | 在堆中划分对象所需内存(指针碰撞 / 空闲列表) |
| 内存零值初始化 | 将分配的内存空间初始化为零值(int→0, boolean→false, 引用→null) |
| 设置对象头 | 设置 Mark Word(哈希码、GC 分代年龄、锁状态)和类型指针 |
执行 <init> | 执行构造函数,赋值实例字段,完成对象构造 |
java
// 对象创建过程对应内存变化
Person p = new Person("Alice", 20);
// 1. 检查 Person.class 是否已加载
// 2. 在堆中分配内存
// 3. name=null, age=0(零值初始化)
// 4. 设置对象头(hash、gc 年龄=0)
// 5. 执行构造函数:name="Alice", age=202. 使用阶段:
对象创建 → 被引用 → 执行业务操作 → 引用变化- 对象被变量引用(栈帧局部变量、类的静态字段、集合中的元素等)
- 通过引用访问对象的属性和方法
- 对象可能在多个线程间共享(需考虑线程安全)
- 对象的 GC 年龄随 Minor GC 递增
3. 销毁阶段:
对象不可达 → GC 标记 → finalize()(如果重写了)→ 回收内存| 销毁步骤 | 说明 |
|---|---|
| 不可达 | 从 GC Roots 出发无法到达该对象 |
| 标记 | 可达性分析中被标记为不可达 |
| finalize() | 如果重写了 finalize(),放入 F-Queue 执行(只执行一次) |
| 回收 | 释放对象占用的堆内存 |
对象在不同内存区域的流转:
Eden 区出生
→ Minor GC 存活 → Survivor 区(GC 年龄 +1)
→ 多次 Minor GC 存活 → 年龄达到阈值(默认 15)→ 晋升老年代
→ 大对象 → 直接进老年代
最终:
→ 老年代中不可达 → Major GC / Full GC → 被回收对象生命周期中的特殊情况:
| 情况 | 说明 |
|---|---|
| 栈上分配 | 逃逸分析确定对象不逃逸 → 直接在栈上分配(随栈帧销毁) |
| 自救 | finalize() 中重新建立引用 → 对象复活(只一次机会) |
| 跨代引用 | 老年代对象引用新生代对象 → 通过卡表(Card Table)记录 |
| 大对象 | 直接进老年代或 Humongous 区 |
追问延伸:
- 逃逸分析是什么?什么情况下对象会在栈上分配?
- 对象的 GC 年龄在什么情况下会重置?(不会重置,只增不减)
Q28: Minor GC、Major GC、Full GC 的区别?什么场景触发 Full GC? 「🟡 中级」
考察点:GC 分类的完整理解和触发条件。
参考答案:
| GC 类型 | 作用区域 | 触发条件 | 特点 |
|---|---|---|---|
| Minor GC(Young GC) | 新生代(Eden + Survivor) | Eden 区空间不足 | 频率高、停顿短 |
| Major GC | 老年代 | 老年代空间不足 | 频率低、耗时较长 |
| Full GC | 整个堆 + 方法区 | 多种触发条件 | 最昂贵、STW 最长 |
Minor GC 详解:
Eden 区满 → 触发 Minor GC
→ Eden + S0(或 S1)存活对象 → 复制到 S1(或 S0)
→ 年龄到达阈值 → 晋升老年代
→ S 空间不够 → 直接进老年代
→ 清空 Eden 和 S0- 只回收新生代,大部分对象朝生夕灭,回收效率高
- 停顿时间短(通常几毫秒到几十毫秒)
- 不一定 STW(G1 的 Minor GC 可以并发)
Major GC 详解:
- 主要回收老年代
- 注意:Major GC 在 JVM 规范中没有严格定义,有时和 Full GC 混用
- CMS 收集器只回收老年代时称为 Major GC
Full GC 触发场景(重点):
1. System.gc() / Runtime.getRuntime().gc()
→ 建议性调用,JVM 不保证立即执行
→ 但很多 JVM 实现会触发 Full GC
2. 老年代空间不足
→ Minor GC 后存活对象 > 老年代剩余空间
→ 大对象直接进老年代,老年代放不下
3. 元空间(Metaspace)空间不足
→ Java 8+ 加载的类信息超过元空间限制
→ 触发 Full GC + 元空间回收
4. 空间分配担保失败
→ Minor GC 前检查老年代连续空间 > 新生代总对象大小
→ 不满足且不允许担保失败 → Full GC
→ 担保失败(Minor GC 后发现老年代放不下)→ Full GC
5. CMS Concurrent Mode Failure
→ CMS 并发回收时老年代空间不够
→ 退化为 Serial Old → Full GC如何减少 Full GC:
| 策略 | 方法 |
|---|---|
| 调大新生代 | 减少 Minor GC 中对象晋升到老年代的概率 |
| 调大老年代 | 降低老年代空间不足的概率 |
| 避免大对象 | 大对象直接进老年代,减少 Full GC 触发 |
| 避免 System.gc() | JVM 参数 -XX:+DisableExplicitGC 禁用 |
| 调整晋升年龄 | -XX:MaxTenuringThreshold 控制晋升速度 |
| 监控元空间 | -XX:MaxMetaspaceSize 防止元空间溢出 |
追问延伸:
- Full GC 为什么比 Minor GC 慢这么多?(扫描整个堆,STW 时间长)
- 如何监控 GC 日志判断 Full GC 频率?(
-Xlog:gc*/ JFR / GCEasy)
Q29: 什么情况下使用 CMS,什么情况下使用 G1? 「🔴 高级」
考察点:GC 收集器选型的工程实践。
参考答案:
CMS 适用场景:
CMS(Concurrent Mark Sweep)特点:
- 老年代收集器,配合 Serial/ParNew 使用
- 标记-清除算法 → 有内存碎片
- 并发标记和清除 → 低停顿
- 有浮动垃圾 → 需要预留空间| 适用条件 | 说明 |
|---|---|
| 低延迟需求 | 对停顿时间敏感的应用 |
| 堆不太大 | 通常 4GB 以下,CMS 在大堆时表现不佳 |
| 老年代回收 | 主要关注老年代的 GC 效率 |
| 可接受碎片 | 能容忍内存碎片,或定期 Full GC 压缩 |
G1 适用场景:
G1(Garbage-First)特点:
- 整堆收集器,新生代 + 老年代统一管理
- 标记-整理算法 → 无碎片
- 可预测停顿时间 → `-XX:MaxGCPauseMillis`
- Region 化设计 → 适合大堆| 适用条件 | 说明 |
|---|---|
| 大堆内存 | 6GB 以上,G1 比 CMS 更有优势 |
| 对碎片敏感 | G1 整理算法无碎片 |
| 可预测停顿 | 需要控制 GC 停顿时间上限 |
| 平衡性能 | 低停顿 + 高吞吐的平衡 |
选型决策树:
堆大小 < 4GB?
→ 是 → 延迟敏感?→ CMS(JDK 8)/ Parallel(默认)
→ 否 → 继续判断
堆大小 4-8GB?
→ 延迟敏感 → G1
→ 吞吐优先 → Parallel
堆大小 > 8GB?
→ G1(JDK 9+ 默认)
→ 或 ZGC(JDK 15+,超低延迟)CMS vs G1 核心对比:
| 维度 | CMS | G1 |
|---|---|---|
| 算法 | 标记-清除 | 标记-整理 |
| 碎片 | 有 | 无 |
| 浮动垃圾 | 有(可能 Concurrent Mode Failure) | 有但影响小 |
| 停顿可控 | 不保证 | 可设置 MaxGCPauseMillis |
| 堆大小 | 适合中小堆(< 4GB) | 适合大堆(> 6GB) |
| 收集范围 | 只老年代 | 全堆 |
| JDK 版本 | JDK 8 可用,JDK 14 移除 | JDK 9+ 默认 |
实际生产建议:
JDK 8:
小堆 + 低延迟 → CMS(-XX:+UseConcMarkSweepGC)
大堆 + 低延迟 → G1(-XX:+UseG1GC)
吞吐优先 → Parallel(-XX:+UseParallelGC,默认)
JDK 9+:
默认就是 G1(-XX:+UseG1GC)
低延迟超大堆 → ZGC(-XX:+UseZGC,JDK 15+ 生产可用)追问延伸:
- CMS 为什么在 JDK 14 中被移除?(维护成本高、有更好的替代品 G1/ZGC)
- ZGC 和 Shenandoah 的区别是什么?(着色指针 vs 读屏障)
Q30: GC 只会对堆进行 GC 吗?方法区的回收是什么? 「🟡 中级」
考察点:GC 作用范围的全面理解。
参考答案:
GC 不仅回收堆,也回收方法区(元空间/永久代),但方法区的回收条件和堆不同。
堆的 GC(主要工作):
堆内存结构:
新生代(Eden + S0 + S1)→ Minor GC
老年代 → Major GC / Full GC
回收对象:不可达的对象实例
回收频率:高(Minor GC 频繁)
回收效率:高(大部分对象朝生夕灭)方法区的 GC(容易被忽略):
方法区(元空间)存储:
- 类信息(类的结构、方法、字段等)
- 运行时常量池
- 静态变量
- 即时编译后的代码
回收内容:
1. 废弃的常量(没有任何地方引用的常量)
2. 无用的类(不再使用的类信息)方法区回收"无用类"的条件(非常严格):
一个类被卸载需要同时满足三个条件:
1. 堆中不存在该类的任何实例
→ 该类所有实例都已被回收
2. 加载该类的 ClassLoader 已经被回收
→ 类加载器本身也是对象,被 GC 回收了
3. 该类对应的 java.lang.Class 对象没有在任何地方被引用
→ 不能通过反射访问该类示例:
自定义 ClassLoader 加载了一个类 MyClass
→ 创建了 MyClass 实例
回收条件:
1. MyClass 实例被 GC 回收(堆中无实例)
2. ClassLoader 对象被 GC 回收
3. 没有地方持有 MyClass.class 对象
三者同时满足 → MyClass 的类信息从方法区卸载为什么方法区回收条件这么严格:
| 原因 | 说明 |
|---|---|
| 安全性 | 防止正在使用的类被误卸载 |
| 反射 | 类可能在运行时通过反射被使用 |
| 类加载器 | 不同 ClassLoader 可以加载同名类 |
| 热部署 | 热部署场景需要卸载旧类,条件严格保证安全 |
方法区回收的实际效果:
方法区的 GC 效果通常不明显:
- 大部分类在应用运行期间一直被使用
- BootstrapClassLoader 永远不会被回收
- 只有自定义 ClassLoader + 动态加载的类才有可能被卸载
场景:
- JSP 热部署 → 旧 JSP 编译的类被卸载
- 动态代理 → 代理类卸载
- OSGi / 插件化框架 → 插件类卸载各区域的 GC 总结:
| 区域 | 是否 GC | 回收内容 | 触发条件 | 效果 |
|---|---|---|---|---|
| 新生代 | 是 | 不可达对象 | Eden 满 | 效率高 |
| 老年代 | 是 | 不可达对象 | 空间不足 / Full GC | 效果中等 |
| 方法区 | 是(Full GC 时) | 废弃常量 + 无用类 | Full GC 时 | 效果通常不明显 |
| 虚拟机栈 | 否 | 栈帧随方法退出自动出栈 | 方法返回 | 自动管理 |
| 程序计数器 | 否 | 线程私有,线程结束自动释放 | 线程结束 | 自动管理 |
| 本地方法栈 | 否 | 同虚拟机栈 | 方法返回 | 自动管理 |
追问延伸:
- 为什么 JDK 8 用元空间替代永久代?(永久代容易 OOM,元空间用本地内存,大小不受 JVM 限制)
- 元空间会 OOM 吗?(会,本地内存不够时
Metaspace Out of Memory)