# 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);
        }
    }
}

排查步骤

  1. 查看GC日志:看GC频率和停顿时间
  2. 使用jstatjstat -gcutil <pid> 1000 每秒查看GC情况
  3. 生成堆转储jmap -dump:format=b,file=heap.hprof <pid>
  4. 用MAT分析:找到占用内存最大的对象
  5. 找到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并发编程深度