image-20260311163121976

类文件结构

1
2
3
4
5
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World");
}
}

他的.class文件为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09 
0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07
0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 12 4c 6f 63
0000120 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 01
0000140 00 04 74 68 69 73 01 00 1d 4c 63 6e 2f 69 74 63
0000160 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f
0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e 01 00 16
0000220 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72
0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 01 00 13
0000260 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69
0000300 6e 67 3b 01 00 10 4d 65 74 68 6f 64 50 61 72 61
0000320 6d 65 74 65 72 73 01 00 0a 53 6f 75 72 63 65 46
0000340 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64
0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e
0000400 00 1f 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64
0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74
0000440 63 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c
0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61
0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61
0000520 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f
0000540 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72
0000560 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76
0000600 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d
0000620 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a
0000640 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01
0000720 00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00
0000740 00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00
0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00
0001000 0f 00 02 00 09 00 00 00 37 00 02 00 01 00 00 00
0001020 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 02 00 0a
0001040 00 00 00 0a 00 02 00 00 00 06 00 08 00 07 00 0b
0001060 00 00 00 0c 00 01 00 00 00 09 00 10 00 11 00 00
0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00
0001120 00 00 02 00 14

根据 JVM 规范,类文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

魔数

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 项表名方法名是 ,即构造方法;然后又引用 #8 项,指定方法描述符为 ()V,即无参无返回值。

然后再一项一项查下去就行了

访问标识与继承信息

