7、类加载的5个过程

1、类加载的本质

将描述类的数据从Class文件加载到内存&对数据进行校验、转换解 和初始化,最终形成可被虚拟机直接使用的Java使用类型:Class文件是一串二进制字节流

2、类加载过程

分为七个步骤,其中五个关键步骤:加载 -> 验证 -> 准备 -> 解析 -> 初始化

这几个阶段中的:加载、验证、准备、初始化、卸载的顺序是固定的。但是解析则有可能在初始化之后才开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。

注意:上文所说按部就班地“开始”,而不是“进行”或“完成”,强调这点是因为这些阶段通常都是互相交叉地混合式进行,通常会在一个阶段执行的过程中调用、激活另一个阶段。加载阶段 与 连接阶段 的部分内容(如一部分字节码文件格式验证动作)是交叉进行的。加载阶段尚未完成,连接阶段可能已经开始,但这些加载加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

  • 虚拟机对于类的初始化阶段严格规定了有且仅有只有5种情况如果对类没有进行过初始化,则必须对类进行“初始化”!
  1. 遇到new、读取一个类的静态字段(getstatic)、设置一个类的静态字段(putstatic)、调用一个类的静态方法(invokestatic)。类没有初始化,那么需要先触发进行初始化。

  2. 使用java.lang.reflect包的方法对类进行反射调用时。

  3. 当类初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。(如果是接口,则不必触发其父类初始化)

  4. 当虚拟机执行一个main方法时,会首先初始化main所在的这个主类。

  5. 当只用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。(暂未研究此种场景)

  • 不会被初始化的三种情况:
  1. 对于静态字段,只有直接定义这个字段的类才会被初始化,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
  2. 所有引用类的方式都不会触发初始化称为被动引用。比如数组 A[] a = new A[];
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接饮用到定义常量的类。进一步解释,虽然在main方法中引用了ConstClass类中的常量HELLO,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello”存储到了Main类的常量池中,之后对ConstClass.HELLO的引用实际上都被转化为Main类对自身常量池的引用。也就是说,两个类在编译过后实际上不存在任何联系了。

步骤1:加载(加载到方法区)

加载,将外部的类文件加载到虚拟机或者存储到方法区内是指查找字节流,并且据此创建类的过程。前面提到,对于数组类来说,它并没有对应的字节流,而是由Java虚拟机直接生成的。对于其他的类来说,Java虚拟机则需要借助类加载器来完成查找字节流的过程。类加载器实现的功能是即为加载阶段获取二进制字节流的时候。

  • 虚拟机完成三件事情:
  1. 通过一个类的全限定名来获取此类的二进制字节流;
    注意:这里的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
    注:“方法区域Java堆一样,是各线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据”。而方法区中的数据存储结构格式虚拟机自行定义。

  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
    注:加载阶段完成后,虚拟机在内存中实例化一个java.lang.Class类的对象(Class是一个实实在在的对象,是记录着类成员、接口等信息的对象)。还有一点是,我们都知道对象肯定是存放在堆中的,但Class对象比较特殊,对于HotSpot虚拟机而言,Class对象是存放在方法区中的。

  • 对于非数组类

非数组类加载过程是开发人员可控性最强的,可以使用系统提供的引导类加载器,也可以由用户自定义类加载器完成(重新一个类加载器 loadClass())。 对于数组类而言,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的,但是数组类的元素类型(Element Type,是指数组去掉所有维度的类型)最终要靠类加载器去创建,一个数组类(简称为C)创建过程要遵循以下规则:

  1. 如果数组的组件类型是引用类型(非基础类型),那就递归去加载这个组件类型数组类将在加载该组件类型的类加载器的类名称空间上被标识。
  2. 如果数组组件类型不是引用类型(例如int[]数组),Java虚拟机将会把该数组标记为与引导类加载器关联。
  3. 数组类的可见性与他的组件类型可见性一致,如果组件类型不是引用类型,那数组的可见性将默认为public。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存实例化一个 java.lang.Class类的对象(并无明确规定是在Java 堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽是对象,但存放在方法区里),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

步骤2:验证

验证是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段非常重要,直接决定了虚拟机是否能承受恶意代码的攻击,从执行性能的角度来讲,该阶段的工作量在虚拟机的类加载子系统中占有了相当大一部分。

