Java虚拟机(二)类加载机制

时间:2022-12-21 11:28:43

大家好,我是一个爱举铁的程序员Shr

 

本篇文章简单介绍Java虚拟机如何加载Class文件。

 

Class文件表示一串二进制字节流,而不是硬盘中.class文件。

 

本篇文章大部分是概念,有一些代码验证,阅读本篇文章你可能需要20分钟。

 

这几天在重新温习Spring的时候遇到了类加载器的问题,翻开了买来就没看过的关于虚拟机的书,看完之后,内心激动地敲下这一篇文章。

 

在虚拟机启动之后,把描述类的数据从Class文件加载到内存,对数据进行校验,转换解析,初始化,最后形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制[1]

 

一个类从被加载到虚拟机内存中到卸载出内存,整个生命周期为:加载,验证准备解析,初始化,使用,卸载7个阶段。其中验证准备解析3个阶段称为链接。如下图所示。

Java虚拟机(二)类加载机制

图中,加载,验证,准备,初始化和卸载5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段不一定,它在某些情况下可以在初始化之后开始这是为了支持Java语言的运行时绑定(动态绑定或晚期绑定)[1]

 

这里所说的按部就班地开始,而不是按部就班地进行完成。因为这些阶段通常都是互相交叉地进行,通常会在一个阶段执行的过程中调用、激活另外一个阶段[1]

 

一、加载

加载是根据特定名称查找类或接口类型的二进制流,并由此二进制流表示创建类或接口的过程[2]。它是类加载过程中的一个阶段。在加载阶段,虚拟机需要完成以下3件事情[1]

1.通过一个类的全限定名来获取定义此类的二进制字节流。

2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。


二、链接

链接是为了让类或接口可以被Java虚拟机执行,而将类或接口并入虚拟机运行时状态的过程。

 

加载阶段和链接阶段的部分内容是交叉进行的,加载还未完成,链接阶段可能已经开始,只是两个阶段的开始时间保持固定的先后顺序[1]

 

Java虚拟机规范允许灵活地选择链接发生的时机,但必须保证以下几点成立[2]

1.在类或接口被链接之前,它必须被成功地加载过。

2.在类或接口初始化之前,它必须被成功地验证准备过。

3.程序的直接或间接行为可能会导致链接发生,链接过程中检查到的错误应该在请求链接的程序处被抛出。

 

2.1 验证

验证是链接的第一步,用来确保类或接口的Class文件二进制字节流中的信息符合当前虚拟机的要求。

 

验证阶段大致会完成下面4个检验动作:

 

2.1.1 文件格式验证

验证字节流是否符合Class文件格式的规范。例如:

主、次版本号是否在当前虚拟机处理范围之内。

常量池的常量中是否有不被支持的常量类型。

......

 

2.1.2 元数据验证

对字节码描述的信息进行语义分析,保证信息符合Java语言规范。例如:

这个类是否有父类(除java.lang.Object之外,所有的类都应该有父类)

这个类是否继承了不允许被继承的类(被final修饰的类)

......

 

2.1.3 字节码验证

这个验证过程是最复杂的,目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如:

保证跳转指令不会跳转到方法体以外的字节码指令上。

保证方法体中的类型转换是有效的。

......

 

2.1.4 符号引用验证

最后一个验证发生在虚拟机将符号引用转化为直接引用的时候,在链接这个阶段的第三步解析中完成。这个验证是对类以外的信息进行匹配性验证。


那什么是符号引用?

 

符号引用以一组符号来描述所引用的目标,引用的目标不一定已经加载到内存中。

 

直接引用是直接指向目标的指针,相对偏移量或能间接定位到目标的句柄。直接引用的目标一定要在内存中存在。例如:

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) throws Exception {
		// str表示符号引用
		String str = new String();
		System.out.println(str);
		// 直接引用字符串"123"在内存中的地址
		System.out.println("123");
	}
}


符号引用验证通常需要验证以下内容,例如:

符号引用中通过字符串描述的全限定名是否能找到对应的类。

符号引用中的类、字段、方法的访问性是否可被当前类访问。

......

 

关于验证的内容还有很多,大家可以自己查阅相关资料。

 

2.2 准备

准备是链接阶段的第二步,是为类或接口的静态字段分配内存并设置默认初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

 

假设一个类的静态字段定义为:

public static int num = 100;

 

那字段num在准备阶段之后的初始值为0而不是100,因为这个时候还未开始执行任何Java方法,将num赋值为100putstatic指令是在初始化阶段才会执行。

 

2.3 解析