标志名称 (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
2
3
4
5
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01
0000720 00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00
0000740 00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00
0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00

附加属性

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
2
3
4
5
6
7
8
public class Demo1 {
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}

对于 short 范围内的整数不会存入运行时常量池中,而是与字节码指令一起存放。short 的最大值是 32767,加一后大于最大值,因此 32768 会被存放到运行时常量池中。

方法字节码载入方法区

image-20260311174148350

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

image-20260311174319405

通过 javap 命令查看的字节码文件中存在:

1
2
3
4
5
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4

stack=2 表示操作数栈的深度是 2
locals=4 表示局部变量表有 4 个槽位

执行引擎开始执行字节码

bipush 10 表示将一个 byte 压入操作数栈(由于操作数栈的宽度占 4 个字节,压入内容不足 4 个字节时,会补齐 4 个字节),类似的指令还有:

sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
ldc 将一个 int 压入操作数栈
ldc2_w 将一个 long 压入操作数栈(因为 long 是 8 个字节,需要分两次压入)
小数字和字节码指令存放在一起,超过 short 范围的数字会存入常量池

image-20260311174459193

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

image-20260311174542189

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

image-20260311174627163

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

image-20260311174744045

接着从局部变量表里读取参与加法运算的两个数:

iload_1 表示从局部变量表 slot 1 里读取数据
iload_2 表示从局部变量表 slot 2 里读取数据

image-20260311174840402

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

image-20260311174911049

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

image-20260311174932025

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

image-20260311175014554

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

image-20260311175111686

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

image-20260311175207053

使用 invokevirtual #5 调用方法打印数据:

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

image-20260311175242922

目标方法执行完毕后,弹出栈帧。

清除 main 操作数栈的内容:

image-20260311175335789

完成 main 方法调用后,弹出 main 栈帧,程序结束。

分析a++

1
2
3
4
5
6
7
8
9
10
public class Demo1_2 {
public static void main(String[] args) {
int a = 10;
int b = a++ + ++a + a--;
// 11
System.out.println(a);
// 34
System.out.println(b);
}
}

字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 10
2: istore_1
3: iload_1
4: iinc 1, 1
7: iinc 1, 1
10: iload_1
11: iadd
12: iload_1
13: iinc 1, -1
16: iadd
17: istore_2
18: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
21: iload_1
22: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
25: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
28: iload_2
29: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
32: return

分析:

iinc 指令是直接在局部变量 slot 上进行运算的
a++ 和 ++a 的区别是先执行 iload 还是先执行 iinc
a++ 会被分解为两条字节码指令:

iload_1
iinc 1,1

image-20260312142749881

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

iinc 1,1

iload_1

image-20260312143130428

image-20260312143317358

image-20260312143441403

image-20260312143508399

image-20260312143531231

image-20260312143549039

条件判断指令

指令 助记符 含义
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
2
3
4
5
6
7
8
9
10
public class Demo1_3 {
public static void main(String[] args) {
int a = 0;
if (a == 0) {
a = 10;
} else {
a = 20;
}
}
}

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
 0: iconst_0
1: istore_1
2: iload_1
3: ifne 12
6: bipush 10
8: istore_1
9: goto 15
12: bipush 20
14: istore_1
15: return

循环控制指令

循环控制指令还是先前的条件判断指令,例如 while 循环:

1
2
3
4
5
6
7
8
public class Demo1_4 {
public static void main(String[] args) {
int a = 0;
while (a < 10) {
a++;
}
}
}

对应的部分字节码:

1
2
3
4
5
6
7
8
 0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return

for循环也是这个

构造方法

()V

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Demo1_8_1 {

static int i = 10;

static {
i = 20;
}

static {
i = 30;
}

}

编译器会按从上到下的顺序,收集所有 static 静态代码块和静态变量赋值的代码,最终合并成一个特殊的方法 ()V,对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
0: bipush        10
2: putstatic #2 // Field i:I
5: bipush 20
7: putstatic #2 // Field i:I
10: bipush 30
12: putstatic #2 // Field i:I
15: return



()V 方法会在类加载的初始化阶段被调用。

因此上述代码里 i 最终的值为 30。

()V

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Demo1_8_2 {

private String a = "s1";

{
b = 20;
}

private int b = 10;

{
a = "s2";
}

public Demo1_8_2(String a, int b) {
this.a = a;
this.b = b;
}

public static void main(String[] args) {
Demo1_8_2 obj = new Demo1_8_2("s3", 30);
// s3
System.out.println(obj.a);
// 30
System.out.println(obj.b);
}

}

编译器会按从上到下的顺序,收集所有 {} 代码块(初始化代码块)和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后。

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
0: aload_0
1: invokespecial #1 // super."<init>":()V
4: aload_0
5: ldc #2 // <- "s1"
7: putfield #3 // -> this.a
10: aload_0
11: bipush 20 // <- 20
13: putfield #4 // -> this.b
16: aload_0
17: bipush 10 // <- 10
19: putfield #4 // -> this.b
22: aload_0
23: ldc #5 // <- "s2"
25: putfield #3 // -> this.a
28: aload_0 // --------------------------------
29: aload_1 // <- slot 1(a) "s3" |
30: putfield #3 // -> this.a |
33: aload_0 |
34: iload_2 // <- slot 2(b) 30 |
35: putfield #4 // -> this.a ---------------------
38: return
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
0 39 0 this Lindi/mofan/Demo1_8_2;
0 39 1 a Ljava/lang/String;
0 39 2 b I



方法调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Demo1_9 {
public Demo1_9() {
}

private void test1() {
}

private final void test2() {
}

public void test3() {
}

public static void test4() {
}

public static void main(String[] args) {
Demo1_9 obj = new Demo1_9();
obj.test1();
obj.test2();
obj.test3();
obj.test4();
Demo1_9.test4();
}

}

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0: new           #2                  // class indi/mofan/Demo1_9
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: aload_1
21: pop
22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V
28: return



可以发现:

调用构造方法、私有方法、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
2
3
4
20: aload_1
21: pop
22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V

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 会经历以下步骤:

  1. 找到实际对象: 通过操作数栈顶的引用,找到堆中真实的对象实例
  2. 获取类型信息: 根据对象头(Object Header)里的类型指针,定位到该对象所属的具体类(Class)
  3. 查表: 到该类的 vtable 中,根据方法在父类中预确定的**偏移量(Offset)**直接取出方法地址。
  4. 执行: 跳转到该地址执行代码。

4. 接口的特殊性:invokeinterface

如果你的引用类型是接口,情况会稍微复杂一点。因为一个类可以实现多个接口,方法在表里的偏移量就没法像 vtable 那样固定了。

这时候 JVM 会使用 itable(Interface Method Table)。它的查找效率比 vtable 略低,但原理类似:先根据接口找到对应的函数表,再进行搜索。

异常处理

try-catch

1
2
3
4
5
6
7
8
9
10
public class Demo1_11_1 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
}
}
}

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 12
8: astore_2
9: bipush 20
11: istore_1
12: return
Exception table:
from to target type
2 5 8 Class java/lang/Exception
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/Exception;
0 13 0 args [Ljava/lang/String;
2 11 1 i I
StackMapTable: ...



使用 try-catch 块后,生成的字节码会多出一个 Exception table 结构。其中的 [from, to) 是一个前闭后开的检测范围,比如这里的 [2, 5) 表示检测第 2 行字节码到第 5 行字节码(不包括第 5 行),一旦这个范围的字节码执行时出现异常,再判断异常类型是否与 type 匹配,如果匹配,则跳到 target 对应的字节码行号。

第 8 行字节码是 astore_2,表示将异常对象 e 的引用存入局部变量表的 slot 2 位置。

多个 single-catch 块的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Demo1_11_2 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (ArithmeticException e) {
i = 30;
} catch (NullPointerException e) {
i = 40;
} catch (Exception e) {
i = 50;
}
}
}

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 26
8: astore_2
9: bipush 30
11: istore_1
12: goto 26
15: astore_2
16: bipush 40
18: istore_1
19: goto 26
22: astore_2
23: bipush 50
25: istore_1
26: return
Exception table:
from to target type
2 5 8 Class java/lang/ArithmeticException
2 5 15 Class java/lang/NullPointerException
2 5 22 Class java/lang/Exception
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/ArithmeticException;
16 3 2 e Ljava/lang/NullPointerException;
23 3 2 e Ljava/lang/Exception;
0 27 0 args [Ljava/lang/String;
2 25 1 i I
StackMapTable: ...

多个 catch 块与单个 catch 块类似,只不过由于只能进入 Exception table 中的一个分支,所以局部变量表 slot 2 会被复用。

finally 块

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo1_11_4 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
}
}

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1
0: iconst_0
1: istore_1 // 0 -> i
2: bipush 10 // try ----------------------------------------
4: istore_1 // 10 -> i |
5: bipush 30 // finally |
7: istore_1 // 30 -> i |
8: goto 27 // return -------------------------------------
11: astore_2 // catch Exception -> e -----------------------
12: bipush 20 // |
14: istore_1 // 20 -> i |
15: bipush 30 // finally |
17: istore_1 // 30 -> i |
18: goto 27 // return -------------------------------------
21: astore_3 // catch any -> slot 3 ------------------------
22: bipush 30 // finally |
24: istore_1 // 30 -> i |
25: aload_3 // <- slot 3 |
26: athrow // throw --------------------------------------
27: return
Exception table:
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any // 剩余的异常类型,比如 Error
11 15 21 any // 剩余的异常类型,比如 Error
LineNumberTable: ...
LocalVariableTable: ...



