你执行 java -jar app.jar,控制台卡了三秒才出现日志。运维说 GC 停顿导致接口 P99 飙到了 500ms。你打开 GC 日志,满屏的 Young GC、Full GC、Mixed GC 看得头皮发麻。一个 .class 文件是怎么一步步变成 CPU 上运行的机器码的?为什么”一次编写,到处运行”会带来这些性能代价?本文深入剖析 JVM 的完整运行机制。
JVM 架构概览
一、类加载机制
1.1 类的生命周期
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证、准备、解析三个阶段统称为 链接(Linking)。
1.2 加载阶段
加载阶段 JVM 需要完成三件事:
- 通过类的全限定名获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
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 规范。
| 验证阶段 | 检查内容 |
|---|---|
| 文件格式验证 | 魔数 0xCAFEBABE、版本号、常量池 |
| 元数据验证 | 是否有父类、是否继承了 final 类、接口实现完整性 |
| 字节码验证 | 数据流分析、控制流分析、类型安全 |
| 符号引用验证 | 引用类是否存在、字段/方法是否可访问 |
准备:为类变量分配内存并设置初始值(零值),不是用户代码中设置的值。
// 准备阶段public static int value = 123;// 准备后 value = 0(零值),初始化阶段才变为 123
public static final int CONSTANT = 123;// 准备后 CONSTANT = 123(final 常量在编译期就确定了)解析:将常量池内的符号引用替换为直接引用。
符号引用 -> 直接引用
com/example/MyClass -> 内存地址 0x7f3a4b2c1.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 类加载器层次
| 类加载器 | 加载路径 | 实现语言 |
|---|---|---|
| Bootstrap ClassLoader | JAVA_HOME/lib (rt.jar 等) | C++ |
| Extension ClassLoader | JAVA_HOME/lib/ext | Java |
| Application ClassLoader | classpath 下的类 | Java |
| Custom ClassLoader | 自定义路径 | Java |
2.2 委派流程
2.3 打破双亲委派
// 线程上下文类加载器(SPI 机制)// 参考: https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/util/ServiceLoader.javaServiceLoader<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 堆内存结构
3.3 虚拟机栈帧结构
// 栈帧示例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 | 永久代 PermGen | JVM 堆 | 固定大小,容易 OOM |
| JDK 8+ | 元空间 Metaspace | 本地内存 | 自动扩展,受限于物理内存 |
# JDK 8+ 元空间相关参数-XX:MetaspaceSize=256m # 初始元空间大小-XX:MaxMetaspaceSize=512m # 最大元空间大小-XX:MinMetaspaceFreeRatio=40 # GC 后最小空闲比例四、字节码执行引擎
4.1 解释执行
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 // 存入局部变量 c4.2 基于栈 vs 基于寄存器
| 特性 | 基于栈(JVM) | 基于寄存器(Dalvik/LuaVM) |
|---|---|---|
| 指令长度 | 短(单字节大多够用) | 较长(需要指定寄存器编号) |
| 指令数量 | 多(需要入栈出栈操作) | 少(直接操作寄存器) |
| 可移植性 | 高(不依赖硬件寄存器) | 低(需要映射到物理寄存器) |
| 执行速度 | 较慢(频繁内存访问) | 较快(寄存器访问更快) |
五、JIT 编译
5.1 热点探测
JVM 通过方法调用计数器和回边计数器检测热点代码:
# JIT 编译相关参数-XX:CompileThreshold=10000 # 方法调用阈值(C2)-XX:CompileThresholdScaling=1.0 # 阈值缩放因子-Xbatch # 同步编译(阻塞等待)-XX:+PrintCompilation # 打印 JIT 编译日志5.2 分层编译
JDK 8 开始默认启用分层编译(Tiered Compilation):
| 层级 | 编译器 | 优化级别 | 特点 |
|---|---|---|---|
| 0 | 解释器 | 无 | 收集 profiling 信息 |
| 1 | C1 | 简单 | 快速编译,简单优化 |
| 2 | C1 | 中等 | 编译 + 部分 profiling |
| 3 | C1 | 完整 | 编译 + 完整 profiling |
| 4 | C2 | 完整 | 高度优化,编译较慢 |
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 实际采用):
6.2 分代收集理论
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 常用垃圾回收器
6.5 G1 垃圾回收器详解
G1 将堆划分为多个大小相等的 Region:
+------+------+------+------+------+------+| E | S | O | E | H | E |+------+------+------+------+------+------+| O | E | E | O | E | S |+------+------+------+------+------+------+
E = Eden RegionS = Survivor RegionO = Old RegionH = Humongous Region(大对象)6.6 ZGC 垃圾回收器
ZGC 是 JDK 11 引入的低延迟垃圾回收器,目标是将 GC 停顿控制在 10ms 以内。
# 启用 ZGCjava -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 抽象结构
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 lockingpublic 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/monitorexitpublic void syncMethod() { synchronized (this) { // monitorenter // 临界区代码 // monitorexit }}锁升级过程:
| 锁状态 | 偏向锁 | 轻量级锁 | 重量级锁 |
|---|---|---|---|
| 适用场景 | 只有一个线程 | 少量线程交替 | 激烈竞争 |
| 获取方式 | CAS 更新 Mark Word | CAS 更新 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.8928.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/GCViewerG1 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 GC | CPU 使用率高,响应变慢 | 检查是否有内存泄漏、大对象 |
| OOM | 应用崩溃 | 分析堆转储,找出最大对象 |
| 元空间溢出 | Metaspace OOM | 检查动态类生成(CGLIB 等) |
| GC 停顿过长 | 接口超时 | 切换低延迟 GC(ZGC/G1) |
| 内存泄漏 | 堆内存持续增长 | 对比多次堆转储的对象变化 |
常见问题
Q1: 为什么 JDK 8 用 Metaspace 替代了永久代?
永久代大小在启动时固定(-XX:MaxPermSize),很难调优。字符串常量池、动态代理(CGLIB)、Groovy 脚本等场景容易导致 java.lang.OutOfMemoryError: PermGen space。Metaspace 使用本地内存,默认只受物理内存限制,大大减少了这类问题。
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 或加锁。
参考资料
- The Java Virtual Machine Specification
- OpenJDK 源码 - HotSpot
- OpenJDK 源码 - ClassLoader
- OpenJDK 源码 - System Dictionary (类加载)
- G1 GC 调优指南
- ZGC 官方文档
- Shenandoah GC
- 《深入理解 Java 虚拟机》(周志明)
- 《Java Performance》(Scott Oaks)
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