解析是链接阶段的第三步,虚拟机将常量池中的符号引用转换为直接引用的过程。


包括类或接口的解析,字段解析,类的普通方法解析,接口方法解析。以下的解析步骤引用[1]中的7.3.4 解析,大家也可以参考[2]中的5.4.3 解析

 

2.3.1 类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:

 

1.如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他类的加载动作,例如加载这个C的父类或实现的接口。一旦这个加载过程出现了异常,解析过程就宣告失败。

 

2.如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava.lang.String”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是java.lang.String,接着由虚拟机生成一个代表此数组维度和元素的数组对象。

 

3.如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为了一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,缺认D是否具备对C的访问权限。如果发现不具备访问权限,抛出java.lang.IllegalAccessError异常。

 

2.3.2 字段解析

要解析一个未被解析过的字段符号引用,首先对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。

 

1.如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

 

2.否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

 

3.否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

 

4.否则,查找失败,抛出java.lang.NoSuchFieldError异常。

 

如果成功返回了引用,对这个字段进行权限验证,如果发现对这个字段没有访问权限,抛出java.lang.IllegalAccessError异常。

 

2.3.3 类的普通方法解析

第一个步骤与字段解析一样,先解析类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,用C表示这个类,虚拟机按照以下步骤搜索:

 

1.类的普通方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,抛出java.lang.IncompatibleClassChangeError异常。

 

2.如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

 

3.否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

 

4.否则,在类C实现的接口列表和它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出java.lang.AbstractMethodError异常。

 

5.否则,方法查找失败,抛出java.lang.NoSuchMethodError异常。

 

如果成功返回了引用,对这个方法进行权限验证,如果发现对这个方法没有访问权限,抛出java.lang.IllegalAccessError异常。

 

2.3.4 接口方法解析

先解析接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,用C表示这个接口,虚拟机按照以下步骤搜索:

 

1.与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,抛出java.lang.IncompatibleClassChangeError异常。

 

2.否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

 

3.否则,在接口C的父接口中递归查找,知道java.lang.Object类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

 

4.否则,方法查找失败,抛出java.lang.NoSuchMethodError异常。

 

由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。

 

三、初始化

初始化是类加载过程的最后一步,除了在加载阶段可以用自定义类加载器参与之外,其余动作都是由虚拟机来主要和控制。

 

虚拟机规范严格规定以下5种情况必须立即对类进行初始化。

 

3.1 第一种情况

遇到newgetstaticputstaticinvokestatic4条字节码指令时,如果类没有进行过初始化,则先触发其初始化。生成这4条字节码指令的场景分别是:使用new关键字实例化对象的时候,读取一个类的静态字段的时候,设置静态字段的值得时候(被final修饰,在编译期就将结果放入常量池的静态字段除外),调用类的静态方法的时候。

 

下面就用代码验证。

 

3.1.1 使用new关键字的时候

新建两个类,一个Main,一个Hello

 

package com.shrmus.jvm;

public class Hello {
	static {
		System.out.println("Hello类被初始化...");
	}
}

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) {
		Hello hello = new Hello();
	}
}


控制台打印结果:

Hello类被初始化...

 

这个结果证明了在使用new关键字的时候,会触发类的初始化。

 

3.1.2 读取类的静态字段的时候

修改Hello类,注意静态字段我用的修饰符是public

package com.shrmus.jvm;

public class Hello {
	static {
		System.out.println("Hello类被初始化...");
	}
	public static String string = "你好。";
	
	public static String getString() {
		return string;
	}
	public static void setString(String string) {
		Hello.string = string;
	}
}

public class Main {
	public static void main(String[] args) {
		System.out.println(Hello.string);
	}
}


控制台打印结果:

Hello类被初始化...

你好。

 

3.1.3 设置类的静态字段的值的时候

修改Main类的main方法:

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) {
		Hello.string = ""; 
	}
}


控制台打印结果:

Hello类被初始化...

 

Hello类的静态字段加上final修饰符看看。加上之后用IDE工具就可以发现字段的setter方法中报错了,为什么会报错?因为final修饰符修饰的字段是常量,不能再被赋值。先把setter方法注释,然后修改Main类的main方法。

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) {
		System.out.println(Hello.string);
	}
}


控制台打印结果:

你好。

 

Hello类中的静态代码块的语句没有被执行。

 

3.1.4 调用类的静态方法的时候

修改Main类中的main方法。

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) {
		Hello.getString();
	}
}


控制台打印结果:

Hello类被初始化...

 

3.2 第二种情况