复制粘贴:把 finally 里的代码拷贝到 try 结束处和 catch 结束处。

兜底捕获:在异常表里注册一个 any 类型的监控,管辖 trycatch 的所有领地。只要有漏网之鱼,先拉到 finally 逻辑里跑一遍,再重新抛出。

finally面试题

finally 出现了 return

运行以下代码,控制台会输出什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo1_12_1 {
public static int test() {
try {
return 10;
} finally {
return 20;
}
}

public static void main(String[] args) {
// 20
System.out.println(test());
}

}

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static int test();
descriptor: ()I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=0
0: bipush 10 // <- 10 放入栈顶
2: istore_0 // 10 -> slot 0 (从栈顶移除)
3: bipush 20 // <- 20 放入栈顶
5: ireturn // 返回栈顶 int(20)
6: astore_1 // catch any -> slot 1
7: bipush 20 // <- 20 放入栈顶
9: ireturn // 返回栈顶 int(20)
Exception table:
from to target type
0 3 6 any
LineNumberTable: ...
StackMapTable: ...

原本应该有的步骤: 如果在 finally 里没有 return,在 20 赋值完后,会有一行 aload(加载异常对象)和 athrow(抛出)。但 return 把这一切都切断了。

结果分析:

  • 返回值:始终是 20。即使 try 块里有 return 10,也会被 finally 里的 return 20 覆盖。
  • 异常情况:无异常抛出。ArithmeticException 被“毁尸灭迹”了。

