实习杂记(30):虚拟机类的加载机制(1)

要深刻理解android的多dex,需要先了解  虚拟机类的加载机制。

类 从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:

加载,验证,准备,解析,初始化,使用,卸载

其中  解析 这步是不确定的,是因为需要支持  运行时绑定,也称为:动态绑定或晚期绑定

执行顺序:加载,验证,准备,初始化,使用,卸载    【这条线是去确定的】

那么解析到底是在什么呢?

答案是:在某些情况下,可以在初始化阶段之后再开始,;;;或者说在某些情况下,这些过程是交叉进行的,这种交叉的理解需要正确对待:执行顺序是保持不变的,只是在前一个阶段的过程中会启动后面一个阶段

一、类的加载时机

什么情况下开始  类加载 ,加载  阶段什么时候被触发呢?

这是由虚拟机控制的,5种情况:

1.遇到 new ,getstatic、pubstatic、invokestatic 这四个字节码指令时,如果类没有进行过初始化,则需要先触发其初始化后,其中最常见的就是1)、new操作符;2)、读取或者设置一个类的静态字段的时候;3)、调用一个类的静态方法

注:如果是final 或者static 变量(所在的类),在编译的时候这些结果值就已经放入常量池了,他们是不会走类的加载的,这个也要正确的理解,static变量在其他的类中使用类名 + ".操作符"即可调用,不会走类的加载意思是说,被调用的类是不需要走类的加和初始化的,因为这个过程是被动的。

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

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

4.当虚拟机启动的时候,用户需要指定一个要执行的主类,(就是包含main的类),虚拟机会初始化这个主类

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

java虚拟机规范中使用   “有且只有” ,这5种场景中的行为    称为对一个类的主动引用,除此之外,所有引用类的方法都不会触发类的加载和初始化过程,他们被称为   被动引用,

被动引用例子1:

public class SuperClass {
	
	static {
		System.out.println("superClass init!");
	}
	
	public static int value = 123;
}

public 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

上述输出没有子类的构造,是因为对于静态字段而言,  只有直接定义这个字段的类才会被初始化,

因此通过其子来引用父类中定义的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

被动引用例子2:

	/**
	 * 通过数组定义来引用类,不会触发此类的初始化
	 */
	public static void main(String[] args) {
		SuperClass[] suc = new SuperClass[10];
	}

这段代码将什么都不会输出,很奇怪这里不是有new,为什么没有走类的初始化呢,

因为此时初始化的对象发生了改变。

假如说上面的SuperClass 所在的包名为 com.javam.classloading

它触发的是这样一个类的加载和初始化:Lcom.javam.classloading.SuperClass,

对于用户而言,这个类是找不到的,因为包找不到,它是由虚拟机自动生成的,直接继承java.lang.Object的子类,创建动作由字节码newarray触发,它代表的是一个数组,一维数组,数组元素类型为:com.javam.classloading.SuperClass,虚拟机自动生成的类包含有常见的length方法,clone方法,他们都是public修饰的。

 补充知识点:java中数组越界检查不是封装在数组元素访问的类中,而是封装在数值访问的xaload、xastore字节码指令中,当检查到发生数组越界时会抛出java.lang.ArrayIndexOutOfBoundsException异常

被动引用例子3:

/**
 * 常量在编译阶段会存入 调用类的常量池中,本质上并没有直接引用到定义常量的类,
 * 因此不会触发定义常量的类的初始化
 */
public class ConstClass {
	static {
		System.out.println("ConstClass init!");
	}
	
	public static final String value = "123234";
	
}

	/**
	 * 通过类名,直接引用常量,并不会导致类的加载和初始化
	 */
	public static void main(String[] args) {
		System.out.println(ConstClass.value);
	}

上述输出结果:

<span style="font-size:18px;">123234</span>

这是因为  编译阶段通过常量传播优化。这里面的过程是这样的:

本身常量是在ConstClass中定义的,在编译的时候,发现main()方法所在的主类调用了这个常量,编译的过程中就会把这个常量移动到这个主类所在的常量池中,实际上main()方法中引用的就是自己的常量池里面的东西,所以ConstClass是没有进行类的加载和初始化的过程的,编译之后,这两个类其实没有任何的关系,

补充知识点:

1.接口中是不允许有static代码块的,但是接口的初始化过程跟类是一样的,编译器也会为接口生成<clinit>()  类构造器,用于初始化接口中定义的成员变量,

