本文为《深入理解Java虚拟机JVM高级特效与最佳实践(第三版)》一书的摘要总结
类加载时机
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称做虚拟机的类加载机制。
在Java语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。
类的生命周期:
其中加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,解析阶段可能在初始化之前也可能在初始化之后进行。
在遇到以下情况,必须对类进行“初始化”(而加载,验证、准备自然需要在此之前进行):
-
遇到new,getstatic,putstatic或invokestatic这4条字节指令时,如果类型没有进行过初始化,则需要先触发器初始化阶段。能够生成这4条指令的典型Java代码场景有:
- 使用
new
关键字实例化对象的时候 - 读取或者设置一个类型的静态字段
- 调用一个类型的静态方法的时候
- 使用
-
使用
java.lang.reflect
包的方法对类型进行反射调用的时候 -
当初始化类的时候,如果发现其父类还没有进行初始化,则需要先触发器父类的初始化
-
当虚拟机启动的时候,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个类
-
当使用JDK7加入的动态语言支持是,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发器初始化阶段。 -
当一个接口中定义了JDK8新加入的默认方法是,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
类的加载过程
加载
在加载阶段,虚拟机主要完成三件事情:
- 通过一个类的全限定名称来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构(这个结构完全由虚拟机实现决定,且只是转换,还没有装入方法区)
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
验证
这一阶段是连接的第一阶段,该阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,确保这些信息被当做代码运行后不会危害虚拟机自身的安全。
验证阶段大致会完成下面4个阶段:
- 文件格式验证
是否以魔数0xCAFEBABE开头;主次版本号是否在当前Java虚拟机接受的范围内;常量池的常量中是否有不被支持的常量类型(检查常量的tag标志)等等;这个阶段主要是保证输入的字节流能够被正确的解析并存入方法区之内,格式上符合描述一个Java类型信息的要求。这个阶段的验证基于二进制流,只有通过了这个验证阶段之后,这段字节流才被允许存入Java虚拟机内存的方法区,所以后面的三个验证阶段全部在方法区的存储结构上进行。
- 元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java虚拟机规范》的要求,主要验证的点有:
- 这个类是否父类(除
java.lang.Object
外,所有的类都应该有父类) - 这个类的父类是否继承了不允许被继承的类(
final
修饰的类) - 如果这个类不是抽象类,是否实现了父类或者接口之中要求实现的所有方法
- 类中的方法、字段是否与父类产生了矛盾(如覆盖了final字段,不符合规则的方法重载等)
- 字节码验证
这个阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是否是合法的、符合逻辑的。就是对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验的类的方法在运行时不会做出危险虚拟机安全的行为,如:
- 保证任何时刻操作数栈的数据类型与指令代码序列都能配合工作
- 保证任何跳转指令都不会跳转到方法体以为的字节码指令上
- 保证方法体中的类型转换总是有效的
- 符号引用检验
这个阶段的检验发生在虚拟机将符号引用转化为直接引用的时候,这个转化的工作将在连接的第三个阶段————解析阶段 中发生。
本阶段通常需要校验一下内容:
- 符号引用中,通过字符串描述的全限定名能否找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性是否可被当前类访问。
符号引用验证的主要目的是为了确保解析行为能够正常执行。
验证阶段对于迅疾的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段。如果程序运行的全被代码都已经反复使用和验证过,在生产环境中的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,一缩短虚拟机类加载的时间。
准备
准备阶段是正式为类中定义的 变量 (静态变量,被static
修饰的变量)分配内存并设置类变量初始值的阶段。
从概念上讲,这些变量都应该被在方区中进行分配,但必须注意这个方法区本身是一个 逻辑上 的区域,在JDK7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;在JDK8及以后,类变量则会随着Class对象一起存在Java堆中。
解析
解析阶段是Java虚拟机将 常量池内的 符号引用替换为直接引用的过程。(就是解析符号引用)
- 符号引用:符号引用以一组 符号 来描述所引用的目标,符号可以是任何形式的 字面量,只要使用时能无歧义的定位到目标即可。
- 直接引用:直接引用是指可以直接指向目标的 指针、相对偏移量 或者是一个能间接定位到目标的 句柄。
类或接口解析
在类D中,将一个从未解析过的符号引用N解析为一个类或者接口C的直接引用,虚拟机的整个解析过程为:
-
如果C不是一个数组类型,虚拟机将代表N的全限定名称传递给D的类加载器去加载这个类C。
-
如果C是一个数组类型,并且数据的元素为对象,那将会按照第一点的规则加载数组 元素类型。
-
如果上面两步没有问题,那么C在虚拟机中已经成为一个有效的类或者接口了。
字段解析
- 先将字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,解析完成后把这个字段所属的类或者接口用C表示;
- 如果C本身就包含了简单名称和字段描述符都与与目标匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果C中实现了接口,将会按照继承关系从下往上地柜搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与与目标匹配的字段,则返回这个字段的直接直接引用。
- 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上地柜搜索其父类,如果在父类中包含了简单名称和字段描述符都与与目标匹配的字段,则返回这个字段的直接直接引用。
- 否则查找失败。
方法解析
- 先将方法表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,解析完成后把这个方法所属的类或者接口用C表示;
- 在类C中查找是否有简单名称和方法描述符都与与目标匹配的方法,则返回这个方法的直接引用,查找结束。
- 否则,在类C的父类中地柜查找是否有简单名称和方法描述符都与与目标匹配的方法,有则返回这个方法的直接引用。
- 否则,在类C实现的接口列表及他们的父接口中递归查找是否有简单名称和方法描述符都与与目标匹配的方法,有则返回这个方法的直接引用。
- 否则查找失败。
接口方法解析
- 先解析出接口方法表中class_index项中索引的CONSTANT_Class_info符号引用进行解析,解析完成后把这个方法所属的类或者接口用C表示;
- 如果发现C是一个类,而不是接口,那么直接爆出异常。
- 否则,在接口C中查找是否有简单名称和方法描述符都与与目标匹配的方法,有则返回这个方法的直接引用。
- 否则在C的父接口中递归查找,知道java.lang.Obect类为止,看是否有简单名称和方法描述符都与与目标匹配的方法,有则返回这个方法的直接引用。
- 在上一步中,可能会找到多个简单名称和方法描述符都与与目标匹配的方法,那将会从多个方法中返回其中一个并结束查找。
- 否则查找结束。
初始化
类的初始化时类加载的最后一个步骤,直到这一步,虚拟机才开始执行类中编写的Java程序代码,将主导权移交给应用程序。初始化阶段是执行类构造器<init>()
方法的过程。<init>()
方法并不是程序员在Java代码中直接编写的方法,而是Javac编译器的自动生成物。
-
<init>()
方法是有编译器自动收集类中所有 类变量的赋值动作 和 静态语句块 中的语句合并产生的。顺序由源文件中出现的顺序决定。静态语句块知道能访问到定义在它之前的变量,定义在它之后的变量只能赋值,无法访问。 -
<init>()
方法与类的构造函数不同,,它不需要显示的调用父类构造器,Java虚拟机会保证在子类的<init>()
方法执行前,父类的<init>()
方法已经执行完毕。因此在Java虚拟机中,第一个被执行的<init>()
方法一定是java.lang.Object类的。 -
<init>()
方法对于类或者接口来说不是必须的,当类既没有赋值操作也没有静态语句块时,或接口没有赋值操作时,编译器就不用为该类或接口生成<init>()
方法。 -
接口中可以有变量初始化的操作,所以也会生成
<init>()
方法,但是接口与类不同的是,执行接口的<init>()
方法不会调用父接口的<init>()
方法,因为只有当父接口定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也不会执行接口的<init>()
方法。 -
Java虚拟机必须保证一个类的
<init>()
方法在多线程环境下被正确的加锁同步。
类加载器
实现通过一个类的全限定名来获取描述该类的二进制字节流这个动作的代码被称为类加载器。
双亲委派模型
在Java虚拟机的角度来看,只存在两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader):这个类加载器是由C++语言实现的,是虚拟机的一部分
- 其他所有类加载器:这些类加载器有Java语言实现,独立于虚拟机,并且全部继承自抽象类
java.lang.ClassLoader
三层类加载器、类加载器双亲委派模型:
-
启动类加载器:这个类负责将<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数指定的路径中存放的Java虚拟机能够识别的类库加载到虚拟机的内存中。
-
扩展类加载器:负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所制定的路径中的类库。
-
应用累加载器:应用类加载器是
ClassLoader
类中的getSystemClassLoader()
方法的返回值,所以有些场合也被称为“系统类加载器”。它负责加载用户类路径(ClassPath)上的所有类库。如果应用中没有自定义类加载器,一般情况下这个就是程序中默认的类加载器。
上面图中所展示的各种类加载器的层次关系被称为类的“双亲委派模型”,类加载器之间的关系一般不死以继承的关系来实现的,而是以组合的关系来复用父加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都会委派给了启动类加载器。只有当父加载器无法完成加载请求是,子加载器才会尝试自己去完成加载。