【JVM】第三章:类加载与字节码技术
一、字节码结构
回顾一下字节码
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class
的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程序上解决了传统解释型语言执行效率低的问题,同时又保留了解释下语言可移植的特点。所以 Java 程序运行时比较高校,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无需重新编译便可在多种不同操作系统的计算机上运行。
可以说 .class
文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因。
1.2 类文件结构
根据 Java 虚拟机规范,Class 文件通过 ClassFile
定义,有点类似 C 语言的结构体。
魔数(Magin Number)
每个 Class 文件的头 4 个字节称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。Java 规范规定魔数为固定值:0xCAFEBABE。如果读取的文件不是以这个魔数开头,Java 虚拟机将拒绝加载它。
Class 文件版本号 (Minor & Minor Version)
紧接着魔数的四个字节存储的是 Class 文件的版本号:第 5 个和第 6 个字节是次版本号,第 7 和第 8 个字节是主版本号。
每当 Java 发布大版本(比如 Java8,Java9)的时候,主版本号都会加 1。使用 javap -v
命令来快速查看 Class 文件的版本号信息。
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的 JDK 版本和生成环境的 JDK 版本保持一致。
常量池
常量池占据 Java 类文件中的大部分空间。存储了各种字面量和符号引用,以供类文件使用。它包含类、接口、字段、方法的符号引用、字面量等信息。
访问标志
用于标识类或接口的访问标志,例如是否为 public、final、abstract 等。
类索引、父类索引和接口索引
用来指示类的继承关系和实现的接口。
类索引用来确定这个类的全限定名,父类索引用于确定这个父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object
之外,所有的 Java 类都有父类。
接口所有集合用来描述这个类实现了哪些接口,这些被实现的接口按 implements
(如果这个类本身是接口的话则是 extends
)后的接口顺序从左到右排列在接口索引集合中。
字段表
描述类或接口中声明的字段信息,包括字段的访问标志、名称索引、描述符索引、属性表等。
方法表
描述类或接口中声明的方法信息,包括方法的访问标志、名称索引、描述符索引、属性表等。
属性表
描述类、字段、方法等的属性信息,包括代码属性、异常表、常量值属性等。
二、字节码指令
查看一个 Java 字节码文件:
2.1 图解方法执行流程
- 原始 Java 代码
/**
* 演示 字节码指令 和 操作数栈、常量池的关系
*/
public class Demo3_1 {
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}
- 编译后的字节码文件
Classfile /D:/study/java/jvm/资料 解密JVM/代码/jvm/src/cn/itcast/jvm/t3/bytecode/Demo3_1.class
Last modified 2023-12-21; size 458 bytes
MD5 checksum a099195e9abf23c79e0ebc4262c7875b
Compiled from "Demo3_1.java"
public class cn.itcast.jvm.t3.bytecode.Demo3_1
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#16 // java/lang/Object."<init>":()V
#2 = Class #17 // java/lang/Short
#3 = Integer 32768
#4 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #20.#21 // java/io/PrintStream.println:(I)V
#6 = Class #22 // cn/itcast/jvm/t3/bytecode/Demo3_1
#7 = Class #23 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 main
#13 = Utf8 ([Ljava/lang/String;)V
#14 = Utf8 SourceFile
#15 = Utf8 Demo3_1.java
#16 = NameAndType #8:#9 // "<init>":()V
#17 = Utf8 java/lang/Short
#18 = Class #24 // java/lang/System
#19 = NameAndType #25:#26 // out:Ljava/io/PrintStream;
#20 = Class #27 // java/io/PrintStream
#21 = NameAndType #28:#29 // println:(I)V
#22 = Utf8 cn/itcast/jvm/t3/bytecode/Demo3_1
#23 = Utf8 java/lang/Object
#24 = Utf8 java/lang/System
#25 = Utf8 out
#26 = Utf8 Ljava/io/PrintStream;
#27 = Utf8 java/io/PrintStream
#28 = Utf8 println
#29 = Utf8 (I)V
{
public cn.itcast.jvm.t3.bytecode.Demo3_1();
descriptor: ()V
flags: 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 4: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #3 // int 32768
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 6: 0
line 7: 3
line 8: 6
line 9: 10
line 10: 17
}
SourceFile: "Demo3_1.java"
- 常量池载入运行时常量池
- 方法字节码载入方法区
- main 线程开始运作,分配栈帧内存
-
stack=2:
-
stack=2 表示该方法在执行时操作数栈的最大深度为 2。
-
操作数栈用于执行方法时的临时数据存储。它存储方法中间结果、操作数和返回值等信息。
-
locals=4:
-
locals = 4 表示该方法有 4 个局部变量
-
局部变量是方法内部定义的变量,包括方法参数和方法内部的临时变量。这些变量的值在方法的生命周期内可变。
-
然后开始执行字节码指令
-
blpush10 :将一个 byte 类型数压入操作数栈(其长度会补齐 4 个字节)
-
sipush 将一个 short 类型数压入操作数栈(其长度会补齐 4 个字节)
-
ldc 将一个 int 类型数压入操作数栈
-
ldc2_w 将一个 long 类型数压入操作数栈(分两次压入,因为 long 是 8 个字节)
-
这里小的数字都是和字节码指令存在一起,超过 short 范围的数字会存入常量池中。
- istore1 :把操作数栈顶的数据放入局部变量表中的位置 1 中。
-
ldc #3 :把常量池#3 中的数据放入到操作数栈中。
-
注意 Short.MAX_VALUE 是 32767,所哟 32768 实际实在编译期间就算好的。
- istore2 :把操作数栈顶的数据放入局部变量表中的位置 2 中。
- iload1:把局部变量表中位置 1 的位置压入操作数栈上。
- iload2:把局部变量表中位置 2 的位置压入操作数栈上。
- iadd:将操作栈中的两个数弹出并将两个数相加在压入到操作数栈中。
- istore3:将操作数栈顶的数弹出栈放入到局部变量表的位置 3 中。
- getstatic #4:去常量池中找到 System.out 的对象在堆中的位置,并将该对象的引用地址放入到操作数栈中。
- iload3:将局部变量表位置 3 中的数据压入操作数栈中。
- invokevirtual #5:去常量池中找到 5 号条目,定位到方法区中的
java/io/PrintStream.println:(I)V
方法。然后生成新的栈帧(每次调用方法都会产生一个新的栈帧加入到虚拟机栈中)。然后传递参数,执行新栈帧中的字节码。
- 执行完毕,弹出栈帧。
- 清除 main 操作数栈内容
-
return
-
完成 main 方法调用,弹出 main 栈帧。
-
程序结束。
2.2 练习 - 分析 i++
目的:从字节码角度分析 a++ 相关题目
源码:
/**
* 从字节码角度分析 a++ 相关题目
*/
public class Demo3_2 {
public static void main(String[] args) {
int a = 10;
int b = a++ + ++a + a--;
System.out.println(a);
System.out.println(b);
}
}
字节码:
Last modified 2023-12-22; size 447 bytes
MD5 checksum a74d7b7f29e5da2c4e5c4bf2e9f5508f
Compiled from "Demo3_2.java"
public class cn.itcast.jvm.t3.bytecode.Demo3_2
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#14 // java/lang/Object."<init>":()V
#2 = Fieldref #15.#16 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #17.#18 // java/io/PrintStream.println:(I)V
#4 = Class #19 // cn/itcast/jvm/t3/bytecode/Demo3_2
#5 = Class #20 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 SourceFile
#13 = Utf8 Demo3_2.java
#14 = NameAndType #6:#7 // "<init>":()V
#15 = Class #21 // java/lang/System
#16 = NameAndType #22:#23 // out:Ljava/io/PrintStream;
#17 = Class #24 // java/io/PrintStream
#18 = NameAndType #25:#26 // println:(I)V
#19 = Utf8 cn/itcast/jvm/t3/bytecode/Demo3_2
#20 = Utf8 java/lang/Object
#21 = Utf8 java/lang/System
#22 = Utf8 out
#23 = Utf8 Ljava/io/PrintStream;
#24 = Utf8 java/io/PrintStream
#25 = Utf8 println
#26 = Utf8 (I)V
{
public cn.itcast.jvm.t3.bytecode.Demo3_2();
descriptor: ()V
flags: 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 4: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 10 // 将10压入操作数栈中
2: istore_1 // 将操作数栈顶弹出压入局部变量表的位置1中
3: iload_1 // 将局部变量表中位置1中的数据复制到操作数栈中
4: iinc 1, 1 // 将局部变量表中位置1的数据+1
7: iinc 1, 1 // 将局部变量表中位置1的数据+1
10: iload_1 // 将局部变量表位置1的数据复制到操作数栈中
11: iadd // 将操作数栈中的两个数据相加并将结果压入栈中
12: iload_1 // 将局部变量表位置1的数据复制到操作数栈中
13: iinc 1, -1 // 将局部变量表位置1的数据-1
16: iadd // 将操作数栈中的两个数相加并将结果压入栈中
17: istore_2 // 将操作数栈中的栈顶弹出放入局部变量表的位置2中。
18: getstatic #2 // 将常量池中的#2放入到操作数栈中 // Field java/lang/System.out:Ljava/io/PrintStream;
21: iload_1 // 将局部变量表位置1的数据复制到操作数栈中
22: invokevirtual #3 // 找到常量池中的#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
LineNumberTable:
line 6: 0
line 7: 3
line 8: 18
line 9: 25
line 10: 32
}
SourceFile: "Demo3_2.java"
2.3 条件判断指令
- byte、short、char 都会按 int 比较,因为操作数栈都是 4 个字节
- goto 用来跳转到指定行号的字节码
源码:
public class Demo3_3 {
public static void main(String[] args) {
int a = 0;
if(a == 0) {
a = 10;
} else {
a = 20;
}
}
}
字节码:
0: iconst_0
1: istore_1
2: iload_1
3: ifne 12 // 判断是否不等于0,如果是就转到12
6: bipush 10 // 不等于0,将10压入栈中
8: istore_1 // 赋值给a
9: goto 15
12: bipush 20
14: istore_1
15: return
2.4 循环控制指令
- while 循环
public class Demo3_4 {
public static void main(String[] args) {
int a = 0;
while (a < 10) {
a++;
}
}
}
字节码:
0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14 // 将操作数栈中的两个数进行比较,判断a是否 >= 10,如果是,直接跳转14行指令
8: iinc 1, 1 // 不是,将a自增1
11: goto 2 // 在转到第2行指令
14: return
- do while 循环
public class Demo3_5 {
public static void main(String[] args) {
int a = 0;
do {
a++;
} while (a < 10);
}
}
字节码:
0: iconst_0
1: istore_1
2: iinc 1, 1 // 先自增1
5: iload_1
6: bipush 10
8: if_icmplt 2 // 判断 a 是否 < 10,如果是,跳转到2号指令
11: return
- 再来看看 for 循环
public class Demo3_6 {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
}
}
}
字节码是:
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
比较 while 和 for 的字节码,发现一摸一样,疏通也能同归。
2.5 练习 - 判断结果
为啥 x = 0, x = x ++
后 x = 0
?
x=0
执行完之后,局部变量表中位置 x 的数是 0,然后再执行 iload_x
指令将局部变量表中 位置 x 的 0 复制到操作数栈中,然后再执行 linc x 1
指令,将局部变量表中位置 x 的数加 1,然后再执行 istore1
指令将操作数栈中的 0 放入到局部变量表位置 x 中,此时 x 的值还是 0。
2.6 构造方法
- ()V
public class Demo3_3 {
public static void main(String[] args) {
static int i = 10;
static {
i = 20;
}
static {
i = 30;
}
}
}
编译器会按从上至下的顺序,收集所有 static 静态代码块和静态成员复制的代码,合并为一个特殊的方法 <cinit>()V
。
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
<cinit>()V
方法会在类加载的初始化阶段被调用。
- ()V
代码:
public class Demo3_8_2 {
private String a = "s1";
{
b = 20;
}
private int b = 10;
{
a = "s2";
}
public Demo3_8_2(String a, int b) {
this.a = a;
this.b = b;
}
public static void main(String[] args) {
Demo3_8_2 d = new Demo3_8_2("s3", 30);
System.out.println(d.a);
System.out.println(d.b);
}
}
编译器会按从上至下的顺序,收集所有 { } 代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后。
public cn.itcast.jvm.t3.bytecode.Demo3_8_2(java.lang.String, int);
descriptor: (Ljava/lang/String;I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: aload_0 // 将堆中的this对象加载到操作数栈中
1: invokespecial #1 // 调用父类构造方法 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String s1 将常量池中的"s1"对象加载到堆栈中
7: putfield #3 // Field a:Ljava/lang/String; // 将"s1"赋值给属性a的
10: aload_0
11: bipush 20
13: putfield #4 // Field b:I
16: aload_0
17: bipush 10
19: putfield #4 // Field b:I
22: aload_0
23: ldc #5 // String s2
25: putfield #3 // Field a:Ljava/lang/String;
28: aload_0
29: aload_1
30: putfield #3 // Field a:Ljava/lang/String;
33: aload_0
34: iload_2
35: putfield #4 // Field b:I
38: return
2.7 方法调用
我们来看一下不同的方法调用对应的字节码指令。
public class Demo3_9 {
public Demo3_9() { }
private void test1() { }
private final void test2() { }
public void test3() { }
public static void test4() { }
@Override
public String toString() {
return super.toString();
}
public static void main(String[] args) {
Demo3_9 d = new Demo3_9();
d.test1();
d.test2();
d.test3();
d.test4();
Demo3_9.test4();
d.toString();
}
}
字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #3 // class cn/itcast/jvm/t3/bytecode/Demo3_9
3: dup
4: invokespecial #4 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #5 // Method test1:()V
12: aload_1
13: invokespecial #6 // Method test2:()V
16: aload_1
17: invokevirtual #7 // Method test3:()V
20: aload_1
21: pop
22: invokestatic #8 // Method test4:()V
25: invokestatic #8 // Method test4:()V
28: aload_1
29: invokevirtual #9 // Method toString:()Ljava/lang/String;
32: pop
33: return
- new 是创建【对象】,给对象分配堆内存,执行成功会将【对象引用】压入栈中。
dup
是赋值操作数栈栈顶的内容,本例为【对象引用】,为什么需要两份引用呢?一个是配合invokespecial
调用该对象的构造方法<init>()V
(会消耗掉栈顶一个引用),另一个要配合astore_1
赋值给局部变量。- 最终方法、私有方法、构造方法都是由
invokespecial
指令来调用,属于静态绑定。 - 普通成员方法是由
invokevirtual
调用,属于动态绑定,即支持多态。 - 成员方法和静态方法调用的另一个区别就是:执行方法钱是否需要【对象引用】
- 比较有意思的是
d.teat4()
是通过【对象引用】调用一个静态方法,可以看到在调用invokestatic
之前执行了pop
指令,把【对象引用】从操作数栈中弹出了。 - 还有一个执
invokespecial
的情况是通过super
调用了父类方法。
2.9 多态的原理
源代码:
import java.io.IOException;
/**
* 演示多态原理,注意加上下面的 JVM 参数,禁用指针压缩
* -XX:-UseCompressedOops -XX:-UseCompressedClassPointers
*/
public class Demo3_10 {
public static void test(Animal animal) {
animal.eat();
System.out.println(animal.toString());
}
public static void main(String[] args) throws IOException {
test(new Cat());
test(new Dog());
System.in.read();
}
}
abstract class Animal {
public abstract void eat();
@Override
public String toString() {
return "我是" + this.getClass().getSimpleName();
}
}
class Dog extends Animal {
@Override
public void eat() {
System.out.println("啃骨头");
}
}
class Cat extends Animal {
@Override
public void eat() {
System.out.println("吃鱼");
}
}
这里没有听明白,之后再回来弄
2.10 异常处理
- try-catch
源代码:
public class Demo3_11_1 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
}
}
}
字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0 // 将整数0压入操作数栈
1: istore_1 // 将0赋值给局部变量表中的位置1
2: bipush 10
4: istore_1
5: goto 12 // 如果在 2 - 5行指令中没有发生异常,直接跳转到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: ...
MethodParameters: ...
- 可以看到多出来一个
Exception table
的结构,[form,to] 是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 来匹配异常类型,如果一直,进入 target 所指示行号。 - 8 行的字节码指令
astore_2
是将异常对象引用存入局部变量表的 slot 2 位置。
- 多个 single catch 块
源代码:
public class Demo3_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;
}
}
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: 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: ...
MethodParameters: ...
- 因为异常出现时,只能进入 Exception table 中的一个分支,所以局部变量表 slot 2 位置被共用。
multi-catch 的情况
源代码:
public class Demo3_11_3 {
public static void main(String[] args) {
try {
Method test = Demo3_11_3.class.getMethod("test");
test.invoke(null);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}
public static void test() {
System.out.println("ok");
}
}
字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: ldc #2 // class cn/itcast/jvm/t3/bytecode/Demo3_11_3
2: ldc #3 // String test
4: iconst_0
5: anewarray #4 // class java/lang/Class
8: invokevirtual #5 // Method java/lang/Class.getMethod:(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;
11: astore_1
12: aload_1
13: aconst_null
14: iconst_0
15: anewarray #6 // class java/lang/Object
18: invokevirtual #7 // Method java/lang/reflect/Method.invoke:(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
21: pop
22: goto 30
25: astore_1 // 将异常对象存储到局部变量表的位置1中
26: aload_1
27: invokevirtual #11 // Method java/lang/ReflectiveOperationException.printStackTrace:()V
30: return
Exception table:
from to target type
0 22 25 Class java/lang/NoSuchMethodException
0 22 25 Class java/lang/IllegalAccessException
0 22 25 Class java/lang/reflect/InvocationTargetException
}
只要在执行【0,22)的过程中出现给定的任何异常中的一种,都会进入 catch 块中。
finally
源代码:
public class Demo3_11_4 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
}
}
字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1
0: iconst_0
1: istore_1 // 0 -> i
2: bipush 10 // 执行try: 10 -> 操作数栈
4: istore_1 // 操作数栈的10 -> 局slot1中
5: bipush 30 // 执行finally :30 -> 操作数栈
7: istore_1 // 操作数栈的10 -> slot 1中
8: goto 27 // 退出
11: astore_2 // 执行catch:如果在try流程执行的过程中出现了Exception异常,异常对象 -> slot2
12: bipush 20 // 20 -> 操作数栈
14: istore_1 // 操作数栈20 -> slot1
15: bipush 30 // 30 -> 操作数栈
17: istore_1 // 操作数栈的30 -> slot1
18: goto 27 // 退出
21: astore_3 // 如果在catch块中出现了任何异常,将异常对象 -> slot3
22: bipush 30 // 30 -> 操作数栈
24: istore_1 // 操作数栈的30 -> slot1
25: aload_3 // slot3 -> 操作数栈
26: athrow // 抛出异常对象
27: return
Exception table:
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any
11 15 21 any
- 可见 finally 中的代码被复制了三分,分别放入了 try 流程,catch 流程,以及 catch 剩余的异常类型流程。
2.11 练习:finally 面试题
- finally 出现了 return
先问问自己,下面的题目输出什么?
public class Demo3_12_1 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
public static int test() {
try {
return 10;
} finally {
return 20;
}
}
}
字节码:
public static int test();
descriptor: ()I
flags: 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 // 20 -> 作为方法返回值返回
6: astore_1 // 当try中出现任何异常时,异常对象->slot1
7: bipush 20 // 20 -> 操作数栈
9: ireturn // 20 -> 作为方法返回值返回
Exception table:
from to target type
0 3 6 any
- 由于 finally 中的被插入了所有可能的流程,因此返回结果肯定以 finally 的为准
- 当 try 中的 10 要返回之前,被先存放到了局部变量表中,然后去执行 finally 块,fianlly 中也返回了值,导致之前存放在局部变量表中的 10 没能被返回。
- 跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如哦在 finally 中出现了 return,会吞掉一次。可以试一下下面的代码:
public class Demo3_12_1 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
public static int test() {
try {
int i = 1 / 0;
return 10;
} finally {
return 20;
}
}
}
执行的结果中并没有抛出异常。
- finally 对返回值影响
同样问问自己,下面的题目输出什么?
public class Demo3_12_2 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}
}
字节码:
public static int test();
descriptor: ()I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=0
0: bipush 10 // 10 -> 栈顶
2: istore_0 // 10 -> slot 0
3: iload_0 // i(10) -> 栈顶
4: istore_1 // 10 -> slot 1,暂存在slot 1,目的是为了固定返回值
5: bipush 20 // 20 -> 栈顶
7: istore_0 // 20 -> slot 0
8: iload_1 // 10 -> 栈顶
9: ireturn // 返回栈顶的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
2.12 synchronized
理解加锁原理:
public class Demo3_13 {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
}
字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // class java/lang/Object 创建一个新对象
3: dup // 复制lock对象
4: invokespecial #1 // Method java/lang/Object."<init>":()V 调用构造方法
7: astore_1 // lock -> slot 1
8: aload_1 // lock -> 栈顶 (加锁开始)
9: dup // 复制lock对象
10: astore_2 // 将lock_2 -> slot 2 (后续解锁使用)
11: monitorenter // lock开启monitor线程
12: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
15: ldc #4 // String ok
17: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
20: aload_2 // lock_2 -> 栈顶
21: monitorexit // lock的monitor线程结束
22: goto 30 // 退出
25: astore_3 // 如果在锁期间发生任何异常,异常对象 -> slot 3
26: aload_2 // lock -> 栈顶
27: monitorexit // 结束 lock 的 monitor线程
28: aload_3 // 异常对象 -> 栈顶
29: athrow // 抛出异常
30: return
Exception table:
from to target type
12 22 25 any
25 28 25 any
三、编译器处理
所谓的语法糖,其实就是指 Java 编译器把 *.java
源码编译为 *.class
字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 Java 编译器给我们的一个额外福利。
注意,以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件 jclasslib 等工具。另外,编译器转换的结果直接就是 class 字节码,只是为了阅读方便,给出了几乎等价的 Java 源码方式,并不是编译器还会转换出中间的 Java 源码。
3.1 默认构造器
public class Candy1 {
}
编译成 class 后的代码:
public class Candy1{
// 这个无参构造是编译器帮助我们加上的
public Candy1(){
super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":()V
}
}
3.2 自动拆装箱
这个特性是 JDK 5
开始加入的,代码片段 1:
public class Candy2 {
public static void main(String[] args) {
Integer x = 1;
int y = x;
}
}
这段代码在 JDK 5
之前是无法编译通过的,必须改写为代码片段 2:
public class Candy2 {
public static void main(String[] args) {
Integer x = Integer.valueOf(1);
int y = x.intValue();
}
}
显然之前的版本的代码太麻烦了,需要在基本类型和包装类之间来回转换(尤其是集合类中的操作的都是包装类型),因此这些转换的事情在 JDK 5
以后都由编译器在编译阶段完成。即编译代码 1 都会在编译阶段被转换为代码片段 2。
3.3 泛型集合取值
泛型也是在 JDK 5
开始加入的特性,但 Java 在编译泛型代码后会执行 泛型擦除
的动作,即泛型在编译为字节码之后就丢失了,实际的类型都当作了 Object 类型来处理:
public class Candy3 {
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
list.add(10); // 实际调用的是 List.add(Object e)
Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index);
}
}
所以在取值时,编译器真正生成的字节码中,还要额外做一个类型转换的操作。
// 需要将 Object 转换为 Integer
Integer x = (Integer) list.get(0);
如果前面的 x 变量类型修改为 int 基本类型那么最终生成的字节码是:
// 需要将Object转换为Integer,并执行拆箱操作
int x = ((Integer)list.get(0)).intValue();
擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 仍然保留了方法参数泛型的信息。
public cn.itcast.jvm.t3.candy.Candy3();
descriptor: ()V
flags: 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 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t3/candy/Candy3;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."
<init>":()V
7: astore_1
8: aload_1
9: bipush 10
11: invokestatic #4 // Method
java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
14: invokeinterface #5, 2 // InterfaceMethod
java/util/List.add:(Ljava/lang/Object;)Z
19: pop
20: aload_1
21: iconst_0
22: invokeinterface #6, 2 // InterfaceMethod
java/util/List.get:(I)Ljava/lang/Object;
27: checkcast #7 // class java/lang/Integer
30: astore_2
31: return
LineNumberTable:
line 8: 0
line 9: 8
line 10: 20
line 11: 31
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 args [Ljava/lang/String;
8 24 1 list Ljava/util/List;
LocalVariableTypeTable:
Start Length Slot Name Signature
8 24 1 list Ljava/util/List<Ljava/lang/Integer;>;
使用反射,仍然能够获得这些 参数泛型 信息:
public Set<Integer> test(List<String> list, Map<Integer, Object> map) {
return null;
}
Method test = Candy3.class.getMethod("test", List.class, Map.class);
Type[] types = test.getGenericParameterTypes();
for (Type type : types) {
if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
System.out.println("原始类型 - " + parameterizedType.getRawType());
Type[] arguments = parameterizedType.getActualTypeArguments();
for (int i = 0; i < arguments.length; i++) {
System.out.printf("泛型参数[%d] - %s\n", i, arguments[i]);
}
}
}
输出:
原始类型 - interface java.util.List
泛型参数[0] - class java.lang.String
原始类型 - interface java.util.Map
泛型参数[0] - class java.lang.Integer
泛型参数[1] - class java.lang.Object
3.4 可变参数
可变参数也是 JDK 5 加入的新特性:
例如:
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 class Candy4 {
public static void foo(String[] args) {
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
进去。
3.5 foreach 循环
仍是 JDK 5 开始引入的语法糖,数组的循环:
public class Candy5_1 {
public static void main(String[] args) {
int[] array = {1, 2, 3, 4, 5}; // 数组赋初值的简化写法也是语法糖哦
for (int e : array) {
System.out.println(e);
}
}
}
会被编译器转换为:
public class Candy5_1 {
public static void main(String[] args) {
int[] array = {1, 2, 3, 4, 5}; // 数组赋初值的简化写法也是语法糖哦
for (int i = 0, i < array.length; ++i) {
int e = array[i];
System.out.println(e);
}
}
}
而集合的循环:
public class Candy5_2 {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
for (Integer i : list) {
System.out.println(i);
}
}
}
会被编译器转换为:
public class Candy5_2 {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Iterator iter = list.iterator();
while(iter.hasNext()){
Integer e = (Integer)ter.next;
System.out.println(e);
}
}
}
foreach 循环写法,能够配合数组,以及所有实现了 Iterable
接口的集合类一起使用,其中 Iterable
用来获取集合的迭代器 Iterator
。
3.6 switch 字符串
从 JDK 7 开始,switch 可以作用域字符串和枚举类,这个功能其实也是语法糖,例如:
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");
break;
}
}
}
}
注意: switch 配合 String 和枚举使用时,变量不能为 null。
会被编译器变转换为:
public class Candy6_1 {
public Candy6_1() {
}
public static void choose(String str) {
byte x = -1;
switch(str.hashCode()) {
case 99162322: // hello 的 hashCode
if (str.equals("hello")) {
x = 0;
}
break;
case 113318802: // world 的 hashCode
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 是为了防止 hashCode 冲突,例如 BM
和 C.
。这两个字符串的 hashCode 值都是 2123
,如果有如下代码:
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;
}
}
}
}
会被编译器转换为:
public class Candy6_2 {
public Candy6_2() {
}
public static void choose(String str) {
byte x = -1;
switch(str.hashCode()) {
case 2123: // hashCode 值可能相同,需要进一步用 equals 比较
if (str.equals("C.")) {
x = 1;
} else if (str.equals("BM")) {
x = 0;
}
default:
switch(x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}
}
3.7 switch 枚举
代码:
enum Sex {
MALE, FEMALE;
}
public class Candy7 {
public static void foo(Sex sex) {
switch (sex) {
case MALE:
System.out.println("男"); break;
case FEMALE:
System.out.println("女"); break;
}
}
}
转换后代码:
public class Candy7 {
/**
* 定义一个合成类(仅 jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
*/
static class $MAP {
// 数组大小即为枚举元素个数,里面存储case用来对比的数字
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;
}
}
}
3.8 枚举类
JDK 7 新增加了枚举类,以前面的性别枚举为例:
enum Sex{
MALE, FEMALE
}
转换后的代码:
public final class Sex extends Enum<Sex> {
public static final Sex MALE;
public static final Sex FEMALE;
private static final Sex[] $VALUES;
static {
MALE = new Sex("MALE", 0);
FEMALE = new Sex("FEMALE", 1);
$VALUES = new Sex[]{MALE, FEMALE};
}
/**
* Sole constructor. Programmers cannot invoke this constructor.
* It is for use by code emitted by the compiler in response to
* enum type declarations.
*
* @param name - The name of this enum constant, which is the identifier
* used to declare it.
* @param ordinal - The ordinal of this enumeration constant (its position
* in the enum declaration, where the initial constant is
assigned
*/
private Sex(String name, int ordinal) {
super(name, ordinal);
}
public static Sex[] values() {
return $VALUES.clone();
}
public static Sex valueOf(String name) {
return Enum.valueOf(Sex.class, name);
}
}
3.9 try-with-resources
JDK 7 开始新增了对需要关闭的资源处理的特殊语法:try-with-resources
:
try(资源变量 = 创建资源对象){
} catch() {
}
之前我们如果要关闭资源的话还要写一个 finally 块用来写关闭资源的那一部分代码。
其中资源对象需要实现 AutoCloseable
接口,例如 InputStream
、OutputStream
等接口都实现了该接口,使用 try-catch-resources
可以不用写 finally
语句块,编译器会帮助生成关闭资源代码,例如:
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class Candy9 {
public static void main(String[] args) {
try(InputStream is = new FileInputStream("d:\\1.txt")) {
System.out.println(is);
} catch (IOException e) {
e.printStackTrace();
}
}
}
会被转换为:
public class Candy9 {
public Candy9() {
}
public static void main(String[] args) {
try {
InputStream is = new FileInputStream("d:\\1.txt");
Throwable t = null;
try {
System.out.println(is);
} catch (Throwable e1) {
// t 是我们代码出现的异常
t = e1;
throw e1; // 抛出异常对象会再finally执行完之后再抛出。
} finally {
// 判断了资源不为空
if (is != null) {
// 如果我们代码有异常
if (t != null) {
try {
is.close();
} catch (Throwable e2) {
// 如果 close 出现异常,作为被压制异常添加
t.addSuppressed(e2);
}
} else {
// 如果我们代码没有异常,close 出现的异常就是最后 catch 块中的 e
is.close();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
为什么要设计一个 addSuppressed(Throwable e)
(添加被压制异常)的方法呢?**
**addSuppressed 是 Java 编程语言中 Throwable 类的一个方法,用于将一个异常添加到另一个异常的抑制异常列表中。抑制异常是在处理主异常时可能抛出的次要异常。这个方法的目的是让程序员能够跟踪多个相关的异常,以便更好地调试问题。
在 Java 中,异常是通过 throw 语句抛出的。当一个异常发生时,通常会创建一个 Throwable 类型的对象,然后将其抛出。在异常处理的过程中,可能会有多个异常发生,但只能捕获和处理一个。为了保存其他的异常信息,Java 引入了抑制异常的概念。
是为了防止异常信息的丢失(想想 try-with-resources 生成的 finally 中如果抛出了异常):
public class Test6 {
public static void main(String[] args) {
try (MyResource resource = new MyResource()) {
int i = 1/0;
} catch (Exception e) {
e.printStackTrace();
}
}
}
class MyResource implements AutoCloseable {
public void close() throws Exception {
throw new Exception("close 异常");
}
}
输出:
java.lang.ArithmeticException: / by zero
at test.Test6.main(Test6.java:7)
Suppressed: java.lang.Exception: close 异常
at test.MyResource.close(Test6.java:18)
at test.Test6.main(Test6.java:6)
如以上代码所示,两个异常信息都不会丢。
3.10 方法重写时的桥接方法
桥接方法:子类重写的方法的返回值可以是父类方法的返回值的子类。
class A {
public Number m() {
return 1;
}
}
class B extends A {
@Override
// 子类 m 方法的返回值是 Integer 是父类 m 方法返回值 Number 的子类
public Integer m() {
return 2;
}
}
由于重写的方法返回值类型不一致,Java 编译器会生成一个桥接方法来保持一致。
class B extends A {
public Integer m() {
return 2;
}
// 此方法才是真正重写了父类 public Number m() 方法
public synthetic bridge Number m() {
// 调用 public Integer m()
return m();
}
}
其中桥接方法比较特殊,仅对 java 虚拟机可见,并且与原来的 public Integer m()
没有命名冲突,可以用下面反射代码来验证:
for (Method m : B.class.getDeclaredMethods()) {
System.out.println(m);
}
会输出:
public java.lang.Integer test.candy.B.m()
public java.lang.Number test.candy.B.m()
3.11 匿名内部类
源代码:
public class Candy11 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok");
}
};
}
}
转换后代码:
// 额外生成的类
final class Candy11$1 implements Runnable {
Candy11$1() {
}
public void run() {
System.out.println("ok");
}
}
public class Candy11 {
public static void main(String[] args) {
Runnable runnable = new Candy11$1();
}
}
引用局部变量的匿名内部类,源代码:
public class Candy11 {
public static void test(final int x) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok:" + x);
}
};
}
}
转换后代码:
// 额外生成的类
final class Candy11$1 implements Runnable {
int val$x;
Candy11$1(int x) {
this.val$x = x;
}
public void run() {
System.out.println("ok:" + this.val$x);
}
}
public class Candy11 {
public static void test(final int x) {
Runnable runnable = new Candy11$1(x);
}
}
注意:这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:因为再创建 Candy11$1
对象时,将 x 的值赋值给了 Candy11$1
对象的 val$x
属性,所以 x 不应该再发生变换了,如果变化,那么 Val$x
属性没有机会再跟着一起变化,因为只有在创建对象时变化这一个入口。
四、类加载阶段
4.1 类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存位置,它的整个生命周期可以简单概括为 7 个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中,验证、准备和解析这三个阶段可以统称为连接。
4.2 类加载过程
在Java中,类的加载是Java虚拟机(JVM)执行的一个重要步骤。类的加载过程包括加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析这三个阶段统称为连接(Linking)阶段。
以下是Java类加载的基本过程:
- 加载(Loading):
- 类加载的第一步是加载类的字节码。
- 加载阶段完成后,字节码被存储在方法区中。
- 验证(Verification):
- 在这个阶段,验证被加载的类的字节码是否符合JVM规范,防止恶意代码对JVM的破坏。
- 这个过程包括文件格式验证、元数据验证、字节码验证和符号引用验证。
- 准备(Preparation):
- 在准备阶段,JVM 为类的静态变量分配内存,并设置默认初始值。
- 这里不包括实例变量,实例变量会在对象实例化时随着对象一起分配在堆中。
- 解析(Resolution):
- 解析阶段是将类、接口、字段和方法的符号引用转化为直接引用的过程。
- 符号引用是一组符号来描述所引用的目标,而直接引用可以是直接指向目标的指针、相对偏移量或者一个能够间接定位到目标的句柄。
- 初始化(Initialization):
- 初始化阶段是类加载的最后一步,也是执行类构造器()方法的过程。
- 类构造器是由编译器自动收集类中所有静态变量的赋值动作和静态代码块中的语句合并产生的。
- 使用(Using):
- 在这个阶段,已经加载、验证、准备、解析并初始化了类,可以开始执行类的方法。
- 卸载(Unloading):
- 卸载阶段是指当类在内存中已经不再需要,并且可以被垃圾收集器回收时,卸载类。
需要注意的是,这些阶段并不是一成不变的,某些阶段的操作可能会在其他阶段之后或之前发生,具体实现取决于JVM的具体实现。
Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
加载
类加载过程的第一步,主要完成下面 3 件事情:
- 通过全类名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口。
加载这一步主要是通过后面要讲到的 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定的。
每个 Java 类都有一个引用指向加载它的 ClassLoader
。不过,数组类不是通过 ClassLoader
创建的,而是 JVM 在需要的时候自动创建的,数组类通过 getClassLoader()
方法获取 ClassLoader
的时候和该数组的元素类型的 ClassLoader
是一致的。
一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass()
方法)。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
验证
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段这一步在整个加载过程中耗费的资源还是相对较多的,但很有必要,可以防止恶意代码的执行。任何时候,程序安全都是第一位的。
不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
验证阶段主要由四个检验阶段组成:
- 文件格式验证(Class 文件格式检查)
- 元数据验证(字节码语义检查)
- 字节码验证(程序语义检查)
- 符号引用验证(类的正确性检查)
文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
方法区属于 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即使编译器 编译后的代码缓存等数据。
符号引用验证发生再类加载过程中的解析阶段,具体点说时 JVM 将符号引用转换为直接引用的时候。
符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常。比如:
java.lang.IllegalAccessError
:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。java.lang.NoSuchFieldError
:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将再方法区中分配。对于该阶段有一下几点需要注意:
- 这时候进行内存分配的仅包括类变量(即静态变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
- 从概念上讲,类变量所使用的内存都应当在方法区中进行分配。不过有一点需要主义的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。而在 JDK 7 之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
- 这里所设置的初始值”通常情况下“是数据类型默认的零值(如 0,0L,null,false 等),比如我们定义了
public static int value = 111
,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value = 111
,那么准备阶段 value 的值就会赋值为 111。如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成。
// 演示 final 对静态变量的影响
public class Load8 {
static int a; //准备阶段设默认值
static int b = 10;// 初始化阶段赋值
static final int c = 20;// 准备阶段赋值
static final String d = "hello";// 准备阶段赋值
static final Object e = new Object();// 初始化阶段赋值
}
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析阶段主要针对类或接口、字段、类方法、接口方法、方法句柄和调用限定符 7 类符号引用进行。
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用:直接引用时可以直接指向目标地指针、相对偏移量或者是一个能简介定位到目标地句柄。
举个例子:在程序指向方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在放发表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
综上,解析阶段时虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或字段、方法在内存中的指针或者偏移量。
初始化
初始化阶段是执行初始化方法 <clinit>()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
说明:<clinit>()
方法是编译之后自动生成的。
类构造器是比由编译器自动收集类中所有静态变量的赋值动作和静态代码块中的语句合并产生的。
import java.io.IOException;
public class Load3 {
static {
System.out.println("main init");
}
public static void main(String[] args) throws ClassNotFoundException, IOException {
// // 1. 静态常量不会触发初始化
// System.out.println(B.b);
// // 2. 类对象.class 不会触发初始化
// System.out.println(B.class);
// // 3. 创建该类的数组不会触发初始化
// System.out.println(new B[0]);
// 4. 不会初始化类 B,但会加载 B、A
ClassLoader cl = Thread.currentThread().getContextClassLoader();
cl.loadClass("cn.itcast.jvm.t3.load.B");
// // 5. 不会初始化类 B,但会加载 B、A
// ClassLoader c2 = Thread.currentThread().getContextClassLoader();
// Class.forName("cn.itcast.jvm.t3.load.B", false, c2);
System.in.read();
// // 1. 首次访问这个类的静态变量或静态方法时
// System.out.println(A.a);
// // 2. 子类初始化,如果父类还没初始化,会引发
// System.out.println(B.c);
// // 3. 子类访问父类静态变量,只触发父类初始化
// System.out.println(B.a);
// // 4. 会初始化类 B,并先初始化类 A
// Class.forName("cn.itcast.jvm.t3.load.B");
}
}
class A {
static int a = 0;
static {
System.out.println("a init");
}
}
class B extends A {
final static double b = 5.0;
static boolean c = false;
static {
System.out.println("b init");
}
}
对于初始换阶段,虚拟机规范了有且只有 6 中情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
分析 1:
public class Load4 {
public static void main(String[] args) {
System.out.println(E.a); // 会在准备阶段加载
System.out.println(E.b); // 准备阶段加载
System.out.println(E.c); // 初始化阶段加载
}
}
class E {
public static final int a = 10;
public static final String b = "hello";
public static final Integer c = 20; // Integer.valueOf(20)
static {
System.out.println("init E");
}
}
分析 2:
public class Load9 {
public static void main(String[] args) {
// Singleton.test(); // 执行该方法,Singleton中的内部静态类并不会被初始化
Singleton.getInstance(); // 只有调用该方法时,LazyHolder才会初始化,并创建一个单例对象,而且这个对象只会被创建一次
}
}
class Singleton {
public static void test() {
System.out.println("test");
}
private Singleton() {}
private static class LazyHolder{
private static final Singleton SINGLETON = new Singleton();
static {
System.out.println("lazy holder init");
}
}
public static Singleton getInstance() {
return LazyHolder.SINGLETON;
}
}
4.3 类卸载
卸载即将该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
- 该类的所有实例对象都已被 GC,也就是说堆中不存在该类的实例对象。
- 该类没有在其他任何地方被引用。
- 该类的类加载器的实例已被 GC。
所以,在 JVM 生命周期内,由 JVM 自带的类加载器加载的类是不会被卸载的。。但是由我们自定义的类加载器加载的类是可能被卸载的。一些 JDK 自带的负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。
4.4 类加载器
类加载器负责将类的字节码加载到内存中,并转换成运行时数据结构。JVM 支持多个类加载器,每个加载器都有自己的加载范围和加载顺序。
- 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
- 每个 Java 类都有一个引用指向加载它的 ClassLoader
- 数组类不是通过
ClassLoader
创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
类加载器主要分为三个层次:
-
启动类加载器:
-
是最顶层的类加载器,负责加载 Java 的核心类库,位于
<JAVA_HOME>/lib
目录下。 -
由 C++实现,不是 Java 类
-
在 JVM 启动时被启动,是其他类加载器对的父加载器,自己没有父加载器。
-
扩展类加载器
-
位于
<JAVA_HOME>/lib/ext
目录下 -
主要负责加载 Java 的扩展类库,可以通过
java.ext.dirs
指定扩展类库的目录。 -
父加载器为启动类加载器
-
应用程序类加载器
-
也称为系统类加载器,负责加载应用程序类路径上指定的类库。
-
是最常用的类加载器,也是默认的类加载器。
-
父加载器为扩展类加载器。
自定义类加载器:用户可以通过继承 java.lang.ClassLoader
类来实现自定义的类加载器,以满足特定的加载需求。
类加载器加载规则:
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才回去加载,这样对内存更加友好。
对于已经加载的类会放在 ClassLoader
中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
4.5 双亲委派模型
双亲委派模型是 Java 类加载器采用的一种加载机制,用于保证类的唯一性和避免重复加载。该模型通过层次化的类加载器结果,将类加载请求委派给父加载器。只有在父加载器无法完成加载时,才由子加载器尝试加载。
这种模型的基本思想是:
- 当一个类加载器收到加载类的请求时,首先不会尝试自己去加载,而是委派给父加载器去加载。
- 如果父加载器还存在父加载器(除了启动类加载器之外的所有加载器都有父加载器),则进一步委派给父加载器的父加载器。
- 最终,请求一致传递到最顶层的启动类加载器。
- 如果父加载器无法加载,子加载器才会尝试加载。
这种层次化的加载机制有几个优势:
- 类的唯一性:通过委派机制,当父加载器已经加载了一个类,自家在其就不再重复加载,确保了类的唯一性。
- 安全性:避免了恶意类的加载,因为恶意类可能会放在某个父加载器的搜索路径中,但由于双亲委派模型,它们无法绕过父加载器直接被子加载器加载。
- 共享类:父加载器加载的类对子加载器可见,而子加载器加载的类对父加载器不可以见,因此可以实现类的共享。
这种层次结构保证了类的唯一性和避免了类的重复加载。
双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现了两个不同的 Object
类。 但如果使用双亲委派模型可以保证加载的是 JRE 里的那个 Object
类,而不是你写的 Object
类。这是因为 AppClassLoader
在加载你的 Object 类时,会委托给 ExtClassLoader
去加载,而 ExtClassLoader
又会委托给 BootstrapClassLoader
,此时自动类加载器发现自己已经加载过了 Object
类,会直接返回,不会去加载你写的 Object
类。
双亲委派模型的执行流程:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//首先,检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
//如果 c 为 null,则说明该类没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {
//当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
//当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//非空父类的类加载器无法找到相应的类,则抛出异常
}
if (c == null) {
//当父类加载器无法加载时,则调用findClass方法来加载该类
//用户可通过覆写该方法,来自定义类加载器
long t1 = System.nanoTime();
c = findClass(name);
//用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//对类进行link操作
resolveClass(c);
}
return c;
}
}
结合上面的源码,简单总结一下双亲委派模型的执行流程:
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
- 类加载器在执行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。 - 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()
方法来加载类)。 - 如果子类加载器也无法加载这个类,那么它会抛出一个
ClassNotFoundException
异常。