2.接口初始化原则和类的初始化是一样的,但是对于上面描述的第三点父类的关系是有区别的,接口在初始化的时候并不需要父类接口就完成初始化,父类的接口是真正等到需要的时候才初始化的

二、类加载的具体过程

一)、加载。第一个阶段

在第一个阶段,虚拟机需要完成3件事情:

1.通过一个类的全限定名  来获取定义此类的二进制字节流,理论上就是class文件,但实际上这个没有指定,开放的

2.将这个字节流所代表的静态存储结构转化为  方法区的运行时数据结构。因为类加载就是在程序运行时完成的

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

关于第一条获取 二进制字节流,虚拟机实际上没有指明从哪里获取,从什么类型的文件获取,虚拟机设计团队在加载阶段搭建了一个相当开放的大舞台,java发展历程中,充满创造力的开发人员在这个舞台上,玩出了各种花样,而很多重要的技术正是基于这个基础搞出来的,

比如可以从ZIP包中读取二进制字节流,如:JAR, EAR, WAR格式的文件

比如可以从网络中获取,典型的应用就是Applet

比如运行时计算生成,这种场景的使用就是动态代理技术,在java.lang.reflect.Proxy中使用ProxyGenerator.generateProxyClass来为特定接口生成形式为 *$Proxy 的代理类的二进制字节流

比如由其他的文件生成:典型的应用就是JSP应用,由JSP文件生成 对应的Class类

比如从数据库中读取,比如中间件服务器 ,放在数据库中实现集群的分发

非数组类的加载阶段:这个加载阶段  准确理解是这样的:获取类的二进制字节流的动作  称为  加载阶段

这个加载阶段是开发人员可控性最强的,因为开发人员可以自己写 类加载器,loadClass 

数组类本身不通过类的加载器创建,它是由java虚拟机直接创建的,数组的元素类型 Element Type 需要类的加载器出创建,具体情况如下 :

1.数组类型如果是组件类型:Component Type,   引用类型,怎么理解,就是数组元素是一个Object,比如Object[],数组类型将在加载该组件类型的类加载器的类名称空间上加上一个标识,

2.数组类型的组件类型不是引用类型,例如 int[] , 此时java虚拟机将会把数组标记为与引导类加载相关联

3.数组类型的可见性与 它的组件类型的可见性是一致的,但是非引用型数组,比如int[], 可见性默认是public的

加载阶段完成后,二进制字节流就按照虚拟机的规范存储在方法区的数据存储格式中,然后在内存中实例化一个java.lang.Class类的对象,这个类对象,并没有明确是在java堆中,这个对象将作为程序访问方法区中的这些数据类型的外部接口

这里面交叉行为是这样的:加载阶段 与  验证阶段  会进行交叉进行,也就是在加载阶段的未完成的某一个时刻,验证阶段已经开始了,

二)、验证。第二个阶段,

连接阶段的第一个阶段

这个阶段目的是为确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全

什么叫安全:比如说:访问数组以外的数据;将一个对象强转成其他的类型;代码跳转执行到不存在的代码中去

Class文件是二进制字节流,并非一定必须是java编写的,可以是其他的任何途径产生的,甚至包括16进制编辑器直接编写产生的Class文件,

所以如果虚拟机不对Class文件的二进制字节流进行验证,完全信任它,可能会导致系统的崩溃,所以验证阶段是非常的重要的

这个阶段也是为了防止:程序是否会被恶意攻击,是否能承受恶意攻击

验证  其实就是验证:Class文件是否符合规范  规定的  Class文件格式中的静态和结构化约束,如何验证发现不符合,就会抛出一个  java.lang.VerfyError异常 或者他的子类异常

1.文件格式验证:

1)、是否以魔数0xCAFEBABE开头,

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

3)、常量池的常量中是否有不被支持的常量类型

4)、指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量

5)、CONSTANT_Utf8_info 型的常量中是否有不符合UTF8编码的数据

6)、Class文件中各个部分及文件本身是否有被删除的或附加的其他的信息

格式验证的目的:就是看输入的二进制字节流在格式上是否符合描述一个java类型信息的要求

这个层面的验证是在字节流上进行验证的,只要验证通过了,这些数据将会被存储在方法区中,后面的验证都是基于方法区的上面的,而这个阶段也正是跟类的加载阶段交叉的部分

