总览
从Java源码到JVM执行,大致分为“编译 -> 类加载 -> 字节码验证与链接 -> 解释/编译执行 -> 运行期优化”几个阶段。
.java -> javac -> .class -> 类加载/验证/链接/初始化 -> 解释执行 + JIT -> 运行期优化 + GC
主流程
源码编译(javac)
- 输入:
.java源码文件 - 输出:
.class字节码文件 - 编译内容:语法/类型检查、常量折叠、语法糖解 desugar、生成字节码与调试信息(如行号表)
这一步只是把源码转换为平台无关的字节码,不生成机器码。
一个小示例
public class Hello {
private static final String NAME = "qtopie";
private static volatile int cnt = 0;
public static synchronized void count() {
++cnt;
}
public static void main(String[] args) {
System.out.println("Hello, World!");
System.out.println(NAME);
count();
System.out.println(cnt);
}
}
反编译为汇编查看字节码javap -v Hello
Classfile /home/qtopierw/workspace/projects/qtopie.github.io/posts/Hello.class
Last modified Feb 10, 2026; size 689 bytes
SHA-256 checksum af50a475fe0a0608d09466e01af47f0658b67fb73543869e55d4a7756913eb1a
Compiled from "Hello.java"
public class Hello
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #8 // Hello
super_class: #2 // java/lang/Object
interfaces: 0, fields: 2, methods: 4, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // Hello.cnt:I
#8 = Class #10 // Hello
#9 = NameAndType #11:#12 // cnt:I
#10 = Utf8 Hello
#11 = Utf8 cnt
#12 = Utf8 I
#13 = Fieldref #14.#15 // java/lang/System.out:Ljava/io/PrintStream;
#14 = Class #16 // java/lang/System
#15 = NameAndType #17:#18 // out:Ljava/io/PrintStream;
#16 = Utf8 java/lang/System
#17 = Utf8 out
#18 = Utf8 Ljava/io/PrintStream;
#19 = String #20 // Hello, World!
#20 = Utf8 Hello, World!
#21 = Methodref #22.#23 // java/io/PrintStream.println:(Ljava/lang/String;)V
#22 = Class #24 // java/io/PrintStream
#23 = NameAndType #25:#26 // println:(Ljava/lang/String;)V
#24 = Utf8 java/io/PrintStream
#25 = Utf8 println
#26 = Utf8 (Ljava/lang/String;)V
#27 = String #28 // qtopie
#28 = Utf8 qtopie
#29 = Methodref #8.#30 // Hello.count:()V
#30 = NameAndType #31:#6 // count:()V
#31 = Utf8 count
#32 = Methodref #22.#33 // java/io/PrintStream.println:(I)V
#33 = NameAndType #25:#34 // println:(I)V
#34 = Utf8 (I)V
#35 = Utf8 NAME
#36 = Utf8 Ljava/lang/String;
#37 = Utf8 ConstantValue
#38 = Utf8 Code
#39 = Utf8 LineNumberTable
#40 = Utf8 main
#41 = Utf8 ([Ljava/lang/String;)V
#42 = Utf8 <clinit>
#43 = Utf8 SourceFile
#44 = Utf8 Hello.java
{
public Hello();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 2: 0
public static synchronized void count();
descriptor: ()V
flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=0, args_size=0
0: getstatic #7 // Field cnt:I
3: iconst_1
4: iadd
5: putstatic #7 // Field cnt:I
8: return
LineNumberTable:
line 8: 0
line 9: 8
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #19 // String Hello, World!
5: invokevirtual #21 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc #27 // String qtopie
13: invokevirtual #21 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: invokestatic #29 // Method count:()V
19: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
22: getstatic #7 // Field cnt:I
25: invokevirtual #32 // Method java/io/PrintStream.println:(I)V
28: return
LineNumberTable:
line 12: 0
line 14: 8
line 16: 16
line 17: 19
line 18: 28
static {};
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: iconst_0
1: putstatic #7 // Field cnt:I
4: return
LineNumberTable:
line 4: 0
}
SourceFile: "Hello.java"
类加载(Class Loading)
当类第一次被“主动使用”时,类加载器会把.class读取进JVM,并生成java.lang.Class对象。主要步骤:
- 加载(Loading):从类路径、模块、网络等位置读取字节码
- 验证(Verification):校验字节码格式、类型安全、栈映射等
- 准备(Preparation):为静态变量分配内存并设置默认值
- 解析(Resolution):将符号引用解析为直接引用
- 初始化(Initialization):执行
<clinit>(静态变量赋值与静态代码块)
类加载器模型:Bootstrap -> Platform -> Application(自定义类加载器可打破双亲委派)。
解析(Resolution)就是把常量池里的“符号引用”(名字+描述信息)转换成“直接引用”(JVM能直接定位到的运行时结构指针/句柄)。
符号引用:比如类名、接口名、字段名、方法名、描述符等,和具体内存地址无关。 直接引用:指向已加载类的运行时数据结构,或可直接定位方法入口的引用(例如方法表中的索引或指针)。 解析发生时机可能是链接阶段(类加载时)或首次使用(惰性解析)。解析完成后,后续调用就不必再通过名字查找,性能更高,也确保访问合法性(权限检查、存在性检查等)。
解释执行与JIT编译
JVM最初会通过解释器执行字节码;当方法被频繁调用(热点方法)时,JIT会把字节码编译成本地机器码,提升性能。
- 解释器:启动快,执行慢
- JIT(C1/C2):启动慢,执行快
- 分层编译:先用C1快速编译,再用C2做更深度优化
运行期优化(JIT优化)
JIT会基于运行时数据做优化,例如:
- 方法内联
- 逃逸分析与标量替换
- 去虚拟化(单态调用)
- 循环展开、消除范围检查
这些优化是“有条件”的,如果运行时假设不成立,JVM会触发反优化并回退到解释执行。
执行时内存与GC
程序运行时主要在堆和方法区(元空间)分配对象和类元数据,GC负责回收无用对象。常见阶段:
- 新生代(Minor GC)
- 老年代(Major/Full GC)
- 元空间回收(类卸载)
关键细节
类加载触发条件(主动使用)
JVM只会在类被“主动使用”时初始化,常见触发场景:
new一个类实例- 访问类的静态字段(非
final编译期常量) - 调用类的静态方法
- 反射调用
- 初始化子类时先初始化父类
- 作为程序入口(包含
main方法的类)
被动引用不会触发初始化,如:
- 通过数组引用类(
SomeClass[]) - 访问
static final编译期常量 - 仅获取
Class对象但不触发初始化(某些反射路径)
字节码验证与链接细节
验证主要为了安全和类型一致性,分为四类检查:
- 文件格式验证:魔数、版本、常量池结构
- 元数据验证:类继承、接口实现、字段方法描述符
- 字节码验证:操作数栈/局部变量表类型匹配、控制流合法
- 符号引用验证:访问权限、引用目标存在
链接阶段包括:
- 准备:为静态变量分配内存并设置默认值(非显式赋值)
- 解析:把常量池符号引用解析为直接引用
执行与优化
C1/C2与分层编译
- C1(Client Compiler):编译速度快,优化较保守
- C2(Server Compiler):编译慢,优化深入
- 分层编译:先C1快速编译,再用C2替换热点机器码
常用参数(仅示意):
-XX:+TieredCompilation-XX:TieredStopAtLevel=1(只用C1)-XX:-TieredCompilation(只用C2)
常见JIT优化案例
- 方法内联:把小方法内联到调用点,减少调用开销
- 去虚拟化:单态调用点变成直接调用
- 逃逸分析:对象不逃逸线程时做栈上分配或标量替换
- 锁消除/锁粗化:删除无竞争锁或合并锁
- 循环优化:展开、范围检查消除
反优化(Deoptimization)
JIT依赖运行时假设(比如类型单态)。当假设失效:
- 触发反优化,已编译代码回退到解释器
- 重新收集运行时信息后再编译
CPU与调度视角
从CPU视角理解执行流程
从CPU的角度看,核心是“取指 -> 译码 -> 执行”,JVM做的是把字节码尽快变成CPU能执行的本地指令,并利用硬件特性提升吞吐。
- 解释执行:解释器每次读取字节码并分发到对应处理逻辑,本质上是“字节码解释器在CPU上运行”。CPU执行的是解释器的机器码,不是Java逻辑的机器码。
- JIT编译:热点方法被编译为本地机器码后,CPU就直接执行这些指令,省掉了解释分发的开销。
- 指令缓存与分支预测:JIT生成的紧凑、内联后的机器码更利于I-Cache命中与分支预测,减少流水线停顿。
- 反优化切换:当运行时假设失效,CPU重新回到解释器或新的机器码版本,类似“代码路径切换”。
一句话理解:JVM在运行时把高层字节码不断“降级/升级”为CPU更容易高效执行的指令序列。
JVM层面的CPU调度视角
需要区分两类线程模型:
- 传统Java线程(平台线程):HotSpot采用1:1映射,每个Java线程对应一个OS线程,CPU时间片主要由操作系统调度器分配。JVM能做的主要是创建/销毁线程、设置优先级(只是调度提示,效果依赖OS)。
- 虚拟线程(JDK 21+):JVM内部做M:N调度,虚拟线程由JVM调度到少量“载体线程”上运行,遇到阻塞点可自动让出载体线程,提高CPU利用率。
JVM内部还有一些“系统线程”,它们也会参与CPU竞争:
- GC线程:并行/并发回收时占用CPU
- 编译线程:JIT后台编译热点方法
- 信号、采样、监控等辅助线程
关键调度时刻:
- 安全点(Safepoint):JVM需要让所有线程到达安全点后再进行某些全局操作(如STW GC),此时会短暂停止应用线程
- 反优化切换:编译代码回退解释器或切换新版本机器码,线程会在安全点或调用边界完成切换
安全点是什么 安全点(Safepoint)是 Java 程序执行过程中的一些特殊位置。在这些位置,线程的状态是确定的,JVM 可以安全地暂停所有线程,来执行一些“破坏性”的操作。
最常见的比喻是:高速公路上的服务区。汽车(线程)不能在路中间随时停下,必须开到指定的服务区(安全点)才能停下来休息或加油(GC)。
为什么需要安全点
- GC 需要做“可达性分析”,线程持续改引用会让分析失真。
- 反优化(Deoptimization)需要在可控点切换执行模式。
- 偏向锁撤销、线程转储等全局操作也需要一致状态。
安全点通常放在哪里
- 循环末尾(避免长循环迟迟不进安全点)。
- 方法返回前或调用之后。
- 抛出异常的位置。
线程如何进入安全点
- JVM 设置全局“安全点标志位”(常见做法是让特定内存页不可读)。
- 线程在轮询点检查标志位,发现已设置就主动挂起。
无法主动轮询的线程
- Sleep 或 Block:已经处于安全状态,醒来前会先检查 GC 是否结束。
- JNI:Native 代码不操作 Java 对象,但返回 Java 前必须先进入安全点等待。
安全区域(Safe Region)
- 指一段引用关系不会变化的代码片段。
- 线程在安全区域内可直接被视为“已进入安全点”。
一句话理解:JVM自身不“直接调度CPU”,更多是通过线程模型与安全点机制协调执行,真正的CPU时间片分配主要由OS完成;虚拟线程是JVM层“主动调度”的典型例外。
运行期监控与排查建议
- 观察类加载:
-XX:+TraceClassLoading - 观察JIT:
-XX:+PrintCompilation - 查看GC:
-Xlog:gc*(JDK9+)
相关知识
被动引用不会初始化与懒加载实现
简单来说,JVM 对类初始化的原则是:不到万不得已,绝不进行初始化。
之所以被称为“被动引用”,是因为它们在 JVM 规范中并未达到触发 <clinit> 方法执行的特定阈值。
以下是被动引用不会初始化深层逻辑的拆解:
通过数组引用类 (SomeClass[])
当你创建一个数组对象时,例如 SomeClass[] arr = new SomeClass[10];,真正被初始化的类并不是 SomeClass,而是由 JVM 自动生成的、代表数组维度的一个特殊类(通常以 [L... 开头)。
- 原因:数组空间的分配只需要知道引用类型的大小,而不需要了解类内部的具体逻辑(如静态变量的赋值)。
- 本质:你创建的是一个“容器”,而不是容器里的“内容”。只有当你真正
new SomeClass()时,该类才会被初始化。
这个以 [ 开头的类,实际上是 Java 虚拟机在运行期间动态生成的一个“影子类”。
它是 JVM 用来管理数组的一种特殊类型。
数组类的命名规则
- 数组类名由 JVM 内部规则生成,不是源码里的普通类名。
[表示数组,L表示引用类型元素,;结束类名。- 例子:
String[]->[Ljava.lang.String;int[]->[I(基本类型有专门缩写)Object[][]->[[Ljava.lang.Object;为什么不会触发 SomeClass 初始化 当执行
SomeClass[] arr = new SomeClass[10];时,JVM 的动作是:
- 确认类型:需要一个存储
SomeClass引用的容器。- 创建数组类:动态创建
[LSomeClass;,该类继承Object并实现Cloneable、Serializable。- 分配空间:在堆中为引用分配连续空间。
- 初始化默认值:所有位置设为
null。关键点:JVM 只是“量尺寸”和“划地盘”,不需要了解
SomeClass的内部实现,因此不会触发它的静态初始化。这个“特殊类”里有什么
- 隐藏的
length字段。- 数组相关字节码指令支持,如
aaload、aastore。- 所有数组类的直接父类都是
java.lang.Object。有趣的事实:打印
new SomeClass[10].getClass().getName(),可以看到[L...这样的名字。什么时候 SomeClass 才会初始化 只有当你真正触碰数组内部内容时:
arr[0] = new SomeClass(); // 实例化触发初始化 SomeClass.staticMethod(); // 调用静态方法触发 System.out.println(SomeClass.staticField); // 访问非编译期常量触发
访问 static final 编译期常量
这里的关键点在于编译期(Compile Time)。
- 常量传播优化:如果一个静态字段被
final修饰,并且在编译阶段就能确定其值(例如public static final int MAX = 100;),那么在编译阶段,这个常量的值就会直接存储在调用方类的常量池中。 - 本质:对于 JVM 来说,调用方实际上是在访问自己家里的副本,它甚至不知道这个值最初是从哪个类里来的。因此,原类完全没有必要初始化。
注意:如果
static final的值需要运行时才能确定(例如public static final int RANDOM = new Random().nextInt();),那么这种情况下依然会触发初始化。
仅获取 Class 对象(如 SomeClass.class)
使用 SomeClass.class 获取字面量常量,或者使用某些特定的反射方法(如 ClassLoader.loadClass())时,不会触发初始化。
- 原因:获取
Class对象只需要完成加载(Loading)阶段,将磁盘上的字节码读入内存并生成方法区的数据结构即可。 - 本质:初始化是类生命周期的最后一步。JVM 允许你先“认识”这个类(获取其结构信息),等到你真的准备调用它的静态方法或创建其实例时,再执行具体的初始化逻辑。
总结对照表
| 触发动作 | 涉及阶段 | 是否执行 <clinit> |
|---|---|---|
new SomeClass() | 加载 -> 验证 -> 准备 -> 解析 -> 初始化 | 是 |
SomeClass.class | 加载 -> 验证 -> 准备 | 否 |
SomeClass[] arr | 初始化数组类,不涉及 SomeClass | 否 |
| 访问编译期常量 | 直接从常量池取值 | 否 |
你可以把初始化想象成一个“开业典礼”。获取 Class 对象只是“看了一眼招牌”,创建数组只是“租下了地皮”,访问常量则是“买了店里印在外面的宣传单”。只有当你想进店消费(实例化)或者找店长谈话(调用静态方法)时,开业典礼才会正式举行。
简单来说,类的初始化是实现“懒加载”(Lazy Loading)的核心底层机制。
在 Java 中,懒加载的本质就是:将对象的创建或某些逻辑的执行,推迟到相关类被“主动引用”而触发初始化那一刻。
以下是它们之间深度绑定的逻辑:
初始化的“被动性”支撑了懒加载
正如我们之前讨论的,JVM 只有在“万不得已”时才会初始化一个类。这种特性天然地支持了懒加载模式。
- 加载(Loading):把类信息放进内存(还没开始干活)。
- 初始化(Initialization):真正执行静态变量赋值和
static块。 - 关联:如果你代码里写了某个类,但从未真正调用它的构造函数、静态方法或非编译期常量,这个类就永远停留在“加载”阶段而不会“初始化”。这就节省了内存和启动时间。
经典案例:静态内部类单例模式
这是利用类初始化机制实现懒加载最优雅的方案。
public class Singleton {
private Singleton() {}
// 静态内部类
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // 只有执行到这里,Holder 类才会被初始化
}
}
- 为什么是懒加载? 当外部类
Singleton被加载时,内部类Holder并不会被加载。只有当有人调用getInstance()并访问Holder.INSTANCE时,JVM 才会发现:“噢,Holder 类还没初始化,现在得开始了。” - 为什么线程安全? 虚拟机会保证一个类的
<clinit>方法在多线程环境中被正确地加锁和同步。如果多个线程同时初始化一个类,只有一个线程会执行初始化,其他线程阻塞等待。
初始化与懒加载的权衡
虽然懒加载能优化启动性能,但也带了一些副作用:
| 维度 | 立即初始化 (Eager) | 懒加载 (Lazy) |
|---|---|---|
| 启动速度 | 较慢(启动时加载所有资源) | 快(按需加载) |
| 内存占用 | 较高(预先占坑) | 较低(不用不占) |
| 响应性 | 运行时调用飞快(已就绪) | 第一次调用时可能有微小延迟(需初始化) |
| 风险 | 错误在启动时就能发现 | 错误可能在运行到一半时才抛出(如 NoClassDefFoundError) |
反射与懒加载的冲突
反射通常会打破懒加载的优雅性。例如,如果你使用 Class.forName("SomeClass"),默认情况下它会强制触发 SomeClass 的初始化(除非你手动指定 initialize = false)。这就意味着,即使你还没准备好使用这个类,反射也会把它“强行唤醒”。
双亲委派(parents delegate)
Java 11 仍然采用“类加载器 + 双亲委派”的经典模型,但需要注意模块化后的变化与实现细节。核心要点如下:
类加载器层次(Java 11)
- Bootstrap ClassLoader:负责加载核心类库(
java.base等)。在实现层面是 JVM 内置的引导加载器。 - Platform ClassLoader:取代了旧的 Extension ClassLoader,负责加载平台类库(
java.*、javax.*中非java.base的模块)。 - Application ClassLoader:加载应用 classpath 或 modulepath 上的类。
- 自定义 ClassLoader:可加载特定路径或隔离的类空间。
双亲委派模型
加载流程是“先委派给父加载器,父加载器找不到再由子加载器尝试”。
- 优点:避免重复加载、确保核心类库安全、维持类的唯一性。
- 典型流程:
loadClass()-> 先parent.loadClass()-> 父失败再findClass()。
模块化(JPMS)带来的变化
Java 11 支持模块系统,类加载路径分为:
- Module Path:优先于 Class Path,模块之间通过
requires/exports控制可见性。 - Class Path:传统方式,仍然兼容。
模块系统让“可见性”不再仅由类加载器决定,还受模块依赖关系约束。
在 JDK 8 时代,我们可以通过往 jre/lib/ext 挪动 JAR 包来扩展 Java 的功能,但在 JDK 11/23 这种现代 Java 中,扩展机制(Extension Mechanism)已经被彻底移除。
为什么不再允许扩展 lib/modules?主要有三个核心原因:
- 性能优化:lib/modules 是以 jimage 格式存储的。JVM 启动时使用预计算哈希进行快速定位,随意塞文件会破坏这一优化。
- 强封装性:模块化强调边界清晰,外部 JAR 混入核心镜像可能绕过安全检查。
- 可靠性:避免“Classpath 地狱”,所有依赖需在启动前通过
--module-path明确声明。推荐方式:
--module-path
- 把 JAR 包放在任意目录下,启动时显式指定 module path。
- 这些类由
AppClassLoader加载,地位与 lib/modules 内类平等,但物理位置分离。
解析与链接时机
类加载包含:加载(Loading)-> 验证(Verification)-> 准备(Preparation)-> 解析(Resolution)-> 初始化(Initialization)。
- 解析可延迟:JVM 可能在首次使用时才解析符号引用(惰性解析)。
- 初始化是按需触发:只有“主动使用”才会执行
<clinit>。
常见“打破委派”的场景
- SPI/ServiceLoader:由子加载器加载实现类,父加载器加载接口,通常依赖上下文类加载器(TCCL)。
- 容器化环境:如应用服务器、插件系统,为隔离不同应用使用自定义类加载器。
参考 https://docs.osgi.org/javadoc/r4v43/core/org/osgi/framework/Bundle.html
- 热部署/隔离:通过不同 ClassLoader 实现版本隔离。
上下文类加载器(TCCL)
为了解决“父加载器看不到子加载器资源”的问题,Java 引入了线程上下文类加载器:
- 默认是
Application ClassLoader。 - 框架(如 JDBC、JNDI、SPI)常用 TCCL 来加载实现类。
类卸载与元空间
- 类卸载发生在对应的
ClassLoader不可达且无存活类实例时。 - 元空间(Metaspace)存放类元数据,GC 可回收对应空间。
Java 11 常见观察手段
-XX:+TraceClassLoading:观察类加载-XX:+TraceClassUnloading:观察类卸载-Xlog:class+load=info:JDK 9+ 统一日志系统