第 7 章 虚拟机类加载机制

7.1 概述

上一章我们了解了Class文件存储格式的具体细节,在Class文件中描述的各种信息,最终都需要加载到虚拟机中之后才能运行和使用

而虚拟机如何加载这些Class文件? Class文件中的信息进入到虚拟机后会发生什么变化?

虚拟机把描述类的数据从 Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

与那些在编译时需要进行连接工作的语言不同,在Java语言里面, 类型的加载、连接和初始化过程都是在程序运行期间完成的, 这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性, Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

例如,如果编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类:

用户可以通过java预定义的和自定义类加载器,让一个本地的应用程序可以在运行时从网络或其他地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已经广泛应用于Java程序之中。

7.2 类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止, 它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading) 7个阶段, 其中

	验证、准备、解析 3 个部分统称为连接(linking)。

在这里插入图片描述

图中,加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的, 类的加载过程必须按照这种顺序按部就班的开始。

而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。注意,这里的顺序是有序地开始,而不是有序地进行或完成。 强调这一点,是因为这些阶段通常都是互相交叉地混合式进行的, 通常会在一个阶段执行的过程中调用、激活另外一个阶段。

类的加载

扫描二维码关注公众号,回复: 8955029 查看本文章

什么情况下需要开始类加载过程的第一个阶段: 加载Java虚拟机规范并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。

但是对于初始化阶段,虚拟机规范则是严格规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

1) 遇到 new、getstatic 、putstatic 或 invokestatic 这4 条字节码指令时, 如果类没有进行过初始化, 则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:

  • 使用 new 关键字实例化对象时;
  • 读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候;
  • 以及调用一个类的静态方法的时候。

2) 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

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

4) 当虚拟机启动时,用户需要指定一个要执行的主类(包含main() 方法 的哪个类),虚拟机会先初始化这个主类。

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

对于这 5 种会触发类进行初始化的场景,是“有且只有”, 这 5 种场景中的行为称为对一个类进行主动引用。 除此之外,所有引用类的方法都不会触发初始化,称为被动引用

// 被动引用例子 之一
package Xg27;
 class SuperClass{
	static {
		System.out.println("SuperClass init!");
	}
	public static int value = 123;
}

 class Subclass extends SuperClass{
	static {
		System.out.println("SubClass init!");
	}
}

public class NotInitialization {
	public static void main(String[] args) {
		System.out.println(Subclass.value);
	}
}

运行结果:

SuperClass init!
123

可以看到,只输出了“SuperClass init!” 而不会输出 “SubClass init!” 。对于静态字段,只有直接定义这个字段的类才会被初始化, 因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

至于是否要触发子类的加载和验证, 在虚拟机规范中并未明确规定, 这点取决于虚拟机的具体实现。对于 Sun HotSpot 虚拟机来说, 可通过 -XX:+TraceClassLoading 参数观察到此操作会导致子类的加载。

//被动引用的例子 之二
package Xg27;
// 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
class ConstClass{
	static {
		System.out.println("ConstClass init!");
	}
	public static final String HELLOWORLD = "hello world";
}


public class NotInitialization {
	public static void main(String[] args) {
		System.out.println(ConstClass.HELLOWORLD);
	}
}

上述代码运行之后,也没有输出“ConstClass init!” ,这是因为虽然在 Java源码中引用了 ConstClass 类中的常量 HELLOWORLD, 但其实在编译阶段通过常量传播优化,已经将此常量的值 “hello world”存储到了 NotInitialization 类的常量池中, 以后 NotInitialization 对常量 ConstClass.HELLOWORLD 的引用实际都被转化为 NotInitialization 类对自身常量池的引用了。

也就是说, 实际上 NotInitialization 的 Class文件之中并没有 ConstClass 类的符号引入入口,这两个类在编译成 Class 之后就不存在任何联系了。

==看起来好像用了 ConstClass,其实连碰都没有碰它,所以它没有触发初始化,所以, 它的常量 HELLOWORLD里什么也没有。

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程, 这点与类是一致的, 上面的代码都是用静态语句块“static{}” 来输出初始化信息的。 而接口中不能使用“static{}” 语句块, 但编译器仍然会为接口生成“< clinit > ()” 类构造器, 用于初始化接口中所定义的成员变量。

接口与类真正有所区别的是前面讲述的 5种“有且仅有” 需要开始初始化场景中的第 3 种,当一个类在初始化时, 要求其父类全部都已经初始化过了, 但是接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

7.3 类加载的过程

7.3.1 加载

“加载” 是类加载(Class Loading) 过程的一个阶段, 在加载阶段,虚拟机需要完成以下 3 件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang. Class 对象,作为方法区这个类的各种数据的访问入口。

虚拟机规范的这 3 点要求其实并不算具体, 因此虚拟机实现与具体应用的灵活度都是相当大的。

例如“通过一个类的全限定名来获取定义此类的二进制字节流” 这条, 他没有指明二进制字节流要从一个Class文件中获取, 准确地说根本没有指明要从哪里获取,怎样获取。

7.3.2 验证

验证时连接阶段的第一步, 这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求, 并且不会危害虚拟机自身的安全。

Java语言本身是相对安全的语言, 使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型,跳转到不存在的代码行之类的事情, 如果这样做了,编译器将拒绝编译。

