JVM 核心教学文档
本文件呈现策略
为什么这样组织内容?
JVM知识有一条天然的”数据流主线”:.class文件 → 类加载 → 运行时内存 → 对象生命周期 → 垃圾回收 → 执行优化 → 调优实战。这条线就是一个Java程序从磁盘到CPU的完整旅程。按这条线组织,每个章节的输入恰好是上一章的输出,知识不会断裂。
呈现方式选择原则:
- 机制/原理 → 类比 + ASCII图 + 一句话锚点
- 流程/过程 → 编号步骤 + 关键判断节点
- 多选项对比 → 表格
- 易错/易混 → ⚠️ 警示框独立标出
一、类加载机制
1.1 类的生命周期
一个类从被引用到被卸载,经历7个阶段:
加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
|←———— 连接 ————→|
加载(Loading):找到 .class 文件的字节流,在方法区生成 Class 对象。来源不限于文件——可以是JAR、网络、动态代理生成的字节码。
验证(Verification):确保字节码不会危害JVM。包括文件格式验证(魔数 0xCAFEBABE)、元数据验证、字节码验证、符号引用验证。
准备(Preparation):为类变量(static字段)分配内存并设零值。注意:static final 的编译期常量在此阶段直接赋目标值。
static int value = 123; // 准备阶段:value = 0,初始化阶段:value = 123
static final int COUNT = 10; // 准备阶段就是 COUNT = 10(ConstantValue属性)
解析(Resolution):将符号引用替换为直接引用。符号引用是字符串形式的描述(如 java/lang/Object),直接引用是内存指针或偏移量。
初始化(Initialization):执行 <clinit>() 方法——按源码顺序收集所有 static {} 块和静态变量赋值语句合并而成。JVM保证 <clinit>() 线程安全且只执行一次(这就是为什么枚举单例天然线程安全)。
⚠️ 易混点:准备 vs 初始化 准备阶段赋零值,初始化阶段赋真实值。面试常考:
static int x = 100在准备阶段是0,不是100。但static final int x = 100因为有ConstantValue属性,准备阶段直接就是100。区分关键:是否final且为编译期常量。
🎯 记忆锚点:类加载 = “加验准解初”,连接是中间三步打包。准备给零值,初始化给真值,final常量是例外。
1.2 类加载器与双亲委派
三层类加载器
┌─────────────────────────────────┐
│ Bootstrap ClassLoader (C++) │ 启动类加载器 加载 $JAVA_HOME/lib(rt.jar等核心类)
│ Java中表现为 null │
└──────────────┬──────────────────┘
↓ 委派
┌─────────────────────────────────┐
│ Extension/Platform ClassLoader│ 扩展类加载器 JDK8: $JAVA_HOME/lib/ext
│ (Java实现) │ JDK9+: 改名 PlatformClassLoader
└──────────────┬──────────────────┘
↓ 委派
┌─────────────────────────────────┐
│ Application ClassLoader │ 应用类加载器 加载 classpath 下的类
│ (Java实现, 默认的加载器) │
└──────────────┬──────────────────┘
↓ 委派
┌─────────────────────────────────┐
│ Custom ClassLoader │ 用户自定义(热部署、模块隔离等)
└─────────────────────────────────┘
双亲委派模型(Parent Delegation Model)
核心逻辑(一句话版):收到加载请求时,先交给父加载器处理,父加载器搞不定了,自己才动手。
loadClass(name) {
1. 查缓存:已经加载过?直接返回
2. 委派:parent.loadClass(name)
3. 父亲加载失败(ClassNotFoundException)?自己 findClass(name)
}
为什么需要双亲委派?
- 安全:防止用户编写的
java.lang.Object覆盖核心类 - 唯一性:同一个类只会被加载一次,避免类型混乱(两个不同ClassLoader加载的同名类是不同类型)
打破双亲委派的三个经典场景
| 场景 | 怎么打破 | 为什么要打破 |
|---|---|---|
| JNDI/SPI | 线程上下文类加载器(Thread.setContextClassLoader) | 核心类需要加载第三方实现(如JDBC驱动),但Bootstrap无法看到classpath |
| OSGi | 网状委派,不是树状 | 模块化热部署,每个Bundle有自己的ClassLoader |
| Tomcat | 每个WebApp一个ClassLoader,优先自己加载 | 不同应用可能依赖同一个库的不同版本 |
⚠️ 面试高频陷阱:双亲委派不是”规范”,是”约定”。
loadClass()里的逻辑可以被覆盖。JDK的SPI机制本身就打破了它——ServiceLoader用线程上下文ClassLoader加载实现类。
🎯 记忆锚点:双亲委派 = “先问爸爸,爸爸不行我再来”。打破它的核心原因都是”上层需要加载下层的类”。
1.3 类加载的触发时机
JVM规范严格定义了6种必须初始化的情况(即”主动引用”):
new、getstatic、putstatic、invokestatic这四条字节码指令:new一个对象、读取静态字段、给静态字段赋值、调用静态方法- 反射调用(
Class.forName()) - 初始化子类时发现父类未初始化,先初始化父类
- 包含
main()方法的启动类 - JDK7+
MethodHandle解析到的类 - JDK8+ 接口的
default方法,实现类初始化时接口也要初始化
不会触发初始化的典型案例:
// 1. 子类引用父类的静态字段 → 只初始化父类
System.out.println(SubClass.parentStaticField);
// 2. 数组定义 → 不会初始化元素类型
MyClass[] arr = new MyClass[10];
// 3. 引用编译期常量 → 常量传播优化,编译期已嵌入调用方
System.out.println(MyClass.CONSTANT); // static final String
🎯 记忆锚点:主动引用才初始化。数组、常量、子类访问父类字段——这三个”看起来像但不是”要记牢。
二、运行时数据区
2.1 内存区域全景图
![[Gemini_Generated_Image_mvisfumvisfumvis.png|717]]
┌───────────────────────────────────────────────────────────┐
│ JVM 运行时数据区 │
├──────────── 线程私有 ──────────┬──────── 线程共享 ──────────┤
│ │ │
│ ┌─────────────────────┐ │ ┌─────────────────────┐ │
│ │ 程序计数器 (PC) │ │ │ 堆 (Heap) │ │
│ │ 当前线程执行位置 │ │ │ 对象实例 + 数组 │ │
│ │ 唯一不会OOM的区域 │ │ │ GC的主战场 │ │
│ └─────────────────────┘ │ └─────────────────────┘ │
│ │ │
│ ┌─────────────────────┐ │ ┌─────────────────────┐ │
│ │ Java虚拟机栈 │ │ │ 方法区/元空间 │ │
│ │ 每个方法 = 一个栈帧 │ │ │ 类信息、常量池 │ │
│ │ -Xss 控制大小 │ │ │ JDK8+用本地内存 │ │
│ └─────────────────────┘ │ └─────────────────────┘ │
│ │ │
│ ┌─────────────────────┐ │ │
│ │ 本地方法栈 │ │ │
│ │ Native方法服务 │ │ │
│ └─────────────────────┘ │ │
└───────────────────────────────┴───────────────────────────┘
2.2 程序计数器(PC Register)
- 每个线程一个,记录当前正在执行的字节码指令地址
- 执行 Native 方法时值为
Undefined - 唯一一个没有
OutOfMemoryError的区域——因为它的大小固定,就是一个指针宽度
2.3 Java虚拟机栈
每个线程创建时同步创建。每调用一个Java方法,就压入一个栈帧(Stack Frame):
┌─────────────────────────────┐ ← 栈顶(当前方法)
│ 栈帧: methodC() │
│ ┌───────────────────────┐ │
│ │ 局部变量表 │ │ 存放方法参数和局部变量
│ │ (Local Variable Table)│ │ 基本类型直接存值,引用类型存指针
│ ├───────────────────────┤ │
│ │ 操作数栈 │ │ 字节码指令的工作区(如 iadd 从栈顶取两个int相加)
│ │ (Operand Stack) │ │
│ ├───────────────────────┤ │
│ │ 动态链接 │ │ 指向运行时常量池中该方法的引用
│ │ (Dynamic Linking) │ │ 支持多态:运行时确定调用哪个版本
│ ├───────────────────────┤ │
│ │ 返回地址 │ │ 方法结束后回到调用者的哪一行
│ │ (Return Address) │ │
│ └───────────────────────┘ │
├─────────────────────────────┤
│ 栈帧: methodB() │
├─────────────────────────────┤
│ 栈帧: methodA() │
└─────────────────────────────┘ ← 栈底
两种异常:
StackOverflowError:栈深度超限(典型:无限递归)OutOfMemoryError:动态扩展时内存不足(HotSpot的栈不支持动态扩展,所以只会SOE)
局部变量表的基本单位是 Slot(变量槽),32位以内类型占1个Slot,long/double 占2个Slot。实例方法的第0个Slot固定是 this。
⚠️ 易混点:HotSpot中虚拟机栈和本地方法栈合二为一,
-Xss同时控制二者总大小。但规范上它们是两个独立区域。
🎯 记忆锚点:虚拟机栈 = 方法调用的”函数调用栈”,每个方法一个栈帧,帧里有四样东西:局部变量表、操作数栈、动态链接、返回地址。
2.4 堆(Heap)
所有对象实例和数组在此分配(JIT的逃逸分析可能导致栈上分配,但这是优化例外,不是规则)。
堆的分代结构(JDK8 经典布局)
-Xms / -Xmx 控制总大小
┌───────────────────────────────────────────────────────────┐
│ Java Heap │
├────────────── 新生代 ──────────────┬────── 老年代 ─────────┤
│ -Xmn 或 -XX:NewRatio 控制比例 │ │
│ │ │
│ ┌──────┬────────┬────────┐ │ │
│ │ Eden │ S0 │ S1 │ │ Old Generation │
│ │ (8) │ (1) │ (1) │ │ │
│ └──────┴────────┴────────┘ │ │
│ -XX:SurvivorRatio=8 │ │
│ 默认 Eden:S0:S1 = 8:1:1 │ │
│ 新对象在Eden分配 │ 长期存活的对象晋升至此 │
├───────────────────────────────────┴───────────────────────┤
│ 默认新生代:老年代 = 1:2 (-XX:NewRatio=2) │
└───────────────────────────────────────────────────────────┘
对象分配流程
new Object()
│
▼
[1] 先判断:对象是否很大?
├─ 是
│ ▼
│ 直接尝试分配到老年代
│
└─ 否
▼
[2] 当前线程开启了 TLAB 吗?
├─ 否
│ ▼
│ 去 Eden 公共区分配(CAS / 重试)
│
└─ 是
▼
[3] 当前线程自己的 TLAB 还放得下吗?
├─ 能放下
│ ▼
│ 直接在 TLAB 分配(指针碰撞,快)
│
└─ 放不下
▼
[4] 能不能重新申请一个新的 TLAB?
├─ 能
│ ▼
│ 换新 TLAB,再分配对象
│
└─ 不能
▼
[5] 去 Eden 公共区分配(CAS / 重试)
TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区:每个线程在Eden区预先划一小块私有区域,分配对象时不需要加锁。默认开启(
-XX:+UseTLAB),通常占Eden的1%。
🎯 记忆锚点:堆 = 对象的家。新生代是”短租房”(Eden+两个Survivor),老年代是”长期住户”。TLAB让分配对象像在自己家里拿东西一样快。
2.5 方法区
实现方式:
-
JDK 7 以前:永久代(PermGen)。物理上在 JVM 内存中,受限于虚拟机内存大小。
-
JDK 8 以后:元空间(Metaspace)。物理上在**本地内存(Native Memory)**中,受限于系统可用内存。
| 组件名称 | JDK 6 位置 | JDK 7 位置 | JDK 8+ 位置 | 最终去向 |
|---|---|---|---|---|
| 类元数据 | 永久代 | 永久代 | 元空间 | 本地内存 |
| 运行时常量池 | 永久代 | 永久代 | 元空间 | 本地内存 |
| 字符串常量池 | 永久代 | 堆 | 堆 | 堆 |
| 静态变量 | 永久代 | 堆 | 堆 |
⚠️ String.intern() 行为差异): 由于字符串常量池移到了堆,导致
intern()逻辑改变:
- JDK 6:常量池在永久代,不在池中则复制对象,返回新地址。
- JDK 7+:常量池在堆中,不在池中则记录堆中引用,返回原地址。
运行时常量池
- 是方法区的一部分,每个Class文件的常量池表在类加载后进入此区域,每个类都有一份
- 运行时常量池具有动态性。例如,通过
String.intern()方法,程序可以在运行期将原本不在 Class 文件常量池中的字符串放入字符串常量池中,从而实现常量的动态添加。 - 内存不足时抛出
OutOfMemoryError
🎯 记忆锚点:永久代是JVM管的”固定仓库”,元空间是操作系统管的”弹性仓库”。JDK8把仓库搬到了JVM外面,所以不容易爆了。字符串常量池在JDK7就先搬到堆里了。
2.6 直接内存(Direct Memory)
- 不属于JVM运行时数据区,但频繁使用(NIO的
DirectByteBuffer) - 通过
Unsafe.allocateMemory()分配,受-XX:MaxDirectMemorySize限制 - 不受GC直接管理,但
DirectByteBuffer对象被回收时会通过Cleaner释放对应的直接内存 - 典型问题:堆内存充足但物理内存不足时OOM,容易被忽略
🎯 记忆锚点:直接内存就是“堆外的特区”:绕过 JVM 的繁琐拷贝,直接跟系统对接。读写快,但申请麻烦,且容易“死在”堆外不被释放。
三、对象的创建、内存布局与访问
3.1 对象创建全流程
new 指令
│
▼
1. 类加载检查 → 常量池中能否找到类的符号引用?该类是否已加载、解析、初始化?
│ 否 → 先触发类加载
▼
2. 分配内存
├─ 堆内存规整(Serial/ParNew等带压缩的收集器)→ 指针碰撞(Bump the Pointer)
│ 指针右移对象大小的距离
└─ 堆内存不规整(CMS等标记清除算法)→ 空闲列表(Free List)
│ 从列表中找一块够大的空间
│
│ 并发安全保障:
│ 方案A: CAS + 失败重试
│ 方案B: TLAB(默认方案,更快)
▼
3. 内存空间初始化为零值(保证字段不赋值也能使用)
▼
4. 设置对象头(Mark Word、类型指针、数组长度)
▼
5. 执行 <init>() 方法(构造函数)
3.2 对象的内存布局
![[Gemini_Generated_Image_7uwahm7uwahm7uwa.png]]
┌──────────────────────────────────────┐
│ 对象头 (Object Header) │
│ ┌────────────────────────────────┐ │
│ │ Mark Word (8字节/64位JVM) │ │ hashCode、GC分代年龄、锁状态标志
│ │ │ │ 偏向线程ID、偏向时间戳
│ ├────────────────────────────────┤ │
│ │ 类型指针 (Klass Pointer) │ │ 指向方法区中的Class元数据
│ │ 开启压缩指针时4字节,否则8字节 │ │ JVM通过它确定对象是哪个类的实例
│ ├────────────────────────────────┤ │
│ │ [数组长度] (4字节, 仅数组对象) │ │
│ └────────────────────────────────┘ │
├──────────────────────────────────────┤
│ 实例数据 (Instance Data) │ 各字段的值,按分配策略排列
│ 排列顺序:longs/doubles → ints → │ (相同宽度的字段分配在一起)
│ shorts/chars → bytes/booleans → │ 父类字段在子类字段之前
│ oops(引用) │
├──────────────────────────────────────┤
│ 对齐填充 (Padding) │ 保证对象大小是8字节的整数倍
└──────────────────────────────────────┘
Mark Word 在不同状态下的内容(64位JVM)
| 锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit(偏向) | 2bit(锁标志) |
|---|---|---|---|---|---|---|
| 无锁 | unused | hashCode | unused | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID(54bit) | Epoch(2bit) | unused | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针(62bit) | 00 | ||||
| 重量级锁 | 指向Monitor的指针(62bit) | 10 | ||||
| GC标记 | 空 | 11 |
⚠️ 关键细节:调用过
hashCode()的对象无法进入偏向锁状态——因为偏向锁需要用Mark Word存储线程ID,没地方放hashCode了。已经处于偏向锁状态的对象调用hashCode()会直接膨胀为重量级锁。
3.3 对象访问方式
| 方式 | 实现 | 优点 | 缺点 | 代表 |
|---|---|---|---|---|
| 句柄访问 | 堆中有句柄池,引用→句柄→对象 | GC移动对象只改句柄,引用稳定 | 多一次间接寻址 | — |
| 直接指针 | 引用直接存对象地址 | 速度快,少一次间接 | GC移动对象需更新所有引用 | HotSpot |
🎯 记忆锚点:对象 = 对象头 + 实例数据 + 对齐填充。对象头里的 Mark Word 是”万用表”——随锁状态变脸。HotSpot用直接指针访问对象(快)。
四、垃圾收集(GC)
4.1 判断对象是否存活
引用计数法(JVM不使用)
每个对象维护一个引用计数器。被引用+1,引用失效-1,为0则可回收。
致命缺陷:循环引用无法回收。
A.field = B; // B引用计数=1
B.field = A; // A引用计数=1
A = null; // A引用计数仍=1(B.field还指着)
B = null; // B引用计数仍=1(A.field还指着)
// A和B永远不会被回收
可达性分析(JVM采用的方案)
从 GC Roots 出发,沿引用链遍历。不可达的对象即为可回收。
GC Roots 包括:
- 虚拟机栈中引用的对象(局部变量)
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- JVM内部引用(基本数据类型对应的Class对象、常驻异常对象、系统ClassLoader)
- 所有被同步锁(
synchronized)持有的对象 - JMXBean、JVMTI中注册的回调、本地代码缓存等
四种引用类型
强引用 > 软引用 > 弱引用 > 虚引用
│ │ │ │
│ │ │ └─ PhantomReference: 无法通过它获取对象
│ │ │ 配合ReferenceQueue做资源清理(替代finalize)
│ │ │
│ │ └─ WeakReference: 下次GC必回收
│ │ 典型:WeakHashMap、ThreadLocal的Entry
│ │
│ └─ SoftReference: 内存不足时才回收
│ 典型:缓存(图片缓存、页面缓存)
│
└─ 强引用: Object o = new Object(); GC绝不回收
finalize() 的”缓刑”机制
对象不可达后并非立即死亡:
- 第一次标记:不可达 → 检查是否覆盖了
finalize()且未执行过 - 若有
finalize():放入F-Queue,由低优先级Finalizer线程执行 - 第二次标记:若
finalize()中重新建立了引用链 → 逃脱回收 finalize()只执行一次——第二次不可达时直接回收
⚠️ 实战建议:
finalize()已在 JDK9 标记为@Deprecated,永远不要依赖它做资源清理。用try-with-resources或Cleaner。
🎯 记忆锚点:判断对象存活 = 从GC Roots画一张图,走不到的就是垃圾。四种引用 = 强软弱虚,强度递减,回收越来越容易。
4.2 垃圾收集算法
基础算法对比
| 算法 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 标记-清除 | 标记存活对象,清除其余 | 简单 | 内存碎片、效率不稳定 | CMS的老年代 |
| 标记-复制 | 存活对象复制到另一块,原空间整体清除 | 无碎片、高效(存活率低时) | 浪费一半空间 | 新生代(Eden+Survivor优化版) |
| 标记-整理 | 标记存活对象,向一端移动,清理边界外 | 无碎片、空间利用率高 | 移动对象需要STW(暂停线程) | 老年代 |
新生代的复制算法优化
IBM研究表明:新生代98%的对象”朝生夕死”。所以不需要1:1划分空间:
分配时使用 Eden + 其中一个Survivor(如S0)
↓ Minor GC
存活对象复制到另一个Survivor(S1),Eden和S0清空
↓ 下次Minor GC
存活对象从Eden+S1复制到S0,Eden和S1清空
↓ 如此交替...
分配担保:当Survivor空间不足以容纳Minor GC后的存活对象时,这些对象直接通过分配担保(Handle Promotion)进入老年代。
分代收集理论的两个假说
- 弱分代假说:绝大多数对象都是朝生夕死的(支撑新生代用复制算法)
- 强分代假说:熬过越多次GC的对象越难以消亡(支撑老年代与新生代分开收集)
由此衍生问题:新生代GC时,老年代对象可能引用新生代对象,难道要扫描整个老年代?
解决方案:记忆集(Remembered Set)与卡表(Card Table)
老年代被划分为一个个Card Page(通常512字节)
│
▼
┌───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 0 │ 1 │ 0 │ 1 │ 0 │ 0 │ ← Card Table(字节数组)
└───┴───┴───┴───┴───┴───┴───┘
↑ ↑
Card Page中有对象引用了新生代对象就改为1
Minor GC时只需扫描这些"脏卡"对应的区域
通过**写屏障(Write Barrier)**在引用赋值时维护卡表的脏标记。
🎯 记忆锚点:三种算法 = 清除(有碎片)、复制(费空间)、整理(要停顿)。新生代用”复制”因为死亡率高,老年代用”整理”因为存活率高。
4.3 垃圾收集器详解
收集器全景与搭配关系(JDK8 HotSpot)
新生代 老年代
┌──────────────┐ ┌──────────────┐
│ Serial │────────→│ Serial Old │
└──────────────┘ └──────────────┘
┌──────────────┐ ┌──────────────┐
│ ParNew │────────→│ CMS │
└──────────────┘ └──────────────┘
┌──────────────┐ ┌──────────────┐
│Parallel Scav.│────────→│ Parallel Old │ ← JDK8默认组合
└──────────────┘ └──────────────┘
┌─────────────────────────────────────────┐
│ G1 (全堆管理) │ ← JDK9+默认
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ ZGC (全堆管理) │ ← JDK15+生产可用
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Shenandoah (全堆管理) │ ← OpenJDK
└─────────────────────────────────────────┘
各收集器核心特征
Serial / Serial Old 串行
- 单线程,GC时必须暂停所有用户线程(STW)
- 新生代:标记-复制 | 老年代:标记-整理
- 适用场景:客户端模式、小堆(几十到一百MB)
- 开启参数:
-XX:+UseSerialGC
ParNew
- Serial的多线程并行版本,仅新生代
- 意义:唯一能配合CMS使用的新生代收集器(JDK9后不再推荐)
- 开启参数:
-XX:+UseParNewGC,线程数-XX:ParallelGCThreads
Parallel Scavenge / Parallel Old
- 关注点:吞吐量(= 用户代码时间 / 总时间)
- JVM 会根据历史的 GC 时间、用户代码运行时间、对象存活率和晋升情况,通过自适应调节策略动态调整新生代大小、Eden/Survivor 比例、晋升阈值和堆容量,从而减少 GC 总时间占比,提高用户代码执行时间在总时间中的比例。
- 提供两个精确控制参数:
-XX:MaxGCPauseMillis:最大停顿时间目标-XX:GCTimeRatio:吞吐量目标(默认99,即GC时间≤1%)
-XX:+UseAdaptiveSizePolicy:自适应调节Eden/Survivor比例和晋升年龄- JDK8默认收集器:
-XX:+UseParallelGC
CMS(Concurrent Mark Sweep)
关注点:最短停顿时间。老年代收集器,使用标记-清除算法。
STW 并发 STW 并发
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┬─────────────┬───────────────┬──────────────┐
│ 初始标记 │ 并发标记 │ 重新标记 │ 并发清除 │
│ 标记GC │ 追踪引用链 │ 修正并发期间 │ 清除死亡对象 │
│ Roots直 │ 与用户线程 │ 变动的标记 │ 与用户线程 │
│ 接关联的 │ 同时运行 │ 增量更新 │ 同时运行 │
│ 对象 │ │ │ │
└──────────┴─────────────┴───────────────┴──────────────┘
非常快 最耗时 较快 较耗时
CMS的三大问题:
- CPU敏感:并发阶段占用线程,默认
(CPU核数+3)/4。CPU少于4核时影响大 - 浮动垃圾:并发清除阶段用户线程产生的新垃圾本次无法回收。需预留空间给用户线程(
-XX:CMSInitiatingOccupancyFraction,JDK6默认92%)。预留不足触发Concurrent Mode Failure→ 退化为 Serial Old(灾难性停顿) - 内存碎片:标记-清除算法必然产生碎片。
-XX:+UseCMSCompactAtFullGC(默认开启)在Full GC后整理,-XX:CMSFullGCsBeforeCompaction控制几次Full GC后整理一次
⚠️ CMS已在JDK9标记废弃,JDK14正式移除。 但面试中仍是高频考点。
G1(Garbage First)
革命性设计:打破了物理上的分代边界,将堆划分为大小相等的 Region。
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ E │ S │ │ O │ O │ H │ H │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O │ │ E │ E │ │ O │ H │ O │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ │ S │ O │ │ E │ │ O │ │
└────┴────┴────┴────┴────┴────┴────┴────┘
E = Eden S = Survivor O = Old H = Humongous 空白 = 空闲
每个Region大小 = 1~32MB(2的幂),由 -XX:G1HeapRegionSize 设定
核心特性:
- 可预测的停顿:
-XX:MaxGCPauseMillis=200(默认200ms)。G1跟踪每个Region的”回收价值”(可回收空间 / 回收耗时),优先回收价值最大的Region - Humongous Region:大于Region一半的对象视为巨型对象,分配在连续的H Region中
- Mixed GC:不只收集新生代,还选择部分老年代Region一起收集
G1的回收过程:
1. 初始标记(STW) ← 标记GC Roots直接关联的对象,借用Minor GC的STW
2. 并发标记 ← SATB快照算法保证一致性
3. 最终标记(STW) ← 处理SATB缓冲区残留的引用变化
4. 筛选回收(STW) ← 按回收价值排序,选择Region,复制存活对象到空Region
SATB vs 增量更新:CMS用增量更新(记录新增引用),G1用SATB(Snapshot At The Beginning,记录删除引用)。SATB更适合Region化内存布局。
G1相比CMS的优势:
- 可控停顿时间
- 不产生内存碎片(Region间复制 = 整理)
- 全堆管理,无需搭配其他收集器
G1适用场景:堆大小 ≥ 6GB,停顿要求 < 500ms。JDK9+默认收集器。
G1 是 HotSpot 中面向服务端、大堆场景的低停顿垃圾收集器,从 JDK9 开始成为默认收集器。它把整个堆划分为多个大小相等的 Region,虽然仍保留分代思想,但新生代和老年代不再是物理连续空间,而是由一组 Region 动态组成。G1 会维护每个 Region 的 Remembered Set,避免回收时全堆扫描;一次回收只选择一批 Region 组成 Collection Set,并结合停顿预测模型尽量满足 MaxGCPauseMillis 这样的停顿目标。它既有日常的 Young GC,也会在全局并发标记后做 Mixed GC,把年轻代和一部分垃圾比例高的老年代 Region 一起回收。相比 CMS,G1 通过复制存活对象完成整理,减少内存碎片,并且更强调停顿可预测性,但实现更复杂,RSet 和写屏障也会带来额外开销。
ZGC(Z Garbage Collector)
设计目标:停顿时间不超过1ms,且不随堆大小增长。
| 特性 | 值 |
|---|---|
| 停顿时间 | < 1ms(亚毫秒级) |
| 支持堆大小 | 8MB ~ 16TB |
| 核心技术 | 染色指针(Colored Pointers) + 读屏障(Load Barrier) |
| 并发阶段 | 几乎全程并发,STW仅在根节点枚举 |
染色指针:在64位指针中拿出4个bit存储GC元数据(Marked0、Marked1、Remapped、Finalizable),因此ZGC不支持压缩指针,不支持32位平台。
64位指针布局:
[16位 unused] [4位 GC标记] [44位 对象地址]
└─ 最大寻址空间 = 2^44 = 16TB
ZGC的并发整理原理:通过读屏障 + 转发表实现并发对象移动。用户线程读取对象时,读屏障检查指针颜色,若发现对象已移动则自修复指针。
开启:JDK15+ -XX:+UseZGC,JDK21+ 分代ZGC -XX:+UseZGC -XX:+ZGenerational(JDK23+默认分代)
Shenandoah
RedHat开发,目标类似ZGC但技术路线不同:用Brooks转发指针 + 读/写屏障实现并发整理。Oracle官方JDK不包含,仅OpenJDK。
收集器选择决策表
| 场景 | 推荐收集器 | 关键参数 |
|---|---|---|
| 小堆(<100MB)/客户端 | Serial | -XX:+UseSerialGC |
| 后台计算/批处理(吞吐量优先) | Parallel | -XX:+UseParallelGC |
| Web应用(JDK8,低延迟) | ParNew + CMS | -XX:+UseConcMarkSweepGC |
| 大堆(≥6GB)/JDK9+ | G1 | -XX:+UseG1GC |
| 超大堆/极致低延迟/JDK15+ | ZGC | -XX:+UseZGC |
🎯 记忆锚点:Serial(单线程)→ Parallel(多线程/吞吐量)→ CMS(并发/低延迟)→ G1(Region/可控停顿)→ ZGC(染色指针/亚毫秒),GC发展史就是”STW越来越短”的历史。
4.4 GC关键概念补充
Minor GC / Major GC / Full GC
| 类型 | 范围 | 触发条件 |
|---|---|---|
| Minor GC (Young GC) | 新生代 | Eden区满 |
| Major GC (Old GC) | 老年代 | 仅CMS有真正意义上的单独Old GC |
| Full GC | 整个堆 + 方法区 | ①老年代空间不足 ②方法区空间不足 ③System.gc()(建议,非强制)④CMS Concurrent Mode Failure ⑤空间分配担保失败 |
⚠️ 概念辨析:很多文章把 Major GC 等同于 Full GC,但严格来说不同。Major GC只收集老年代,Full GC收集全堆。实际对话中注意上下文语义。
对象晋升老年代的条件
- 年龄阈值:对象在Survivor区每熬过一次Minor GC,年龄+1,达到
-XX:MaxTenuringThreshold(默认15,CMS默认6)时晋升 - 动态年龄判定:Survivor中相同年龄所有对象大小之和 > Survivor空间的一半 → 该年龄及以上的对象直接晋升
- 大对象直接进入老年代:
-XX:PretenureSizeThreshold(仅Serial和ParNew有效)
空间分配担保
Minor GC前,JVM检查:老年代最大可用连续空间 > 新生代所有对象总空间?
- 是 → 安全,直接Minor GC
- 否 → 检查
-XX:HandlePromotionFailure是否允许担保(JDK6 Update 24后默认允许)- 允许 → 检查老年代最大可用连续空间 > 历次晋升的平均大小?是则冒险Minor GC,否则Full GC
- 不允许 → Full GC
🎯 记忆锚点:对象进老年代 = 年龄够了 / Survivor太挤 / 对象太大。Full GC是”大扫除”,触发条件要记住5个。
五、JIT编译优化
5.1 解释执行与编译执行
HotSpot采用混合模式:解释器 + JIT编译器共存。
Java字节码
│
├─→ 解释器逐条执行(启动快,执行慢)
│
└─→ 检测到"热点代码" → JIT编译为本地机器码(编译慢,执行快)
│
├─ C1编译器(Client Compiler):快速编译,轻量优化
│ 适用:启动速度敏感的客户端应用
│
└─ C2编译器(Server Compiler):深度优化,编译慢但执行极快
适用:长期运行的服务端应用
JDK8+默认:分层编译(-XX:+TieredCompilation),C1和C2协同工作
分层编译的5个层级
| 层级 | 描述 |
|---|---|
| 0 | 解释执行(收集性能监控数据) |
| 1 | C1编译,无性能监控 |
| 2 | C1编译,带方法调用计数和回边计数 |
| 3 | C1编译,带全部性能监控(最慢的C1层) |
| 4 | C2编译,深度优化 |
常见路径:0 → 3 → 4(先C1全量监控,再C2深度优化)
5.2 热点探测
怎么判断代码是”热点”? 基于计数器:
- 方法调用计数器:方法被调用次数。阈值:Client模式1500次,Server模式10000次。可通过
-XX:CompileThreshold修改 - 回边计数器:循环体执行次数(回边 = 字节码中向后跳转的指令)。触发栈上替换(OSR, On Stack Replacement)——循环还在执行中就替换为编译后的代码
热度衰减:方法调用计数器不是绝对次数,而是”一段时间内的相对频率”。超过半衰期未达到阈值,计数减半。-XX:-UseCounterDecay 关闭衰减。
5.3 关键JIT优化技术
方法内联(Method Inlining)
将被调用方法的代码直接嵌入调用处,消除方法调用开销,更重要的是为后续优化打开空间。
// 内联前
int result = add(a, b);
private int add(int x, int y) { return x + y; }
// 内联后
int result = a + b; // 消除了方法调用,后续可以做常量折叠等优化
- JVM最重要的优化,没有之一。大多数其他优化都依赖内联后的代码
- 限制:方法体太大(默认 > 325字节不内联)、虚方法(需要先做类型推测)
-XX:MaxInlineSize(小方法直接内联阈值,默认35字节)-XX:FreqInlineSize(热方法内联大小上限,默认325字节)
逃逸分析(Escape Analysis)
分析对象的动态作用域,判断对象是否”逃逸”出方法或线程。
不逃逸 ─→ 可做标量替换、栈上分配
方法逃逸(传给其他方法)─→ 仍可做锁消除(如果不逃逸出线程)
线程逃逸(被其他线程访问)─→ 无法优化
基于逃逸分析的三大优化:
① 栈上分配(Stack Allocation) 对象不逃逸出方法 → 直接在栈帧上分配,方法结束自动回收,不进入堆。
HotSpot实际上通过标量替换间接实现了栈上分配的效果。
② 标量替换(Scalar Replacement) 将对象拆解为其中的基本类型字段,分别存放在局部变量表中。
// 优化前
Point p = new Point(1, 2);
System.out.println(p.x + p.y);
// 标量替换后(不再创建Point对象)
int x = 1;
int y = 2;
System.out.println(x + y);
-XX:+EliminateAllocations(默认开启)
③ 锁消除(Lock Elimination) 对象不逃逸出线程 → 这个对象上的同步操作可以消除。
// StringBuffer.append() 内部有 synchronized
// 但如果sb不逃逸出方法,锁可以被消除
public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
-XX:+EliminateLocks(默认开启)
其他重要优化
- 公共子表达式消除:
a*b + a*b→ 计算一次a*b,复用结果 - 数组边界检查消除:编译期能证明下标不越界时,去掉运行时检查
- 锁粗化:连续对同一对象加锁解锁 → 合并为一次更大范围的锁
🎯 记忆锚点:JIT三大法宝 = 方法内联(最重要)、逃逸分析(最智能)、分层编译(C1快启动 + C2深优化)。逃逸分析带来三个红利:栈上分配、标量替换、锁消除。
六、性能调优工具与实战
6.1 JDK自带监控工具
| 工具 | 全称 | 用途 | 典型命令 |
|---|---|---|---|
jps | JVM Process Status | 列出Java进程 | jps -mlvV |
jstat | JVM Statistics Monitoring | GC统计信息 | jstat -gcutil <pid> 1000 10 |
jinfo | Configuration Info | 查看/修改JVM参数 | jinfo -flags <pid> |
jmap | Memory Map | 堆内存快照 | jmap -dump:format=b,file=heap.hprof <pid> |
jstack | Stack Trace | 线程快照 | jstack -l <pid> |
jcmd | JVM Command | 万能工具(JDK7+) | jcmd <pid> GC.heap_dump /tmp/heap.hprof |
jstat 关键输出解读
$ jstat -gcutil <pid> 1000
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 87.44 62.31 43.22 95.81 92.35 28 0.182 3 0.456 0.638
- S0/S1:Survivor区使用率(%),任何时刻必有一个是0
- E:Eden区使用率
- O:Old区使用率
- M:Metaspace使用率
- YGC/YGCT:Young GC次数/耗时
- FGC/FGCT:Full GC次数/耗时
- 关注重点:FGC频率和FGCT耗时。如果FGC频繁或FGCT持续增长,需要排查
jstack 关键场景
死锁检测:jstack 会在输出末尾自动检测并打印死锁信息:
Found one Java-level deadlock:
=============================
"Thread-1": waiting to lock monitor 0x00007f...
which is held by "Thread-0"
"Thread-0": waiting to lock monitor 0x00007f...
which is held by "Thread-1"
CPU飙高排查步骤:
# 1. 找到CPU最高的Java进程
top -c
# 2. 找到该进程中CPU最高的线程
top -Hp <pid>
# 3. 线程ID转十六进制
printf "%x\n" <tid>
# 4. 在jstack输出中搜索该线程
jstack <pid> | grep -A 30 "nid=0x<hex_tid>"
6.2 GC日志分析
开启GC日志
# JDK8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log
# JDK9+(统一日志框架)
-Xlog:gc*:file=/path/gc.log:time,uptime,level,tags
GC日志示例(G1)
[2024-01-15T10:30:15.123+0800] GC pause (G1 Evacuation Pause) (young)
[Eden: 204.0M(204.0M)->0.0B(186.0M)
Survivors: 8192.0K->26.0M
Heap: 320.5M(512.0M)->128.3M(512.0M)]
[Times: user=0.08 sys=0.01, real=0.03 secs]
解读:Eden从204M全部清空,Survivor从8M涨到26M(存活对象),堆总使用从320M降到128M,实际停顿时间看 real(0.03秒=30ms)。
6.3 常见调优场景与实战
场景一:频繁Full GC
排查路径:
jstat观察 → FGC频繁增长
│
├─ 老年代占用持续增长不下降 → 可能存在内存泄漏
│ └─ jmap dump堆快照 → MAT/VisualVM分析大对象和引用链
│
├─ 老年代占用不高就Full GC → 元空间不足?
│ └─ 检查 -XX:MaxMetaspaceSize,是否动态加载了大量类
│
└─ 大量对象直接进入老年代 → 对象过大?Survivor太小?
└─ 调整 -XX:PretenureSizeThreshold 或增大Survivor
场景二:Young GC耗时过长
可能原因与对策:
- 存活对象太多:检查是否有不必要的大对象在新生代中,是否有缓存泄漏
- 引用处理耗时:弱引用/软引用/虚引用过多,
-XX:+ParallelRefProcEnabled开启并行引用处理 - 新生代过大:减小
-Xmn,让每次GC扫描范围更小
场景三:内存泄漏排查
1. 确认现象:jstat观察Old区持续增长,Full GC后无法有效回收
2. 获取堆转储:
jmap -dump:live,format=b,file=heap.hprof <pid>
或 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/
3. 分析工具:
- Eclipse MAT: Leak Suspects → Dominator Tree → 找到占用最大的对象
- 查看 GC Roots 到泄漏对象的引用链
4. 常见泄漏模式:
- 静态集合类持有对象引用(如 static Map 不断 put 不 remove)
- 未关闭的资源(Connection、Stream)
- ThreadLocal 未 remove(线程池场景下线程复用,ThreadLocal不会被回收)
- 内部类持有外部类引用
场景四:常用JVM参数模板
# 通用生产环境基线(JDK8 + G1,8核16G机器,堆分配4G)
-Xms4g -Xmx4g # 堆大小固定,避免动态扩缩
-XX:+UseG1GC # G1收集器
-XX:MaxGCPauseMillis=200 # 目标最大停顿200ms
-XX:+HeapDumpOnOutOfMemoryError # OOM时自动dump
-XX:HeapDumpPath=/data/logs/heapdump.hprof
-XX:+PrintGCDetails -XX:+PrintGCDateStamps # GC日志
-Xloggc:/data/logs/gc.log
-XX:MetaspaceSize=256m # 元空间初始大小
-XX:MaxMetaspaceSize=256m # 避免无限增长
⚠️ 关键原则:
-Xms和-Xmx设为相同值。原因:避免堆大小动态调整时的性能开销和Full GC。MetaspaceSize和MaxMetaspaceSize也建议设相同值。
GC调优核心指标
| 指标 | 健康范围 | 告警信号 |
|---|---|---|
| Young GC频率 | 10~30秒一次 | 每秒多次 |
| Young GC耗时 | < 50ms | > 200ms |
| Full GC频率 | 几小时甚至几天一次 | 每分钟一次 |
| Full GC耗时 | < 1s | > 5s |
| 堆使用率(GC后) | < 70% | > 90%持续不降 |
🎯 记忆锚点:调优四步走 = 看指标(jstat)→ 看日志(GC log)→ 看堆(jmap + MAT)→ 看线程(jstack)。核心目标:减少Full GC,控制停顿时间。
七、JVM知识图谱总览
.class 字节码文件
│
┌─────────▼──────────┐
│ 类加载子系统 │
│ 加载→连接→初始化 │
│ 双亲委派模型 │
└─────────┬──────────┘
│
┌───────────────▼───────────────┐
│ 运行时数据区 │
│ │
│ 线程私有: │
│ PC寄存器 │ 虚拟机栈 │ 本地方法栈│
│ │
│ 线程共享: │
│ 堆(分代) │ 方法区/元空间 │
└───────┬───────────┬───────────┘
│ │
┌───────▼───┐ ┌───▼────────────┐
│ 对象分配 │ │ 执行引擎 │
│ TLAB/CAS │ │ 解释器 + JIT │
│ 内存布局 │ │ 方法内联/逃逸 │
└───────┬───┘ └────────────────┘
│
┌───────▼───────────┐
│ 垃圾收集器 │
│ Serial→Parallel │
│ →CMS→G1→ZGC │
│ 标记/复制/整理 │
└───────┬───────────┘
│
┌───────▼───────────┐
│ 性能监控与调优 │
│ jstat/jmap/jstack │
│ GC日志/堆分析 │
└───────────────────┘
这套体系从字节码进入JVM开始,沿着”加载 → 存储 → 分配 → 回收 → 优化 → 调优”的主线一路贯穿。掌握了这条线,JVM的知识不再是碎片化的,而是一个有机的整体。