虚拟机把Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
在Java语言里,类型的加载和连接过程都是在程序运行期间完成的,这样会在类加载时稍微增加一些性能开销,但是却能提供更好的灵活性。
类加载的时机
类从加载到虚拟机内存开始到卸载出内存为止,其生命周期主要包括:Loading、Verification、Preparation、Resolution、Initialization、Using、Unloading等阶段。其中Verification、Preparation、Resolution等三个阶段合为Linking阶段。
什么情况下需要开始Loading阶段虚拟机规范中并没有进行强制约束,但是对于初始化阶段,迅即规范则严格规定了有且只有四种情况必须立即对类进行初始化(Initialization):
- 遇到new、getstatic、putstatic或invokestatic这4调字节码指令的时候,如果类没有初始化,则需要先出发其初始化。其对应的java代码一般是使用new创建对象,读取或对静态变量赋值(常量池静态变量除外)以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候
- 当初始化一个类的时候需先初始化其父类
- 虚拟机启动的时候会初始化执行主类(含main方法,且是程序入口)
对于这四种场景中外的情况,都属于被动引用,不会触发初始化。
对于静态字段,其被访问的时候,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
通过数组定义来引用类,不会触发此类的初始化egSuperClass[] sca = new SuperClass[10];
并不会初始化SuperClass
对字符串常量的引用会转化成对自己类文件中常量池的中常量的引用,因此也不会初始化被引用的类。
接口中不能使用static{}语句块,但编译器仍然会为接口生成"<clinit>()'类构造器,用于初始化接口中定义的成员变量。接口初始化时并不要求其父接口全部初始化,只有在真正使用到父接口的时候才进行初始化。
类加载的过程
加载(Loading)
加载阶段虚拟机需要完成以下三件事情:
- 通过一个类的权限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法去的运行时的数据结构
- 在Java堆中生成一个代表这个类的Class对象,作为方法去这些数据的访问入口
加载类的二进制流,其来源是 不限制的,可以来自文件、网络、数据库、运行时生成等。加载完成时,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在Java堆中实例化一个Class类的对象,这个对象将作为俄日程序访问方法区中的这些类型数据的外部接口。加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的。
校验(Verification)
校验是连接阶段的第一步,其目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。虚拟机对输入的字节流不符合Class文件的存储格式就抛出一个java.lang.VerifyError异常或者其子类。
文件格式的验证
这一阶段主要验证字节流是否符合Class文件格式规范,并且能被当前版本的虚拟机处理。可能包括:
- 是否以魔数0xCAFEBABE开头。
- 主次版本号是否在当前虚拟机处理的范围内
- 常量池的常量中是否有不被支持的常量类型
- 指向常量的各种索引值中是否有指向不存在的常量或者不符合类型的常量
- CONSTANT_Urf8_info型常量中是否有不符合UTF8编码的数据
- Class文件中各个部分及文件本身是否有被删除或附加的其他信息
- ...
经过这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面三个验证阶段全部是基于方法区的存储结构进行的。
元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证信息符合Java语言规范的要求
- 这个类是否有父类(除Object之外,所有的类都应当有父类)
- 这个类是否继承了不允许被继承的类
- 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的全部方法
- 类中的字段、方法是否与父类产生了矛盾
- ...
字节码验证
第三阶段是最复杂的一个阶段,主要工作是进行数据流和控制流分析。这个类主要对类中方法体进行校验分析。这个阶段的任务是保证被校验的方法运行时不会做出危害虚拟机安全的行为:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现操作栈上放置一个int类型的数据,使用时却按long类型来加载入本地变量表中。
- 保证跳转指令不会跳转到方法体外的字节码指令上
- 保证方法体中的类型转换是有效的。例如把子类赋给父类是合法的反之则非法
- ...
由于数据流验证的高复杂性,虚拟机设计团队为了避免将过多的时间消耗在字节码验证阶段。在JDK1.6之后Javac编译器中进行了一项优化,给方法体的Code属性的属性表中新增了一个“StackMapTable”的属性,这项属性描述了方法体重所有的基本块开始时本地变量表和操作栈应有的状态,这可以将字节码验证的类型推导转变为类型检查从而节省一些时间。
符号引用验证
对后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作在解析阶段中发生。符号引用验证可以看作是对类自身以外的信息进行匹配性的校验:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段和方法的访问控制权限是否允许被当前类访问。
符号引用验证的目的是保证解析动作能正常运行。
校验阶段对于虚拟机类加载机制来说是一个非常重要的但非必要的阶段,可以通过-Xverify:none参数来关闭大部分类验证措施,以缩短虚拟机类加载的时间。
准备(Preparation)阶段
准备阶段是正式为类变量分配内存并设置类初始变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段内存分配只包含类变量而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是变量初始值为默认值。例如public static int value = 123
准备阶段后初始值为0而不是123因为这个时候尚未开始执行任何Java代码,而value赋值的指令时编译后放到<clinit>()方法中的。如果字段的字段属性表中存在ConstantValue属性,那再准备阶段变量value就会被初始化为ConstantValue属性指定的值,如public static final int value = 123
在准备阶段就会被直接赋值
解析(Resolution)
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。
符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。
直接引用:直接引用可以是直接指向目标的指针,相对偏移量或是一个能简介定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实力上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在了。
关于符号引用和直接引用的区别可参考https://www.zhihu.com/question/30300585
虚拟机规范中并未规定解析阶段发生的具体时间,只要求在执行anewarray、checkcast、个体field
getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic13个用于操作符号引用的字节码指令之前,先对他们所适应的符号引用进行解析。所以虚拟机实现会根据需要来判断,到底是在类被加载时就对常量池中的符号引用进行解析还是等到一个符号引用将要使用前才去解析它。解析动作做主要针对类或接口、字段、类方法、接口方法 四类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info及CONSTANT_InterfaceMethodref_info四种常量类型。
类或者接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的复活药引用N解析为一个类或者接口C的直接引用,那虚拟机完成整个解析的过程包括以下3个步骤:
- 如果C不是一个数组类型,那虚拟机将会把代表N的权限定名传递给D的类加载器去加载C这个类。在加载过程中,由于无数据验证、字节码验证的需要,又将可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
- 如果C是一个数组类型,并且数组的元素类型为对象,那将会按照第一条的规则加载数组元素类型。接着由虚拟机生成一个代表此数组维度和元素的数组对象。
- 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或者接口了,但在解析完成前还需要进行符号引用验证,确认D是否具备对C的访问权限,如果发现不具备访问权限将抛出 java.lang.IllegalAccessError.
字段解析
首先根据class_index解析其类型,如果解析完成。先查找类本身,而后自上而下查找接口最后自上而下查找父类。如果查找失败抛出NoSuchFieldError.如果查找成功,将会对这个字段进行权限验证。
类方法解析
先根据class_index索引解析所属的类或者接口的符号引用,如果解析成功。按照以下顺序对方法进行搜索:如果在类方法表中发现class_index中索引是个接口,直接抛出java.lang.IncompatibleClassChangeError.而后在类中查找,继续在父类中递归查找,如果没找到则到此类实现的接口或者父接口中递归查找,如果找到说明此类为抽象类。如果没查找到则抛出NoSuchMethodError。
接口方法解析
与类解析相反,如果在接口方法中发现class_index中的索引是个类而不是接口直接抛出IncompatibleClassChangeError。而后在接口本身内部查找,进而在接口的父接口中递归查找,如果都查不到则抛出NoSuchMethodError异常。
初始化(Initialization)
初始化阶段才真正开始执行类中定义的Java程序代码
初始化阶段是执行类构造器<clinit>()方法的过程。
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器手机的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
- <clinit>()方法与类的构造函数不同,它不需要显式地调用父类的构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
- 如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法
- 接口中不能使用静态代码块,但仍然有变量初始化的赋值操作,接口的<clinit>()方法调用的时候不一定需要调用父接口对应的方法。而接口的实现类在初始化时也一样不会调用接口的<clinit>()方法。
- 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程回去执行这个类的<clint>()方法,其他线程都需要阻塞等待。
类加载器
类加载器最初为了Java Applet来设计,目前在类层次划分,OSGi,热部署,代码加密等领域应用广泛。
类与类加载器
对于任一类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性。如果不注意Class的唯一性标识,可能会导致Class对象的equals, isAssignableFrom方法,isInstance等方法返回结果与预期不同。
双亲委派模型
站在Java虚拟机的角度讲,只存在两种不同的类加载器:一种是启动类的加载器(Bootstrap ClassLoader)这个类加载器使用C++实现,是虚拟机自身的一部分。另一种是所有其他类的加载器,这些来加载器都由Java语言实现,独立于虚拟机外部,并且全部继承自java.lang.ClassLoader
绝大部分Java程序会使用到以下三种类加载器
- Bootstrap ClassLoader:这个类加载器负责将存放在<JAVA_HOME>\lib目录中或者被-Xbootclasspath参数指定的路径中的,并且是虚拟机识别的(仅按照文件名识别)类库加载到虚拟机中,此加载器无法被Java程序直接引用。
- Extension ClassLoader:这个类加载器由sum.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。
- Application ClassLoader:这个加载器由sun.misc.Launcher$AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也成为系统类加载器。它负责加载用户类路径上指定的类库,开发者可以使用这个类加载器。一般情况下这个基于是程序中默认的类加载器。
类加载器之间的这种层次关系,成为类加载器的双亲委派模型。双亲委派模型要求除了Bootstrap ClassLoader其余的类加载器都应当有自己的父类加载器。这些类加载器之间一般不使用继承而是使用组合来复用类加载器代码。一般的做法是只有父类加载器无法完成加载动作的时候,子类加载器才会真正的去加载某个类。这样保证了Java继承体系的正常运作。