Java语言本身是相对安全的语言(相对于C/C++),使用纯粹的Java代码无法做到诸如访问数组边界之外的数据、将一个对象转型为它未实现的数据类型、跳转到不存在的代码行等,如果这样做了,编译器将拒绝编译,但是Class文件不一定由Java源码编译而来,完全可以使用任何途径,如:用十六进制编辑器直接编写来产生Class文件,在字节码层面上,上述Java代码无法做到的事情都是可以实现的,此时虚拟机如果不检查输入的字节流,很有可能因为载入了有害的字节流而导致系统崩溃,所以验证时虚拟机对自身保护的一项重要工作。

验证阶段是非常重要的,但不是一定必要的阶段(对程序运行期没有影响),如果运行的全部代码都被反复使用和验证过,那么在实施阶段可以考虑通过参数-Xverify:none 来关闭类验证措施,以缩短虚拟机类加载的时间。

大致都会完成以下四个阶段的验证:

  1. 文件格式的验证
  2. 元数据的验证
  3. 字节码验证
  4. 符号引用验证

步骤3:准备

  • 正式为类变量(静态变量)分配内存并设置变量初始值的阶段这些变量所使用的内存都在方法区中进行分配,对静态字段的具体初始化,则会在稍后的初始化阶段中进行。
  • 除了分配内存外,部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。
  • 在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

此阶段有2个容易混淆的部分需要特别强调:

该阶段进行内存分配的仅包括类变量(被static修饰的变量),不包括实例变量,实例变量将在对象初始化时随对象一起分配在堆内存中 这里所说的初始值“通常情况下”是指数据类型的零值 假设一个类变量的定义为:

1
public static int value = 123;

那变量value在准备阶段过后的初始化值为0而不是123,因为这是尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后存放在类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。

上面提到的在“通常情况”下初始值为零值,但还是会有一些特殊情况,如下:

类字段的字段属性表中存在ConstantValue属性(只有同时被final和static修饰的字段才有ConstantValue属性),那在准备阶段变量value就会被初始化微ConstantValue属性所指定的值。编译时Javac将会为vaue生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

1
public final static int value = 123;

