JVM字节码和类加载

类文件结构
1 | public class HelloWorld { |
他的.class文件为:
1 | 0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09 |
根据 JVM 规范,类文件结构如下:
1 | ClassFile { |
魔数
0~3 字节,表示是否是 class 类型的文件。
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
cafebabe代表是Java类型
版本
4~7 字节,表示类的版本。
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
34 是十六进制,对应十进制 52,表示类的版本是 Java 8。
常量池
| 常量类型 (Constant Type) | 标志值 (Value) | 描述 (Description) |
|---|---|---|
| CONSTANT_Utf8 | 1 | UTF-8 编码的字符串(如类名、方法名) |
| CONSTANT_Integer | 3 | 整型字面量 |
| CONSTANT_Float | 4 | 浮点型字面量 |
| CONSTANT_Long | 5 | 长整型字面量(占 2 个常量池槽位) |
| CONSTANT_Double | 6 | 双精度浮点型字面量(占 2 个常量池槽位) |
| CONSTANT_Class | 7 | 类或接口的符号引用 |
| CONSTANT_String | 8 | 字符串类型字面量 |
| CONSTANT_Fieldref | 9 | 字段的符号引用 |
| CONSTANT_Methodref | 10 | 类中方法的符号引用 |
| CONSTANT_InterfaceMethodref | 11 | 接口中方法的符号引用 |
| CONSTANT_NameAndType | 12 | 字段或方法的名称和类型描述符 |
| CONSTANT_MethodHandle | 15 | 方法句柄(用于支持动态语言) |
| CONSTANT_MethodType | 16 | 方法类型(用于支持动态语言) |
| CONSTANT_InvokeDynamic | 18 | 动态方法调用点(Lambda 表达式核心) |
8~9 字节,表示常量池长度:
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
23 十进制对应 35,表示常量池有 #1 ~ #34 项,#0 项不计入,也没有值。
#1
#1 项 0a 对应十进制 10,根据上表查询得知,表示 CONSTANT_Methodref,即方法信息。00 06 和 00 15(21) 表示它引用了常量池中 #6 和 #21 项来获取这个方法的所属类和方法名:
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
00 06 对应第 #6 项,表示 Class 信息,然后它又引用了第 #28 项,指明具体的 Class 是 java/lang/Object。
00 15 对应第 #21 项,表示方法名、参数类型和返回值类型。它分别引用 #7 项表名方法名是
然后再一项一项查下去就行了
访问标识与继承信息
| 标志名称 (Flag Name) | 标志值 (Value) | 中文含义 (Interpretation) |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 标识为 public 类型,可以被包外访问。 |
| ACC_FINAL | 0x0010 | 标识为 final 类型,不允许有子类(禁止继承)。 |
| ACC_SUPER | 0x0020 | 使用新的 invokespecial 语义(现代编译器的必选项)。 |
| ACC_INTERFACE | 0x0200 | 标识这是一个接口,而不是一个普通的类。 |
| ACC_ABSTRACT | 0x0400 | 标识为 abstract 类型,不能被实例化。 |
| ACC_SYNTHETIC | 0x1000 | 标识该类由编译器自动生成,源码中并不存在。 |
| ACC_ANNOTATION | 0x2000 | 标识这是一个注解类型(Annotation)。 |
| ACC_ENUM | 0x4000 | 标识这是一个枚举类型(Enum)。 |
00 21 (由上表中的 0x0001 和 0x0020 相加获得)表示该 class 是一个公共的类:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
00 05 表示根据常量池中的 #5 项找到 本类 的全限定名:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
00 06 表示根据常量池中的 #6 找到 父类 的全限定名:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
00 00 表示该类实现的接口数量,此处为 0:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
Field 信息
00 00 表示成员变量数量,此处为 0:
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
| 标识字符 (FieldType) | 对应类型 (Type) | 中文解释 (Interpretation) |
|---|---|---|
| B | byte | 有符号字节型 |
| C | char | Unicode 字符(使用 UTF-16 编码) |
| D | double | 双精度浮点型 |
| F | float | 单精度浮点型 |
| I | int | 整型 |
| J | long | 长整型(因为 L 给了对象,所以用 J) |
L 类名 ; |
reference | 对象引用类型(以 L 开头,分号 ; 结尾) |
| S | short | 有符号短整型 |
| Z | boolean | 布尔型(true 或 false) |
| [ | reference | 数组维度(一个 [ 代表一维数组) |
Method 信息
00 02 表示方法数量,本类为 2(默认无参构造方法与 main 方法):
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
一个方法由访问修饰符、名称、参数描述、方法属性数量、方法属性组成。
eg:构造方法
1 | 0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01 |
附加属性
0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00
0001120 00 00 02 00 14
00 01 表示附加属性数量
00 13 表示引用了常量池 #19 项,即 SourceFile,表示字节码文件对应的 Java 源文件名称
00 00 00 02 表示此属性长度
00 14 表示引用了常量池 #20 项,即 HelloWorld.java
字节码指令
eg:
对应字节码指令:
b2 00 02 12 03 b6 00 04 b1
查询 JVM 规范得知,b2 对应 getstatic,用于加载静态变量
00 02 引用常量池中 #2 项,表示 getstatic 需要加载的静态变量信息,简单来说是 System.out
12 对应 ldc(load constant),用于加载参数
03 引用常量池中 #3 项,即字符串常量 Hello World
b6 对应 invokevirtual,预备调用成员方法
00 04 引用常量池中 #4 项,即 println 方法
b1 表示返回
javap
Oracle 提供了 javap 工具来反编译 class 文件:
1 | javap -v HelloWorld.class |
-v 参数表示输出 class 文件的详细信息。
图解方法执行流程
示例代码:
1 | public class Demo1 { |
对于 short 范围内的整数不会存入运行时常量池中,而是与字节码指令一起存放。short 的最大值是 32767,加一后大于最大值,因此 32768 会被存放到运行时常量池中。
方法字节码载入方法区

main 线程开始运行,分配栈帧内存

通过 javap 命令查看的字节码文件中存在:
1 | public static void main(java.lang.String[]); |
stack=2 表示操作数栈的深度是 2
locals=4 表示局部变量表有 4 个槽位
执行引擎开始执行字节码
bipush 10 表示将一个 byte 压入操作数栈(由于操作数栈的宽度占 4 个字节,压入内容不足 4 个字节时,会补齐 4 个字节),类似的指令还有:
sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
ldc 将一个 int 压入操作数栈
ldc2_w 将一个 long 压入操作数栈(因为 long 是 8 个字节,需要分两次压入)
小数字和字节码指令存放在一起,超过 short 范围的数字会存入常量池

istore_1 表示弹出操作数栈栈顶数据,存入局部变量表的 slot 1:

ldc #3 表示从运行时常量池中加载 #3 数据到操作数栈。

istore_2 表示弹出操作数栈栈顶数据,存入局部变量表的 slot 2:

接着从局部变量表里读取参与加法运算的两个数:
iload_1 表示从局部变量表 slot 1 里读取数据
iload_2 表示从局部变量表 slot 2 里读取数据

然后执行 iadd 弹出堆操作数栈里的两个整型数据进行相加:

再把运算结果压入操作数栈顶:

istore_3 再弹出操作数栈栈顶数据,存入局部变量表的 slot 3:

getstatic #4 从运行时常量池中获取成员变量 System.out 的引用,然后把该对象的 引用 添加进操作数栈中:

在调用 println 方法前,需要先加载所需的参数,使用 iload_3 从局部变量表 slot 3 里读取数据:

使用 invokevirtual #5 调用方法打印数据:
找到运行时常量池 #5 项
定位到方法区 java/io/PrintStream.println:(I)V 方法
生成新的栈帧(分配 locals、stack 等)
传递参数,执行新栈帧中的字节码

目标方法执行完毕后,弹出栈帧。
清除 main 操作数栈的内容:

完成 main 方法调用后,弹出 main 栈帧,程序结束。
分析a++
1 | public class Demo1_2 { |
字节码为:
1 | public static void main(java.lang.String[]); |
分析:
iinc 指令是直接在局部变量 slot 上进行运算的
a++ 和 ++a 的区别是先执行 iload 还是先执行 iinc
a++ 会被分解为两条字节码指令:
iload_1
iinc 1,1

++a 也会被分解为两条字节码指令,内容也与 a++ 一样,但顺序不同:
iinc 1,1
iload_1






条件判断指令
| 指令 | 助记符 | 含义 |
|---|---|---|
0x99 |
ifeq |
判断是否 == 0 |
0x9a |
ifne |
判断是否 != 0 |
0x9b |
iflt |
判断是否 < 0 |
0x9c |
ifge |
判断是否 >= 0 |
0x9d |
ifgt |
判断是否 > 0 |
0x9e |
ifle |
判断是否 <= 0 |
0x9f |
if_icmpeq |
两个 int 是否 == |
0xa0 |
if_icmpne |
两个 int 是否 != |
0xa1 |
if_icmplt |
两个 int 是否 < |
0xa2 |
if_icmpge |
两个 int 是否 >= |
0xa3 |
if_icmpgt |
两个 int 是否 > |
0xa4 |
if_icmple |
两个 int 是否 <= |
0xa5 |
if_acmpeq |
两个引用是否 == |
0xa6 |
if_acmpne |
两个引用是否 != |
在实际的 JVM 运行中,这些指令如果条件成立,就会跳转到偏移量指向的代码行;如果条件不成立,就会直接执行紧接着的下一行代码。
byte、short 和 char 都会按 int 比较,因为操作数栈都是 4 字节
goto 用来进行跳转到指定行号的字节码
1 | public class Demo1_3 { |
对应的部分字节码:
1 | 0: iconst_0 |
循环控制指令
循环控制指令还是先前的条件判断指令,例如 while 循环:
1 | public class Demo1_4 { |
对应的部分字节码:
1 | 0: iconst_0 |
for循环也是这个
构造方法
()V
1 | public class Demo1_8_1 { |
编译器会按从上到下的顺序,收集所有 static 静态代码块和静态变量赋值的代码,最终合并成一个特殊的方法
1 | 0: bipush 10 |
因此上述代码里 i 最终的值为 30。
()V
1 | public class Demo1_8_2 { |
编译器会按从上到下的顺序,收集所有 {} 代码块(初始化代码块)和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后。
对应的部分字节码:
1 | 0: aload_0 |
方法调用
1 | public class Demo1_9 { |
对应的部分字节码:
1 | 0: new #2 // class indi/mofan/Demo1_9 |
可以发现:
调用构造方法、私有方法、final 方法时,使用 invokespecial 指令
调用公共方法时,使用 invokevirtual 指令
调用静态方法时,使用 invokestatic 指令
其中,invokespecial 和 invokestatic 属于静态绑定,能够在编译期确定调用的目标方法,性能更高;而 invokevirtual 属于动态绑定,调用的公共方法可能是当前类的、也可能是父类的(方法重写),在运行时才能确定调用的目标方法,性能相对更低。
new一个对象
1 | Demo1_9 obj = new Demo1_9(); |
上述 Java 代码对应 4 步字节码:
new:在堆空间为需要创建的对象分配内存,分配成功后把对象引用放入操作数栈
dup:复制操作数栈上的栈顶数据(现在操作数栈里有两份相同的引用)
invokespecial:弹出操作数栈的栈顶数据(弹出一份引用)并调用构造方法
astore_1:弹出操作数栈的栈顶数据(弹出另一份引用),并将其存入局部变量表的 slot 1
使用实例对象调用静态方法
可以直接按照 类名.静态方法名() 的形式去调用静态方法,但使用 实例对象.静态方法名() 来调用静态方法也不会编译报错。
那它们在字节码指令层面上有什么区别吗?
1 | 20: aload_1 |
20~22 对应使用 实例对象.静态方法名() 的形式来调用静态方法,25 对应使用 类名.静态方法名() 的形式来调用静态方法。
当使用 实例对象.静态方法名() 来调用静态方法时,先执行 aload_1 从将局部变量表中 slot 1 位置的数据加载到操作数栈中,由于调用静态方法并不需要实例对象,因此紧接着执行 pop 弹出操作数栈的栈顶元素,最后再使用 invokestatic 执行静态方法。
因此在日常编码时 不 推荐使用 实例对象.静态方法名() 的形式来调用静态方法,因为这会多出两条冗余的字节码指令。
多态原理
当执行 invokevirtual 指令时:
先通过栈帧中的对象引用找到对象
分析对象头,找到对象的实际 Class
Class 结构中有 vtable(在类加载的链接阶段根据方法的重写规则生成好)
查表得到方法的具体地址
执行目标方法的字节码
具体步骤:
1. 核心指令:invokevirtual
在 Java 字节码层面,调用普通实例方法通常使用的是 invokevirtual 指令。这个指令就是多态的“发令枪”。
与静态绑定的 invokestatic(调用静态方法)或 invokespecial(调用构造器、私有方法)不同,invokevirtual 在运行时并不直接跳到一个固定的地址,而是要先去找。
2. 秘密武器:虚方法表 (vtable)
为了让寻找过程变快,JVM 给每个类都准备了一张表,叫 vtable(Virtual Method Table)。它存放在类的方法区(Metaspace)中。
- 表里装什么? 存放着该类所有实例方法的直接引用(内存地址)。
- 继承的艺术:
- 如果子类没有重写父类的方法,那么子类 vtable 里该方法的地址就指向父类的实现。
- 如果子类重写了方法,子类 vtable 里的地址就会替换成子类自己的实现地址。
- 关键点: 相同签名的方法,在父类和子类的 vtable 中占据的索引下标(Offset)是一致的。
3. 寻找真相的过程(查找流程)
当执行 invokevirtual 时,JVM 会经历以下步骤:
- 找到实际对象: 通过操作数栈顶的引用,找到堆中真实的对象实例。
- 获取类型信息: 根据对象头(Object Header)里的类型指针,定位到该对象所属的具体类(Class)。
- 查表: 到该类的 vtable 中,根据方法在父类中预确定的**偏移量(Offset)**直接取出方法地址。
- 执行: 跳转到该地址执行代码。
4. 接口的特殊性:invokeinterface
如果你的引用类型是接口,情况会稍微复杂一点。因为一个类可以实现多个接口,方法在表里的偏移量就没法像 vtable 那样固定了。
这时候 JVM 会使用 itable(Interface Method Table)。它的查找效率比 vtable 略低,但原理类似:先根据接口找到对应的函数表,再进行搜索。
异常处理
try-catch
1 | public class Demo1_11_1 { |
对应的部分字节码:
1 | public static void main(java.lang.String[]); |
使用 try-catch 块后,生成的字节码会多出一个 Exception table 结构。其中的 [from, to) 是一个前闭后开的检测范围,比如这里的 [2, 5) 表示检测第 2 行字节码到第 5 行字节码(不包括第 5 行),一旦这个范围的字节码执行时出现异常,再判断异常类型是否与 type 匹配,如果匹配,则跳到 target 对应的字节码行号。
第 8 行字节码是 astore_2,表示将异常对象 e 的引用存入局部变量表的 slot 2 位置。
多个 single-catch 块的情况
1 | public class Demo1_11_2 { |
对应的部分字节码:
1 | public static void main(java.lang.String[]); |
多个 catch 块与单个 catch 块类似,只不过由于只能进入 Exception table 中的一个分支,所以局部变量表 slot 2 会被复用。
finally 块
1 | public class Demo1_11_4 { |
对应的部分字节码:
1 | public static void main(java.lang.String[]); |
复制粘贴:把 finally 里的代码拷贝到 try 结束处和 catch 结束处。
兜底捕获:在异常表里注册一个 any 类型的监控,管辖 try 和 catch 的所有领地。只要有漏网之鱼,先拉到 finally 逻辑里跑一遍,再重新抛出。
finally面试题
finally 出现了 return
运行以下代码,控制台会输出什么呢?
1 | public class Demo1_12_1 { |
对应的部分字节码:
1 | public static int test(); |
原本应该有的步骤: 如果在 finally 里没有 return,在 20 赋值完后,会有一行 aload(加载异常对象)和 athrow(抛出)。但 return 把这一切都切断了。
结果分析:
- 返回值:始终是
20。即使try块里有return 10,也会被finally里的return 20覆盖。 - 异常情况:无异常抛出。
ArithmeticException被“毁尸灭迹”了。
运行以下代码,控制台又会输出什么呢?
1 | public class Demo1_12_2 { |
对应的部分字节码:
1 | public static int test(); |
finally 块中的数据没有被 return,生成的字节码指令中有 athrow 指令,如果出现异常不会被吞。
synchronized
1 | synchronized (lock) { |
对应的部分字节码:
1 | 3: aload_1 // 将 lock 对象引用压栈 |
块同步:靠 monitorenter / monitorexit 指令对。
方法同步:靠 ACC_SYNCHRONIZED 标志位。****
编译期处理(语法糖)
默认构造器
1 | public class Candy1 { |
编译后生成的字节码等价于:
1 | public class Candy1 { |
自动装箱拆箱
在 JDK 5 中添加了自动拆装箱特性:
1 | public class Candy2 { |
以上代码在 JDK 5 之前是无法编译通过的,必须改写为:
1 | public static void main(String[] args) { |
在 JDK 5 之前,包装类型和基本类型之间的转换需要手动处理(尤其是集合类中操作的都是包装类型),这非常麻烦,JDK 5 引入自动拆装箱后,这些手动处理的代码都可以由编译器在编译阶段完成。
泛型
泛型也是在 JDK 5 中加入的特性,但 Java 在编译泛型代码后会执行 泛型擦除 的动作,即泛型信息在编译为字节码之后就丢失了,实际类型都被当做 Object 类型来处理:
1 | public class Candy3 { |
所以在取值时,编译器真正生成的字节码中还要额外做一个类型转换的操作:
1 | // 将 Object 转换成 Integer |
擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 中仍然保留了方法参数泛型信息:
1 | LocalVariableTypeTable: |
可变参数
可变参数也是 JDK 5 中添加的新特性。
1 | public class Candy4 { |
可变参数 String… args 的本质是 String[] args,Java 编译器会在编译阶段将上述代码转换为:
public static void foo(String[] args) {
1 | String[] array = args; |
注意,如果调用了 foo() 则等价于 foo(new String[]{}),创建了一个空数组,而不是传入 null。
注意,如果调用了 foo() 则等价于 foo(new String[]{}),创建了一个空数组,而不是传入 null。
foreach 循环
foreach 循环也是 JDK 5 引入的语法糖:
1 | public class Candy5_1 { |
编译后生成的字节码等价于:
1 | public class Candy5_1 { |
如果是在集合上使用 foreach 循环呢?
1 | public class Candy5_2 { |
应用于集合的 foreach 循环会被编译期转换为对迭代器的调用,编译后生成的字节码等价于:
1 | public class Candy5_2 { |
foreach 循环的写法能够配合数组、实现了 Iterable 接口(提供获取 Iterator 的方式)的集合类一起使用。
switch 字符串
从 JDK 7 开始,switch 可以作用于字符串和枚举类:
1 | public class Candy6_1 { |
字节码:
1 | public class Candy6_1 { |
在编译生成的字节码中执行了两次 switch,第一次根据字符串的 hashCode 和 equals 将字符串的转换为相应 byte 类型,第二次再利用 byte 进行比较。
为什么第一次时必须既比较 hashCode,又比较 equals 呢?
hashCode 是为了提高效率,减少比较次数;equals 是为了防止哈希冲突。
例如 BM 和 C. 两个字符串的 hashCode 值都是 2123:
1 | public class Candy6_2 { |
编译后生成的字节码等价于:
1 | public class Candy6_2 { |
switch 枚举
1 | public class Candy7 { |
字节码:
1 | public class Candy7 { |
$MAP 是一个合成类,仅 JVM 使用,开发者不可见,用来映射枚举的 ordinal 与数组元素的关系。枚举的 ordinal 表示枚举对象的序号,从 0 开始,即 MALE.ordinal() = 0,FEMALE.ordinal() = 1。
枚举类
JDK 7 新增了枚举类,现有如下枚举:
1 | public enum Day { |
编译后生成的字节码等价于:
1 | // 1. 变成一个普通的类,且是 final 的(不能被继承) |
try-with-resources
1 | try (InputStream is = new FileInputStream("test.txt")) { |
字节码:
1 | InputStream is = new FileInputStream("test.txt"); |
旧时代的坑: 在传统的 try-finally 中,如果 try 块报错了,而 finally 块里的 is.close() 也报错了,finally 里的异常会把 try 里的真凶给“顶掉”。你最终只能看到 close 失败的报错,真正的业务逻辑错误被弄丢了。
TWR 的救赎: 编译器利用了 Throwable 类里的 addSuppressed() 方法。
- 主异常(业务逻辑报错)会被抛出。
- 次要异常(关闭资源报错)会被附加在主异常的“压制列表”里。
- 你可以通过
e.getSuppressed()拿到那些被压制的异常。
方法重写时的桥接方法
方法重写时的返回值有两种情况:
父子类的返回值完全一致
子类返回值可以是父类返回值的子类,如:
1 | static class A { |
对于子类,Java 编译器会做以下处理:
1 | static class B extends A { |
桥接方法比较特殊,仅对 Java 虚拟机可见,并且与原来的 public Integer m() 没有命名冲突
匿名内部类
1 | public class Candy11 { |
1 | // 编译器偷偷生成的类 |
如果匿名内部类里引用了局部变量:
1 | public static void test(final int x) { |
当你编译这段代码时,编译器生成的 Candy11$1.class 实际上长这样(伪代码):
Java
1 | // 编译器生成的类 |
而在你的 test 方法里,调用变成了这样:
Java
1 | public static void test(final int x) { |
这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:在创建 Candy11$1 对象时,将 x 的值赋值给了 Candy11$1 对象的 val$x 属性,所以 x 不应该再发生变化,如果变化,那么 val$x 属性没有机会再跟着一起变化。
类加载阶段
加载 (Loading)
1. 找:获取二进制字节流
JVM 并不关心你的 .class 文件是从哪儿来的,它只需要能拿到符合规范的字节流就行。这就是 Java 强大扩展性的来源:
- 从本地文件读: 最常见的,从你的
bin目录或target目录读。 - 从压缩包读: 比如
JAR、WAR包(这奠定了 Java 生态的基础)。 - 从网络读: 比如早期的 Applet(虽然现在基本没人用了)。
- 动态生成: 这在 Spring/Hibernate 里极其常见,比如 CGLIB、JDK 动态代理,它们在运行时直接在内存里“现编”一段字节码并交给加载器。
- 其他源: 甚至可以从数据库读,或者先进行解密(为了防止代码被反编译,有些公司会对
.class加密,加载时再解密)。
2. 存:将静态结构转化为运行时数据
拿到这一串 0 和 1 的字节流后,JVM 会按照自己的逻辑把它塞进方法区(Method Area)(Java 8 后是元空间)。
- 逻辑转化: 把
.class里的各种常量池、字段、方法、字节码指令,转化为 JVM 内部定义好的、易于快速访问的内存数据结构。 - 这里的细节: 这一步的存储格式完全由具体的 JVM 实现(比如 HotSpot)决定,规范并没有死磕细节。
3. 创:生成 java.lang.Class 对象
这是最关键的一步,也是我们开发者最能感知到的。
- 入口点: JVM 会在 堆(Heap) 中创建一个
java.lang.Class类的实例。 - 作用: 它就像是一面“镜子”。即便原始的字节流已经进了方法区,外部程序也没法直接访问方法区的底层二进制。这个
Class对象就是暴露给我们的 API 入口,让我们能通过反射获取类名、方法名、构造函数等。
连接 (Linking) —— 分为三小步
| 子阶段 | 核心任务 | 通俗解释 |
|---|---|---|
| 验证 (Verification) | 确保 Class 文件的字节流符合 JVM 规范,没有安全风险。 | 检查文件头是不是 0xCAFEBABE,代码有没有语法错误。 |
| 准备 (Preparation) | 为类的静态变量 (static) 分配内存,并设置默认初始值。 | 此时 static int a = 10;,a 的值是 0 而不是 10。 |
| 解析 (Resolution) | 将常量池内的符号引用替换为直接引用。 | 把原本模糊的“名字”指向真正的“内存地址”。 |
如果 static 变量是 final 的基本类型和字符串字面量,其值在编译阶段就确定了,因此这类变量的赋值会在准备阶段完成
如果 static 变量是 final 的引用类型,赋值就是在初始化阶段完成
初始化 (Initialization)
这才是真正执行你写的代码的阶段。
- 做了什么: 执行类构造器
<clinit>()方法的过程。 - 关键逻辑: 之前在“准备”阶段
static int a = 10;的a只是 0,到了这一步,JVM 才会真正把10赋值给a。
概括地说,类初始化是「懒惰的」:
主动引用:
| 场景 | 核心逻辑 |
|---|---|
main() 所在的类 |
程序的入口,必须先初始化环境。 |
new 关键字 |
要造房子(对象)了,必须先把地基(类的静态环境)打好。 |
| 访问非 final 静态成员 | 要读写变量或调方法了,不初始化的话,变量还是 0 或 null。 |
| 父子继承关系 | 先父后子。如果老爸还没准备好,儿子也没法出来。 |
Class.forName() |
反射调用时,默认参数会强制触发初始化。 |
不会导致类初始化的情况:
访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
使用 类对象.class 不会触发初始化
创建该类的数组不会触发初始化
调用类加载器的 loadClass() 方法加载一个类
使用与卸载 (Using & Unloading)
- 使用: 你的程序开始欢快地跑代码。
- 卸载: 当该类的 Class 对象不再被引用,且加载它的 ClassLoader 已经被回收时,这个类才会被销毁。
类加载器
| 加载器名称 | 加载范围 (路径) | 特点 |
|---|---|---|
| 启动类加载器 (Bootstrap ClassLoader) | JRE/lib 下的核心库 (如 rt.jar) |
最顶层。由 C++ 实现,你在 Java 代码里拿不到它的引用(返回 null)。 |
| 扩展类加载器 (Extension ClassLoader) | JRE/lib/ext 下的扩展库 |
负责加载一些非核心但常用的扩展功能。 |
| 应用程序类加载器 (App ClassLoader) | 用户 ClassPath、项目中的类 | 我们平时写的代码、引入的 Maven 依赖,默认都由它加载。 |
| 自定义类加载器 (Custom ClassLoader) | 开发者自己定义的路径 | 比如从数据库读、从解密流读。继承 java.lang.ClassLoader 即可。 |
核心机制:双亲委派模型 (Parents Delegation Model)
这当一个类加载器收到加载请求时,它不会自己先去加载,而是:
- 向上委托: 先问自己的“父亲”:你能加载这个类吗?
- 递归向上: 父亲又问爷爷,一直传到最顶层的 Bootstrap ClassLoader。
- 向下尝试: 只有当父类加载器反馈自己无法加载(在它的搜索范围内找不到)时,子加载器才会尝试自己去加载。
为什么要这么麻烦?
- 安全性 (Security): 防止核心 API 被篡改。如果没有这个机制,我写一个恶意的
java.lang.String并放在 ClassPath 下,App 加载器直接加载了它,那整个 Java 程序的逻辑就全乱套了。有了双亲委派,java.lang.String永远由最顶层的 Bootstrap 加载,保证了“全家桶”的一致性。 - 唯一性 (Uniqueness): 避免同一个类被重复加载。在 JVM 中,类加载器 + 全限定类名 才唯一确定一个类。
线程上下文类加载器
线程上下文类加载器(Thread Context ClassLoader,简称 TCCL)是 Java 类加载机制中为了解决“父类加载器无法看见子类加载器加载的类”这一尴尬局面而设计的“后门”。
如果说双亲委派模型是“层层上报”的官僚体系,那么 TCCL 就是一份**“特派员证”**,让高层的类(如系统核心库)能够跨级调动基层的资源(如第三方驱动)。
1. 为什么要打破双亲委派?(痛点所在)
在标准双亲委派模型中,子加载器能看到父加载器的类,但父加载器看不见子加载器的类。
最经典的冲突:SPI(Service Provider Interface)机制
以 JDBC 为例:
- 接口定义:
java.sql.DriverManager在rt.jar中,由 Bootstrap ClassLoader 加载。 - 具体的实现:MySQL 或 Oracle 的驱动包(Driver Implementation)在项目的
lib下,由 AppClassLoader 加载。
尴尬的情况:
DriverManager 需要去加载并初始化具体的数据库驱动。但按照双亲委派,Bootstrap 加载器根本找不到 CLASSPATH 下的驱动类。它就像一个身在高层的指挥官,想调用基层的士兵,却发现自己的视野里只有其他指挥官。
2. TCCL 的工作原理:走后门
TCCL 允许程序在运行时,通过 Thread 对象手动设置和获取一个类加载器。
- 默认值:当一个线程被创建时,它会继承父线程的上下文类加载器。如果你在主逻辑中,默认就是 AppClassLoader。
- 核心 API:
Thread.currentThread().getContextClassLoader();// 获取Thread.currentThread().setContextClassLoader(cl);// 设置
破局思路:
高层的 DriverManager 不再尝试用自己的加载器去加载驱动,而是说:“既然我是由 Bootstrap 加载的,看不见基层,那我就问问当前正在跑代码的这个线程,借它的加载器用一下!”
因为当前线程通常是应用线程,它的上下文加载器正是 AppClassLoader。这样,高层的类就成功通过“线程”这个媒介,反向委托子加载器去完成任务。
3. ServiceLoader
在 Java 6 之后,ServiceLoader 成了 TCCL 的主要舞台。当你调用 ServiceLoader.load(Class<S> service) 时,它的内部源码其实是这样的:
Java
1 | // 核心逻辑简写 |
4. 常见应用场景
除了 JDBC,你在开发 Spring Boot 或使用 RuoYi 框架时,TCCL 也在暗中发力:
- Tomcat/Jetty:Web 容器需要加载不同 Web App 的类。它会先将线程的 TCCL 设置为当前 Web App 的类加载器,然后再去执行具体的业务代码。这样业务代码里的
Class.forName就能准确找到对应的类。 - JNDI、JAXB:这些涉及核心库与第三方实现交互的技术,基本都离不开 TCCL。
- 框架扩展:很多插件化架构的框架,在调用插件代码前,都会习惯性地切一下 TCCL,确保插件里的资源能被正确识别。
“既然 TCCL 打破了双亲委派,那它安全吗?”
它并不是破坏了安全性,而是为了功能性不得不做的一种补偿。它通常只用于加载“接口的实现类”,而核心接口本身依然是由 Bootstrap 牢牢把控的,所以整体安全底座依然稳固。
自定义类加载器
在 JVM 的世界里,默认的类加载器(Bootstrap, Ext, App)已经涵盖了绝大多数场景。但当你想要打破常规——比如从数据库读代码、给代码加密、或者在同一个应用里运行两个版本的同一个 JAR 包时,自定义类加载器就该登场了。
实现一个自定义类加载器,本质上是你在告诉 JVM:“如果标准路径下找不到这个类,或者这个类需要特殊处理,请按我的规矩来。”
1. 如何实现?(核心两步走)
要写一个自己的类加载器,你只需要继承 java.lang.ClassLoader 并遵循以下原则:
第一步:继承 ClassLoader
不要去动 loadClass 方法(除非你想彻底破坏双亲委派),而是去重写 findClass(String name)。
第二步:调用 defineClass
在 findClass 内部,你需要:
- 根据类名找到对应的字节码(比如读文件、下网络包)。
- 调用父类的
defineClass()方法,把这一串byte[]转化为真正的Class对象。
2. 核心代码示例(逻辑模板)
这是一个从特定路径加载加密/特殊文件的类加载器伪代码:
Java
1 | public class MyClassLoader extends ClassLoader { |
4. 自定义类加载器的“必杀技”场景
作为一名准备后端面试的开发者,理解这些实战场景能让你显得更有深度:
A. 代码加密 (Code Obfuscation/Encryption)
为了防止核心算法被反编译,公司会将 .class 文件加密。标准的加载器看不懂加密后的乱码,只有你自己写的、带了解密逻辑的类加载器才能让程序跑起来。
B. 隔离性 (Isolation) —— 经典的 Tomcat 案例
在一个 Web 容器里,可能同时跑着两个 Spring 项目,一个用 Spring 4,一个用 Spring 5。
- 如果用 AppClassLoader 加载,由于类名完全一样,它们会产生冲突。
- 解决方案: Tomcat 为每个 Web 应用创建一个独立的自定义类加载器实例。这样,虽然类名一样,但在 JVM 看来,“不同的类加载器 + 相同的类名 = 不同的类”,完美隔离。
C. 热部署 (Hot Swap)
在不重启 JVM 的情况下更新代码。做法是:销毁旧的自定义类加载器,创建一个新的加载器去加载修改后的 .class 文件。
运行时优化
1 | public static void main(String[] args) { |
运行程序后在控制台上能发现随着外部循环的执行,内部循环耗费的时间越来越少。
原因是什么呢?
JVM 将执行状态分成了 5 个层次:
0 层,解释执行(Interpreter)
1 层,使用 C1 即时编译器编译执行(不带 profiling)
2 层,使用 C1 即时编译器编译执行(带基本的 profiling)
3 层,使用 C1 即时编译器编译执行(带完全的 profiling)
4 层,使用 C2 即时编译器编译执行
profiling 是指在运行过程中收集一些程序执行状态的数据,例如「方法的调用次数」,「循环的回边次数」等。
只有被频繁调用的“热点代码”(Hotspot),才会被送到 C2 进行最高级优化
即时编译器(JIT)与解释器的区别:
解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
JIT 是将一些字节码编译为字节码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
解释器是将字节码解释为针对所有平台都通用的机器码
JIT 会根据平台类型,生成平台特定的机器码
逃逸分析
这是判断一个对象“生命周期”的技术。如果一个对象在方法内部创建,且没有被外部引用(没逃出方法),JVM 就会进行以下优化:
| 优化手段 | 核心逻辑 | 收益 |
|---|---|---|
| 栈上分配 (Stack Allocation) | 既然对象不逃逸,直接把对象分配在栈上。 | 方法结束对象直接销毁,减轻 GC 压力。 |
| 标量替换 (Scalar Replacement) | 把对象拆解成一个个基本类型的变量(如 int, long)。 |
甚至不需要在内存里创建对象,直接用寄存器存储。 |
| 同步消除 (Lock Elision) | 如果发现一个锁对象只会被当前线程访问。 | 直接去掉 synchronized,消除性能损耗。 |
可以使用 JVM 参数 -XX:-DoEscapeAnalysis 来关闭逃逸分析
方法内联
有以下代码:
1 | private static int square(final int i) { |
如果发现 square() 是热点代码,并且长度不太长,就会进行内联。所谓内联指的是将方法内的代码拷贝、粘贴到调用者的位置:
1 | System.out.println(9 * 9); |
还能够进行常量折叠(constant folding)的优化:
1 | System.out.println(81); |
字段优化
1. 字段读取消除
这是最常见、最有效的优化。JVM 发现如果你在一个局部范围内多次读取同一个字段,且中间没有改变它的操作,它就会只读一次。
逻辑:第一次读取时,把值存在 寄存器 或 栈 里,后面直接用这个“备份”,不再去翻内存。
代码示例:
Java
1
2
3
4
5
6
7
8
9
10
11
12
13// 优化前
public void doSomething(User user) {
int a = user.age; // 读内存
// ... 一些不改 age 的操作
int b = user.age; // 又读一遍内存
}
// 优化后 (JIT 视角)
public void doSomething(User user) {
int tempAge = user.age; // 只读一次
int a = tempAge;
int b = tempAge;
}
注意:如果字段被
volatile修饰,JVM 就不敢做这个优化,必须每次都老老实实去主内存读,以保证可见性。
2. 字段标量替换
这是配合“逃逸分析”的大招。如果 JVM 发现一个对象不会逃逸出方法,它干脆连对象都不建了。
- 逻辑:把对象的各个字段拆散,直接变成方法里的局部变量。
- 好处:局部变量直接存在寄存器或栈上,访问速度比在堆里的字段快几个数量级,且不触发 GC。
3. 常量折叠与传播
这主要针对 static final 修饰的字段。
逻辑:在编译期或运行期,如果 JVM 确定一个字段的值永远不变,它会把所有用到这个字段的地方直接替换成具体的值。
代码示例:
1
2
3
4public static final int MAX_RETRY = 3;
// 代码里的 if (count < MAX_RETRY)
// 会被 JIT 编译成 if (count < 3)
4. 字段排列与填充 (Field Reordering & Padding)
这是针对 CPU 硬件特性 的底层优化。
- 字段重排:JVM 会重新排列对象中字段的顺序。它会把相同宽度的字段排在一起(比如 long 挨着 long,int 挨着 int),为了内存对齐,减少空间浪费。
- 消除伪共享 (False Sharing):在多线程高并发下,如果两个频繁修改的字段刚好在同一个 CPU 缓存行(Cache Line)里,会导致缓存频繁失效。
- 黑科技:Java 8 引入了
@Contended注解。JVM 会在字段前后自动填充一些空字节(Padding),强行把字段隔开,确保它们不在同一个缓存行,从而让并发性能暴增。
- 黑科技:Java 8 引入了
反射优化
1. 初始阶段:本地方法 (Native MethodAccessor)
当你前几次(默认是前 15 次)调用反射时,JVM 使用 C++ 编写的 Native 方法。
- 优点:不需要额外生成代码,启动快。
- 缺点:每次都要跨越 Java/Native 的边界,跑多了就慢。
2. 膨胀阶段:字节码生成 (Generated MethodAccessor)
如果一个反射方法被调用的次数超过了阈值(默认 15 次),JVM 就会觉得:“这家伙是个热点!”
- 动作:JVM 会动态生成一段新的字节码,直接去调用目标方法。
- 结果:原本的“反射调用”变成了“正常的 Java 调用”。生成的字节码可以被 JIT 编译器进行方法内联等终极优化。
参数控制:你可以通过
-Dsun.reflect.inflationThreshold=N来调整这个阈值。如果设为 0,则一上来就直接生成字节码。