2.元数据验证:

指的是对字节码描述的信息进行语义分析,义保证其描述的信息符合java语言的规范

1)、这个类是否有父类,除了java.lang.Object之外,所有的类都是由父类的

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

3)、如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法

4)、类中的字段、方法是否与父类产生矛盾,比如覆盖了父类的final 字段,方法和参数都一致是不被允许的

3.字节码验证:

最复杂的一个阶段,通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。

这个阶段实质是对:类的方法体  进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机的安全的事情

比如说:保证类型转换是有效的,把父类赋值给子类是不允许的,

如果一个类的方法体的字节码没有通过字节码验证,那肯定是有问题的,;如果通过了字节码验证,也不能说一定就是安全的,通过程序去检验程序逻辑是无法做到绝对的准确的,

这个过程其实消耗了很多时间,在JDK1.6之后,虚拟机设计团队在javac编译器和java虚拟机中进行了一项优化,给方法体的Code属性的属性表中增加了一项名为:StackMapTable的属性,这项属性描述了方法体中所有的基本块,开始时本地变量表和操作栈对应的状态,在字节码验证期间,就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样将字节码验证的类型推导转变为类型检查从而节省一些时间

理论上StackMapTable也存在错误或者被篡改的可能,在篡改Code属性的同时,也生成相应的StackMapTable来骗过虚拟机

4.符号引用验证:

虚拟机将符号引用转化为直接引用,这个转化的动作在  解析阶段发生,这里是一个交叉行为

符号引用验证可以看做是对类自身以外的信息进行匹配性校验:

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

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

目的是为了确保  解析阶段能够正常运行,

如果没有通过符号引用验证,抛出的异常有:

java.lang.IncompatibleClassChangeError

java.lang.IllegalAccessError

java.lang.NoSuchFieldError

java.lang.NoSuchMethodError

验证阶段是非常重要的,但是不是必须的,如果这个代码已经被使用了很多次,有没有修改,可以跳过这个阶段,

-Xverify:none

三)、准备。第三个阶段,

准备阶段  是正式为  类变量 分配内存并设置  类变量 初始值的阶段,这些变量所使用的内存都将在方法区中进行分配

这个阶段中的变量指的是  static 变量,不包括实例变量,

实例变量的内存分配将会在对象的实例化时随着对象一起分配在java堆中的,

这个阶段的初始值  通常情况下是数据类型的零值,假设一个变量的定义为:

public static int value = 1234;

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

char 零值是:‘\u0000’

但是有特殊的情况:

如果上面的被final修饰了,那么将不是零值了,

public static final int value = 1234;

在准备阶段,这个vaule属性变量的值就是1234了,

四)、解析。第四个阶段,

这个阶段是虚拟机将常量池内的符号引用  替换为  直接引用的过程,

符号引用在Class文件中它以:

CONSTANT_Class_info, 

CONSTANT_Fieldref_info,

CONSTANT_Methodref_info,

CONSTANT_IntefaceMethodref_info,

CONSTANT_MethodType_info,

CONSTANT_MethodHandle_info,

CONSTANT_InvokeDynamic_info,

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

直接引用:直接引用可以是直接指向目标的指针,相对偏移量或者是一个能间接定位到目标的句柄,

直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在

虚拟机规范并没有具体规定  解析阶段  发生的时间,只要求在执行了anewarray, checkcast, getfield, getstatic ,instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multianewarray, new , putfield, putstatic这16个用于操作符号引用的字节码指令之前,先对他们所使用的符号进行解析,

所以虚拟机实现可以根据需要来判断  到底是在类 被加载器  加载时  就对  常量池中的符号引用进行解析,

还是等到一个符号引用将要被使用前才去解析他 

对同一个符号引用进行多次解析请求是很常见的事,除了invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,,在运行时常量池中记录直接引用,并把常量标识位已经解析状态,从而避免解析动作的重复执行,

无论是否进行了重复解析的动作,虚拟机需要保证的是在同一个实体中,如果一个符号引用之前 已经被成功 解析过,那么后续的引用解析请求就应该一直是成功的,同样的,如果第一次解析失败了,后面的其他的指令对这个符号引用的解析请求都将是同样的结果,

对于invokedynamic,上面的规则是不成立的。