运行以下代码,控制台又会输出什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Demo1_12_2 {

public static void main(String[] args) {
// 10
System.out.println(test());
}

public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}

}

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static int test();
descriptor: ()I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=0
0: bipush 10 // <- 10 放入栈顶
2: istore_0 // 10 -> i
3: iload_0 // <- i(10)
4: istore_1 // 10 -> slot 1,暂存至 slot 1,目的是为了固定返回值
5: bipush 20 // <- 20 放回栈顶
7: istore_0 // 20 -> i
8: iload_1 // <- slot 1(10) 载入 slot 1 暂存的值
9: ireturn // 返回栈顶的 int(10)
10: astore_2
11: bipush 20
13: istore_0
14: aload_2
15: athrow
Exception table:
from to target type
3 5 10 any
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I
StackMapTable: ...

finally 块中的数据没有被 return,生成的字节码指令中有 athrow 指令,如果出现异常不会被吞。

synchronized

1
2
3
synchronized (lock) {
i++;
}

对应的部分字节码:

1
2
3
4
5
6
7
8
9
10
11
12
3: aload_1         // 将 lock 对象引用压栈
4: monitorenter // 【获取锁】
5: iinc 1, 1 // 执行 i++
8: aload_1 // 将 lock 再次压栈
9: monitorexit // 【释放锁:正常路径】
10: goto 18 // 跳过异常处理
13: astore_2 // 异常发生了!异常对象存入槽位
14: aload_1 // 将 lock 再次压栈
15: monitorexit // 【释放锁:异常路径,保证不锁死】
16: aload_2
17: athrow // 重新抛出异常
18: return

块同步:靠 monitorenter / monitorexit 指令对。

方法同步:靠 ACC_SYNCHRONIZED 标志位。****

编译期处理(语法糖)

默认构造器

1
2
public class Candy1 {
}

编译后生成的字节码等价于:

1
2
3
4
5
6
7
public class Candy1 {
// 这个无参构造器会由编译器自动加上
public Candy1() {
// 调用父类的 Object 的无参构造,即 java/lang/Object."<init>":()V
super();
}
}

自动装箱拆箱

在 JDK 5 中添加了自动拆装箱特性:

1
2
3
4
5
6
public class Candy2 {
public static void main(String[] args) {
Integer x = 1;
int y = x;
}
}

以上代码在 JDK 5 之前是无法编译通过的,必须改写为:

1
2
3
4
5
6
public static void main(String[] args) {
// 基本类型 -> 包装类型:装箱
Integer x = Integer.valueOf(1);
// 包装类型 -> 基本类型:拆箱
int y = x.intValue();
}

在 JDK 5 之前,包装类型和基本类型之间的转换需要手动处理(尤其是集合类中操作的都是包装类型),这非常麻烦,JDK 5 引入自动拆装箱后,这些手动处理的代码都可以由编译器在编译阶段完成。

泛型

泛型也是在 JDK 5 中加入的特性,但 Java 在编译泛型代码后会执行 泛型擦除 的动作,即泛型信息在编译为字节码之后就丢失了,实际类型都被当做 Object 类型来处理:

1
2
3
4
5
6
7
8
9
public class Candy3 {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
// 实际调用的是 List#add(Object e)
list.add(10);
// 实际调用的是 Object obj = List#get(int index)
Integer x = list.get(0);
}
}

所以在取值时,编译器真正生成的字节码中还要额外做一个类型转换的操作:

1
2
// 将 Object 转换成 Integer
Integer x = (Integer) list.get(0);

擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 中仍然保留了方法参数泛型信息:

1
2
3
LocalVariableTypeTable:
Start Length Slot Name Signature
8 24 1 list Ljava/util/List<Ljava/lang/Integer;>;

可变参数

可变参数也是 JDK 5 中添加的新特性。

1
2
3
4
5
6
7
8
9
10
11
12
public class Candy4 {
public static void foo(String... args) {
// 可变参数可直接赋值给数组
String[] array = args;
System.out.println(Arrays.toString(array));
}

public static void main(String[] args) {
foo("hello", "world");
}

}

可变参数 String… args 的本质是 String[] args,Java 编译器会在编译阶段将上述代码转换为:

public static void foo(String[] args) {

1
2
3
4
5
6
7
    String[] array = args;
​ System.out.println(Arrays.toString(array));
}

public static void main(String[] args) {
foo(new String[]{"hello", "world"});
}

