mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2203 字
6 分钟
Java JVM 运行机制:从 .class 到机器码
2023-02-04

你执行 java -jar app.jar,控制台卡了三秒才出现日志。运维说 GC 停顿导致接口 P99 飙到了 500ms。你打开 GC 日志,满屏的 Young GC、Full GC、Mixed GC 看得头皮发麻。一个 .class 文件是怎么一步步变成 CPU 上运行的机器码的?为什么”一次编写,到处运行”会带来这些性能代价?本文深入剖析 JVM 的完整运行机制。

JVM 架构概览#

flowchart TB subgraph 类加载子系统 A[.class 文件] --> B[类加载器] B --> C[加载] C --> D[链接] D --> E[初始化] end subgraph 运行时数据区 F[方法区<br/>Method Area] G[堆<br/>Heap] H[虚拟机栈<br/>VM Stack] I[程序计数器<br/>PC Register] J[本地方法栈<br/>Native Stack] end subgraph 执行引擎 K[解释器<br/>Interpreter] L[JIT 编译器<br/>HotSpot C1/C2] M[垃圾回收器<br/>GC] end E --> F E --> G K --> H L --> H M --> G

一、类加载机制#

1.1 类的生命周期#

flowchart LR A[加载<br/>Loading] --> B[验证<br/>Verification] B --> C[准备<br/>Preparation] C --> D[解析<br/>Resolution] D --> E[初始化<br/>Initialization] E --> F[使用<br/>Using] F --> G[卸载<br/>Unloading]

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证、准备、解析三个阶段统称为 链接(Linking)

1.2 加载阶段#

加载阶段 JVM 需要完成三件事:

  1. 通过类的全限定名获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象
// 类加载的触发条件
public class ClassLoadingTrigger {
// 1. new 关键字
Object obj = new Object();
// 2. 反射调用
// Class.forName("com.example.MyClass")
// 3. 子类初始化触发父类初始化
// class Child extends Parent {}
// 4. 虚拟机启动时的主类(含 main 方法)
// 5. MethodHandle/VarHandle 的使用
}

1.3 链接阶段#

验证:确保 Class 文件的字节流包含的信息符合 JVM 规范。

flowchart TB A[文件格式验证] --> B[元数据验证] B --> C[字节码验证] C --> D[符号引用验证]
验证阶段检查内容
文件格式验证魔数 0xCAFEBABE、版本号、常量池
元数据验证是否有父类、是否继承了 final 类、接口实现完整性
字节码验证数据流分析、控制流分析、类型安全
符号引用验证引用类是否存在、字段/方法是否可访问

准备:为类变量分配内存并设置初始值(零值),不是用户代码中设置的值。

// 准备阶段
public static int value = 123;
// 准备后 value = 0(零值),初始化阶段才变为 123
public static final int CONSTANT = 123;
// 准备后 CONSTANT = 123(final 常量在编译期就确定了)

解析:将常量池内的符号引用替换为直接引用。

符号引用 -> 直接引用
com/example/MyClass -> 内存地址 0x7f3a4b2c

1.4 初始化阶段#

执行类构造器 <clinit>() 方法,按源码顺序收集所有静态变量赋值语句和静态代码块。

public class InitOrder {
static {
System.out.println("1"); // <clinit> 中第一行
}
private static int value = getValue();
static {
System.out.println("3"); // <clinit> 中第三行
}
private static int getValue() {
System.out.println("2");
return 42;
}
// 输出顺序: 1 -> 2 -> 3
}

二、双亲委派模型#

2.1 类加载器层次#