当碰到某个前面已经有invokedynamic指令触发过的解析符号的引用时,并不意味着这个解析结果对于其他的invokedynamic指令也同样生效,因为invokedynamic指令的目的本来就是动态语言支持,它对应的引用被称为 动态调用点限定符,,这里的动态的含义   就是必须等到程序实际运行到这条指令的时候,解析动作才能进行,相对的,其余的可触发解析的指令都是静态的,可以在刚刚完成加载阶段,还没有开始执行代码时就进行解析

解析动作主要针对的是 类,接口,字段,类方法,接口方法,方法类型,方法句柄,和调用点限定符,7类符号引用,

CONSTANT_Class_info, 类或者接口的解析

类或者接口解析分为  :非数组类型和数组类型,数组类型又分为引用类型和非引用类型,其中里面还涉及到父类的加载,如果这一步都是成立的,还需要去验证当前的类是否对解析的那个类有没有访问权限,

如果没有权限,则抛出:java.lang.IllegalAccessError

CONSTANT_Fieldref_info,字段解析

字段解析,首先需要去解析这个字段是属于那个类的,需要去解析字段所属的类,然后可能是直接返回,如果是接口,可能是按照递归的思路从上往下依次寻找,如果有继承关系可是从下往上寻找,

如果失败了,则抛出:java.lang.NoSuchFieldError错误

如果成功了,还需要进行访问权限进行验证,如果没有权限,则抛出:java.lang.IllegalAccessError

特别需要注意的是:接口和父类都有同一个属性变量,可能会拒绝编译

public class FieldResolution {

	
	interface Interface0 { 
		int A = 0;
	}
	
	interface Interface1 extends Interface0 {
		int A = 1;
	}
	
	interface Interface2 {
		int A = 2;
	}
	
	static class Parent implements Interface1 {
		public static int A = 3;
	}
	
	static class Sub extends Parent implements Interface2 {
		public static int A = 4;
	}
	
	public static void main(String[] args) {
		System.out.println(Sub.A);
	}

}

输出结果为 4

CONSTANT_Methodref_info,类方法解析

类方法解析,第一步首先也要去解析这个字段是属于哪个类的,然后先去解析方法所属的类,

然后去父类或者接口中递归找,如果成功最后判断访问权限,

这里如果发现类方法是  一个接口名,则是错误的,抛出异常:java.lang.IncompatibleClassChangeError

如果发现这个方法是一个抽象方法,也是错误的,抛出异常:java.lang.AbstarctMethodError

如果解析失败,抛出异常:java.lang.NoSuchMethodError

如果没有权限,则抛出:java.lang.IllegalAccessError

CONSTANT_IntefaceMethodref_info,接口方法解析

首先要找到这个接口方法是属于哪个接口的,然后去找这个接口类,

然后去父类或者接口中递归找,接口方法默认都是public,不需要判断访问权限的问题

这里如果发现接口方法是  一个类名,则是错误的,抛出异常:java.lang.IncompatibleClassChangeError

如果解析失败,抛出异常:java.lang.NoSuchMethodError

五)、初始化。第五个阶段,

这个阶段是:真正执行类定义中的java程序代码

1.<clinit>()方法是由编译器自动收集  类中  的所有  类变量  的赋值动作和静态语句块中的  语句合并产生的,

编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语义块之前的变量

定义在它之后的变量,在前面的静态语句块可以赋值,,但是不能访问,

这里面有一个非法向前引用  的编译问题

public class IllegalStaticCall {
	
	static {
		i = 0;/**给变量赋值可以正常通过*/
		System.out.println(i);/**这句编译器提示: 在定义之前是无法访问的,*/
	}
	
	static int i = 1;
}

2.<clinit>()方法与类的构造函数不同,它不需要显示的调用  父类的构造函数,虚拟机  会 保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕,因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object

3.由于父类的<clinit>()的方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

public class ClassStaticPropertyInitOrder {
	
	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);
	}

}

输出结果是:2

4.<clinit>()方法不是必须的,如果类中如果没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法,

5.接口中不能使用静态方法,但接口与类方法不同,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,

只有当父接口中定义的变量使用时,父接口才会初始化,另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法,

6.虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁,同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他的线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕,如果在一个类的<clinit>()方法中有耗时很长的操作,那么就有可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的,

在多线程阻塞时,其他的线程虽然会被阻塞,但是如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他的线程唤醒之后不会再次进入<clinit>()方法。同一个类加载器下,一个类型只会被初始化一次。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券