注意,如果调用了 foo() 则等价于 foo(new String[]{}),创建了一个空数组,而不是传入 null。

注意,如果调用了 foo() 则等价于 foo(new String[]{}),创建了一个空数组,而不是传入 null。

foreach 循环

foreach 循环也是 JDK 5 引入的语法糖:

1
2
3
4
5
6
7
8
9
public class Candy5_1 {
public static void main(String[] args) {
// 数组赋初值的简化写法也是语法糖
int[] array = {1, 2, 3, 4, 5};
for (int x : array) {
System.out.println(x);
}
}
}

编译后生成的字节码等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Candy5_1 {

public Candy5_1() {
}

public static void main(String[] args) {
int[] array = new int[]{1, 2, 3, 4, 5};
for (int i = 0; i < array.length; ++i) {
int x = array[i];
System.out.println(x);
}
}

}

如果是在集合上使用 foreach 循环呢?

1
2
3
4
5
6
7
8
public class Candy5_2 {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
for (Integer x : list) {
System.out.println(x);
}
}
}

应用于集合的 foreach 循环会被编译期转换为对迭代器的调用,编译后生成的字节码等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Candy5_2 {

public Candy5_2() {
}

public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Iterator iterator = list.iterator();
while (iterator.hasNext()) {
Integer x = (Integer) iterator.next();
System.out.println(x);
}
}

}

foreach 循环的写法能够配合数组、实现了 Iterable 接口(提供获取 Iterator 的方式)的集合类一起使用。

switch 字符串

从 JDK 7 开始,switch 可以作用于字符串和枚举类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Candy6_1 {
public static void choose(String str) {
switch (str) {
case "hello": {
System.out.println("h");
break;
}
case "world": {
System.out.println("w");
}
}
}
}



字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Candy6_1 {
public Candy6_1() {
}

public static void choose(String str) {
byte x = -1;
switch (str.hashCode()) {
case 99162322: {
if (str.equals("hello")) {
x = 0;
}
break;
}
case 113318802: {
if (str.equals("world")) {
x = 1;
}
}
}
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}

}

在编译生成的字节码中执行了两次 switch,第一次根据字符串的 hashCode 和 equals 将字符串的转换为相应 byte 类型,第二次再利用 byte 进行比较。

为什么第一次时必须既比较 hashCode,又比较 equals 呢?

hashCode 是为了提高效率,减少比较次数;equals 是为了防止哈希冲突。

例如 BM 和 C. 两个字符串的 hashCode 值都是 2123:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Candy6_2 {
public static void choose(String str) {
switch (str) {
case "BM": {
System.out.println("h");
break;
}
case "C.": {
System.out.println("w");
break;
}
}
}
}

