如何理解虚拟机类加载过程详解
Posted 小孟的coding之旅
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何理解虚拟机类加载过程详解相关的知识,希望对你有一定的参考价值。
当程序使用某个类时,如果该类还未被加载到内存中,则JVM会通过加载、链接、初始化三个步骤对该类进行类加载。
Java Class 文件
class 文件是一组以 8 位字节为基础的二进制流,各个数据项目按照顺序排列在 class 文件中,中间没有任何分隔符。因此整个 class 文件中存储的内容几乎全是程序运行时的必要数据。当遇到需要占用8位以上字节空间的数据项时,会按照高位在前的方式分割成若干个 8 位字节存储。
我们首先需要定义一个 Java 类:
public class SumDemo {
public static void main(String[] args) {
int a=1;
int b=2;
System.out.println(a+b);
}
}
复制代码
我们知道编写的 Java 代码是不能直接运行的,需要变成 class 文件才行。这个编译。需要用 JDK 内置的 Java 对命令就可以实现了。
比如,我们生成某一个类的字节码文件,只需要使用 javac SumDemo.java 即可以获得一个 class 文件。当然了,在实际项目中,我们一般都不会人工用 javac 命令去编译,而是借助 IDE 或者是 Maven、Grande 等等工具,帮助我们更加方便的把 Java 代码编译成 class 文件。
编译出来的 class 文件并不是一个文本文件,它是没有办法直接打开阅读的,比如我们使用 notepad++ 打开,可以发现是一片乱码。
那么如果想要阅读的话,可以使用 java p 命令反编译一下。这是一个 Java 内置的反编译工具,我们来看看怎么用。如下图所示:
下面是使用 javap -v -p 反编译生成的文件。注意的是,在这个时候不需要跟上 .java,因为反编译的是 class 文件,这样就可以看到 class 文件里面的内容。同时也可将其输出到一个 .txt 文件,使用的命令是:javap -v -p xxx > xxx.txt 。
E:\\JavaSpace\\Java-Prepare-Lesson\\Java-High\\JVM-Frame\\JVM-Chapter01-Demo\\src\\main\\java\\com\\itbbfx>javap -v -p SumDemo
警告: 二进制文件SumDemo包含com.itbbfx.SumDemo
#描述信息
Classfile /E:/JavaSpace/Java-Prepare-Lesson/Java-High/JVM-Frame/JVM-Chapter01-Demo/src/main/java/com/itbbfx/SumDemo.class
Last modified 2021-5-16; size 409 bytes
MD5 checksum b9b13ea5dba3f2b62f4764d30eafc7fc
Compiled from "SumDemo.java"
#描述信息
public class com.itbbfx.SumDemo
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 // com/itbbfx/SumDemo
#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 SumDemo.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 com/itbbfx/SumDemo
#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 com.itbbfx.SumDemo();
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 3: 0
#方法信息
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: iload_1
8: iload_2
9: iadd
10: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
13: return
LineNumberTable:
line 18: 0
line 19: 2
line 20: 4
line 21: 13
}
SourceFile: "SumDemo.java"
复制代码
来分析一下反编译之后的结果,可以看到一个 class 文件包含了几部分,第一部分是类的一些描述信息。记录了 class 文件存储的位置,什么时候修改过,这个 class 文件的 MD5 值,以及从哪个 Java 类里面编译出来的。第二部分还是一些描述信息。主要描述了这个类是用什么样版本的 JDK 编译的, major version: 52 表示是 JDK 8。第三部分是常量池。第四部分是字段的信息。最后第五部分是方法的信息。事实上,在整个文件里面有各种各样的指令,这些指令呢我们很难直接去看懂。
可以参照这篇文章里面指令表,地址:docs.oracle.com/javase/spec…。
class 文件加载
那么 JVM 是怎么样加载 class 文件的呢?当一个类被创建实力或者被引用到的时候,如果虚拟机发现之前没有加载过这个类,就会通过类加载器,也就是 ClassLoader 把 class 文件加载到内存,在加载的过程中主要做了三件事。
第一:读取类的二进制流,
第二:把二进制流转换为方法区的数据结构,并且把数据存到方法区里面。
最后:会在 Java 堆里面产生一个 java.lang.Class 对象。
加载 .class 文件的方式:
- 从本地系统中直接加载
- 通过网络获取,典型场景:Web Applet
- 从zip压缩文件中读取,成为日后jar、war格式的基础
- 运行时计算生成,使用最多的是:动态代理技术
- 由其他文件生成,比如 JSP 应用
- 从专有数据库提取.class 文件,比较少见
- 从加密文件中获取,典型的防 Class 文件被反编译的保护措施
链接
加载完成之后又会进入链接的步骤,链接这个步骤又可以细分为验证、准备和解析。
验证
验证好理解,就是验证 class 文件是不是符合规范,保证被加载类的正确性,不会危害虚拟机自身安全。这里面包含了多个层面的验证,主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
文件格式的验证:比如这个文件是不是以 0xCAFEBABE 开头,这一点可以使用十六进制编辑器打开查看。比如我们使用 Beyond Compare 去打开 SumDemo.class 文件看一下。你也可以使用其他的十六进制的编辑器打开查看。
在这里就可以看到 CAFEBABE 后面是一段数字,那这个数字呢叫模数。
元数据的验证:比如验证这个类是不是有父类验证,这个类是不是实现了 final, 因为 final 类是不能继承的,又比如一个非抽象类。是不是实现了所有的抽象方法,如果没有实现的话,这个类也是无效的。
字节码的验证:字节码的验证是非常复杂的。一个 class 文件能够通过字节码的验证,并不代表这个 class 没有问题。但是如果没有通过字节码的验证,那么必然是有问题的。字节码的验证主要由包括了运行的检查、数据类型和操作码操作的参数是不是吻合(比如:比如栈空间只有 2 字节,但其实却需要大于 2 字节,此时就认为这个字节码是有问题的)以及跳转指令是不是指向了合理的位置等等。
符号引用验证:那么这里我们先不去探讨什么是符号引用,一会儿大家就会知道了。符号引用验证又包括验证常量池里面的描述类是不是存在,访问的方法或者是字段是不是存在,而且有足够的权限。如果你事先已经确认你的代码是安全无误的。那么可以在启动的时候,添加这样的参数: -Xverify:none,去关闭掉验证,就是加快类的加载。
比如:IDEA 的 help - > Edit Custom VM Options。
这里可以查看 IDEA 的启动参数,它的启动参数里面就有一项 -Xverify:none。
这样在启动 IDEA 的时候就不会去做验证了,从而加快 IDEA 启动的速度,你也可以为你的 eclipse 或者是其他的 IDE设置下这个选项,加快 IDE 的启动速度。
好,可以发现验证的步骤细节非常的多,不过建议你在初学的时候,不要去过分关注里面的细节,你就理解成是一个校验 Java 类是不是正常的步骤就可以了。如果经过验证,发现 class 文件没有问题的话,就会进入准备环节。
准备
准备阶段是正式为类变量(static变量)分配内存并设置初始值的阶段,这些变量所使用的内存,将在方法区分配。
准备这个环节的作用,是为类的静态变量分配内存,把它初始化为系统的初始值。那么对于final static 修饰的变量会在准备这个环节直接为这个变量赋值,为用户定义的值。比如 private final static int value = 123456,我们是这样定义的,在这个阶段就会直接为 value 赋值 123456。
但是对于 static 变量在这个阶段,它的值还是 0,比如:private static int value =123456,该阶段值依然是 0,而不是123,因为这时尚未执行任何 Java 方法,把变量赋值为123456,是在初始化阶段才会执行)。准备完成之后就可以进入解析了。
解析
符号引用就是一些字面量的引用,和虚拟机的内部数据结构和内存分布无关。 比较容理解的就是在 Class 类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的。
解析的作用是把符号引用转换成直接引用。所谓的符号引用就是指在编译期,Java 类还不知道所引用的对象,它的实际地址。所以只能使用一个符号说我现在想要引用谁。比如在我们 class 文件的常量池里面,存储的都是符号引用,包括内核接口的完全限定名、方法的引用、成员变量的引用等等。那么要想真正引用到这些类、方法或者是变量,就是要把这些符号转换成能够找到的对象指针或者是地址偏移量,转换之后的引用就是直接引用。
比如当如下 println() 方法被调用时,系统需要明确知道该方法的位置。举例:输出操作 System.out.println() 对应的字节码:
如果说的再通俗一点,符号引用就是说做了一个标记,说的是现在要引用谁,而直接用的话就是真正去引用这个对象。解析完成之后就会进入初始化阶段。
初始化
初始化阶段,简言之,为类的静态变量赋予正确的初始值。类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表 示类可以顺利装载到系统中。此时,类才会开始执行 Java 字节码。(即:到了初 始化阶段,才真正开始执行类中定义的 Java 程序代码)。
初始化阶段的重要工作是执行类的初始化方法:() 方法。JVM 会先执行 的方法。 的方法是由编译器自动收集类里面的所有静态变量,它的赋值动作以及静态语句快去合并而成的,也叫类构造器方法。
方法里面的代码执行顺序和源文件里面的顺序是一致的。我们来运行一个例子看看,这里我们运行一下。
static int a=1;
static {
a = 3;
}
public static void main(String[] args) {
System.out.println(a);
}
复制代码
输出的结果是:3.
可以看到,对于我们选用了一个静态变量,在这里又有一个静态代码块,先把 a 赋值 1,又把 a 赋值 3。 的方法会把这两段代码合到一起,变成一个 的方法,所以最后 a 的值为 3。接下来我们再把顺序调一下,执行成功之后输出的结果是 1。
子类的 的方法被调用之前会先调用父类的 的方法。
JVM 会保证 的方法的线程安全性。
此外在 JVM 初始化代码执行的时候,如果实例化了一个新对象,会调用 的方法,对实例变量进行初始化,并且执行相应构造方法里面的代码,我们再来运行一个例子看看。
static {
System.out.println("JVM 静态块");
}
{
System.out.println("JVM 构造块");
}
public SumDemo(){
System.out.println("JVM 构造方法");
}
public static void main(String[] args) {
System.out.println("main");
new SumDemo();
}
复制代码
那么在这个类里面有静态代码块、有构造块、有构造方法,还有 main 方法。那么运行的顺序是怎样的呢?运行下看看。
可以看到先执行了静态代码块,然后执行了 main 方法、在之后执行了构造派,最后是构造方法。
我们再来看一下这个例子。
static {
System.out.println("JVM 静态块");
}
{
System.out.println("JVM 构造块");
}
public SumDemo(){
System.out.println("JVM 构造方法");
}
public static void main(String[] args) {
new Sub();
}
public class Super {
static {
System.out.println("Super 静态块");
}
public Super(){
System.out.println("Super 构造方法");
}
{
System.out.println("Super 构造块");
}
}
public class Sub extends Super {
static {
System.out.println("Sub 静态块");
}
public Sub(){
System.out.println("Sub 构造方法");
}
{
System.out.println("Sub 构造块");
}
复制代码
在这个案例中,main 方法实例化一个 Sub 对象,而 Sub 类继承自 Super 。这个案例中,、SumDemo、Supper 以及 Sub 都有静态代码块、构造方法以及构造块,运行看看,控制台输出什么样的结果。
可以看到,首先打印了在 JVM 静态代码块,并没有去执行构造派以及构造方法的代码。这个很好理解,因为我们没有去 new SumDemo。然后当代码执行到 main 方法的时候,去 new Sub 对象,new 之前它去执行这里的 的方法,于是它就去执行 Sub 类中的代码。
但是前面我们说了子类的 的方法调用之前,会调用父类的 的方法,于是会先去调用 Super 类的 static 的方法,再去调用 Sub 类的 static 方法,调用完成了之后。又去调用构造块,我们知道构造块执行的顺序在构造方法之前,也是先执行 Super 类的构造块,然后再去执行 Super 类的构造方法,最后再执行 Sub 类的构造块以及 Sub 类的构造方法。
使用与卸载
任何一个类型在使用之前都必须经历过完整的加载、链接和初始化 3 个类加载步骤。 一旦一个类型成功经历过这 3 个步骤之后,便“万事俱备,只欠东风”, 就等着开发者使用了 开发人员可以在程序中访问和调用它的静态类成员信息(比如:静态字段、静 态方法),或者使用 new 关键字为其创建对象实例。
由 Java 虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。Java 虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的 Class 对象,因此这些 Class 对象始终是可触及的。由用户自定义的类加载器加载的类是可以被卸载的。
启动类加载器加载的类型在整个运行期间是不可能被卸载的(JVM 和 JLS 规范)。
被系统类加载器和标准扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者标准扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable 的可能性极小。(当然,在虚拟机快退出的时候可以,因为不管 ClassLoader 实例或者 Class(java.lang.Class )实例也都是在堆中存在,同样遵循垃圾收集的规则)。
被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到.可以预想,稍微复杂点的应用场景中(尤其很多时候,用户在开发自定义类加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的)。
好,始化完成之后,可以使用这个类了,但不使用这个类的时候可以把它卸载掉,这个很好理解,下图只是一个比较常规的类加载流程。事实上类加载的时候,并不一定完全按照这个流程去做。
比如解析不一定在初始化之前,也有可能在初始化之后才去做解析。
以上是关于如何理解虚拟机类加载过程详解的主要内容,如果未能解决你的问题,请参考以下文章