JVM加载机制之类加载器的执行过程
在 JVM类加载器分类对类加载器的角色有了了解,那么类加载器的执行过程如何呢?实际类从被加载到虚拟机内存中开始,到卸载出内存,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initiallization)、使用(Using)和卸载(Unloading)这7个阶段。其中验证、准备、解析3个部分统称为连接(Linking),这七个阶段的发生顺序如下:
图中,加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始。而解析阶段不一定:它在某些情况下可以初始化阶段之后在开始,这是为了支持Java语言的运行时绑定(也称为动态绑定)——即解析与初始化的顺序在特定情况下是可以交换的。
下面主要讲解加载、验证、准备、解析、初始化五个步骤,这五个步骤组成了一个完整的类加载过程。卸载属于GC的工作,后续有详细文章单独介绍。
一、加载
加载是类加载的第一个阶段。有两种时机会触发类加载:
1、预加载
写一个空的main函数,设置虚拟机参数为"-Xlog:class+load=trace"来获取类加载信息:
2、运行时加载
加载阶段做了什么,其实加载阶段做了有三件事情:
1)获取.class文件的二进制流:二进制流从何而来,虚拟机规范并没有明确具体的要求,那么二进制流就可以从如下且不限于如下方式:
-
-
-
- 从zip包中获取,这就是以后jar、ear、war格式的基础
- 从网络中获取,典型应用就是Applet
- 运行时计算生成,典型应用就是动态代理技术
- 由其他文件生成,典型应用就是JSP,即由JSP生成对应的.class文件
- 从数据库中读取,这种场景比较少见
-
-
2)将类信息、静态变量、字节码、常量这些.class文件中的内容放入元数据区中。
3)在堆内存中生成一个代表这个.class文件的java.lang.Class对象,作为元数据区这个类的各种数据的访问入口。
这一部分对于开发者而言是可控性最强的一个阶段,发挥机会相对较多。
二、链接
一)验证
连接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。为什么会有保证虚拟机自身安全的问题呢?
Java语言本身是相对安全的语言(相对C/C++来说),但是前面说过,.class文件未必要从Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生.class文件。在字节码语言层面上,Java代码至少从语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
这一阶段主要验证的内容如下:
-
-
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
-
二)准备
准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在元数据区中分配。static修饰的变量有以下两点需要关注:
-
- 这时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中。
- 这个阶段赋初始值的变量指的是那些不被final修饰的static变量,比如"public static int value = 123",value在准备阶段过后是0而不是123,给value赋值为123的动作将在初始化阶段才进行;比如"public static final int value =123;"就不一样了,在准备阶段,虚拟机就会给value赋值为123。
即该阶段主要对static filnal修饰的变量进行赋值操作,对static修饰的变量初始化零值。不同类型零值如下:
从上图得到验证:类变量有两次赋初始值的过程,一次在准备阶段,赋予初始值(也可以是指定值);另外一次在初始化阶段,赋予程序员定义的值。
而非类变量不赋初值无法使用:
三)解析Resolution
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。这两引用是啥咧(太抽象)?符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。
1、符号引用
这个其实是属于编译原理方面的概念,符号引用包括了下面三类常量:
-
-
-
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
-
-
通过下面代码理解,可能就不那么抽象了:
javap -v TestMain.class,得到字节码:
看到Constant Pool也就是常量池中有诸多内容,其中带"Utf8"的就是符号引用。比如#8,它的值是"com/lifish/unit2/TestMain",表示的是这个类的全限定名;又比如#9为i,#10为I,它们是一对的,表示变量时Integer(int)类型的,名字叫做i;#11为D、#12为d也是一样,表示一个Double(double)类型的变量,名字为d;#18、#19表示的都是方法的名字。
那总而言之,符号引用和我们上面讲的是一样的,是对于类、变量、方法的描述。符号引用和虚拟机的内存布局是没有关系的,引用的目标未必已经加载到内存中了。
2、直接引用
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机示例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在在内存中了。
解析阶段负责把整个类激活,串成一个可以找到彼此的网,过程不可谓不重要。那这个阶段都做了哪些工作呢?大体可以分为:
-
-
-
- 类或接口的解析
- 类方法解析
- 接口方法解析
- 字段解析
-
-
三、初始化
类的初始化阶段是类加载过程的最后一个步骤, 之前介绍的几个类加载的动作里, 除了在加载阶 段用户应用程序可以通过自定义类加载器的方式局部参与外, 其余动作都完全由Java虚拟机来主导控制。 直到初始化阶段, Java虚拟机才真正开始执行类中编写的Java程序代码, 将主导权移交给应用程序。
初始化阶段就是执行类构造器()方法的过程。 ()并不是程序员在Java代码中直接编写的方法, 它是Javac编译器的自动生成物——由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。 编译器收集的顺序是由语句在源文件中出现的顺序决定的, 静态语句块中只能访问 到定义在静态语句块之前的变量; 定义在它之后的变量, 在前面的静态语句块可以赋值但是不能访问。
()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同, 它不需要显式地调用父类构造器, Java虚拟机会保证在子类的()方法执行前, 父类的()方法已经执行完毕。 因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object,由于父类的()方法先执行, 也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。如下,字段B的值将会是2而不是1:
()方法对于类或接口来说并不是必需的, 如果一个类中没有静态语句块, 也没有对变量的 赋值操作, 那么编译器可以不为这个类生成()方法。 接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成 ()方法。
但接口与类不同的是, 执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用时, 父接口才会被初始化。 此外, 接口的实现类在初始化时也 一样不会执行接口的()方法。