但 Class文件并不一定要求用Java源码编译而来, 可以使用任何途径产生,在字节码语言层面上,上述Java代码无法做到的事情都是可以实现的,虚拟机如果不检查输入的字节流, 对其完全信任的话,很可能因为载入了有害的字节流而导致系统崩溃。所以验证是一项重要工作。

从整体来看, 验证阶段大致上会完成下面 4 个阶段的检验动作: 文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 1. 文件格式验证

第一阶段要验证字节流是否符合 Class文件格式的规范, 并且能被当前版本的虚拟机处理, 这一阶段可能包含下面的验证点:

  • 是否已魔数 0xCAFEBABE 开头
  • 主次版本号是否在当前虚拟机处理范围之内。
  • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
    ……

该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个java类型信息的要求。这阶段的验证是基于二进制字节流进行的。

只有通过了这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面的 3 个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

  • 2. 元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求, 这个阶段可能包括的验证点如下:

  • 这个类是否有父类
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段,方法是否与父类产生矛盾
    ……

第二阶段的目的是对类的元数据进行语义校验,保证不存在不符合Java语言规范的元数据信息。

  • 3. 字节码验证

第三阶段时整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

在第二阶段对元数据信息中的数据类型做完校验后,这个阶段对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都配合工作。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转换是有效的, 例如可以把一个子类对象赋值给父类数据类型。这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
    ……
  • 4. 符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。 符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符号方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可以被当前类访问。
    ……

符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将抛出异常。

对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要的阶段,有时可以选择关掉,如果所运行的全部代码都已经被反复使用和验证过。

7.3.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都将在方法区中进行分配。 这个阶段中有两个容易产生混淆的概念需要强调一下:

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

public static int value = 123;

那么变量 value 在准备阶段过后的初始值为 0 而不是 123, 因为这时候尚未开始执行任何 Java 方法, 而把 Value赋值为 123 的 putstatic 指令时程序被编译后,存放与类构造器 < clinit > () 方法之中, 所以把 value 赋值为 123 的动作将在初始化阶段才会执行, 表 7-1 列出了 Java所有基本数据类型的零值。

在这里插入图片描述
上面提到, 在 “通常情况下”, 所以相对的会有一些“特殊情况” : 如果类字段的字段属性表中存在 ConstantValue 属性, 那在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值, 假设上面类变量 Value 定义变为:

public static final int value = 123;
编译时 Javac 将会Value 生成 ConstantValue 属性, 在准备阶段虚拟机就会根据 ConstantValue 的设置将 Value 赋值为 123。

7.3.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在前一章讲解 Class 文件格式的时候已经出现过多次。

在 Class文件中它以 CONSTANT_Class_info 、 CONSTANT_Fieldref_info 、 CONSTANT_Methodref_info 等类型的常量出现,

那解析阶段中所说的直接引用与符号引用又有什么关联?

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。 符号引用与虚拟机实现的内存布局无关**,引用的目标不一定已经加载到内存中**,各种虚拟机实现的内存布局可以各不相同, 但是它们能接受的符号引用必须是一致的, 因为符号引用的字面量形式明确定义在 Java虚拟机规范的 Class 文件格式中。
  • 直接引用(Direct References): 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

虚拟机规范之中并未规定解析阶段发生的具体时间,只要求了在执行 anewarray/、 checkcast、 getfield等等 16 个用于操作符号引用的字节码指令之前, 先对它们所使用的符号引用进行解析, 所以虚拟机时间可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

对同一个符号引用进行多次解析请求是很常见的事情,

7.3.5 初始化

类初始化阶段时类加载过程的最后一步, 前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制, 到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说字节码)。

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达: 初始化阶段时执行类构造器 () 方法的过程。

在这里,我们先看一下 () 方法执行过程中一些可能会影响程序运行行为的特点和细节, 这部分相对更贴近与普通的程序开发人员。

  • () 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的, 编译器收集的顺序是由语句在源文件中出现的顺序所决定的, 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,
public class Test{
	static {
			i = 0;    //给变量赋值可以正常编译通过(可以赋值)
			System.out.print(i); // 这句编译器会提示“非法向前引用” (即无法访问)
		}
		static int i = 1;
	}
  • < client > () 方法与类的构造函数(或者说实例构造器< init> () 方法不同), 它不需要显式地调用父类构造器, 虚拟机会保证在子类的 < clinit > () 发方法执行之前,父类的 () 方法已经执行完毕。 因此在虚拟机中第一个被执行的 < clinit >() 方法的类肯定是 java.lang.Object。
  • 由于父类的< clinit> () 方法先执行, 也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,
static class Parent{
	public static int A = 1;
	static {
	 	A = 2;}

static class Sub extends Parent{
	public static int B = A;
}

public static void main(String[] args){
	System.out.println(Sub.B);    //输出 B 等于 2.
}
  • < client>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成< client>() 方法。
  • 接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成< clinit>() 方法。 但接口与类不同的是,执行接口的< clinit>() 方法不需要先执行父接口的 < clinit>() 方法,只有当父接口中定义的变量使用时, 父接口才会初始化,另外,接口的实现类在初始化时也不会执行接口的 < clinit>() 方法。
  • 虚拟机会保证一个类的 < clinit> () 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 < clinit>() 方法,其他线程都需要阻塞等待, 直到活动线程执行完毕,
发布了202 篇原创文章 · 获赞 4 · 访问量 4187

猜你喜欢

转载自blog.csdn.net/qq_44587855/article/details/104092001