编译后生成的字节码等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Candy6_2 {

public Candy6_2() {
}

public static void choose(String str) {
byte x = -1;
switch (str.hashCode()) {
case 2123:
if (str.equals("C.")) {
x = 1;
} else if (str.equals("BM")) {
x = 0;
}
// 没 break
default:
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}

}

switch 枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Candy7 {
public static void foo(Sex sex) {
switch (sex) {
case MALE:
System.out.println("男");
break;
case FEMALE:
System.out.println("女");
break;
}
}
}

enum Sex {
MALE, FEMALE
}

字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Candy7 {

static class $MAP {
static int[] map = new int[2];

static {
map[Sex.MALE.ordinal()] = 1;
map[Sex.FEMALE.ordinal()] = 2;
}
}

public static void foo(Sex sex) {
int x = $MAP.map[sex.ordinal()];
switch (x) {
case 1:
System.out.println("男");
break;
case 2:
System.out.println("女");
break;
}
}

}

$MAP 是一个合成类,仅 JVM 使用,开发者不可见,用来映射枚举的 ordinal 与数组元素的关系。枚举的 ordinal 表示枚举对象的序号,从 0 开始,即 MALE.ordinal() = 0,FEMALE.ordinal() = 1。

枚举类

JDK 7 新增了枚举类,现有如下枚举:

1
2
3
public enum Day {
MONDAY, TUESDAY
}

编译后生成的字节码等价于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 1. 变成一个普通的类,且是 final 的(不能被继承)
public final class Day extends Enum<Day> {

// 2. 每一个枚举项,本质上都是该类的一个 static final 实例
public static final Day MONDAY;
public static final Day TUESDAY;

// 3. 编译器私下里维护的一个数组,存放所有枚举实例
private static final Day[] $VALUES;

static {
// 4. 在静态代码块里完成实例化
MONDAY = new Day("MONDAY", 0);
TUESDAY = new Day("TUESDAY", 1);
$VALUES = new Day[]{ MONDAY, TUESDAY };
}

// 5. 私有构造函数,接收名称和序号(ordinal)
private Day(String name, int ordinal) {
super(name, ordinal);
}

// 6. 编译器自动生成的 values() 方法
public static Day[] values() {
return $VALUES.clone(); // 返回克隆数组,保证安全性
}

// 7. 编译器自动生成的 valueOf() 方法
public static Day valueOf(String name) {
return Enum.valueOf(Day.class, name);
}
}

try-with-resources

1
2
3
4
5
try (InputStream is = new FileInputStream("test.txt")) {
int data = is.read();
} catch (IOException e) {
e.printStackTrace();
}

字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
InputStream is = new FileInputStream("test.txt");
Throwable primaryException = null; // 业务逻辑产生的异常

try {
int data = is.read();
} catch (Throwable e) {
primaryException = e; // 记录业务异常
throw e;
} finally {
if (is != null) {
if (primaryException != null) {
try {
is.close(); // 尝试关闭资源
} catch (Throwable suppressedException) {
// 【核心重点】如果关闭也报错,不覆盖原异常,而是“压制”它
primaryException.addSuppressed(suppressedException);
}
} else {
is.close(); // 没有业务异常,直接关闭
}
}
}

旧时代的坑: 在传统的 try-finally 中,如果 try 块报错了,而 finally 块里的 is.close() 也报错了,finally 里的异常会把 try 里的真凶给“顶掉”。你最终只能看到 close 失败的报错,真正的业务逻辑错误被弄丢了。

TWR 的救赎: 编译器利用了 Throwable 类里的 addSuppressed() 方法。

  • 主异常(业务逻辑报错)会被抛出。
  • 次要异常(关闭资源报错)会被附加在主异常的“压制列表”里。
  • 你可以通过 e.getSuppressed() 拿到那些被压制的异常。

方法重写时的桥接方法

方法重写时的返回值有两种情况:

父子类的返回值完全一致

子类返回值可以是父类返回值的子类,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
static class A {
public Number m() {
return 1;
}
}

static class B extends A {
@Override
// 子类 m 方法的返回值是 Integer,是父类 m 方法返回值 Number 的子类
public Integer m() {
return 2;
}
}

对于子类,Java 编译器会做以下处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static class B extends A {
public Integer m() {
return 2;
}


// 此方法才是真正重写父类的 m 方法
public synthetic bridge Number m() {
// 调用 public Integer m()
return m();
}

}



桥接方法比较特殊,仅对 Java 虚拟机可见,并且与原来的 public Integer m() 没有命名冲突

匿名内部类

1
2
3
4
5
6
7
8
9
public class Candy11 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
public void run() {
System.out.println("ok");
}
};
}
}
1
2
3
4
5
6
7
8
9
10
11
12
// 编译器偷偷生成的类
class Candy11$1 implements Runnable {
@Override
public void run() {
System.out.println("ok");
}
}

// 你的 main 方法里实际变成了这样:
public static void main(String[] args) {
Runnable runnable = new Candy11$1(); // 实例化那个偷偷生成的类
}

如果匿名内部类里引用了局部变量:

1
2
3
4
5
6
7
8
public static void test(final int x) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok" + x);
}
};
}

当你编译这段代码时,编译器生成的 Candy11$1.class 实际上长这样(伪代码):

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 编译器生成的类
class Candy11$1 implements Runnable {
// 1. 偷偷定义了一个成员变量,用来存 x 的副本
private final int val$x;

// 2. 构造函数被魔改了,必须把 x 传进来
Candy11$1(int x) {
this.val$x = x;
}

@Override
public void run() {
// 3. 你以为在用外面的 x,其实是在用自己的成员变量 val$x
System.out.println("ok" + this.val$x);
}
}

而在你的 test 方法里,调用变成了这样:

Java