步骤4:解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程

  • 符号引用 (Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须一致,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

  • 直接引用(Direct Refenrences):直接引用可以是直接目标的指针、相对偏移量或是一个能间接定位到目标的句柄。 直接引用是和虚拟机实现的内存布局有关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那么引用的目标必定已经在内存中存在


对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。但对于invokedynamic指令,上面规则则不成立。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对其他invokedynamic指令也同样生效。因为invokedynamic指令是JDK1.7新加入的指令,目的用于动态语言支持,它所对应的引用称为“动态调用点限定符”(Dynamic Call Site Specifier),这里“动态”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有执行代码时就进行解析。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号进行引用,下面只对前4种引用的解析过程进行介绍,对于后面3种与JDK1.7新增的动态语言支持息息相关。

  1. 类或接口解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成这个解析的过程需要以下3个步骤
  • C不是一个数组类型,虚拟机会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,有可能触发其他相关类的加载动作。
  • 如果C是一个数组类型,并且数组的元素类型为对象,例如N是“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型,如果N的描述符是前面那样,需要加载的元素类型就是“java.lang,Integer”,接着由虚拟机生一个代表此数组维度和元素的数组对象。
  • 如果上面的步骤没有出现异常,C已经在虚拟机中成为了一个有效的类和接口了,但是解析完成之前还有进行符号引用验证,确保D是否具备对C的访问权限。
  1. 字段解析:要解析字段符号引用,首先要对字段表内字段所属的类或接口的符号引用进行解析,如果解析成功,那这个字段所属的类或接口用C表示,虚拟机规范要求安好如下步骤对C进行后续字段的搜索
  • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
  • 否则如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
  • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果父类包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
  • 否则查找失败,抛出异常java.lang.NoSuchMethodError
  • 如果上面的步骤没有出现异常,但是解析完成之前还有进行符号引用验证,确保是否具备对字段的访问权限。
  1. 类方法解析:要解析类方法符号引用,首先要对类方法表中方法所属的类或接口的符号引用进行解析,如果解析成功,那这个方法所属的类或接口用C表示,虚拟机规范要求安好如下步骤对C进行后续类方法的搜索
  • 类方法和接口房符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,直接抛出java.lang.IncompatibleClassChangeError异常
  • 如果C本身就包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
  • 否则,在C的父类中递归查找,如果父类包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
  • 在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法, 如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出java.lang,AbstractMethodError异常 这个需要这么理解,如果是普通的类去实现某一个接口的方法的话,那么它肯定在第(2)步已经直接返回.如果能执行到第(4)步,则说明C本身的常量池中并没有对应的直接引用.那么只能是说明这个方法是抽象方法.包含抽象方法的类必定是抽象类,所以这里有个结论就是C是抽象类.
  • 否则查找失败,抛出异常java.lang.NoSuchMethodError
  • 如果上面的步骤没有出现异常,但是解析完成之前还有进行符号引用验证,确保是否具备对方法的访问权限。
  1. 接口方法解析:要解析接口方法符号引用,首先要对接口方法表中方法所属的类或接口的符号引用进行解析,如果解析成功,那这个方法所属接口用C表示,虚拟机规范要求安好如下步骤对C进行后续类方法的搜索
  • 类方法和接口房符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个类而不是接口,直接抛出java.lang.IncompatibleClassChangeError异常
  • 如果C本身就包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
  • 否则,在C的父接口中递归查找,直到找到java.lang.Object类位置。如果包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
  • 否则查找失败,抛出异常java.lang.NoSuchMethodError 接口中方法默认都是public的,因此不存在访问权限的事

符号引用就是一个类中(当然不仅是类,还包括类的其他部分,比如方法,字段等),引入了其他的类,可是JVM并不知道引入的其他类在哪里,所以就用唯一符号来代替,等到类加载器去解析的时候,就把符号引用找到那个引用类的地址,这个地址也就是直接引用。

步骤5:初始化

将一个类中所有被static关键字标识的代码统一执行一遍,如果执行的是静态变量,那么就会使用用户指定的值覆盖之前在准备阶段设置的初始值;如果执行的是static代码块,那么在初始化阶段,JVM就会执行static代码块中定义的所有操作。

在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。 除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。 类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。

  • ()方法是有编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是有语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
  • ()方法与类的构造函数(或者说实例构造器()方法)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕;
  • 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作;
  • ()方法对于类和接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法;
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的()方法。
  • 虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果一个类的()方法中有耗时很长的操作,就可能造成多个进程阻塞。

那么,类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  5. 子类的初始化会触发父类的初始化;
  6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  7. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

例子

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
39
40
41
42
43
44
45
46
47
class Singleton{
private static Singleton singleton = new Singleton();
public static int value1;
public static int value2 = 0;

private Singleton(){
value1++;
value2++;
}

public static Singleton getInstance(){
return singleton;
}

}

class Singleton2{
public static int value1;
public static int value2 = 0;
private static Singleton2 singleton2 = new Singleton2();

private Singleton2(){
value1++;
value2++;
}

public static Singleton2 getInstance2(){
return singleton2;
}

}

public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println("Singleton1 value1:" + singleton.value1);
System.out.println("Singleton1 value2:" + singleton.value2);

Singleton2 singleton2 = Singleton2.getInstance2();
System.out.println("Singleton2 value1:" + singleton2.value1);
System.out.println("Singleton2 value2:" + singleton2.value2);
}

//说出运行的结果:
Singleton1 value1 : 1
Singleton1 value2 : 0
Singleton2 value1 : 1
Singleton2 value2 : 1

分析

Singleton输出结果:1 0
原因:

  1. 首先执行main中的Singleton singleton = Singleton.getInstance();
  2. 类的加载:加载类Singleton
  3. 类的验证
  4. 类的准备:为静态变量分配内存,设置默认值。这里为singleton(引用类型)设置为null,value1,value2(基本数据类型)设置默认值0
  5. 类的初始化(按照赋值语句进行修改):

执行private static Singleton singleton = new Singleton();

先执行Singleton的构造器:

value1++;

value2++;

此时value1,value2均等于1

再执行

public static int value1;

public static int value2 = 0;

此时value1=1,value2=0

Singleton2输出结果:1 1

原因:

  1. 首先执行main中的Singleton2 singleton2 = Singleton2.getInstance2();
  2. 类的加载:加载类Singleton2
  3. 类的验证
  4. 类的准备:为静态变量分配内存,设置默认值。这里为value1,value2(基本数据类型)设置默认值0,singleton2(引用类型)设置为null,
  5. 类的初始化(按照赋值语句进行修改):

执行

public static int value2 = 0;

此时value2=0(value1不变,依然是0);

执行

private static Singleton singleton = new Singleton();

执行Singleton2的构造器:value1++;value2++;

此时value1,value2均等于1,即为最后结果