使用反射调用的时候,如果类没有初始化,则先触发其初始化,修改Main类的main方法。

 

package com.shrmus.jvm;

import java.lang.reflect.Method;

public class Main {
	public static void main(String[] args) throws Exception {
		Class clazz = Hello.class;
		// 反射获取类的getString()方法
		Method method = clazz.getMethod("getString");
		/*
		 	调用该方法
		 	第一个参数表示调用底层方法的对象,如果底层方法是静态的,该参数可以为null
		 	第二个参数表示该方法接收的形参,提供的 args 数组长度可以为 0 或 null
		 */
		method.invoke(null,null);
	}
}


控制台打印结果:

Hello类被初始化...

  

3.3 第三种情况

初始化一个类的时候,其父类还没有初始化,则先触发其父类初始化。

新建一个类SuperHello类继承Super类。

package com.shrmus.jvm;

public class Super {
	static {
		System.out.println("Super类被初始化...");
	}
}


修改Main类的main方法。

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) throws Exception {
		Hello hello = new Hello();
	}
}


控制台打印结果:

Super类被初始化...

Hello类被初始化...

 

3.4 第四种情况

虚拟机启动时,先初始化程序中包含main()方法的类。

Main类中添加静态代码块。

package com.shrmus.jvm;

public class Main {
	static {
		System.out.println("Main类被初始化...");
	}
	public static void main(String[] args) throws Exception {
		Hello hello = new Hello();
	}
}


控制台打印结果:

Main类被初始化...

Super类被初始化...

Hello类被初始化...

 

3.5 第五种情况

使用动态语言支持时,初次调用java.lang.invoke.MethodHandle实例时,最后的解析结果是REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,这个方法句柄所对应的类没有进行过初始化,先触发其初始化。


对应的字节码指令就是getstaticputstaticinvokestatic,参考[2]中的5.4.3.5 方法类型与方法句柄解析

 

3.6 不会被初始化的情况

3.6.1 第一个例子

首先在Super类中添加静态字段。

package com.shrmus.jvm;

public class Super {
	static {
		System.out.println("Super类被初始化...");
	}
	public static String string = "我是超级类";
}

Hello类删除原来的静态字段,但还是继承Super类。

package com.shrmus.jvm;

public class Hello extends Super{
	static {
		System.out.println("Hello类被初始化...");
	}
}


修改Main类的main方法。

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) throws Exception {
		System.out.println(Hello.string);
	}
}


控制台打印结果:

Super类被初始化...

我是超级类

 

没有输出Hello类被初始化,对于静态字段,只有直接定义这个字段的类才会被初始化,通过子类来引用父类定义的静态字段时,只触发父类的初始化,不会触发子类的初始化。

 

3.6.2 第二个例子

修改Main类的main方法。

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) throws Exception {
		Super[] supers = new Super[2];
	}
}


控制台没有打印Super类被初始化,说明没有触发com.shrmus.jvm.Super类的初始化,但是这段代码触发了[Lcom.shrmus.jvm.Super;的类的初始化,它是由虚拟机自动生成的,直接继承java.lang.Object,创建动作由newarray字节码指令触发[1]

 

对于一个M维的数组类,名称会以MASCII字符“[”开头,随后是数组元素的类型表示,如果该数组的元素类型是Java原始类型之一,它就以相应的字段描述符来表示。如果该数组元素类型是某种引用类型,就以ASCII字符“L”加上二进制名称,并以字符“;”结尾的字符串表示[2]

 

上面的代码打印supers后,控制台的打印结果:

[Lcom.shrmus.jvm.Super;@70dea4e

 

改成基本数据类型后:

package com.shrmus.jvm;

public class Main {
	public static void main(String[] args) throws Exception {
		int[][] intArray = new int[2][3];
		System.out.println(intArray);
	}
}

控制台打印结果:

[[I@70dea4e

 

接口也有初始化过程,这点与类是一致的,但是接口不能用静态语句块来表示初始化,但编译器会为接口生成<clinit>()”类构造器用于初始化接口中定义的常量。接口的成员变量默认是public static final 修饰。接口和类的区别就是:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正用到父接口的时候才会初始化,如引用接口中的常量[1]

 

总结

有关于类的加载就到这里结束了,如果有不懂的可以给我留言,有不完整的地方会在后面补上。

 

参考文献

[1] 周志明.深入理解Java虚拟机:JVM高级特性与最佳实践[M].机械工业出版社,2013

[2] Tim Lindholm,Frank Yellin.Java虚拟机规范[M].机械工业出版社,2011