flowchart TB A[Bootstrap ClassLoader<br/>启动类加载器<br/>rt.jar, resources.jar] --> B[Extension ClassLoader<br/>扩展类加载器<br/>ext/*.jar] B --> C[Application ClassLoader<br/>应用程序类加载器<br/>classpath] C --> D[自定义 ClassLoader]
类加载器加载路径实现语言
Bootstrap ClassLoaderJAVA_HOME/lib (rt.jar 等)C++
Extension ClassLoaderJAVA_HOME/lib/extJava
Application ClassLoaderclasspath 下的类Java
Custom ClassLoader自定义路径Java

2.2 委派流程#

sequenceDiagram participant A as Application ClassLoader participant E as Extension ClassLoader participant B as Bootstrap ClassLoader A->>A: 检查是否已加载 A->>E: 委派给父加载器 E->>E: 检查是否已加载 E->>B: 委派给父加载器 B->>B: 检查是否已加载 B->>B: 尝试加载 alt Bootstrap 加载成功 B-->>E: 返回 Class 对象 E-->>A: 返回 Class 对象 else Bootstrap 加载失败 B-->>E: 返回 null E->>E: 尝试加载 alt Extension 加载成功 E-->>A: 返回 Class 对象 else Extension 加载失败 E-->>A: 返回 null A->>A: 尝试加载 end end

2.3 打破双亲委派#

// 线程上下文类加载器(SPI 机制)
// 参考: https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/ServiceLoader.java
ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class);
// ServiceLoader 使用线程上下文类加载器
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(service, cl);
}

OSGi 模块化:每个模块有自己的类加载器,形成网状而非树状的委派关系。

传统双亲委派(树状):
Bootstrap -> Extension -> Application
OSGi(网状):
Bundle A <-> Bundle B <-> Bundle C
^ ^ ^
+-----------+-----------+

三、运行时数据区#

3.1 内存布局#

+--------------------------------------------------+
| JVM 运行时数据区 |
+--------------------------------------------------+
| 线程共享 |
| +--------------------+ +---------------------+ |
| | 方法区 (Metaspace) | | 堆 (Heap) | |
| | - 类元数据 | | - 新生代 | |
| | - 常量池 | | - Eden | |
| | - 静态变量 | | - Survivor 0 | |
| | - JIT 代码缓存 | | - Survivor 1 | |
| +--------------------+ | - 老年代 | |
| +---------------------+ |
+--------------------------------------------------+
| 线程私有 |
| +--------------+ +-----------+ +------------+ |
| | 虚拟机栈 | | 本地方法栈 | | 程序计数器 | |
| | - 栈帧 | | - Native | | - 字节码 | |
| | - 局部变量表 | | 方法调用 | | 行号 | |
| | - 操作数栈 | | | | | |
| | - 动态链接 | | | | | |
| | - 返回地址 | | | | | |
| +--------------+ +-----------+ +------------+ |
+--------------------------------------------------+

3.2 堆内存结构#

flowchart TB subgraph Java Heap subgraph 新生代 Young Generation A[Eden<br/>80%] B[Survivor 0<br/>10%] C[Survivor 1<br/>10%] end subgraph 老年代 Old Generation D[Tenured<br/>长期存活对象] end end E[new 对象] --> A A -->|GC| B A -->|GC| C B -->|年龄>=15| D C -->|年龄>=15| D

3.3 虚拟机栈帧结构#

flowchart TB subgraph 栈帧 Stack Frame A[局部变量表<br/>Local Variable Table] B[操作数栈<br/>Operand Stack] C[动态链接<br/>Dynamic Linking] D[方法返回地址<br/>Return Address] E[附加信息<br/>附加信息] end F[方法调用] --> A F --> B
// 栈帧示例
public int add(int a, int b) {
int c = a + b;
return c;
}
// 对应字节码(局部变量表和操作数栈的交互)
// 0: iload_1 // 将局部变量表 slot 1 (a) 压入操作数栈
// 1: iload_2 // 将局部变量表 slot 2 (b) 压入操作数栈
// 2: iadd // 弹出两个值相加,结果压栈
// 3: istore_3 // 弹出结果存入局部变量表 slot 3 (c)
// 4: iload_3 // 加载 c
// 5: ireturn // 返回

3.4 方法区演变#

JDK 版本方法区实现存储位置特点
JDK 7永久代 PermGenJVM 堆固定大小,容易 OOM
JDK 8+元空间 Metaspace本地内存自动扩展,受限于物理内存
# JDK 8+ 元空间相关参数
-XX:MetaspaceSize=256m # 初始元空间大小
-XX:MaxMetaspaceSize=512m # 最大元空间大小
-XX:MinMetaspaceFreeRatio=40 # GC 后最小空闲比例

四、字节码执行引擎#

4.1 解释执行#

flowchart LR A[.class 文件] --> B[字节码<br/>Bytecode] B --> C[解释器<br/>Interpreter] C --> D[逐条解释执行] D --> E[机器码执行]

HotSpot JVM 使用的是基于栈的解释器。字节码指令通过操作数栈完成计算。

// 源码
int a = 1;
int b = 2;
int c = a + b;
// 字节码
// 0: iconst_1 // 将常量 1 压入操作数栈
// 1: istore_1 // 存入局部变量 a
// 2: iconst_2 // 将常量 2 压入操作数栈
// 3: istore_2 // 存入局部变量 b
// 4: iload_1 // 加载 a
// 5: iload_2 // 加载 b
// 6: iadd // 加法
// 7: istore_3 // 存入局部变量 c

4.2 基于栈 vs 基于寄存器#

特性基于栈(JVM)基于寄存器(Dalvik/LuaVM)
指令长度短(单字节大多够用)较长(需要指定寄存器编号)
指令数量多(需要入栈出栈操作)少(直接操作寄存器)
可移植性高(不依赖硬件寄存器)低(需要映射到物理寄存器)
执行速度较慢(频繁内存访问)较快(寄存器访问更快)

五、JIT 编译#

5.1 热点探测#

JVM 通过方法调用计数器和回边计数器检测热点代码:

flowchart TB A[方法调用] --> B{调用计数器<br/>超过阈值?} B -->|否| C[解释执行] B -->|是| D[触发 JIT 编译] D --> E[C1 编译<br/>Client Compiler] E --> F{方法调用<br/>更加频繁?} F -->|是| G[C2 编译<br/>Server Compiler] F -->|否| H[C1 生成代码<br/>继续执行] G --> I[C2 生成优化代码] C --> B
# JIT 编译相关参数
-XX:CompileThreshold=10000 # 方法调用阈值(C2)
-XX:CompileThresholdScaling=1.0 # 阈值缩放因子
-Xbatch # 同步编译(阻塞等待)
-XX:+PrintCompilation # 打印 JIT 编译日志

5.2 分层编译#

JDK 8 开始默认启用分层编译(Tiered Compilation):

层级编译器优化级别特点
0解释器收集 profiling 信息
1C1简单快速编译,简单优化
2C1中等编译 + 部分 profiling
3C1完整编译 + 完整 profiling
4C2完整高度优化,编译较慢

5.3 JIT 优化技术#

方法内联(Inlining):最基础也最有效的优化。

// 优化前
int result = add(a, b);
public int add(int x, int y) {
return x + y;
}
// JIT 内联后(等价于直接计算)
int result = a + b;
# 查看内联情况
-XX:+PrintInlining

逃逸分析(Escape Analysis):分析对象的作用域,决定是否可以在栈上分配。

public void process() {
// 对象不会逃逸出方法,可以在栈上分配
// 不需要 GC 回收
Point p = new Point(1, 2);
int sum = p.x + p.y;
}

标量替换(Scalar Replacement):将对象拆解为标量(基本类型)。

// 逃逸分析发现 Point 不逃逸
// 标量替换后,不创建对象
int x = 1;
int y = 2;
int sum = x + y;

循环优化

// 循环展开(Loop Unrolling)
// 优化前
for (int i = 0; i < 1000; i++) {
sum += array[i];
}
// 优化后(减少循环开销)
for (int i = 0; i < 1000; i += 4) {
sum += array[i];
sum += array[i+1];
sum += array[i+2];
sum += array[i+3];
}

5.4 C1 与 C2 对比#

特性C1 (Client Compiler)C2 (Server Compiler)
编译速度
优化程度较低高度优化
适用场景启动性能敏感长期运行的服务端应用
优化手段方法内联、简单优化逃逸分析、循环优化、向量化

六、垃圾回收#

6.1 如何判断对象可回收#

引用计数法(Java 未采用):

对象 A <- 引用计数 = 2
^ ^
ref1 ref2
循环引用问题:
A <-> B 两者引用计数都不为 0,但实际已无法访问

可达性分析(Java 实际采用):

flowchart TB subgraph GC Roots A[栈帧中的局部变量] B[静态变量] C[JNI 引用] D[同步锁持有的对象] end A --> E[对象 X] B --> F[对象 Y] E --> G[对象 Z] F --> G H[孤立对象 A] --> I[孤立对象 B] I --> H

6.2 分代收集理论#

flowchart LR subgraph 新生代 A[Eden] -->|Minor GC| B[Survivor 0] B -->|Minor GC| C[Survivor 1] end subgraph 老年代 D[Tenured] end C -->|年龄 >= 15<br/>或 Survivor 满了| D A -->|大对象直接进入| D

GC 类型:

GC 类型回收区域触发条件特点
Minor GC新生代Eden 区空间不足频繁,速度快
Major GC老年代老年代空间不足较慢
Mixed GC新生代+部分老年代G1 特有G1 的回收策略
Full GC整个堆+方法区多种触发条件最慢,应尽量避免

6.3 GC 算法#

标记-清除(Mark-Sweep)

标记前: [A][B][空][C][D][空][E][空][F]
标记后: [A][B][空][X C][X D][空][E][空][F]
清除后: [A][B][空][空][空][空][E][空][F]
^ 内存碎片

复制算法(Copying)

回收前:
Eden: [A][B][C][D][E][F]
S0: [空]
S1: [G][H]
存活对象复制到 S1:
S1: [A][B][C][D][E][F][G][H]
Eden: [空](全部清空)
S0: [空]
交换 S0 和 S1 的角色

标记-整理(Mark-Compact)

标记前: [A][空][B][空][C][空][D]
整理后: [A][B][C][D][空][空][空]
^ 无碎片,但移动对象开销大

6.4 常用垃圾回收器#

flowchart TB subgraph 新生代回收器 A[Serial<br/>单线程,STW] B[ParNew<br/>多线程版 Serial] C[Parallel Scavenge<br/>吞吐量优先] end subgraph 老年代回收器 D[Serial Old<br/>单线程,标记-整理] E[CMS<br/>并发标记-清除] F[Parallel Old<br/>多线程,吞吐量优先] end subgraph 整堆回收器 G[G1<br/>分区收集,可预测停顿] H[ZGC<br/>超低延迟,并发整理] I[Shenandoah<br/>并发压缩,低延迟] end A ---|配合| D B ---|配合| E C ---|配合| F

6.5 G1 垃圾回收器详解#

G1 将堆划分为多个大小相等的 Region

+------+------+------+------+------+------+
| E | S | O | E | H | E |
+------+------+------+------+------+------+
| O | E | E | O | E | S |
+------+------+------+------+------+------+
E = Eden Region
S = Survivor Region
O = Old Region
H = Humongous Region(大对象)
sequenceDiagram participant Y as Young GC participant C as Concurrent Mark participant M as Mixed GC participant F as Full GC Note over Y: 回收所有 Eden 和 Survivor Y->>C: 老年代占用达到阈值 C->>C: 初始标记 (STW) C->>C: 并发标记 C->>C: 最终标记 (STW) C->>C: 筛选回收 (STW) C->>M: 选择回收价值高的 Region M->>F: 回收失败时退化

6.6 ZGC 垃圾回收器#

ZGC 是 JDK 11 引入的低延迟垃圾回收器,目标是将 GC 停顿控制在 10ms 以内。

# 启用 ZGC
java -XX:+UseZGC -Xmx4g -jar app.jar
# ZGC 关键参数
-XX:ZCollectionInterval=0 # GC 间隔(0 为不限制)
-XX:ZAllocationSpikeTolerance=2 # 分配峰值容忍度
-XX:+UnlockDiagnosticVMOptions -XX:+ZStatisticsForceTrace # 统计追踪

ZGC 着色指针和读屏障:

着色指针(Colored Pointer):
+------+------+------+------+------+------+
| 固定位 | finalizable | remap | marked1 | marked0 |
+------+------+------+------+------+------+
^
通过指针上的标记位判断对象状态
读屏障(Load Barrier):
对象引用加载时检查指针颜色
如果颜色不对,通过转发表找到新地址
整个过程与应用线程并发执行

七、Java 内存模型 (JMM)#

7.1 JMM 抽象结构#

flowchart TB subgraph 线程 A A1[工作内存<br/>Working Memory] end subgraph 线程 B B1[工作内存<br/>Working Memory] end subgraph 主内存 C1[共享变量<br/>Main Memory] end A1 <-->|read/write<br/>save/load| C1 B1 <-->|read/write<br/>save/load| C1 A1 -.->|不可直接通信| B1

7.2 happens-before 规则#

规则说明
程序顺序规则同一线程内,操作按代码顺序 happens-before
锁定规则unlock 操作 happens-before 后续 lock 操作
volatile 规则volatile 写 happens-before 后续 volatile 读
传递性规则A -> B 且 B -> C,则 A -> C
线程启动规则Thread.start() happens-before 线程内操作
线程终止规则线程内操作 happens-before Thread.join() 返回

7.3 volatile 实现#

// volatile 写操作的字节码层面
// 会插入内存屏障指令
// 写操作前: StoreStore 屏障
// 写操作后: StoreLoad 屏障
// 读操作前: 无屏障
// 读操作后: LoadLoad 屏障 + LoadStore 屏障
// 单例模式中的 double-check locking
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // volatile 防止指令重排序
}
}
}
return instance;
}
}
// 没有 volatile 可能的问题:
// instance = new Singleton() 实际分三步:
// 1. 分配内存空间
// 2. 初始化对象
// 3. 将引用指向内存地址
// 重排序后可能变成 1->3->2,导致其他线程拿到未初始化的对象

7.4 synchronized 实现#

// synchronized 在字节码层面使用 monitorenter/monitorexit
public void syncMethod() {
synchronized (this) {
// monitorenter
// 临界区代码
// monitorexit
}
}

锁升级过程

flowchart LR A[无锁<br/>Unlocked] --> B[偏向锁<br/>Biased Lock] B -->|第二个线程竞争| C[轻量级锁<br/>Lightweight Lock] C -->|自旋失败| D[重量级锁<br/>Heavyweight Lock]
锁状态偏向锁轻量级锁重量级锁
适用场景只有一个线程少量线程交替激烈竞争
获取方式CAS 更新 Mark WordCAS 更新 Mark Word操作系统互斥量
性能最高较高较低(涉及内核态切换)

八、JVM 调优实战#

8.1 常用参数#

# 堆大小设置
-Xms2g # 初始堆大小
-Xmx4g # 最大堆大小(建议与 Xms 相同)
-Xmn1g # 新生代大小
-Xss512k # 线程栈大小
# GC 选择
-XX:+UseG1GC # 使用 G1
-XX:+UseZGC # 使用 ZGC(JDK 11+)
-XX:+UseShenandoahGC # 使用 Shenandoah(JDK 12+)
# GC 日志(JDK 9+ 统一日志格式)
-Xlog:gc*:file=gc.log:time,uptime,level,tags
# 元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# JIT 相关
-XX:ReservedCodeCacheSize=256m # JIT 代码缓存大小

8.2 内存泄漏排查#

# 1. 生成堆转储
jmap -dump:format=b,file=heap.hprof <pid>
# 或使用 JVM 参数自动在 OOM 时生成
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heap.hprof
# 2. 查看堆中对象统计
jmap -histo <pid> | head -20
# 3. 查看 GC 状况
jstat -gcutil <pid> 1000 10 # 每秒打印一次,共 10 次
# 输出示例:
# S0 S1 E O M CCS YGC YGCT FGC FGCT
# 0.00 45.23 67.89 72.34 95.12 91.56 156 2.345 3 0.892

8.3 GC 日志分析#

# 启用 GC 日志(JDK 8)
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/tmp/gc.log
# 启用 GC 日志(JDK 9+)
-Xlog:gc*:file=gc.log:time,level,tags
# 使用 GCEasy 分析: https://gceasy.io/
# 使用 GCViewer 分析: https://github.com/chewiebug/GCViewer

G1 GC 日志示例:

[GC pause (G1 Evacuation Pause) (young), 0.0234567 secs]
[Eden: 256.0M(256.0M)->0.0B(230.0M)
Survivors: 0.0B->26.0M
Heap: 256.0M(512.0M)->24.0M(512.0M)]
[Times: user=0.08 sys=0.01, real=0.02 secs]

8.4 常见问题与调优策略#

问题现象排查方向
频繁 Full GCCPU 使用率高,响应变慢检查是否有内存泄漏、大对象
OOM应用崩溃分析堆转储,找出最大对象
元空间溢出Metaspace OOM检查动态类生成(CGLIB 等)
GC 停顿过长接口超时切换低延迟 GC(ZGC/G1)
内存泄漏堆内存持续增长对比多次堆转储的对象变化

常见问题#

Q1: 为什么 JDK 8 用 Metaspace 替代了永久代?#

永久代大小在启动时固定(-XX:MaxPermSize),很难调优。字符串常量池、动态代理(CGLIB)、Groovy 脚本等场景容易导致 java.lang.OutOfMemoryError: PermGen spaceMetaspace 使用本地内存,默认只受物理内存限制,大大减少了这类问题。

Q2: 什么时候会触发 Full GC?#

  • 调用 System.gc()(不保证立即执行)
  • 老年代空间不足
  • 方法区/元空间空间不足
  • Minor GC 后存活对象大于老年代剩余空间
  • CMS GC 的 Concurrent Mode Failure

Q3: 如何选择垃圾回收器?#

  • 追求吞吐量:Parallel Scavenge + Parallel Old
  • 追求低延迟:G1(JDK 9 默认)、ZGC(JDK 15+ 生产可用)
  • 内存小、应用简单:Serial
  • 云原生/容器化:推荐 G1 或 ZGC

Q4: JIT 编译会不会导致运行变慢?#

JIT 编译本身消耗 CPU 和内存。对于短生命周期的应用(如 CLI 工具),编译开销可能得不偿失。此时可以考虑使用 AOT 编译(Ahead-Of-Time Compilation)(如 GraalVM Native Image)提前编译为本地可执行文件。

Q5: volatile 能保证线程安全吗?#

volatile 只保证可见性和有序性,不保证原子性。例如 volatile int count; count++ 不是线程安全的,因为 ++ 操作包含读、加、写三步。需要原子操作时使用 AtomicInteger 或加锁。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Java JVM 运行机制:从 .class 到机器码
https://blog.souloss.com/posts/principles/principles-java-jvm-runtime/
作者
Souloss
发布于
2023-02-04
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时