# JVM 深度原理
用生活化的比喻,让你彻底理解 JVM 的内存模型、垃圾回收、JIT 编译和类加载机制
前置知识:2_1_v8Learn(V8 GC 和 JIT 编译原理)
阅读指南(初学者必看)
为什么你需要学习 JVM 深度原理?
你已经学了 V8 引擎的 GC 和 JIT 编译。但 Java 后端运行在 JVM 上,不理解 JVM 就无法:
- 理解游戏服务器为什么偶尔卡顿(STW 导致)
- 选择合适的垃圾回收器(ZGC vs G1)
- 排查线上 OOM 问题
- 理解类加载和热更新机制
学完本章,你能回答:
- JVM 内存有哪些区域?堆和非堆分别存什么?
- G1、ZGC、Shenandoah 各适合什么场景?游戏服务器该选哪个?
- JIT 编译的 C1/C2 和 V8 的 Sparkplug/TurboFan 有什么异同?
- 双亲委派模型是什么?为什么游戏热更新要打破它?
本文结构
第一部分:JVM 内存模型(建立全景认知)
第二部分:垃圾回收器深度(GC 演进路线 + 游戏服务器选型)
第三部分:JIT 编译机制(对比 V8 的编译体系)
第四部分:类加载机制(双亲委派 + 热更新基础)
一、JVM 内存模型
生活类比:JVM 内存就像一个公司的办公区域。
JVM 内存
├── 堆(Heap)── 开放办公区,所有员工共享
│ ├── 新生代(Young Generation)── 新员工工位
│ │ ├── Eden 区 ── 新对象分配
│ │ ├── Survivor 0 ── 存活一次的对象
│ │ └── Survivor 1 ── 存活两次的对象
│ └── 老生代(Old Generation)── 老员工工位
│
├── 方法区 / 元空间(Metaspace)── 公司档案室
│ └── 类信息、常量池、静态变量
│
├── 虚拟机栈(VM Stack)── 每个线程独立办公室
│ └── 栈帧(局部变量表、操作数栈、动态链接、返回地址)
│
├── 本地方法栈(Native Method Stack)── 外包团队办公室
│
└── 程序计数器(PC Register)── 每个线程的工作进度板
对比 V8:JVM 的分代 GC 和 V8 的 Orinoco GC 原理相通!
| JVM | V8 | |
|---|---|---|
| 新生代 | Eden + S0 + S1 | New Space |
| 老生代 | Old Generation | Old Space |
| 新生代 GC | Minor GC(复制算法) | Scavenge |
| 老生代 GC | Major GC / Full GC | Mark-Compact |
| 并发 GC | G1、ZGC | Orinoco(并发标记/整理) |
各区域详解
堆(Heap):
- 所有对象实例和数组都在堆上分配
- 堆是 GC 的主要工作区域
- 游戏服务器中,战斗对象、玩家数据、消息队列都在堆上
// 示例:对象都创建在堆上
public class HeapDemo {
public static void main(String[] args) {
Player player = new Player(); // player引用在栈上,对象在堆上
player.name = "张三"; // name字符串在堆上
int[] scores = new int[100]; // scores引用在栈,数组在堆
}
}
class Player {
String name;
int level;
}
方法区 / 元空间(Metaspace):
- JDK 8 前:永久代(PermGen),大小固定容易 OOM
- JDK 8+:元空间(Metaspace),使用本地内存,自动扩容
- 存储类元信息、常量池、静态变量
虚拟机栈(VM Stack):
- 每个线程私有
- 每个方法调用创建一个栈帧
- 栈帧包含:局部变量表、操作数栈、动态链接、返回地址
- 栈溢出:
StackOverflowError(递归太深)
程序计数器(PC Register):
- 每个线程私有
- 记录当前执行的字节码指令地址
- 唯一不会 OOM 的区域
生活类比总结:
| JVM 区域 | 公司类比 | 共享性 | 是否会 OOM |
|---|---|---|---|
| 堆 | 开放办公区 | 所有线程共享 | ✅ 最常见 |
| 元空间 | 档案室 | 所有线程共享 | ✅ |
| 虚拟机栈 | 独立办公室 | 线程私有 | ✅ StackOverflow |
| 本地方法栈 | 外包办公室 | 线程私有 | ✅ |
| 程序计数器 | 工作进度板 | 线程私有 | ❌ 唯一不会 |
二、垃圾回收器深度
GC 演进路线:
Serial → Parallel → CMS → G1 → ZGC / Shenandoah
(单线程) (多线程) (并发) (分区) (超低延迟)
| GC | 适用场景 | STW 时间 | 游戏服务器推荐 |
|---|---|---|---|
| G1 | 通用,堆 4GB+ | 10-200ms | ✅ 默认选择 |
| ZGC | 超低延迟,堆 8GB+ | <1ms | ✅ 对延迟敏感的服务 |
| Shenandoah | 超低延迟 | <10ms | ✅ 替代 ZGC |
| CMS | 已废弃 | — | ❌ |
游戏服务器为什么特别关注 GC?
- 游戏服务器是实时系统,STW = 所有玩家卡顿
- 16ms 的 STW 在 60fps 游戏中就会导致 1 帧卡顿
- ZGC 的 <1ms STW 是游戏服务器的最佳选择
G1 垃圾回收器详解
生活类比:G1 就像一个分区清洁的物业公司。
堆被划分为多个等大的 Region:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ S0 │ O │ O │ H │ E │ E │ S1 │
└────┴────┴────┴────┴────┴────┴────┴────┘
E = Eden S = Survivor O = Old H = Humongous(大对象)
优先回收垃圾最多的 Region(Garbage First 名字由来)
G1 核心机制:
- 分区收集:不再整代收集,按 Region 回收
- 预测停顿:根据历史数据预测每个 Region 的回收时间
- 混合回收:同时回收新生代和部分老生代 Region
ZGC 垃圾回收器详解
生活类比:ZGC 就像不暂停营业的商场清洁。
传统 GC:暂停营业 → 清洁 → 恢复营业
ZGC:边营业边清洁,顾客几乎感觉不到
核心技术:读屏障(Load Barrier)+ 染色指针(Colored Pointers)
ZGC 为什么能做到 <1ms STW?
- 标记阶段:并发标记,不停顿
- 转移阶段:并发转移,只停顿寻址
- 读屏障:保证并发期间指针一致性
垃圾回收算法
标记-清除(Mark-Sweep)
生活类比:就像清理房间。先标记出哪些东西是垃圾(标记),然后把垃圾扔掉(清除)。
步骤:
1. 标记阶段:从GC Roots遍历,标记所有可达对象
2. 清除阶段:回收未被标记的对象
内存状态变化:
[存活][垃圾][存活][垃圾][垃圾][存活]
v
[存活][ 空 ][存活][ 空 ][ 空 ][存活]
缺点:产生大量内存碎片!
适用场景:老年代(因为老年代对象存活率高,复制成本高)
复制(Copying)
生活类比:有两个抽屉,只用一个。清理时把有用的东西搬到另一个抽屉,然后整个清空原来的抽屉。
内存分为两半:From空间和To空间(比例8:1:1中的S0和S1)
初始:
From: [存活][存活][垃圾][垃圾][存活]
To: [空][空][空][空][空]
复制后:
From: [ 空 ][ 空 ][ 空 ][ 空 ][ 空 ] <- 整体清空
To: [存活][存活][存活][空][空] <- 有用的搬过来
下次GC时,From和To交换角色
优点:没有内存碎片 缺点:内存利用率只有50%(实际上新生代用Eden:S0:S1 = 8:1:1,利用率90%)
适用场景:新生代(因为新生代对象存活率低,复制成本低)
标记-整理(Mark-Compact)
生活类比:就像整理书架。先标记出不要的书(标记),然后把要的书推到一边挤紧(整理),最后把空出来的位置统一清理。
步骤:
1. 标记存活对象
2. 将存活对象向一端移动
3. 清理边界外的内存
[存活][垃圾][存活][垃圾][垃圾][存活]
v
[存活][存活][存活][ 空 ][ 空 ][ 空 ]
优点:没有内存碎片,内存利用率高 缺点:移动对象需要暂停程序(STW - Stop The World)
适用场景:老年代
分代收集理论
Java堆分为新生代和老年代,因为"大多数对象朝生夕死"。
对象的一生:
出生在 Eden 区
|
v
第一次GC,存活 -> 移动到 Survivor区(S0或S1)
|
v
在S0和S1之间来回移动,每熬过一次GC,年龄+1
|
v
年龄达到阈值(默认15岁)-> 晋升到老年代
|
v
在老年代长期存活,直到死亡
// JVM参数设置新生代和老年代比例
// -Xms512m -Xmx512m -XX:NewRatio=2
// 表示老年代:新生代 = 2:1,即老年代约341m,新生代约171m
// 设置晋升阈值
// -XX:MaxTenuringThreshold=15
三、JIT 编译机制
生活类比:JIT 就像翻译官的"笔记"。
第一次执行:逐行解释执行(慢,像口译)
↓ 热点检测(这个方法被调用超过阈值)
编译为机器码:C1 编译器(快速编译,适度优化)
↓ 更热点(调用更频繁)
编译为更好机器码:C2 编译器(慢编译,深度优化)
对比 V8:
| JVM | V8 | |
|---|---|---|
| 解释器 | 字节码解释器 | Ignition |
| 基线编译器 | C1(Client Compiler) | Sparkplug |
| 优化编译器 | C2(Server Compiler) | TurboFan/Maglev |
| 去优化 | Deoptimization | Deoptimization |
JIT 编译的游戏服务器影响
游戏服务器启动后:
1. 开始都是解释执行(较慢)
2. 战斗逻辑被频繁调用 → C1 编译(快了一些)
3. 继续频繁调用 → C2 编译(最快)
问题:C2 编译本身消耗 CPU!
- 编译线程占用 CPU 可能影响业务线程
- 解决:-XX:CICompilerCount 调整编译线程数
JIT 优化技术
方法内联:
类比:把常用菜谱步骤写进肌肉记忆。
private int add(int a, int b) { return a + b; }
public int calculate() {
int x = add(1, 2); // JIT后: int x = 3;
int y = add(3, 4); // JIT后: int y = 7;
return x + y; // 直接返回10
}
逃逸分析:
核心:对象会逃出方法作用域吗?
public void noEscape() {
Point p = new Point(1, 2);
System.out.println(p.x + p.y);
// 优化1:栈上分配
// 优化2:标量替换 -> int x=1, y=2
// 优化3:消除同步
}
public void escape() {
Point p = new Point(1, 2);
global = p; // 逃了!必须在堆上分配
}
栈上分配与标量替换:
// 标量替换前
class Point { int x, y; }
Point p = new Point(1, 2);
// 标量替换后(JIT优化)
int x = 1;
int y = 2;
// 根本没有Point对象!
去优化(Deoptimization)
生活类比:翻译官发现之前的笔记有误,需要重新口译。
C2 编译时做了假设(如:这个方法不会被重写)
↓ 运行时假设被打破(子类被加载,方法被重写)
触发去优化:丢弃 C2 编译的机器码
↓ 回退到解释执行
↓ 重新收集 Profile
↓ 再次触发 JIT 编译(这次不做那个假设)
四、类加载机制
类加载过程:
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution) → 初始化(Initialization)
双亲委派模型:
Bootstrap ClassLoader(rt.jar)
↑ 委派
Extension ClassLoader(ext 目录)
↑ 委派
Application ClassLoader(classpath)
↑ 委派
自定义 ClassLoader
双亲委派模型详解
生活类比:双亲委派就像公司的审批流程——先问上级能不能批,上级再问上上级。
加载 com.game.BattleService 时:
1. 自定义 ClassLoader → 先问 Application ClassLoader
2. Application ClassLoader → 先问 Extension ClassLoader
3. Extension ClassLoader → 先问 Bootstrap ClassLoader
4. Bootstrap 找不到 → Extension 找 → Application 找 → 自定义找
为什么需要双亲委派?
- 保证核心类(如
java.lang.Object)只被加载一次 - 防止自定义类冒充核心类(安全)
游戏热更新的基础
游戏热更新的基础:自定义 ClassLoader 可以实现类的热替换。
// 热更新原理(简化)
public class HotReloadClassLoader extends ClassLoader {
public Class<?> loadClass(String name, byte[] classBytes) {
// 不走双亲委派,直接用新的字节码定义类
return defineClass(name, classBytes, 0, classBytes.length);
}
}
// 使用:
// 1. 修改 BattleService.java
// 2. 编译为 BattleService.class
// 3. 用新的 ClassLoader 加载新的 class
// 4. 新请求使用新类,旧请求继续用旧类
注意:
- 新旧类不是同一个类(ClassLoader 不同 + 名称相同 ≠ 同一个类)
- 需要管理好旧类实例的迁移
- OSGi 和 Java Agent 提供了更成熟的热更新方案
五、GC 调优与 OOM 排查
常用 JVM 参数
# 堆内存设置
-Xms512m # 初始堆大小
-Xmx512m # 最大堆大小(通常和Xms设成一样,避免动态扩缩容)
-Xmn128m # 新生代大小
# GC收集器选择
-XX:+UseG1GC # 使用G1
-XX:+UseZGC # 使用ZGC
# GC日志(JDK9+)
-Xlog:gc*:file=gc.log:time
# OOM时生成堆转储
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump.hprof
GC 问题排查步骤
// 示例:模拟内存泄漏
public class MemoryLeakDemo {
// 静态集合一直持有对象引用
private static List<byte[]> leakList = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
while (true) {
// 每次添加1MB数据,但从不清理
leakList.add(new byte[1024 * 1024]);
Thread.sleep(100);
}
}
}
排查步骤:
- 查看GC日志:看GC频率和停顿时间
- 使用jstat:
jstat -gcutil <pid> 1000每秒查看GC情况 - 生成堆转储:
jmap -dump:format=b,file=heap.hprof <pid> - 用MAT分析:找到占用内存最大的对象
- 找到GCRoots:看看是谁持有这些对象不让回收
调优案例
案例:游戏服务器频繁 Full GC
症状:每5分钟一次Full GC,每次停顿3秒
分析:
1. jstat发现老年代增长很快
2. 堆转储发现大量Player对象堆积
3. 代码审查:离线玩家的Player对象还在缓存里
解决方案:
- 给缓存加过期时间(Caffeine expireAfterAccess)
- 降低晋升阈值,让短命对象在新生代就被回收
- 调大新生代比例
调整参数:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-Xms2g -Xmx2g
-XX:NewRatio=1 # 新生代老年代1:1
游戏服务器 JVM 推荐配置
java -server \
-Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:+AlwaysPreTouch \
-XX:+HeapDumpOnOutOfMemoryError \
-Xlog:gc*:file=gc.log:time \
-jar game-server.jar
监控指标清单
| 指标 | 正常范围 | 告警阈值 |
|---|---|---|
| Old区使用率 | <70% | >85% |
| Full GC频率 | <1次/小时 | >1次/10分钟 |
| Full GC停顿 | <100ms | >500ms |
| MetaSpace使用率 | <80% | >90% |
| 堆内存使用率 | <80% | >90% |
六、常见问题
Q:游戏服务器应该选 G1 还是 ZGC? A:取决于对延迟的要求。如果 STW 200ms 可以接受(回合制、卡牌),选 G1(更成熟稳定)。如果是 MOBA、FPS 等对延迟极度敏感的场景,选 ZGC(STW <1ms)。JDK 17+ 的 ZGC 已经生产可用。
Q:堆是不是越大越好? A:不是。堆越大,Full GC 的停顿时间越长(G1 除外,G1 通过分区控制停顿)。一般游戏服务器堆 4-8GB 为宜,配合 ZGC 可以到 TB 级别。
Q:JVM 的 JIT 和 V8 的 JIT 有什么本质区别? A:核心原理相同(热点检测 → 分层编译 → 去优化),但 JVM 是字节码 JIT(先编译为字节码,再 JIT 为机器码),V8 是源码 JIT(直接从 JS 源码编译为机器码)。JVM 多了一层字节码,但字节码是平台无关的中间表示。
Q:为什么游戏服务器需要关注类加载? A:游戏需要热更新——修复 Bug 不停服。自定义 ClassLoader 打破双亲委派,可以实现类的热替换。但要注意旧实例的迁移和内存泄漏。
Q:元空间(Metaspace)和永久代(PermGen)有什么区别? A:PermGen 在 JDK 8 前使用,大小固定,容易 OOM。Metaspace 从 JDK 8 开始使用,使用本地内存,默认无上限(受物理内存限制),大大减少了 OOM 风险。
Q:对象一定在堆上分配吗? A:不一定!如果满足逃逸分析的条件(对象不逃出方法作用域),JVM可以在栈上分配(随着栈帧销毁而销毁,不用GC)。另外小对象还可能被标量替换(把对象拆成几个基本类型变量)。
实践任务
- 任务1:用
jmap -heap查看一个 Java 进程的堆内存分布,画出内存区域图 - 任务2:分别用 G1 和 ZGC 启动同一个应用,用 GC 日志对比 STW 时间差异
- 任务3:写一个递归方法触发
StackOverflowError,分析栈帧数量和栈大小关系 - 任务4:用 JMH 对比解释执行和 JIT 编译后的性能差异(-Xint vs -Xcomp)
- 任务5:实现一个自定义 ClassLoader,加载外部 .class 文件并调用其方法
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| JVM 内存模型 | 第02章 并发编程 | 线程栈和内存屏障依赖内存模型 |
| GC 机制 | 第09章 JVM 调优实战 | GC 选型和参数调优 |
| JIT 编译 | 2_1_v8Learn | JVM C1/C2 vs V8 Sparkplug/TurboFan |
| 类加载 | 第07章 字节码与动态编程 | 字节码操作依赖类加载机制 |
| GC 停顿 | 第11章 游戏服务器架构 | STW 导致玩家卡顿 |
上一章:学习路线图 | 下一章:02-Java并发编程深度