本文系统梳理 JVM 核心知识体系,按照"基础概念 → 底层原理 → 调优实战 → 面试精讲"的路径组织,覆盖大厂面试中 90% 以上的 JVM 高频考点。
目录
- [一、JVM 概述:Java 程序运行的基础](#一、JVM 概述:Java 程序运行的基础)
- 二、类加载子系统
- [三、JVM 内存管理](#三、JVM 内存管理)
- 四、垃圾回收机制
- [五、JVM 调优实战](#五、JVM 调优实战)
- [六、面试高频 50 问精讲](#六、面试高频 50 问精讲)
一、JVM 概述:Java 程序运行的基础
1.1 JVM、JRE、JDK 的关系
| 概念 | 全称 | 包含内容 | 面向用户 |
|---|---|---|---|
| JVM | Java Virtual Machine | 类加载器、运行时数据区、执行引擎 | 核心运行环境 |
| JRE | Java Runtime Environment | JVM + 核心类库 + 资源文件 | Java 程序运行用户 |
| JDK | Java Development Kit | JRE + 开发工具(javac、javap、jdb 等) | Java 开发者 |
三者关系:JDK ⊃ JRE ⊃ JVM。JVM 是整个 Java 生态的基石,屏蔽了底层操作系统的差异,实现了"一次编写,到处运行"。
1.2 JVM 整体架构
JVM 的架构可以分为三大子系统:
┌─────────────────────────────────────────────────────────┐
│ JVM 架构总览 │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │
│ │ 类加载子系统 │ │ 运行时数据区 │ │ 执行引擎 │ │
│ │ │ │ │ │ │ │
│ │ Bootstrap │ │ 方法区/元空间 │ │ 解释器 │ │
│ │ Extension │ │ 堆 │ │ JIT 编译器 │ │
│ │ Application│ │ 虚拟机栈 │ │ GC 垃圾回收 │ │
│ │ Custom │ │ 本地方法栈 │ │ │ │
│ │ │ │ 程序计数器 │ │ │ │
│ └─────────────┘ └──────────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────┘
- 类加载子系统 :负责将
.class文件加载到 JVM 内存中 - 运行时数据区:JVM 运行过程中所使用到的内存空间
- 执行引擎:解释执行字节码、JIT 即时编译、GC 垃圾回收
1.3 Java 程序运行流程
.java 源文件 ──javac编译──▶ .class 字节码 ──类加载器──▶ 方法区(类的元数据)
│
操作系统 ◀──机器码◀── 执行引擎(解释/JIT) ◀── 运行时数据区
Java 之所以被称为"半解释半编译"语言,是因为 JVM 中的执行引擎既包含逐条翻译字节码的解释器 ,也包含将热点代码直接编译为本地机器码的 JIT(Just-In-Time)编译器。
二、类加载子系统
🔥 面试热度:★★★★★。类加载过程和双亲委派模型是面试中的必问项,通常与 SPI 机制、Tomcat 类加载打破双亲委派等场景结合考察。
2.1 类加载过程详解
类从 .class 文件到 JVM 中可用的 Class 对象,经历了三个大阶段:加载 → 链接 → 初始化,其中链接又可细分为验证、准备、解析三个小阶段。
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution) → 初始化(Initialization)
│────────── 链接(Linking) ──────────────────│
2.1.1 加载(Loading)
JVM 在加载阶段完成三件事:
- 通过全限定名获取类的二进制字节流 :来源不限于
.class文件,可以是 ZIP 包(jar/war)、网络流(Applet)、运行时计算生成(动态代理)、数据库等 - 将字节流的静态存储结构转换为方法区的运行时数据结构
- 在堆中生成
java.lang.Class对象,作为该类在方法区中数据的访问入口
💡 深入思考 :数组类和普通类的加载有何不同?
数组类本身不通过类加载器创建,而是由 JVM 在运行时直接创建。但数组的元素类型(如
String[]中的String)仍然需要类加载器加载。
2.1.2 链接(Linking)
验证(Verification)
确保 Class 文件的字节流符合 JVM 规范,不会危害 JVM 安全。包括:
- 文件格式验证 :魔数
0xCAFEBABE、版本号检查等 - 元数据验证:是否有父类、是否继承了 final 类、抽象方法实现等
- 字节码验证:类型转换是否合法、跳转指令是否正确等
- 符号引用验证:通过全限定名能否找到对应的类
⚠️ 验证阶段虽重要但耗时,
-Xverify:none可关闭(正式环境不建议)。
准备(Preparation)
为类的静态变量 分配内存并设置零值(默认初始值),注意这里分配的内存位于方法区中。
| 静态变量类型 | 准备阶段初始值 |
|---|---|
static int |
0 |
static boolean |
false |
static Object |
null |
java
public static int value = 123; // 准备阶段 value = 0,初始化阶段才赋值为 123
public static final int CONST = 123; // final 修饰的常量在编译期就生成了 ConstantValue,准备阶段直接赋值为 123
解析(Resolution)
将常量池中的符号引用 替换为直接引用。解析动作主要针对类/接口、字段、方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用。
2.1.3 初始化(Initialization)
初始化是类加载过程的最后一步,JVM 真正开始执行类中定义的 Java 程序代码 。初始化阶段执行的是类构造器 <clinit>() 方法:
<clinit>()由编译器自动收集类中所有静态变量的赋值动作 和静态代码块中的语句合并产生- 收集顺序严格按源文件中出现的顺序
<clinit>()对于类/接口来说并不是必需的,如果一个类没有静态代码块和静态变量赋值,可以不生成- JVM 保证一个类的
<clinit>()在多线程环境下被正确加锁同步
类加载的时机(主动引用 vs 被动引用):
六种主动引用会触发初始化:
new关键字创建对象实例- 读取或设置一个类的静态字段(
final修饰的常量除外) - 调用一个类的静态方法
- 使用
java.lang.reflect对类进行反射调用 - 初始化子类时,若父类未初始化,先触发父类初始化
- 包含
main()方法的主类
三种被动引用不会触发初始化(常被面试问到):
java
// 1. 通过子类引用父类静态字段 → 不会触发子类初始化
class Parent { static int value = 1; }
class Child extends Parent { static int childValue = 2; }
int v = Child.value; // 只初始化 Parent,不初始化 Child
// 2. 通过数组定义引用类 → 不会触发初始化
Parent[] arr = new Parent[10]; // 触发的是数组类 [LParent 的初始化
// 3. 引用常量 → 不会触发初始化(常量在编译期已存入常量池)
class ConstClass { static final String HELLO = "hello"; }
String s = ConstClass.HELLO; // 不触发 ConstClass 初始化
2.2 类加载器体系
JVM 中的类加载器遵循层次结构:
┌──────────────────────┐
│ Bootstrap ClassLoader │ ← 启动类加载器(C++ 实现,JVM 的一部分)
│ 加载 <JAVA_HOME>/lib │ 加载 rt.jar、tools.jar 等
└──────────┬───────────┘
│ 父加载器
┌──────────▼───────────┐
│ Extension/Platform │ ← 扩展类加载器(JDK8 及之前)
│ 加载 <JAVA_HOME>/lib/ext│ JDK9 起变为 Platform ClassLoader
└──────────┬───────────┘
│ 父加载器
┌──────────▼───────────┐
│ Application │ ← 应用类加载器(默认)
│ 加载 classpath 下的类 │ sun.misc.Launcher$AppClassLoader
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Custom ClassLoader │ ← 用户自定义类加载器
└──────────────────────┘
| 类加载器 | JDK 8 | JDK 9+ | 加载路径 |
|---|---|---|---|
| 启动类 | Bootstrap CL | Bootstrap CL | jre/lib/rt.jar |
| 扩展类 | Extension CL | Platform CL | jre/lib/ext → 模块化后改变 |
| 应用类 | Application CL | Application CL | classpath |
2.3 双亲委派模型
🔥🔥🔥 面试最高频考点之一,必须能画流程图、说出源码逻辑、并能举出打破该模型的场景。
2.3.1 工作原理
当一个类加载器收到类加载请求时:
-
它不会自己先去加载,而是委托给父类加载器去完成
-
每一层的类加载器都这样向上委托,直到顶层的 Bootstrap ClassLoader
-
只有当父加载器反馈自己无法完成加载时,子加载器才会尝试自己加载
请求加载 java.lang.String
│
▼
Application CL ──▶ Extension CL ──▶ Bootstrap CL
│
找到了!由 Bootstrap 加载
确保核心类不被篡改
2.3.2 源码分析(ClassLoader.loadClass())
java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 第1步:检查类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 第2步:如果有父加载器,委托父加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 第3步:父为null时,直接由Bootstrap ClassLoader加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器加载失败,忽略异常
}
if (c == null) {
// 第4步:父加载器无法加载时,调用自己的findClass()加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
2.3.3 为什么要使用双亲委派模型
一、避免类的重复加载:父加载器加载过的类,子加载器不会再次加载,确保 JVM 中每个类的唯一性。
二、保证核心类库的安全 :java.lang.String、java.lang.Object 等核心类只能被 Bootstrap ClassLoader 加载。即使用户自定义了一个同名的 java.lang.String 类,双亲委派机制也会让 Bootstrap ClassLoader 先加载官方的 String,防止核心 API 被篡改。
2.3.4 破坏双亲委派模型的经典案例
🎯 面试中能说出以下三种破坏场景,面试官基本认定你对类加载机制有深度理解。
场景一:SPI 机制(JDBC 驱动加载)
这是 JDK 官方"自己破坏自己规则"的典型案例。SPI(Service Provider Interface)接口定义在 rt.jar 中由 Bootstrap 加载,但具体的实现类(如 com.mysql.cj.jdbc.Driver)位于 classpath 下,Bootstrap 无法加载。
解决方式是引入了线程上下文类加载器(Thread Context ClassLoader):
java
// java.sql.DriverManager 中的核心逻辑(简化版)
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ServiceLoader.load() 内部使用线程上下文类加载器来加载实现类
// 线程上下文类加载器默认就是 Application ClassLoader
具体流程:
DriverManager (Bootstrap加载)
│ 需要加载 Driver 的实现类 (classpath 下的 MySQL Driver)
▼
使用 Thread.currentThread().getContextClassLoader()
│ (Application ClassLoader)
▼
找到并加载 com.mysql.cj.jdbc.Driver
场景二:Tomcat 的 WebApp ClassLoader
Tomcat 需要为每个 Web 应用提供独立的类加载器,实现应用隔离和热部署:
Bootstrap ClassLoader
│
┌─────────┼─────────┐
│ │ │
System CL Common CL (共享)
│
┌─────────┼─────────┐
│ │ │
WebApp1 CL WebApp2 CL (隔离)
每个 WebApp ClassLoader 优先加载自己 /WEB-INF/classes 和 /WEB-INF/lib 下的类,打破了先向上委托的规则,确保:
- 不同 Web 应用的库互不干扰
- 同一个类库的不同版本可以共存
- 卸载 Web 应用时对应的类加载器可以一起被回收
场景三:OSGi 模块化框架
OSGi 实现了网状结构的类加载器,不再是单一的父子委派链。每个模块(Bundle)都有自己的类加载器,类加载请求在 Bundle 之间按依赖关系传递,完全不遵循双亲委派模型。
2.4 符号引用 vs 直接引用
| 对比维度 | 符号引用 | 直接引用 |
|---|---|---|
| 产生时机 | 编译期 | 解析阶段 |
| 存在形式 | 字符串字面量 | 内存地址/偏移量 |
| 与内存关系 | 无关,独立于虚拟机内存布局 | 直接指向运行时内存中的具体位置 |
| 跨平台性 | 支持(编译后不变) | 不支持(不同 JVM 内存布局不同) |
使用符号引用的原因:编译器无法预知运行时类、方法、字段在内存中的真实位置,使用符号引用实现编译解耦、跨平台兼容,并支持动态链接和懒加载机制。
2.5 解释执行与编译执行
JVM 的执行引擎同时采用了解释执行 和编译执行两种方式:
解释执行:字节码 ──逐条解释──▶ 机器码 (启动快,执行慢)
编译执行:字节码 ──批量编译──▶ 机器码 (启动慢,执行快)
JIT 编译器在运行时编译热点代码
热点代码检测 :JVM 采用热点探测(Hot Spot Detection)来识别需要 JIT 编译的方法:
- 基于采样的热点探测:周期性检查各线程的栈顶,经常出现的方法即为热点方法
- 基于计数器的热点探测(HotSpot 默认):为每个方法维护两个计数器------方法调用计数器和回边计数器(循环),超过阈值触发 JIT 编译
分层编译(JDK 7+):
第0层:纯解释执行,不采集性能数据
第1层:C1 编译(Client Compiler),快速编译,轻度优化
第2层:C1 编译 + 更多 profiling 数据
第3层:C1 编译 + 完整 profiling 数据
第4层:C2 编译(Server Compiler),耗时较长,激进优化
三、JVM 内存管理
🔥 面试热度:★★★★★。内存区域划分、对象创建过程、OOM 排查是面试中最常被问到的内容之一,通常结合线上问题排查场景来考察。
3.1 运行时数据区域
根据 JVM 规范,运行时数据区分为以下五大区域:
┌─────────────────── 线程私有 ────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │程序计数器 │ │ 虚拟机栈 │ │本地方法栈 │ │
│ │ PC │ │VM Stack │ │Native │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
├─────────────────── 线程共享 ────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ 堆 Heap │ │ 方法区/元空间 │ │
│ │ (对象、数组) │ │ (类信息、常量、 │ │
│ │ │ │ 静态变量、JIT) │ │
│ └─────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
3.1.1 程序计数器(Program Counter Register)
- 线程私有,生命周期与线程相同
- 一块较小的内存区域,是当前线程所执行的字节码的行号指示器
- 唯一一个在 JVM 规范中没有规定任何
OutOfMemoryError情况的区域 - 若线程执行的是 Native 方法,计数器值则为空(Undefined)
- 分支、循环、跳转、异常处理、线程恢复等基本功能都依赖 PC 寄存器
3.1.2 Java 虚拟机栈(JVM Stack)
- 线程私有,生命周期与线程相同
- 每个方法执行时,JVM 会同步创建一个栈帧(Stack Frame)
- 异常情况:
StackOverflowError(栈深度超限)和OutOfMemoryError(栈扩展无法申请足够内存)
栈帧的内部结构:
┌─────────────────────────┐
│ 栈帧 Stack Frame │
├─────────────────────────┤
│ 局部变量表(Local Vars) │ ← 方法参数 + 方法体内局部变量
│ 操作数栈(Operand Stack) │ ← 字节码执行时操作数据的区域
│ 动态链接(Dynamic Link) │ ← 指向运行时常量池中该方法的符号引用
│ 方法返回地址(Return Addr) │ ← 恢复到调用者的位置继续执行
│ 附加信息 │ ← 调试信息等
└─────────────────────────┘
局部变量表(Local Variables Table):
- 最基本存储单元是 Slot(变量槽),当前 32 位以内的类型(byte、short、int、float、reference)占 1 个 Slot,64 位的(long、double)占 2 个 Slot
- 实例方法第 0 位默认存放
this引用 - 局部变量表在编译期已经确定大小,运行期不会改变
操作数栈(Operand Stack):
- 与局部变量表一样,编译期就确定了最大深度
- 方法刚开始时操作数栈为空,通过字节码指令进行压栈和弹栈操作
动态链接(Dynamic Linking):
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用
- 持有这个引用是为了支持方法调用过程中的动态链接
- 符号引用一部分会在类加载解析阶段转为直接引用(静态解析),另一部分在每次运行期间转为直接引用(动态链接)
💡 静态解析 vs 动态链接:
- 静态解析的对象是在编译期可知、运行期不可变的方法(如静态方法、私有方法、实例构造器、父类方法、final 方法),在类加载的解析阶段就可以把符号引用转换为直接引用
- 动态链接针对的是需要在运行期间才能确定具体调用目标的方法(如重写方法),每次运行时重新转换
3.1.3 本地方法栈(Native Method Stack)
- 与虚拟机栈功能类似,但服务于 Native 方法(用 C/C++ 编写的本地方法)
- HotSpot 将本地方法栈和虚拟机栈合二为一
- Native 方法通过 JNI(Java Native Interface) 与底层系统交互
3.1.4 Java 堆(Heap)
🔥🔥🔥 面试最核心的内存区域:几乎所有对象都在堆中分配,GC 的主要工作区域。
- 线程共享,JVM 启动时创建
- JVM 中最大的一块内存区域,主要存储对象实例和数组
- GC 垃圾回收的主战场,因此也称为 GC 堆(Garbage Collected Heap)
- 所有线程共享的 Java 对象都可能被分配在这里
堆中的特殊区域:
① 字符串常量池(String Constant Pool)
JDK 7 之前字符串常量池位于方法区中(永久代),JDK 7 开始被移到堆中,原因是永久代空间有限且 GC 回收效率低。
java
String s1 = "hello"; // 直接从常量池获取
String s2 = new String("hello"); // 在堆中新建对象
String s3 = s2.intern(); // 将对象引用放入常量池并返回
System.out.println(s1 == s2); // false
System.out.println(s1 == s3); // true
② TLAB(Thread Local Allocation Buffer)
由于堆是线程共享的,并发分配内存需要加锁,影响效率。JVM 为每个线程在 Eden 区分配一小块私有缓存区域,称为 TLAB,线程在自己的 TLAB 中分配对象无需同步。TLAB 用完后再申请新的,才需要同步锁定。
┌────── Eden 区 ──────────────────┐
│ TLAB-1 │ TLAB-2 │ TLAB-3 │ ... │
│(Thread1│(Thread2│(Thread3│ │
│ 专属) │ 专属) │ 专属) │ │
└─────────────────────────────────┘
配置参数:-XX:+UseTLAB(默认开启)、-XX:TLABSize 设置大小。
③ 逃逸分析与栈上分配
JDK 6u23 后 HotSpot 默认开启逃逸分析。当一个对象只在方法内部使用,不会被外部引用时:
- JVM 可以将其分配在栈上(随着栈帧的出栈而自动销毁,无需 GC)
- 对未逃逸对象还可以进行标量替换(将对象打散为其成员变量,直接在栈上分配)
- 对未逃逸的锁可以实施锁消除
java
// 逃逸分析示例
public void test() {
Point p = new Point(1, 2); // p 未逃逸 → 可能在栈上分配
System.out.println(p.x);
}
// 参数: -XX:+DoEscapeAnalysis(默认开启)-XX:+EliminateAllocations
3.1.5 方法区 / 元空间(Method Area / Metaspace)
方法区是 JVM 规范中的逻辑概念 ,HotSpot 在 JDK 8 之前用永久代 (PermGen)实现,JDK 8 起用元空间(Metaspace)替代。
| 对比维度 | 永久代(JDK ≤ 7) | 元空间(JDK ≥ 8) |
|---|---|---|
| 存储位置 | JVM 堆内存中 | 本地内存(Native Memory) |
| 大小限制 | 受 -XX:MaxPermSize 限制,默认 85MB |
受操作系统可用内存限制,-XX:MaxMetaspaceSize 可设上限 |
| OOM 现象 | java.lang.OutOfMemoryError: PermGen space |
java.lang.OutOfMemoryError: Metaspace |
| 回收策略 | Full GC 时回收 | GC 时回收,可设置 -XX:MaxMetaspaceFreeRatio |
元空间替代永久代的原因:
- 永久代大小难以确定:不同应用加载的类数量差异大,固定空间易 OOM
- 永久代 GC 复杂且效率低:类元数据和对象混在一起回收
- 方便与 JRockit 等 HotSpot 以外的 JVM 实现保持一致(JRockit 从未有永久代概念)
- 字符串常量池移到堆后,永久代只剩类元数据,用本地内存更灵活
运行时常量池(Runtime Constant Pool):
- 是方法区的一部分,Class 文件中常量池表在运行时的表现形式
- 存储编译期生成的各种字面量 (文本字符串、final 常量值)和符号引用(类/接口全限定名、字段名和描述符、方法名和描述符)
- 动态性:运行期间也可以将新的常量放入池中(如
String.intern())
3.1.6 直接内存(Direct Memory)
- 不属于 JVM 运行时数据区,也不是 JVM 规范定义的内存区域
- NIO 使用 Native 函数库直接分配堆外内存,通过
DirectByteBuffer对象作为引用操作 - 避免了在 Java 堆和 Native 堆之间来回复制数据,显著提升 I/O 性能
- OOM 排查时容易被忽略:受操作系统内存限制,
-XX:MaxDirectMemorySize可指定上限
3.2 HotSpot 对象探秘
3.2.1 对象创建过程(五步详解)
当 JVM 遇到 new 指令时:
new 指令
│
▼
① 类加载检查 ──▶ 检查常量池中是否有该类的符号引用
检查该类是否已完成加载、解析、初始化
(未完成则先执行类加载)
│
▼
② 分配内存 ────▶ 从堆中划分一块确定大小的内存给新对象
分配方式:指针碰撞 / 空闲列表
并发安全:CAS + 失败重试 / TLAB
│
▼
③ 初始化零值 ──▶ 将分配到的内存空间初始化为零值
保证对象的实例字段不赋初值就能直接使用
int→0, boolean→false, 引用类型→null
│
▼
④ 设置对象头 ──▶ 设置 Mark Word(哈希码、GC分代年龄、锁状态等)
设置类型指针(指向方法区中类的元数据)
数组对象额外设置数组长度
│
▼
⑤ 执行 <init> ──▶ 按"父类→子类"顺序执行构造方法
完成实例变量的显式赋值
分配内存的两种方式:
| 分配方式 | 适用场景 | 原理 |
|---|---|---|
| 指针碰撞(Bump the Pointer) | 堆内存规整(Serial、ParNew 等带整理功能的收集器) | 已用内存和空闲内存之间维护一个分界指针,分配时指针向空闲方向移动对象大小的距离 |
| 空闲列表(Free List) | 堆内存不规整(CMS 这种基于标记-清除的收集器) | JVM 维护一个空闲块列表,从中找到足够大的空间分配给对象 |
并发分配内存的线程安全问题:
- CAS + 失败重试:对分配空间动作进行同步处理
- TLAB:每个线程预先在 Eden 区分配一块私有缓存,大部���对象分配在 TLAB 中完成(线程内无竞争),TLAB 用完后才需要同步锁定分配新的 TLAB
3.2.2 对象的内存布局
HotSpot 虚拟机中,对象在堆内存中的存储布局分为三部分:
┌──────────────────────────────────────────────┐
│ 对象内存布局 │
├──────────┬──────────────┬────────────────────┤
│ 对象头 │ 实例数据 │ 对齐填充 │
│ (Header) │ (Instance │ (Padding) │
│ │ Data) │ │
├──────────┴──────────────┴────────────────────┤
│ Mark Word (32/64 bit) │
│ Klass Pointer (32/64 bit,压缩后32bit) │
│ [Array Length](仅数组对象) │
│ ...实例字段(按类型宽度排序)... │
│ 对齐填充到 8 字节的整数倍 │
└──────────────────────────────────────────────┘
对象头详解:
Mark Word(标记字段):存储对象自身的运行时数据
32 位 JVM 下 Mark Word 的结构(不同锁状态下内容不同,复用存储空间):
| 锁状态 | 23 bit | 2 bit | 4 bit | 1 bit(是否偏向) | 2 bit(锁标志) |
|---|---|---|---|---|---|
| 无锁 | 对象的 hashCode | 分代年龄 | 0 | 0 | 01 |
| 偏向锁 | ThreadID(23) | Epoch(2) | 分代年龄(4) | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针(30) | 00 | |||
| 重量级锁 | 指向重量级锁的指针(30) | 10 | |||
| GC标记 | 空(30) | 11 |
64 位 JVM 默认开启指针压缩(+UseCompressedOops)后,Mark Word 占 8 字节。
Klass Pointer (类型指针):指向方法区中类元数据的指针,确定对象是哪个类的实例。默认开启 -XX:+UseCompressedClassPointers 压缩为 4 字节。
实例数据 (Instance Data):父类字段 + 子类字段,相同宽度的字段分配在一起,在满足要求的前提下父类变量会出现在子类变量之前(可设置 -XX:FieldsAllocationStyle 调整)。
对齐填充(Padding):HotSpot 要求对象起始地址必须是 8 字节的整数倍,对象的整体大小也必须是 8 字节的整数倍,不足时补齐。
3.2.3 对象的访问定位
JVM 规范只规定了 reference 类型是一个指向对象的引用,没有规定如何定位。主流的访问方式有两种:
① 句柄访问:
reference ──▶ 句柄池 ──▶ 实例数据指针 ──▶ 对象实例数据(堆中 Instance Pool)
──▶ 类型数据指针 ──▶ 对象类型数据(方法区)
优点:GC 移动对象时只需修改句柄中的实例数据指针,reference 本身不用变。
② 直接指针访问(HotSpot 使用):
reference ──▶ 对象实例数据(堆中)──▶ 对象类型数据(方法区)
优点:访问速度快,减少一次指针定位开销;HotSpot 中 GC 时用写屏障追踪引用变化。
3.3 堆内存分区(分代模型)
🔥 面试最常见的问题之一:为什么分代?Eden 到 Survivor 怎么复制?什么对象进入老年代?
┌─────────────────────────────────────────────────┐
│ Java 堆 │
├──────────────────┬──────────────────────────────┤
│ 新生代 │ 老年代 │
│ (Young Gen) │ (Old Gen) │
│ ┌───┬────┬────┐ │ │
│ │E │S0 │ S1 │ │ │
│ │d │ │ │ │ 长期存活对象 │
│ │e │ │ │ │ (年龄 ≥ 15) │
│ │n │ │ │ │ 大对象(直接进入) │
│ └───┴────┴────┘ │ │
│ 默认比例 8:1:1 │ │
├──────────────────┴──────────────────────────────┤
│ 默认比例 1:2 │
│ (新生代:老年代 = 1:2) │
└─────────────────────────────────────────────────┘
分代收集的理论基础:
- 弱分代假说:绝大多数对象都是朝生夕灭的(新对象很快变成垃圾)
- 强分代假说:熬过越多次垃圾收集的对象就越难消亡
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
Eden → Survivor 复制过程:
- 新对象优先分配在 Eden 区
- Eden 满 → 触发 Minor GC(Young GC)
- 存活对象从 Eden + From Survivor 复制到 To Survivor
- 每经历一次 Minor GC 且存活,对象年龄 +1
- To Survivor 满了 → 存活对象直接进入老年代
- From 和 To 角色互换,始终保持一个 Survivor 区为空
对象晋升老年代的条件:
| 条件 | 说明 |
|---|---|
| 年龄阈值 | 对象每熬过一次 Minor GC 年龄 +1,达到 -XX:MaxTenuringThreshold(默认15)则晋升 |
| 动态年龄判定 | 当 Survivor 中相同年龄的所有对象大小总和 > Survivor 空间的一半,年龄 ≥ 该值的对象直接晋升 |
| 大对象 | 占用连续空间超过 -XX:PretenureSizeThreshold 的对象,直接在老年代分配 |
| 空间担保失败 | Survivor 空间无法容纳存活对象时,直接进入老年代 |
3.4 内存溢出与内存泄漏
🔥🔥🔥 线上问题排查的必考场景,面试官期待的不只是概念,更是完整的排查思路和工具使用经验。
3.4.1 内存溢出(OutOfMemoryError)
| OOM 类型 | 错误信息 | 常见原因 |
|---|---|---|
| Java 堆溢出 | java.lang.OutOfMemoryError: Java heap space |
对象过多、大对象持有时间过长、内存泄漏 |
| 元空间溢出 | java.lang.OutOfMemoryError: Metaspace |
动态生成了大量类、类加载器泄漏 |
| 栈溢出 | java.lang.StackOverflowError |
递归过深、方法调用层级过多 |
| 直接内存溢出 | java.lang.OutOfMemoryError: Direct buffer memory |
NIO 使用不当、直接内存未释放 |
| GC 开销超限 | java.lang.OutOfMemoryError: GC overhead limit exceeded |
GC 时间占总运行时间 98% 以上,但回收的内存不足 2% |
| 创建线程失败 | java.lang.OutOfMemoryError: unable to create new native thread |
线程数量到达操作系统上限 |
| Map 操作异常 | java.lang.OutOfMemoryError: Map failed |
mmap 映射文件时系统资源不足 |
3.4.2 内存泄漏(Memory Leak)的常见原因
内存泄漏指的是不再使用的对象不能被 GC 回收,它们占用的内存持续增长,最终可能导致 OOM。
高频内存泄漏场景:
① 静态集合类持有对象引用:
java
// ❌ 问题代码
public class MemoryLeak {
private static List<Object> list = new ArrayList<>();
public void addData() {
for (int i = 0; i < 100000; i++) {
Object obj = new Object();
list.add(obj); // 静态集合持有引用,永不释放
obj = null; // 局部变量置空也无济于事
}
}
}
// ✅ 修复:定期清理或使用 WeakHashMap
② ThreadLocal 内存泄漏 ⭐ 重点详解:
ThreadLocal 的内部结构决定了它天生容易导致内存泄漏:
java
// ThreadLocal 的核心存储结构
Thread → ThreadLocalMap → Entry(WeakReference<ThreadLocal>, value)
│
Key 是弱引用,一旦 GC 就可能变为 null
而 value 仍然是强引用,无法回收!
关键点:
- ThreadLocalMap 的 Entry 中,Key(ThreadLocal 对象)是弱引用 ,但 Value 是强引用
- 当 ThreadLocal 对象不再被外部引用时,GC 会回收它,Map 中的 Key 变为 null
- 但 Value 仍然被 Entry 强引用,不会被回收,造成内存泄漏
- 线程池中的线程生命周期很长,ThreadLocalMap 不会被清除,泄漏持续累积
java
// ✅ 正确用法:finally 中必须调用 remove()
ThreadLocal<UserContext> threadLocal = new ThreadLocal<>();
try {
threadLocal.set(new UserContext(user));
// ... 业务逻辑
} finally {
threadLocal.remove(); // 必须手动清理!
}
③ 数据库/IO/Socket 连接未关闭:
java
// ❌ 问题代码
Connection conn = dataSource.getConnection();
// ... 使用 conn
// 忘记 close(),GC 不会自动释放连接池资源
return;
// ✅ try-with-resources(JDK7+ 自动调用 close())
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
// ...
}
④ 单例模式持有外部引用:单例的生命周期与应用相同,如果单例持有了短生命周期对象的引用,这些对象将永远无法被回收。
⑤ 内部类持有外部类引用:非静态内部类默认持有外部类的强引用,如果内部类实例的生命周期超过外部类(如被提交到线程池),外部类实例无法回收。
java
// ❌ 匿名内部类隐式持有外部类引用
public class Outer {
public void submit() {
executor.submit(new Runnable() {
@Override public void run() { /* 持有 Outer.this */ }
});
}
}
// ✅ 改为静态内部类或 Lambda(Lambda 不持有 this 除非引用外部变量)
⑥ 监听器/回调未移除:注册了事件监听器但忘记反注册,导致观察者持有被观察者的引用。
3.4.3 OOM 排查思路与常用参数
排查标准流程:
怀疑 OOM
│
▼
① 获取 dump 文件
├─ JVM 参数启动: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump
├─ 手动导出: jmap -dump:format=b,file=heap.hprof <pid>
└─ Arthas: heapdump /tmp/dump.hprof
│
▼
② 使用 MAT / JProfiler / VisualVM 分析 dump
├─ 查看占用最大的对象(Dominator Tree)
├─ 分析 GC Roots 引用链(Path to GC Roots)
└─ 定位导致泄漏的代码位置
│
▼
③ 结合 jstat 实时监控
jstat -gc <pid> 1000 10 # 每秒输出一次 GC 情况,共 10 次
├─ 观察 Eden/Old/Metaspace 各区域使用率变化
└─ 关注 Full GC 频率和回收效果
四、垃圾回收机制
🔥🔥🔥 JVM 面试最高频的模块:GC 算法、CMS vs G1、三色标记法、ZGC 等是区分候选人水平的关键问题。
4.1 判断对象是否存活
4.1.1 引用计数法(Reference Counting)
- 每个对象维护一个引用计数器,被引用时 +1,引用失效时 -1
- 计数器为 0 时即可回收
- 优点:原理简单、判定效率高
- 缺点 :无法解决循环引用问题(A 引用 B,B 引用 A,计数器永远不为 0)
- Java 没有采用此方法(Python 采用了引用计数 + 标记清除的混合方案)
java
public class CycleRef {
Object ref;
public static void main(String[] args) {
CycleRef a = new CycleRef();
CycleRef b = new CycleRef();
a.ref = b;
b.ref = a; // 循环引用,引用计数法无法回收
a = null;
b = null; // 实际上已不可达,但计数器仍为 1
}
}
4.1.2 可达性分析算法(Reachability Analysis)
Java、C# 等主流语言所采用的判定方式:
-
以一组称为 GC Roots 的根对象为起点
-
从 GC Roots 出发,根据引用关系向下搜索,搜索路径叫做引用链(Reference Chain)
-
如果对象到 GC Roots 没有任何引用链相连,即证明此对象可被回收
GC Roots / │ \ ▼ ▼ ▼ obj1 obj2 obj3 │ │ ▼ ▼ obj4 obj5 ← 从 GC Roots 可达 → 存活 obj6 (孤立对象,没有任何 GC Roots 引用) → 可回收
4.1.3 GC Roots 有哪些
| GC Roots | 说明 | 示例 |
|---|---|---|
| 虚拟机栈中的引用对象 | 当前正在执行方法里的局部变量、参数 | 方法中的局部变量 User u = new User() |
| 本地方法栈中 JNI 的引用 | Native 方法引用的 Java 对象 | JNI 代码中的全局引用 |
| 方法区中类的静态变量 | 类的静态属性引用的对象 | private static User instance = ... |
| 方法区中常量引用的对象 | 字符串常量池、运行时常量池中的引用 | String s = "hello" |
| 所有被同步锁持有的对象 | synchronized 关键字锁定的对象 |
synchronized(lock) {...} |
| JVM 内部的引用 | 基本类型的 Class 对象、常驻异常对象、系统类加载器 | Object.class、NullPointerException |
| 反映 JVM 内部情况的 JMXBean | JVMTI 中注册的回调、本地代码缓存等 |
4.2 引用类型详解
🔥 面试高频考点:四种引用的区别及使用场景。
| 引用类型 | 回收时机 | 使用场景 |
|---|---|---|
| 强引用(Strong) | 永不回收(即使 OOM) | 99% 的对象引用,Object obj = new Object() |
| 软引用(Soft) | 内存不足时回收 | 本地图片缓存、内存敏感的高速缓存 |
| 弱引用(Weak) | 下一次 GC 时回收 | WeakHashMap、ThreadLocal 的 Key |
| 虚引用(Phantom) | 随时可能回收(无法通过它获取对象) | 堆外内存回收跟踪(NIO)、对象被 GC 时的通知机制 |
java
// 软引用示例:内存缓存
SoftReference<Bitmap> cache = new SoftReference<>(bitmap);
Bitmap b = cache.get();
if (b == null) {
b = loadFromDisk(); // 已被回收,重新加载
cache = new SoftReference<>(b);
}
// 弱引用示例:WeakHashMap
WeakHashMap<String, Object> map = new WeakHashMap<>();
String key = new String("key");
map.put(key, value);
key = null; // 下一次 GC 时,该 Entry 自动被清除
// 虚引用示例:跟踪对象回收
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> ref = new PhantomReference<>(obj, queue);
// obj 被 GC 回收后,ref 进入 queue,可通过 queue 感知回收事件
4.3 finalize() 方法
- 对象在被 GC 回收前,如果重写了
finalize()且未被调用过,会被放入 F-Queue 中 - 一个低优先级的 Finalizer 线程会执行
finalize()方法 finalize()中对象可以"自救"------重新与 GC Roots 建立引用- 任何对象的
finalize()只会被 JVM 调用一次 - 不推荐使用 :运行代价高、不确定性大、无法保证执行顺序,JDK 9 已标记为
@Deprecated
💡 JVM 使用 Cleaner 机制来替代
finalize(),NIO 中的DirectByteBuffer就是通过 Cleaner 释放直接内存。
4.4 垃圾回收算法
🔥 三大基础算法及其优缺点、适用场景必须倒背如流。
4.4.1 标记-清除(Mark-Sweep)
标记阶段:从 GC Roots 遍历,标记所有可达对象
清除阶段:遍历堆,回收未标记的对象
优点:不需要移动对象,实现简单
缺点:① 执行效率不稳定(对象越多,标记清除越慢)
② 产生大量不连续的内存碎片
4.4.2 标记-复制(Mark-Copy)
将内存分为两块,每次只使用其中一块
GC 时:将存活对象复制到另一块 → 清除当前整块
优点:无内存碎片、分配效率高(指针碰撞)
缺点:可用内存缩小一半、对象存活率较高时需要大量复制
适用于:新生代 GC(Minor GC),92%~98% 的对象会死亡
HotSpot 的半区复制(Appel 式回收):
- 新生代分 Eden + 两块 Survivor(8:1:1,可通过
-XX:SurvivorRatio调整) - 每次只用 Eden + 一块 Survivor(可用空间 = 90%)
- GC 时存活对象复制到另一块 Survivor
- Survivor 放不下时,剩余对象直接进老年代(内存分配担保)
4.4.3 标记-整理(Mark-Compact)
标记阶段:同标记-清除
整理阶段:将所有存活对象向内存一端移动,然后清除边界外的所有内存
优点:无内存碎片
缺点:移动对象需要 STW 并更新引用关系,效率低于标记-清除
适用于:老年代 GC
为什么老年代不用标记-复制?
老年代对象存活率高、大对象多,复制成本极高,且没有额外的空间进行担保。
4.4.4 算法对比总结
| 算法 | 是否移动对象 | 是否有碎片 | 内存利用率 | 适用场景 |
|---|---|---|---|---|
| 标记-清除 | 否 | 是 | 100% | CMS 老年代 |
| 标记-复制 | 是 | 否 | 50% → 90%(HotSpot 优化后) | 新生代 |
| 标记-整理 | 是 | 否 | 100% | Serial Old、Parallel Old |
4.5 Stop The World(STW)
🔥 中文面试常问:STW 是什么?为什么需要 STW?哪些 GC 阶段的 STW 时间最长?
STW 定义 :JVM 在执行 GC 的某些阶段时,需要暂停所有用户线程(Java 线程),以保证内存的一致性。所有线程在安全点(Safepoint)上暂停,等待 GC 完成后恢复。
STW 的根本原因 :可达性分析必须在一个能保障一致性的快照中进行,如果在分析过程中对象的引用关系还在不断变化,分析结果的准确性就无法保证。
各收集器 STW 情况:
| 收集器 | 涉及 STW 的阶段 | STW 耗时特征 |
|---|---|---|
| Serial | 全程 STW(单线程) | 较长 |
| ParNew | 全程 STW(多线程) | 中等 |
| Parallel Scavenge | 全程 STW(多线程) | 中等,追求吞吐量 |
| CMS | 初始标记 + 重新标记 | 初始标记极短,重新标记较短 |
| G1 | 初始标记 + 重新标记 + 部分清理 | 可预期停顿时间 |
| ZGC | 极少量 STW 阶段 | < 1ms(JDK16+) |
4.6 安全点与安全区域
安全点(Safepoint) :用户线程需要暂停时并非在任意位置都能停下,只能在安全点 处暂停。安全点的选择标准:是否具有让程序长时间执行的特征,如方法调用、循环跳转、异常跳转等。
如何在 GC 时让所有线程到达安全点:
- 抢先式中断(几乎不用):GC 时中断所有线程,如果发现不在安全点就恢复线程直至到达
- 主动式中断(HotSpot 使用):设置一个标志位,各线程到达安全点后主动轮询这个标志,发现需要中断则自行挂起
安全区域(Safe Region):如果线程处于 Sleep 或 Blocked 状态(不在安全点,无法响应中断请求),JVM 使用安全区域来解决。安全区域是指一段引用关系不会发生变化的代码片段,只要在这个区域内任意位置开始 GC 都是安全的。线程离开安全区域前需要检查 GC 是否已完成。
4.7 经典垃圾收集器
4.7.1 七大收集器全景图
┌─────────── 新生代 ───────────┐ ┌───── 老年代 ─────┐
│ │ │ │
Serial ─────┤ │ │ │
│ │ │ │
ParNew ─────┤ ┌──────────────────┐ ├────┤ CMS │
│ │ G1 (跨代) │ │ │ │
Parallel ───┤ │ ZGC (跨代) │ ├────┤ Serial Old │
Scavenge │ │ Shenandoah(跨代)│ │ │ │
│ └──────────────────┘ ├────┤ Parallel Old │
│ │ │ │
└─────────────────────────────┘ └────────────────┘
| 收集器 | 作用区域 | 算法 | 线程模型 | 特点 | 状态 |
|---|---|---|---|---|---|
| Serial | 新生代 | 标记-复制 | 单线程 | 简单高效,STW | |
| ParNew | 新生代 | 标记-复制 | 多线程 | Serial 多线程版 | JDK 9 不推荐 |
| Parallel Scavenge | 新生代 | 标记-复制 | 多线程 | 关注吞吐量 | JDK 14 移除 CMS 后的默认组合 |
| Serial Old | 老年代 | 标记-整理 | 单线程 | CMS 后备方案 | |
| Parallel Old | 老年代 | 标记-整理 | 多线程 | 关注吞吐量 | |
| CMS | 老年代 | 标记-清除 | 并发 | 低停顿 | JDK 9 弃用,JDK 14 移除 |
| G1 | 全堆 | 标记-整理+复制 | 并发 | 可控停顿 | JDK 9+ 默认 |
| ZGC | 全堆 | 标记-整理(染色指针) | 并发 | 超低延迟 | JDK 15 生产可用 |
4.7.2 CMS(Concurrent Mark Sweep)详解 ★★★
🔥🔥🔥 面试最高频的收集器问题,必须能把四个阶段、三色标记法、漏标处理、以及 CMS 的缺陷讲清楚。
设计目标:以最短的回收停顿时间为目标的收集器(低延迟优先)。
四个阶段:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 初始标记 │───▶│ 并发标记 │───▶│ 重新标记 │───▶│ 并发清除 │
│ STW ✓ │ │ 并发 ✗ │ │ STW ✓ │ │ 并发 ✗ │
│ 耗时极短 │ │ 耗时最长 │ │ 耗时较短 │ │ 耗时较长 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
标记GC Roots 遍历整个 修正并发标记 清除未标记
直接关联的 对象图 阶段的误差 的对象
老年代对象
阶段详解:
-
初始标记(Initial Mark)--- STW:
- 只标记 GC Roots 能直接关联到的老年代对象
- 需要 STW,但耗时极短,通常仅几毫秒
-
并发标记(Concurrent Mark)--- 与用户线程并发:
- 从 GC Roots 出发遍历整个老年代对象图
- 耗时最长,但与用户线程并发执行,不影响应用
- 此阶段可能产生漏标问题(详见下文三色标记法)
-
重新标记(Remark)--- STW:
- 修正并发标记期间因用户线程运行导致的标记变化
- STW 时间远小于并发标记阶段
- 仅扫描并发标记阶段发生过变化的对象(通过写屏障记录的卡表)
-
并发清除(Concurrent Sweep)--- 与用户线程并发:
- 清除已标记为垃圾的对象
- 使用标记-清除算法,会产生内存碎片
CMS 的三大问题:
| 问题 | 原因 | 后果 |
|---|---|---|
| ① 并发标记产生浮动垃圾 | 并发清除阶段用户线程新产生的垃圾,本次 GC 无法回收 | 只能等下次 CMS 周期处理,占据老年代空间 |
| ② Concurrent Mode Failure | 并发清除时老年代剩余空间不足以容纳从新生代晋升的对象 | 触发 Serial Old 单线程 Full GC,导致长时间 STW(秒级) |
| ③ 内存碎片化 | 标记-清除算法不整理内存 | 大对象可能无法分配,触发 Full GC |
三色标记法(Tri-color Marking)------ CMS/G1 并发标记的核心算法:
并发标记阶段,对象被分为三种颜色:
| 颜色 | 含义 | 状态 |
|---|---|---|
| 白色 | 尚未被标记器访问过 | 标记结束时仍为白色 → 垃圾 |
| 灰色 | 已被标记器访问,但其引用的子对象还未全部访问 | 待扫描 |
| 黑色 | 已被标记器访问,且其所有引用子对象也都已访问 | 存活对象 |
消失的对象问题 (漏标):并发标记过程中,用户线程修改了引用关系,可能导致原本存活的对象被误判为垃圾。漏标需要同时满足两个条件:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
CMS 解决漏标方案 --- 增量更新(Incremental Update):
- 当黑色对象插入指向白色对象的新引用时,将黑色对象重新标记为灰色
- 重新标记阶段会重新扫描这些变灰色的对象
CMS 相关 JVM 参数:
bash
-XX:+UseConcMarkSweepGC # 启用 CMS
-XX:CMSInitiatingOccupancyFraction=75 # 老年代使用率达到 75% 触发 CMS(JDK 6 默认 92)
-XX:+UseCMSInitiatingOccupancyOnly # 只根据设定阈值触发,不自动调整
-XX:+CMSParallelRemarkEnabled # 并行重新标记
-XX:+CMSScavengeBeforeRemark # 重新标记前先执行一次 Young GC
-XX:+CMSClassUnloadingEnabled # CMS 回收 PermGen/Metaspace
-XX:+UseCMSCompactAtFullCollection # Full GC 后整理碎片(默认开启)
-XX:CMSFullGCsBeforeCompaction=0 # 多少次 Full GC 后整理碎片(0=每次)
4.7.3 G1(Garbage First)详解 ★★★
🔥🔥🔥 JDK 9+ 的默认收集器,面试必问。需要彻底理解 Region 模型、SATB、Mixed GC、写屏障等技术。
设计目标:在延迟可控的前提下,获得尽可能高的吞吐量。
核心创新 --- Region 内存布局:
G1 不再坚持固定大小的分代划分,而是将 Java 堆划分为多个大小相等的独立 Region(默认约 2048 个,每个 1~32MB):
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ E │ S │ E │ O │ H │ E │ O │ S │ ... │ Free │ Free │
│(新生)(存活)(新生)(老年)(大对)(新生)(老年)(存活) │ │ │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
E=Eden, S=Survivor, O=Old, H=Humongous
- 每个 Region 角色不固定,可在 Eden、Survivor、Old 之间动态切换
- Humongous 区域:用于存储大对象(大小 ≥ Region 容量的 50%),专门处理巨型对象,避免在新生代和老年代之间频繁复制大对象
G1 的核心运行过程:
┌──────────┐ ┌──────────────────────────┐ ┌─────────────────┐
│ Young GC │ │ 并发标记周期 │ │ Mixed GC │
│ (仅年轻代)│ │ ═══════════════════════ │ │ (年轻代+部分老年代)│
│ │ │ ① 初始标记(STW) │ │ │
│ │ │ ② 根区域扫描(并发) │──▶│ 筛选回收价值最高 │
│ │ │ ③ 并发标记(并发) │ │ 的 Region 进行回收│
│ │ │ ④ 重新标记(STW) │ │ │
│ │ │ ⑤ 清理(STW,统计回收价值) │ │ │
└──────────┘ └──────────────────────────┘ └─────────────────┘
↑ 此阶段中仍然可以发生 Young GC ↑
G1 并发标记周期详解:
-
初始标记(Initial Mark)--- STW:
- 标记 GC Roots 直接可达的对象
- 借用 Young GC 的 STW 完成,实际额外开销很小
-
根区域扫描(Root Region Scan)--- 并发:
- 扫描 Survivor 区中被初始标记阶段标记的对象
- 必须在下一个 Young GC 之前完成
-
并发标记(Concurrent Mark)--- 并发:
- 使用 SATB 算法进行整个堆的并发可达性分析
- 与用户线程并发,此过程中也可发生 Young GC
-
重新标记(Remark)--- STW:
- SATB 保证并发标记的正确性,修正并发期间的引用变化
- G1 的重新标记比 CMS 的更快,因为 SATB 快照缩小了重标记范围
-
清理(Cleanup)--- 部分 STW:
- 统计各 Region 的存活对象数量,计算回收价值
- 将回收价值高的 Region 加入回收集(Collection Set, CSet)
SATB(Snapshot-At-The-Beginning)算法:
G1 使用 原始快照 而不是 CMS 的增量更新来解决并发标记的漏标问题:
-
在并发标记开始时,为当时的对象图拍一个快照
-
并发过程中,当灰色对象删除指向白色对象的引用时,记录这个被删除的引用(写屏障的前置操作)
-
重新标记阶段,以这些记录的引用为根再扫描一次
SATB 解决漏标的逻辑:
条件②(灰色→白色引用被删除)发生后,将被删引用的白色对象记录为 SATB 待扫描对象
→ 该白色对象不会被漏标 → 存活对象不会被误回收CMS 增量更新的逻辑:
条件①(黑色→白色引用被插入)发生后,将黑色对象改为灰色重新扫描
→ 扫描范围更大,但不会漏标
💡 SATB vs 增量更新:
- SATB 关注引用删除 ,增量更新关注引用插入
- SATB 会造成更多"浮动垃圾"(本应在并发期间变为垃圾的对象仍被标记为存活),但重新标记阶段更快
- G1 定位是"可预期停顿",SATB 使重新标记更轻量,与其设计理念一致
Mixed GC:
G1 最独特的能力:不只回收新生代,还会回收回收价值最高的部分老年代 Region。这被称为 Mixed GC。
- 并发标记周期结束后,G1 知道每个老年代 Region 中的垃圾占比
- Mixed GC 选取垃圾占比最高的若干个老年代 Region 进行回收
- 垃圾较少的 Region 留到后续 Mixed GC 处理
G1 的写屏障(Write Barrier):
写屏障是 G1 并发执行的关键支撑技术。每当用户线程修改对象引用时,写屏障会做两件事:
- SATB 前置写屏障:记录引用修改前的值(用于 SATB 并发标记)
- 后置写屏障:更新卡表(Remembered Set),记录跨 Region 的引用关系
Remembered Set(记忆集):
每个 Region 都维护一个 RSet,记录了哪些 Region 中的对象引用了本 Region 中的对象。这样在回收某个 Region 时,不需要扫描整个堆来找 GC Roots,只扫描 RSet 中记录的 Region 即可。
G1 vs CMS 对比:
| 对比维度 | CMS | G1 |
|---|---|---|
| 内存划分 | 连续的新生代 + 老年代 | 大小相等的 Region |
| 收集范围 | 仅老年代(配合 ParNew 收集新生代) | 全堆(Young GC + Mixed GC) |
| 并发算法 | 增量更新 | SATB(原始快照) |
| 碎片处理 | 无(标记-清除,会产生碎片) | 有(复制/整理,无碎片) |
| 停顿控制 | 不可控,可能 Full GC | 用户可指定 -XX:MaxGCPauseMillis(默认 200ms) |
| 内存占用 | 较小 | 较大(Remembered Set 占用额外内存) |
| 适用场景 | 低延迟,小~中堆 | 大堆(>6GB)、可预测停顿 |
G1 常用参数:
bash
-XX:+UseG1GC # 启用 G1
-XX:MaxGCPauseMillis=200 # 期望的最大 GC 停顿时间(ms)
-XX:G1HeapRegionSize=4m # Region 大小,须为 2 的幂次,1~32MB
-XX:InitiatingHeapOccupancyPercent=45 # 堆使用率达 45% 触发并发标记周期
-XX:G1NewSizePercent=5 # 新生代最小占比
-XX:G1MaxNewSizePercent=60 # 新生代最大占比
-XX:G1MixedGCCountTarget=8 # Mixed GC 次数目标
-XX:G1MixedGCLiveThresholdPercent=85 # Region 中存活对象超过 85% 不回收
4.8 低延迟收集器
4.8.1 ZGC(Z Garbage Collector)
JDK 11 实验性引入,JDK 15 生产可用,JDK 16+ 支持并发线程栈处理
核心特点:
- 亚毫秒级停顿:所有 STW 阶段都在 1ms 以内,JDK 16+ 实现了并发线程栈处理(零 STW 候选)
- 不分代(JDK 21 已加入分代支持):使用 Region(分为小型 2MB、中型 32MB、大型 2MB 的倍数)
- 染色指针技术(Colored Pointer):将对象存活信息、地址信息记录在 64 位指针中,使用指针的 4 个 bit 存储元数据
- 读屏障:在读取对象引用时执行额外检查(自愈机制),保证并发整理的正确性
染色指针结构(64位):
┌──┬────┬─────────────────┬──────────────────────────┐
│ │ │ │ │
│ │ GC │ unused │ 对象地址 │
│ │标记│ │ (实际可寻址范围) │
│ └────┴─────────────────┴──────────────────────────┘
4 bit 12 bit 48 bit
(Finalizable, Remapped, Marked1, Marked0)
ZGC 回收阶段(几乎所有阶段都并发执行):
- 初始标记(STW,极短,<1ms)
- 并发标记/重映射
- 再标记前 STW(极短)
- 并发转移(整理)+ 并发重映射
4.8.2 Shenandoah
RedHat 主导开发,功能与 ZGC 类似,使用读屏障和 Brooks 转发指针实现并发整理。JDK 12 引入,JDK 15 生产可用。
4.9 如何选择垃圾收集器
应用特征分析
│
├─ 小数据量(< 100MB)、单机客户端
│ └─▶ Serial + Serial Old
│
├─ 追求吞吐量(批处理、科学计算)
│ └─▶ Parallel Scavenge + Parallel Old
│
├─ 低延迟(Web 应用、实时系统)、堆 ≤ 4~6GB
│ └─▶ CMS(JDK 8)/ G1(JDK 9+,优先使用)
│
├─ 大堆(> 6GB)、需要可控停顿
│ └─▶ G1
│
└─ 超低延迟(< 1ms 停顿)、大堆
└─▶ ZGC(JDK 15+)/ Shenandoah
五、JVM 调优实战
🔥 有经验候选人的分水岭:不是罗列命令,而是展示发现问题 → 分析 → 定位 → 解决的完整思路。
5.1 调优的目标和思路
三个维度:
- 吞吐量:用户代码执行时间 / (用户代码执行时间 + GC 时间)
- 延迟:单次 GC 的停顿时间(STW 时长)
- 内存占用:GC 所需的内存开销
三者不可兼得,需要根据业务场景做权衡。
调优黄金法则:
凡事三思而后调 → 没有明确目标不调 → 不监控不调 → 可回滚可对比
5.2 常用 JVM 参数速查
内存参数
bash
# 堆
-Xms2g # 初始堆大小
-Xmx4g # 最大堆大小
-Xmn1g # 新生代大小(或 -XX:NewRatio=2 设置老/新比例)
-XX:SurvivorRatio=8 # Eden:Survivor = 8:1:1
# 元空间
-XX:MetaspaceSize=256m # 元空间初始大小
-XX:MaxMetaspaceSize=512m # 元空间上限
# 栈
-Xss1m # 每个线程栈大小
# 直接内存
-XX:MaxDirectMemorySize=512m
# 大对象阈值
-XX:PretenureSizeThreshold=3m # 超过 3MB 的对象直接在老年代分配
GC 参数
bash
# GC 选择
-XX:+UseG1GC # 使用 G1
-XX:+UseZGC # 使用 ZGC(JDK 15+)
# GC 日志(JDK 9+)
-Xlog:gc*:file=/path/gc-%t.log:time,tags,level:filecount=10,filesize=100M
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log
# OOM 诊断
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/heapdump
-XX:ErrorFile=/path/to/hs_err_pid%p.log
-XX:+ExitOnOutOfMemoryError
# GC 调优关键参数
-XX:MaxGCPauseMillis=200 # G1 期望停顿(默认 200ms)
-XX:G1HeapRegionSize=4m
-XX:InitiatingHeapOccupancyPercent=45
-XX:+ParallelRefProcEnabled # 并行处理引用对象
5.3 常用诊断命令与工具
bash
# jps --- 列出 Java 进程
jps -l -v
# jstat --- 实时监控 JVM 状态(最常用)
jstat -gc <pid> 1000 10 # GC 统计,每秒一次,共 10 次
jstat -gcutil <pid> 1000 # GC 各区域使用百分比
jstat -gccapacity <pid> # 各区域容量
jstat -class <pid> # 类加载统计
# jmap --- 内存分析
jmap -heap <pid> # 堆概要
jmap -histo:live <pid> | head -20 # 堆中对象统计(触发 FGC 获取精确数据)
jmap -dump:format=b,file=heap.hprof <pid> # 导出堆转储
# jstack --- 线程分析
jstack <pid> # 线程快照
jstack -l <pid> # 含锁信息
# 排查死锁: jstack <pid> | grep -A 30 "deadlock"
# jinfo --- 查看运行时参数
jinfo -flags <pid> # 所有 JVM 参数
jinfo -flag MaxHeapSize <pid> # 单个参数
# jcmd --- JDK 7+ 统一诊断命令
jcmd <pid> GC.heap_info # 同 jmap -heap
jcmd <pid> Thread.print # 同 jstack
jcmd <pid> GC.run # 触发 GC
jcmd <pid> VM.system_properties
5.4 典型问题排查案例
案例一:频繁 Full GC
现象:应用响应变慢,系统 CPU 飙升
排查流程:
bash
# 1. 查看 GC 频率和时间
jstat -gc <pid> 1000
# 2. 看到 FGC 次数持续增长,FGC 平均耗时 > 500ms
# 3. 分析 Eden 和 Old Gen 使用率
# 4. 可能原因:
# - 对象分配速率过高
# - 老年代空间不足(Minor GC 后的对象无法进入老年代)
# - CMS 的 Concurrent Mode Failure
# 5. 导出 dump 分析大对象
jmap -dump:format=b,file=heap.hprof <pid>
常见解决方案:
- 调大堆空间或调整各区域比例
- 分析代码中是否有内存泄漏点
- 针对 CMS 可降低
CMSInitiatingOccupancyFraction
案例二:元空间 OOM
现象 :java.lang.OutOfMemoryError: Metaspace
排查:
bash
jstat -gc <pid> # 查看 MU(Metaspace Used)持续增长
jmap -clstats <pid> # 查看类加载器及加载的类数量
常见原因:
- 大量动态代理生成类(如 CGLIB、Javassist、Spring AOP)
- 大量 JSP 文件(每个 JSP 编译为独立的类)
- 类加载器泄漏(如频繁热部署后旧类加载器未回收)
案例三:CPU 100% 排查
bash
# 1. 找到 CPU 最高的进程
top -H -p <pid>
# 2. 找到 CPU 最高的线程(十进制转十六进制)
printf "%x\n" <tid>
# 3. 查看线程状态
jstack <pid> | grep -A 30 <hex_tid>
# 4. 常见结果:
# - 死循环代码 → 定位业务代码
# - GC 线程高 CPU → GC 频繁,分析 GC 日志
# - 锁竞争 → 多个线程 BLOCKED 在同一把锁上
六、面试高频 50 问精讲
以下问题根据近三年大厂面试真题整理,按频率排序,★★★ 为必考 ,★★ 为高频 ,★ 为选择性考察。
类加载篇
Q1 ★★★ 类加载过程有哪几个阶段?每个阶段做了什么?
加载(获取二进制流→方法区运行时结构→生成 Class 对象)、链接(验证→准备→解析)、初始化(执行
<clinit>())。详见 2.1 节。
Q2 ★★★ 什么是双亲委派模型?为什么要用?如何打破?
三层委派机制→保证类唯一性和安全性→SPI/Tomcat/OSGi 三种打破场景。详见 2.3 节。
Q3 ★★ 你自定义过类加载器吗?怎么做?
继承
ClassLoader,重写findClass()方法(而不是loadClass())。从特定来源(如加密的 class 文件、网络)获取字节流,调用defineClass()生成 Class 对象。
Q4 ★ 静态变量和静态代码块的初始化顺序?
按代码中书写的顺序依次执行。父类的
<clinit>()先于子类执行。注意:接口的<clinit>()不需要先执行父接口的<clinit>(),只有在用到父接口中的变量/方法时才初始化父接口。
内存管理篇
Q5 ★★★ JVM 内存区域划分?每个区域的作用?
PC 寄存器、虚拟机栈、本地方法栈、堆、方法区/元空间。详见图 3.1。
Q6 ★★★ 堆和栈的区别?
① 线程共享(堆共享,栈私有);② 生命周期(堆由 GC 管理,栈随方法结束释放);③ 存储内容(堆存对象实例,栈存局部变量/方法参数/对象引用);④ 内存大小(堆远大于栈);⑤ 异常(堆 OOM,栈 StackOverflowError/OOM)。
Q7 ★★★ 对象创建过程是怎样的?
类加载检查→分配内存(指针碰撞/空闲列表)→初始化零值→设置对象头→执行
<init>()。详见 3.2.1 节。
Q8 ★★★ 对象的内存布局是怎样的?
对象头(Mark Word + Klass Pointer + 可能的数组长度)+ 实例数据 + 对齐填充。详见 3.2.2 节。
Q9 ★★★ 为什么 JDK 8 用元空间替代永久代?
永久代受
-XX:MaxPermSize限制、难以确定大小、GC 复杂。元空间使用本地内存,OOM 概率更低。详见 3.1.5 节。
Q10 ★★★ 什么情况下对象会进入老年代?
① 年龄阈值(默认 15);② 动态年龄判定;③ 大对象直接分配;④ Survivor 空间不足。详见 3.3 节。
Q11 ★★ TLAB 是什么?为什么需要?
线程本地分配缓冲(Thread Local Allocation Buffer),每个线程在 Eden 区拥有私有的分配区域,避免并发分配内存时的锁竞争。
-XX:+UseTLAB默认开启。
Q12 ★★ 逃逸分析是什么?有什么作用?
分析对象作用域:对象未逃逸→栈上分配(无需 GC)+ 标量替换(打散为基本类型分配)+ 锁消除。JDK 6u23+ 默认开启。
Q13 ★★★ 什么情况会 OOM?你遇到过哪些?怎么排查的?
堆溢出、元空间溢出、栈溢出、直接内存溢出、GC overhead limit exceeded 等。完整排查流程见 3.4.3 节。
Q14 ★★★ 内存泄漏怎么排查?
① 观察监控指标(Old Gen 持续增长,Full GC 后内存不下降)→ ②
jmap -dump导出 → ③ MAT 分析 Dominator Tree → ④ 追踪 GC Root 引用链 → ⑤ 定位代码。常见场景见 3.4.2 节。
Q15 ★★★ ThreadLocal 为什么会导致内存泄漏?如何避免?
Entry 的 Key 是弱引用(GC 后变 null),Value 是强引用(永不释放),线程池中线程复用导致累积。必须
finally { threadLocal.remove(); }。详见 3.4.2 节。
Q16 ★★★ String 的 intern() 方法作用?JDK 6/7/8 中字符串常量池的位置?
将字符串放入常量池并返回引用。JDK 6:方法区(永久代);JDK 7+:堆中。
intern()在 JDK 7 后首次遇到字符串时将堆中的对象引用记录到常量池中(而不是复制一份)。
垃圾回收篇
Q17 ★★★ 如何判断一个对象是否应该被回收?
可达性分析算法:从 GC Roots 出发,无法到达的对象判定为垃圾。详见 4.1.2。
Q18 ★★★ GC Roots 有哪些?
虚拟机栈中的引用、静态变量、常量池引用、JNI 引用、被锁持有的对象、JVM 内部引用。详见 4.1.3。
Q19 ★★★ Java 中有哪几种引用类型?分别用于什么场景?
强/软/弱/虚引用。软引用→缓存;弱引用→WeakHashMap、ThreadLocal;虚引用→堆外内存回收跟踪。详见 4.2。
Q20 ★★★ 讲讲你知道的垃圾回收算法?
标记-清除(碎片多)、标记-复制(新生代,无碎片但浪费空间)、标记-整理(老年代,无碎片但 STW 长)。详见 4.4。
Q21 ★★★ 为什么新生代要用复制算法?
新生代对象 98% 朝生夕灭,存活率极低,复制成本很小。复制算法无碎片且分配效率高(指针碰撞)。
Q22 ★★★ CMS 的回收过程是怎样的?有什么优缺点?
四个阶段:初始标记→并发标记→重新标记→并发清除。优点:低停顿;缺点:浮动垃圾、Concurrent Mode Failure、内存碎片。详见 4.7.2。
Q23 ★★★ G1 收集器的核心原理?和 CMS 有什么区别?
Region 分配 + SATB 并发标记 + Mixed GC。对比 CMS 见 4.7.3 节对照表。
💡 加分回答:G1 的 SATB 保证标记的正确性但会产生更多浮动垃圾,CMS 的增量更新浮动垃圾少但重新标记阶段更慢------这是两种不同的 trade-off。
Q24 ★★★ 什么是 STW?为什么需要 STW?
Stop The World。可达性分析要求对象引用关系的"一致性快照",GC 期间必须暂停所有修改对象引用的用户线程。详见 4.5。
Q25 ★★ 什么是三色标记法?CMS 和 G1 分别如何处理漏标问题?
白色(未访问)→ 灰色(已访问,扫描中)→ 黑色(已访问且扫描完成)。漏标需要同时满足插入+删除两个条件。CMS:增量更新(插入黑色→白色引用时,将黑色变灰);G1:SATB(删除灰色→白色引用时,记录被删引用)。详见 4.7.2 和 4.7.3。
Q26 ★★ 什么是跨代引用?怎么处理?
老年代对象引用新生代对象。Minor GC 需要扫描这些引用来确定新生代对象存活。解决方式:卡表(Card Table)------ 老年代被划分为 512 字节的卡片,一旦老年代对象引用新生代对象,对应的卡片变"脏",Minor GC 时只扫描脏卡片而非整个老年代。
Q27 ★★ ZGC 了解吗?为什么能做到超低延迟?
染色指针 + 读屏障 + 并发整理。几乎所有阶段都并发执行,STW 阶段小于 1ms。详见 4.8.1。
Q28 ★★ 什么时候会触发 Full GC?
①
System.gc()建议执行;② 老年代空间不足;③ 方法区/元空间不足;④ Minor GC 后晋升老年代的平均大小 > 老年代剩余空间;⑤ CMS 的 Concurrent Mode Failure;⑥ G1 的 Evacuation Failure。
Q29 ★ Minor GC、Major GC、Full GC 的区别?
- Minor GC / Young GC:仅回收新生代(Eden + Survivor)
- Major GC / Old GC:仅回收老年代(CMS 的并发回收就是 Major GC)
- Full GC:回收整个堆 + 方法区/元空间,STW 时间最长
- Mixed GC(G1 特有):回收新生代 + 部分老年代 Region
性能调优篇
Q30 ★★★ 你做过 JVM 调优吗?说说具体过程和思路?
① 明确目标(吞吐量、延迟、内存占用)→ ② 收集指标(GC 日志、监控平台)→ ③ 分析问题(GC 频率、STW 时长、内存趋势)→ ④ 调整参数(逐步调整,一次只改一个)→ ⑤ 验证对比。详见第五章。
Q31 ★★★ 线上突然 CPU 100% 怎么排查?
top 找进程→top -H 找线程→jstack 定位线程状态→分析是死循环/GC/锁竞争。详见 5.4 案例三。
Q32 ★★★ 频繁 Full GC 怎么排查?
jstat -gc 观察频率→jmap dump→MAT 分析大对象/泄漏→检查 GC 参数是否合理。详见 5.4 案例一。
Q33 ★★ 高并发场景下 JVM 参数如何设置?
堆足够大但不超过物理内存的 75%;使用 G1 并设置
MaxGCPauseMillis;打开 GC 日志;设置-XX:+HeapDumpOnOutOfMemoryError;-XX:+AlwaysPreTouch减少运行时缺页开销。
Q34 ★ 常用的性能监控工具有哪些?
命令行:jstat、jmap、jstack、jcmd、jinfo;可视化:JVisualVM、JConsole、Java Mission Control(JMC + JFR);第三方:Arthas(阿里巴巴,功能极其强大)、MAT、GCViewer。
进阶原理篇
Q35 ★★★ synchronized 底层是如何实现的?锁升级过程是怎样的?
Monitor 机制(
monitorenter/monitorexit指令)+ 锁升级:无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(OS 互斥量)。Mark Word 中记录了锁状态。
Q36 ★★ volatile 的实现原理?
① 可见性:写 volatile 变量后强制刷新到主内存,读之前强制从主内存加载(内存屏障
lock前缀指令);② 禁止指令重排:内存屏障禁止特定类型的指令重排序。
Q37 ★★ happens-before 原则是什么?
JMM(Java 内存模型)定义的偏序关系,保证可见性:① 程序次序规则;② 管程锁定规则(unlock happens-before lock);③ volatile 规则(写 happens-before 读);④ 传递性;⑤ 线程 start/join 规则等。
Q38 ★ CAS 的原理?ABA 问题怎么解决?
Compare And Swap:比较内存值与期望值,相等则更新(原子操作,CPU
cmpxchg指令)。ABA:用AtomicStampedReference加版本号解决。
附录
A. JVM 参数速查表
| 分类 | 参数 | 说明 | 默认值 |
|---|---|---|---|
| 堆 | -Xms |
初始堆大小 | 物理内存的 1/64 |
| 堆 | -Xmx |
最大堆大小 | 物理内存的 1/4 |
| 堆 | -Xmn |
新生代大小 | |
| 堆 | -XX:NewRatio |
老年代:新生代比例 | 2 |
| 堆 | -XX:SurvivorRatio |
Eden:Survivor 比例 | 8 |
| 栈 | -Xss |
线程栈大小 | 1MB(Linux x64) |
| 元空间 | -XX:MetaspaceSize |
元空间初始大小 | ~21MB |
| 元空间 | -XX:MaxMetaspaceSize |
元空间上限 | 无限制 |
| GC | -XX:+UseG1GC |
使用 G1 | JDK 9+ 默认 |
| GC | -XX:+UseZGC |
使用 ZGC | |
| GC | -XX:MaxGCPauseMillis |
期望最大 STW(G1) | 200ms |
| GC 日志 | -Xlog:gc* |
GC 日志(JDK 9+) | |
| OOM | -XX:+HeapDumpOnOutOfMemoryError |
OOM 时 dump | false |
| OOM | -XX:HeapDumpPath |
dump 文件路径 | 当前目录 |
B. GC 日志解读示例
G1 GC 日志片段:
[2024-01-15T10:30:00.123+0800][info][gc] GC(100) Pause Young (Normal) (G1 Evacuation Pause) 85M->62M(200M) 12.3ms
[2024-01-15T10:30:05.456+0800][info][gc] GC(101) Pause Initial Mark (G1 Evacuation Pause) 90M->70M(200M) 15.1ms
[2024-01-15T10:30:10.789+0800][info][gc] GC(102) Pause Remark 75M->75M(200M) 2.1ms
[2024-01-15T10:30:15.012+0800][info][gc] GC(103) Pause Mixed (G1 Evacuation Pause) 80M->50M(200M) 25.8ms
解读:
GC(100):第 100 次 GCPause Young (Normal):普通新生代 GC(G1 中称为 Evacuation Pause)85M->62M(200M):GC 前堆占用 85MB → GC 后 62MB,堆总大小 200MB12.3ms:本次 GC STW 耗时 12.3 毫秒Pause Initial Mark:初始标记阶段(并发标记周期的开始)Pause Mixed:Mixed GC(同时回收新生代和部分老年代 Region)
C. 推荐阅读
- 《深入理解 Java 虚拟机(第3版)》------ 周志明
- Java 虚拟机规范(Java SE 8/11/17 Edition)
- 美团技术博客 --- JVM 系列
- G1GC 官方文档
- ZGC 官方 Wiki
📝 写作说明:本文以面试为导向,系统梳理 JVM 从基础概念到调优实战的全部知识体系。内容结构上,前半部分按知识点展开,后半部分聚焦面试问答,每一节都标注了面试热度等级,方便读者按优先级复习。文中"💡"标注的内容属于加分知识点,掌握后可在面试中展现更深入的理解。