1
2
3
4
public static void test(final int x) {
// 实例化时,把 x 的值当做参数传给构造函数
Runnable runnable = new Candy11$1(x);
}

这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:在创建 Candy11$1 对象时,将 x 的值赋值给了 Candy11$1 对象的 val$x 属性,所以 x 不应该再发生变化,如果变化,那么 val$x 属性没有机会再跟着一起变化。

类加载阶段

加载 (Loading)

1. 找:获取二进制字节流

JVM 并不关心你的 .class 文件是从哪儿来的,它只需要能拿到符合规范的字节流就行。这就是 Java 强大扩展性的来源:

  • 从本地文件读: 最常见的,从你的 bin 目录或 target 目录读。
  • 从压缩包读: 比如 JARWAR 包(这奠定了 Java 生态的基础)。
  • 从网络读: 比如早期的 Applet(虽然现在基本没人用了)。
  • 动态生成: 这在 Spring/Hibernate 里极其常见,比如 CGLIBJDK 动态代理,它们在运行时直接在内存里“现编”一段字节码并交给加载器。
  • 其他源: 甚至可以从数据库读,或者先进行解密(为了防止代码被反编译,有些公司会对 .class 加密,加载时再解密)。

2. 存:将静态结构转化为运行时数据

拿到这一串 01 的字节流后,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)

这当一个类加载器收到加载请求时,它不会自己先去加载,而是:

  1. 向上委托: 先问自己的“父亲”:你能加载这个类吗?
  2. 递归向上: 父亲又问爷爷,一直传到最顶层的 Bootstrap ClassLoader
  3. 向下尝试: 只有当父类加载器反馈自己无法加载(在它的搜索范围内找不到)时,子加载器才会尝试自己去加载。

为什么要这么麻烦?

  • 安全性 (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.DriverManagerrt.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
2
3
4
5
6
// 核心逻辑简写
public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取当前线程的上下文类加载器,而不是 ServiceLoader 自己的加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}

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 内部,你需要:

  1. 根据类名找到对应的字节码(比如读文件、下网络包)。
  2. 调用父类的 defineClass() 方法,把这一串 byte[] 转化为真正的 Class 对象。

2. 核心代码示例(逻辑模板)

这是一个从特定路径加载加密/特殊文件的类加载器伪代码:

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MyClassLoader extends ClassLoader {
private String classPath;

public MyClassLoader(String classPath) {
this.classPath = classPath;
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 获取字节码(这里可以加入解密逻辑)
byte[] data = loadClassData(name);
if (data == null) {
throw new ClassNotFoundException();
} else {
// 2. 将字节数组转化为 Class 对象
return defineClass(name, data, 0, data.length);
}
}

private byte[] loadClassData(String name) {
// 具体的读取逻辑:如从文件、数据库或网络读取二进制流
return null;
}
}

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
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {
long start = System.nanoTime();
for (int j = 0; j < 1000; j++) {
new Object();
}
long end = System.nanoTime();
System.out.printf("%d\t%d\n", i, end - start);
}
}

运行程序后在控制台上能发现随着外部循环的执行,内部循环耗费的时间越来越少。

原因是什么呢?

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
2
3
4
private static int square(final int i) {
return i * i;
}
System.out.println(square(9));

如果发现 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
    4
    public 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),强行把字段隔开,确保它们不在同一个缓存行,从而让并发性能暴增。

反射优化

1. 初始阶段:本地方法 (Native MethodAccessor)

当你前几次(默认是前 15 次)调用反射时,JVM 使用 C++ 编写的 Native 方法。

  • 优点:不需要额外生成代码,启动快。
  • 缺点:每次都要跨越 Java/Native 的边界,跑多了就慢。

2. 膨胀阶段:字节码生成 (Generated MethodAccessor)

如果一个反射方法被调用的次数超过了阈值(默认 15 次),JVM 就会觉得:“这家伙是个热点!”

  • 动作:JVM 会动态生成一段新的字节码,直接去调用目标方法。
  • 结果:原本的“反射调用”变成了“正常的 Java 调用”。生成的字节码可以被 JIT 编译器进行方法内联等终极优化。

参数控制:你可以通过 -Dsun.reflect.inflationThreshold=N 来调整这个阈值。如果设为 0,则一上来就直接生成字节码。