jvm-class文件加载过程
20 May 2020Java虚拟机可以把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这个过程被称为虚拟机的类加载机制(Class Loading)。
类加载的时机
Class文件只有在必须要被使用的时候才会被加载,java虚拟机规定,一个类或者接口在被初次使用前,必须执行初始化(这时就要加载Class文件了)。这里的”使用“指的是主动使用,当出现以下几种情况时算作主动使用:
- 当创建一个类的实例时,比如使用new创建对象、反射、克隆、反序列化。
- 当调用类的静态方法时。
- 当使用类或者接口的静态字段时(final常量除外)。
- 当初始化子类时,要求先初始化父类。
- 放使用
java.lang.reflect
包中的方法反射类的方法时。 - 作为启动虚拟机,含有main()方法的那个类。
除了以上了情况属于主动使用,其他情况均属于被动使用。被动使用不会引起类的初始化。
类加载的过程
类加载(Class Load)过程可以分为加载、连接、初始化三个过程,其中连接又可以细分为验证、准备、解析三个过程。如下图所示:
加载
加载(Loading)阶段是整个”类加载“(Class Loading)过程中的第一个阶段,这两个概念得先区分下。java虚拟机在加载阶段需要完成以下三件事情:
- 通过一个类的全限定名来过去此类的二进制字节流。
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。(class文件中定义的的静态变量会存放在方法区,运行时常量池也是方法区的一部分)。
- 当类型数据妥善安置在方法区后,(虚拟机)会在堆内存中实例化一个
java.lang.Class
类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
虚拟机要读取的是类的二进制字节流,这个字节流不一定非得在class文件中,虚拟机可以从多种方式获得,这个地方被java开发者玩出了许多花样,但这里不做介绍。再有就是类的二进制文件加载需要用到类加载器和双亲委派模型。
连接
连接阶段可细分为验证、准备、解析三个部分。
验证
验证是连接阶段的第一步,验证的主要目的是确保Class文件的字节流包含的信息符合《java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。验证阶段可以分为四个部分:文件格式验证、元数据验证、字节码验证和符号引用验证。
文件格式验证
目的是验证字节流是否符合Class文件规范,并且能被当前的虚拟机所处理。比如:是否以指定魔数开头,主、次版本号是否在当前java虚拟机可接受的范围内,常量池中的常量类型是否有不被支持的常量类型,指向常量池的索引的值是否有指向不存在的常量,等等。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后(字节流格式上符合一个java类型信息的要求),这段字节流才被允许进入java虚拟机内存的方法区进行存储,所以后面三个验证全都是基于方法去扥存储结构上进行的,不会再直接读取,操作字节流了。
元数据验证
是对字节码描述的信息进行语义分析(主要侧重于数据类型),以保证其描述的信息符合《Java语言规范》的要求。比如:一些被定义为final的方法或者类被重写或者继承了。但凡语义上不符合规范的,虚拟机不会通过。
字节码验证
(这块的验证更侧重于逻辑)是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在对”元数据验证“中的数据类型校验完毕后,这一步骤就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机安全的行为。比如:函数调用是否传递了正确类型的参数,变量赋值是不是给了正确的数据类型等等。Code属性中有个StackMapTable属性就是在这个阶段用于检测在特定的字节码处,局部变量表和操作数栈是否有着正确的数据类型。
符号引用验证
主要目的是确保后面的解析行为能正常执行,class文件在常量池会通过字符串记录自己要使用的其他类或者方法,”符号引用验证“就是检查这些类或者方法是否确实存在,并且当前类有权访问这些类和方法。
准备
当一个类通过验证时,虚拟机就会进入准备阶段,在这个阶段,虚拟机会为这个类的类变量(被static关键字修饰的成员变量是类变量,没有被static修饰的成员变量是实例变量)分配存储空间,并设置初始值。在JDK8以后,类变量则会随着Class对象一起存放在堆中,”类变量在方法区”这时候就完全是一种对逻辑概念的表述了。还有就是这里所说的初值”通常情况”下是数据类型对应的零值,下表中列出了java所有基本数据类型的零值。
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0L | float | 0.0f |
short | (short)0 | double | 0.0d |
char | ‘\u0000’ | reference | null |
byte | (byte)0 |
“通常情况”下初值是零值,但特殊情况下不是零值。被final关键字修饰的类变量如果给定初值,那在准备阶段该类变量的初值是给定初值而不是默认的类型零值。
以如下方式声明value变量:
public static final int value = 123;
则在准备阶段,value的值会被初始化成123。
以如下方式声明value变量:
public static int value = 123
则在准备阶段,value的值会被初始化成0。
以如下方式声明value变量:
public int value = 123
则在准备阶段,虚拟机不会给value分配内存空间,因为value是对象变量而非类变量。
解析
解析阶段的工作是将类、接口、字段、方法的符号引用转化为直接引用,也就是得到类、字段方法在内存中的指针或者偏移量。可以这么说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段,但只存在符号引用,不确定系统中一定存在该对象。
初始化
这是类加载的最后一个阶段,初始话阶段的就是执行类构造器 <clinit>()
方法的过程。<clinit>()
方法并不是开发者在java代码中直接编写的方法,它是javac编译器自动生成的。类构造器的作用就是给类的静态变量赋值和执行静态代码块:static{}
。
如果一个类中定义了如下代码:
public class SimpleStatic{
public static final int value = 123;
public static int id = 1;
public static int number;
static{
number = 4;
}
}
则在SimpleStatic的class文件加载过程中,在连接的准备阶段中,虚拟机给类变量(静态变量)value分配内存空间并赋初值123,虚拟机给类变量id和number分配内存空间并初始化为类型零值。在初始化阶段中,虚拟机给id赋值为1,执行static代码块后给number赋值为4。
<clinit>
方法与类的构造函数(即在虚拟机视角中的实例构造器 <init>()
方法,我们平时也把实力构造方法后称为类构造器,但在此处要做区分)不同。它不需要显式地调用父类构造器,java虚拟机会保证在子类的<clinit>
方法执行前,父类的<clinit>
方法已经执行完毕,因此,在java虚拟机中第一个被执行的<clinit>
方法的类型肯定是 java.lang.Object
。
父类的<clinit>
方法先与子类执行。也就意味着父类中的静态代码块先于子类的静态代码块执行。我们从new一个子类对象开始看起,方法的执行顺序应该是:父类的静态代码块 > 父类的实例构造函数 > 子类的静态代码块 > 子类的实例构造函数
。
父类的<clinit>
方法不一定是必须的,如果一个类没有静态变量也没有静态代码块时,javac编译器就不会为这个类生成<clinit>
方法。
总结
- 一个类只有后它被需要被主动使用的时候,虚拟机才会去加载它的二进制字节流(字节流一般在class文件中)。
- 类加载过程主要分为加载、验证、准备、解析、初始化这五个过程。
- 加载作用是定位类的二进制字节流在哪里,并且将它读进方法区,最后在堆中生成一个
java.lang.Class
对象。 - 验证可以细分为四个部分:文件格式验证、元数据验证、字节码验证、符号引用验证。
- 准备的作用是给类中的静态变量分配内存空间,并赋初值,如果静态变量被final修饰且指定了初值,则该静态变量的初值就是指定的初值,为被final修饰的静态变量赋它的类型初值。
- 解析的作用是将class文件中的类、字段、方法的符号引用转化成直接引用。
- 初始化过程就是执行类构造器
<clinit>
方法。该方法由java编译器自动生成,目的是给类的静态变量赋初值并且执行类中的静态代码块。 - 当我们在java代码中new一个对象时(第一次使用该对象),从代码执行角度看只是执行了一个实例构造函数。从虚拟机角度看,是执行了一整套的类加载过程,直到初始化阶段完毕后,最后才执行new关键字后面跟着的实例构造方法。