一、什么是类加载机制?
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
二、类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking),这7个阶段的发生顺序如图:
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
三、类加载过程
1、加载
“加载”是“类加载”(Class Loading)过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
虚拟机规范的这三个规则都是不具体的,所以虚拟机的实现与具体应用的灵活性 就第一条而言
- 类加载可以从 zip中加载 war rar jar
- 可以从网络中加载常见的Applet
- 运行时动态生,这种场景多用于动态代理技术,在java.lang.reflect.Proxy中就是使用ProxyGenerator.generateProxyClass 来为特定的接口生成形式为 $Proxy的代理类的二进制字节流
- 由其他文件生成,最典型的就是jsp,由jsp生成的对应的Class 类
- 从数据库中读取,这种场景很少见,例如有些中间件服务器,(SAP Netweaver) 可以选择把程序安装到数据库中完成代码在集群中的分发
相对于类加载过程的其他阶段,一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法(点击查看))。
相对于数组而言情况有些不同数组本身不通过类加载器创建,它是由java 虚拟机直接创建,但是数组类和类加载器有密切的关系,因为数组类的元素数据类型(Element Type ,指的是数组去掉所有维度的类型)最终靠类加载器创建,一个数组类创建过程遵循一下规则:
1)如果数组组件类型(数组去掉一个维度的类型)是引用类型,呢就递归采用本节中定义的加载过程去加载这个组建类型,数组C将在加载该类组件类型的类加载器的类名称空间上标识(一个类必须与类加载器一起确定类型)
2)如果数组不是引用类型,jvm会将数组C标记为引导类加载器关联
3)数组类的可见性和他的组件的可见性一致,如果数组类型不是引用类型,呢数组类的可见性默认为public
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
2、验证
验证是链接阶段的第一步,这一步主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。
验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
1.文件格式验证
1. 是否以魔数开头0xCAFEBABE
2. 主,次版本是否在当前虚拟机的范围内,
3.常量池的常量中是否有不支持的常量
4.指向常量的各种索引是否有不支持的常量类型(检查常量tag类型)
5.COUNSTANT_Utf8_info型的常量中是否有不符合utf编码的数据
6.Class 文件中的各个以及文件本身是否被删除,或者附加其他信息
第一阶段远远不止这些,这只是从Hotspot虚拟机源码中摘抄的一小部分,该阶段的目的是可以正确的把流解析存储到方法区中,格式符合描述一个java类型信息要求,这阶段验证是基于二进制字节流进行的,字节流通过解析之后就会存方法区内存中,后面的三个验证都是基于方法区存储的存储结构进行的不会操作字节流。
2.元数据验证
对字节码的信息进行语义处理分析,保证描述的信息符合java 语言规范的要求,包括验证点如下:
1. 这个类是否包含子类(除了java.lang.Object)其他类都有父类、
2.这个类是否继承了不可进行的类 final修饰、
3.这个类不是抽象类,是否实现了父类,接口中的实现的方法、
4.类中的字段,方法和父类是否矛盾(覆盖了fnal,出现不合法的方法负载)、
第二部主要对类的元数据进行检验,保证不符合java语言规范的元数据信息。
3.字节码验证
主要目的是通过数据流的控制流分析,确定程序语义的合法,符合逻辑,这个阶段主要对方法体进行检验,确保验证类的方法在运行的时候不会对jvm产生危害,
1. 保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作,不会出现栈中放置一个int类型,使用时候却按照long类型来加载本地变量表中
2 .保证跳转指令不会跳转到方法体以外的代码上,
3 .保证方法体的中的类型转换是有效的,例如把子类对象赋值给了父类数据类型,这是安全的,但是但是如果让父类赋值给一个子类类型这样是不安全的不合法的,甚至把一个毫不相干的类型之间赋值,这些都是不可以的
如果一个类的方法体没有通过字节码验证,那这个方法体肯定有问题,但是一个方法体通过了字节码验证也不一定是安全的,这里涉及到著名的离散数学问题,Halting Problem 通俗说法就是通过程序去验证程序逻辑这一点是很难准确做到的---不能通过程序准确的检验程序是否在有限的时间内结束运行
对于数据流验证的高复杂性,jvm设计团队为了避免过多的时间耗费在字节码的检验上,在JDK1.6之后的javac 编译器进行了一系列的优化,给方法体中的code 属性的属性表中添加了一个名为StackMapTable 的属性
该属性描述了方法体中的基础块开始时本地变量表和操作栈的所有状态,在字节码验证期间就不需要根据程序推导这些状态的合法性,只需要检测StackMapTable中属性是否合法即可,这样可以节省时间,理论上StackMapTable 属性是可以被篡改的。
4.符号引用验证
最后一个阶段的校验发生在jvm将符号引用转换为直接引用的时候,这个转化动作在解析中发生,符号引用验证可以看作对类自身以外(常量池中的各种引用)的信息发生校验,
1. 符号引用中通过字符串描述的全限定名能否找到对应的类
2. 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段描述符以及简单名称所描述的方法和字段
3.符号引用中的类. 字段 方法的访问性(private public default protected ) 是否被当前类引用
如果这一步出错会产生 java.lang.IncompatibleClassChangeError ,对于虚拟机类加载机制来说验证阶段是一个非常重要,但是不是必须的阶段,可以通过-Xverify:none进行控制。
3、准备
准备阶段是正式为类变量分配内存设置类变量初始化值的阶段,这些变量所使用的内存都将在方法区分配,(这里的变量指的是被static 修饰的类变量)不包括实例变量,实例变量将会在对象被实例化的时候进入堆内存中,这里的变量初始化值为0 比如
private static int a = 1234 ;
这个值在准备阶段过后他的值是 0 ,因为这个时候并没有开始执行java 的任何方法,而把value 赋值为 1234的是putstatic 指令被程序编译过后放在<clinit>() 构造方法中,所以value = 1234 是在初始化阶段执行的,在准备过程下图的类型值通常情况下都是0
特殊情况下,如果类字段的字段属性表中存在ConstantValue属性,那么value 会被率先初始化为对应的值比如
private static final int value = 123
编译时javac 直接把value 生成ConstantValue 属性,在准备阶段就会根据ConstanValue 把value 设置为 123。
4、解析
解析阶段是虚拟机常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。
直接引用:直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。
虚拟机规范并没有规定解析阶段发生的具体时间,只要求了在执行anewarry、checkcast、getfield、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic这13个用于操作符号引用的字节码指令之前,先对它们使用的符号引用进行解析,所以虚拟机实现会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
解析的动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。分别对应编译后常量池内的CONSTANT_Class_Info、CONSTANT_Fieldref_Info、CONSTANT_Methodef_Info、CONSTANT_InterfaceMethoder_Info四种常量类型。
对一个符号引用对此解析是很常见的事情,这样来说就会降低jvm 的性能,所以jvm 在除了invokedynamic 指令之外,虚拟机实现可以对一些解析的结果进行缓存(在运行时常量池中记录直接引用,把常量标识为已经解析状态)这样可以避免重复解析同一个符号引用多次,提高效率,无论是否真正执行了多少次解析动作,虚拟机需要保证的是在同一个实体中,如果一个符号引用被成功解析过,那么后续的引用解析请求一定成功,如果第一次失败,那么其他指令对这个指令的解析请求也会收到相同的异常。
对于invokedynamic 指令上面个的规则是不成立的,当遇到某个前面已经由invokedynamic 指令引发的解析的引用符号时,并不意味着其他的invokedynamic 引用指令也会同样生效,因为invokedynamic 的目的就是为了支持动态语言(java 不会生成这个指令)因为java 是静态强制性语言, 动态是指,必须等到程序实际运行到这条指令的时候解析动作才会开始,相对的就是静态, 在加载完成还没有开始执行代码的时候就开始解析。
1.类或者接口的解析
如果现在有一个类C ,如果要把一个 从未解析过的符号引用N解析为一个类或者接口C 的直接引用,虚拟机要完成下面3个步骤
1. 如果C 不是一个数组类型,那么JVM会把C 的全限权限类名传递给D 的类加载器去加载C ,在加载的过程中由于元数据的验证,字节码验证的需要,可能触发相关联的加载动作,例如加载这个类的父类或者实现的接口,一旦这个加载过程出现异常,那么解析就失败,
2.如果C 是一个数组类型,并且数组的元素类型是对象,那么N 的描述会是 [Ljava/lang/Integer 的形式,那么就会按照规则的第一点加载数组中的元素类型,如果N 的描述符是前面假设的形式,需要加载的类型就是java.lang.Integer 接着有虚拟机生成一个数组维度和数组对象
3.如果上面没有出现异常,C 会在jvm中成为了一个有效的接口或者类,但是解析还是需要符号引用验证,确定D 是否由访问C 的全限如果没有全限还是会抛出java.lang.IllegalAccessError 异常或者子类
2.字段解析
要解析一个字段符号引用,首先会对字段表中的class_index索引的COUNSTANT_Class_info符号进行解析,也是字段所属类或者接口的符号引用,如果解析这个类或者接口的符号引用 的过程中,出现任何异常,都会导致字段符号引用失败,如果解析完成这个字段所属的类接口用C 表示,虚拟机规范按照一下步骤对C 进行后续字段搜索、
1.如果C 本身包含简单名称和字段描述都与目标匹配的字段,则返回这个字段的直接引用,查找结束
2.否则在C 中时间接口,会按照上下关系依次递归搜索各个父接口,如果接口中有简单名和字段的描述相同返回直接引用
3 .否则,如果C 不是java.lang.Object 将会按照继承关系往上递归寻找字段如果有简单名和字段的描述与之匹配返回直接引用, 查找结束
4 .抛出异常查找失败,java.lang.NoSuchFieldError
如果查找到对应的字段,还会对字段的访问权限进行验证,如果访问权限不对应抛出java.lang.IllegalAccessError
3. 类方法解析
类方法解析的和类字段解析大同小异 ,首先解析出来类方法表的class_index项中索引方法所属类或者接口的符号引用,如果解析成功我们依然用C表示这个类,jvm 发生以下步骤:
1. 类和接口符号引用个的常量类型是分开的,如果发现类方法表中class_index中所以C是一个接口,直接抛出java.lang.incompatibleClassChangeError
2 .在类C 中查找是否有简单名称和描述都是目标相匹配的方法,如果发现有简单名称和描述符 直接返回这个方法的引用,查找结束 (以下相同。。。)
4 .接口方法解析
(同)类的字段解析
5、初始化
类的初始化阶段是类加载过程的最后一步,在准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器< clinit >()方法的过程。在以下四种情况下初始化过程会被触发执行:
1.遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需先触发其初始化。生成这4条指令的最常见的java代码场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用类的静态方法的时候。
2.使用java.lang.reflect包的方法对类进行反射调用的时候
3.当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先出发其父类的初始化
4.jvm启动时,用户指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个类
在上面准备阶段 public static int value = 12; 在准备阶段完成后 value的值为0,而在初始化阶调用了类构造器< clinit >()方法,这个阶段完成后value的值为12。
*类构造器< clinit >()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句快可以赋值,但是不能访问。
类构造器< clinit >()方法与类的构造函数(实例构造函数< init >()方法)不同,它不需要显式调用父类构造,虚拟机会保证在子类< clinit >()方法执行之前,父类的< clinit >()方法已经执行完毕。因此在虚拟机中的第一个执行的< clinit >()方法的类肯定是java.lang.Object。
由于父类的< clinit >()方法先执行,也就意味着父类中定义的静态语句快要优先于子类的变量赋值操作。
< clinit >()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句,也没有变量赋值的操作,那么编译器可以不为这个类生成< clinit >()方法。
接口中不能使用静态语句块,但接口与类不同的是,执行接口的< clinit >()方法不需要先执行父接口的< clinit >()方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的< clinit >()方法。
虚拟机会保证一个类的< clinit >()方法在多线程环境中被正确加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程执行这个类的< clinit >()方法,其他线程都需要阻塞等待,直到活动线程执行< clinit >()方法完毕。如果一个类的< clinit >()方法中有耗时很长的操作,那就可能造成多个进程阻塞。
参考资料: