类加载
类加载时机
类加载的过程
新术语
类加载器 简单的理解为将类转换为二进制流的类或接口。
数组的元素类型 数组去掉所有维度的类型。
数组的组件类型 数组去掉一个维度的类型。
基本块 按照控制流拆分的代码块。
1. 加载
加载是类加载过程的一个阶段。加载阶段主要完成三件事情:
- 根据类的全限定名,获取定义此类的二进制字节流。
- 将这个二进制字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
java.lang.Class
对象,作为访问方法区中该类各种数据的外部接口。
在上述的1中,没有限定此类的格式,所以可以是一个class文件,可以是一个jar包,也可以是运行时生成等等。我们可以通过重写一个类加载器的findclass()
方法或者loadClass
方法来自定义字节流的获取方法。
数组与类加载器
数组类本身是由JVM在内存中直接构造的,但是又与类加载器紧密联系,其遵循规则如下:
-
如果数组的组件类型是一个引用类型,那么会递归的使用加载过程去加载该组件类型,数组将被标识在加载该组件类型的类加载器的类名称空间上。
-
若组件类型是基本类型,JVM会将该数组标记为与
BootStrapClassLoader
关联 -
数组类的可访问性与其组件类型的可访问性一致。
2. 验证
验证是连接的第一步,器目的是确保Class文件的字节流中包含的信息完全符合《JVM规范》中的全部约束条件,保证这些信息在运行时不会威胁到JVM的安全。其大致可分为四个阶段:文件格式验证,元数据验证,字节码验证和符号引用验证。
文件格式验证
这一阶段要验证字节流是否符合Class文件格式的规范,以保证输入的字节流能够正确的解析并存储到方法区内。格式上要符合一个Java类型信息的要求。只有文件格式验证通过后,才能将字节流中的信息存储到方法区中,所有后面的是三个验证,都是基于方法区的存储结构进行的,而不是字节流。
元数据验证
这个阶段要求对字节码的描述信息进行语义分析,也就是保证其描述的信息符合《Java语言规范》的要求。
字节码验证
该验证的目的是通过数据流分析和控制流分析,确定程序语义的合法性、合逻辑性。在元数据验证通过后,该阶段对类的方法体(也就是Class文件中的Code属性)进行校验分析,保证类的方法不会在运行时危害到JVM。
在JDK6后,将尽可能多的校验辅助措施挪到javac编译器中,具体的做法是在Code属性中增加了一个StackMapTable
属性,该属性描述了方法体所有的基本块开始时本地变量和操作栈应有的状态。在字节码验证时,只需要检查StackMapTable
中记录是否合法即可,而不用根据程序推导这些状态的合法性。
符号引用验证
该阶段发生在JVM将符号引用转为直接引用的时候,其在连接的第三阶段解析阶段才发生。是对类自身以外的各类信息进行匹配性校验,比如说该类是否缺少或禁止访问它依赖的某些额外部类等,若无法通过验证,会排除java.lang.IncompatibleClassChangeError
的子类异常(java.lang.NoSuchFieldError
等)。
3. 准备
准备阶段是正式为被static修饰的变量(类变量)分配内存并设置初始值的阶段。
特别注意:
-
此阶段的内存分配,仅仅包括类变量,不包括实例变量。
-
若类变量同时被final修饰(也就是通常说的常量),那么其赋值不会是基本类型的零值,而是指定的值。
例如
public staitc final int INIT_VALUE=99
,那么在准备阶段INIT_VALUE会被赋值为99,而不是0,这是因为被static final
同时修饰是,在javac
编译时,字段属性表中会有一个ConstantValue
属性,在准备阶段,该变量值就会被初始化为ConstantValue
属性所指定的初始值。
基本数据的零值
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | ‘\u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
4. 解析
解析是JVM将符号引用转换为直接引用的过程。
符号引用 以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要在使用时能够无歧义的定义到目标即可。符号引用与JVM的内存布局无关。
直接引用 是可以直接指向目标的指针、相对偏移量或者一个能够间接定位到目标的句柄。其与JVM内存布局直接相关。
句柄 是由系统所管理的引用标识,该标识可以被系统重新定位到一个内存地址上。
解析动作主要针对类或结构、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符
这七类符号医用进行,其对应着8种常量类型。
4.1 类或者接口的解析
假设当前的类为D,要将其中未解析过的符号引用N解析为一个类或者接口C的直接引用。大概过程如下:
- 若C不是一个数组,那么JVM会将代表N的全限定类名交个D的类加载器,由其去加载这个类C。若在C的加载过程中出现异常,那么解析过程宣告失败。
- 若C是一个数组,并且数组的元素类型是对象,那么会按照上一步的步骤去加载元素类型,再由JVM生成数组对象。
- 若1、2都没问题,则检查D对C的访问权限。若没有权限则会抛出
java.lang.IllegalAccessError
。
4.2 字段解析
若要对一个没有经过解析的字段进行解析,首先我们需要看字段表内的class_index项中的CONSTANT_class_info符号引用进行解析(参考类文件结构),也就是对字段对应的类或者接口的引用的解析。
假设字段对应的类或接口为C,那么在解析类或接口成功后,会根据《JVM规范》对字段进行搜索:
- 若C本身包含的简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,搜索结束。
- 否则,若C实现了接口,那么将会按照继承关系从下向上递归搜索各个接口及父接口,若接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,搜索结束。
- 否则,若C不是
java.lang.Object
的话,就会按照继承关系从下向上递归搜索其父类,若在父类中包含了简单名称和字段描述符与之相匹配的字段,则返回该字段的直接引用,搜索结束。 - 否则查找失败,抛出
java.lang.NoSuchFieldError
异常。 - 若查找过程成功返回了引用,则进行访问权限验证,若无权限抛出
java.lang.IllegalAccessError
异常。
4.3 方法解析
方法解析的第一步也是对方法表内的class_index项中的CONSTANT_class_info符号引用进行解析(参考类文件结构)。若解析成功则会按照如下的规则来搜寻对应的方法。
- Class文件格式中类的方法和接口的方法的符号引用时分开存储的,所以若类的方法表中发现class_index对应的是一个接口的,则抛出
java.lang.IncompatibleChangeError
。 - 若1通过,若类C中有简单名称和描述符都与目标匹配的,则返回这个方法的直接引用,搜索结束。
- 否则,在类C的父类中递归查找简单名称和描述符与目标都匹配的方法,若有则返回这个方法的直接引用,搜索结束。
- 否则,在类C的接口列表及他们的父接口中递归查询简单名称和描述符与目标都匹配的方法,若有,则表示C是一个抽象类,搜索结束,抛出
java.lang.AbstractMethodError
。 - 否则,查找失败,抛出
java.lang.NoSuchMethodError
。 - 若查找过程成功返回了引用,则进行访问权限验证,若无权限抛出
java.lang.IllegalAccessError
异常。
4.4 接口方法解析
基本同方法解析。若接口解析成功,接下来的方法搜索规则如下:
- 若方法表中class_index对应的是一个类,
java.lang.IncompatibleChangeError
排除异常。 - 否则在C中查找是否有简单名称和描述符都与目标相配的方法,则返回该方法的直接引用,搜索结束。
- 否则,在接口C的父类中递归查找,直到
java.lang.Object
,有简单名称和描述符都与目标相配的方法,则返回该方法的直接引用,搜索结束。 - 对于3,由于接口运行多继承,所以存在在多个接口中都有简单名称和描述符都与目标相配的情况,那么会从这些方法中返回一个,并结束查找。
- 否则,查找失败,抛出
java.lang.NoSuchMethodError
。 - 若查找过程成功返回了引用,则进行访问权限验证,若无权限抛出
java.lang.IllegalAccessError
异常。(JDK9的影响)
5. 初始化
类初始化是类加载过程的最后一个阶段。在这个阶段JVM才真正开始执行类中编写的程序代码,将主导权交给程序。在准备阶段,已经对类变量赋了零值,在这一阶段,将会根据程序编码去初始化类变量和其它资源。也可以说初始化就是执行类构造器()方法的过程。
接下来我们对<clinit>()
方法做一些说明。
<clinit>()
方法是由javac
编译器自动生成的,是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序觉得的,静态语句块中只能访问到在静态语句块之前的变量,定义在其之后的变量,在该语句块中只能赋值不能访问。<clinit>()
方法与<init>()
方法(类的构造函数)不同,它不需要显示的调用父类构造器,JVM会保证在子类的<clinit>()
执行前,父类的<clinit>()
已经执行完毕。可以推论出,JVM中第一个被执行的<clinit>()
方法是java.lang.Object
的。- 由于父类的
<clinit>()
先执行,所以父类的静态语句块要优先于子类的变量赋值操作。 <clinit>()
对于接口来说是非必要的。- 接口中不能使用静态代码块,但是可能存在类变量的赋值操作,因而接口也会生成
<clinit>()
方法。但是当接口的<clinit>()
方法执行时,不要求父接口的<clinit>()
方法先执行,只有当父类中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也不会执行接口的<clinit>()
方法。 - JVM必须保证一个类的
<clinit>()
方法在多线程环境下被正确的加锁同步。若一个类的<clinit>()
方法中有耗时很长的操作,那就可能造成阻塞。
类加载器
对于任意一个类,都必须由加载它的类加载器和这个类本身一起确立其在JVM中的唯一性。
双亲委派模式
从虚拟机的视角类加载器可以分为:
BootStrapClassLoader
启动类加载器,用C++实现,是JVM的一部分。- 其他类加载器,用Java实现,都继承了抽象类
java.lang.ClassLoader
,独立存在于JVM之外。
从使用这角度可分为:
-
BootStrapClassloader
负责加载在
<JAVA_HOME>\lib
目录,或者被-Xbootclasspath
参数所指定的路径中存放的,并且是能够被JVM所识别的类库加载到虚拟机内存中。 -
Extension Class Loader
这个类是在sum.mis.Launcher$ExtClassLoader
中以Java代码实现的。它负责加载<JAVA_HOME>\ext\
目录中,或者被java.ext.dirs
系统变量所指定的路径中的所有类库。 -
Application Class Loaser
这个类由``sum.mis.Launcher$AppClassLoader`来实现,有时也被称为“系统类加载器”,它用来加载用户类路径上所有的类库。
JDK9之前的Java应用都是由这三类加载器来相互配合完成加载。通常这些类加载器按照下图的协作关系来完成加载:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ztTqTNW1-1617897204791)(https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg4.mukewang.com%2F5bdf01aa0001a43210380303.jpg&refer=http%3A%2F%2Fimg4.mukewang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1620482748&t=d3df08dd58dfb0a1299f9f0207afff4c)]
这样的模型被称为双亲委派模型,其工作过程如下:
若一个类加载器收到了类加载请求,它首先不会自己去加载,而是将这个请求委托给父类加载器去完成,每一层次的类加载器都是如此。因此所有的加载请求最终都应该传送到最顶层的BootStrapClassloader
,只有当父加载器反馈自己无法完成这个加载请求(及在它的搜索范围类没有找到所需的类),子需求才会去尝试自己完成加载。
注意:双亲委派中的父加载器,不是继承关系中的父子关系,而是通过组合关系来复用父加载器的代码。
破坏双亲委派
在上一点中我们提到通常情况下,加载是按照双亲委派模型执行,意味着存在这其它方法,也就是双亲委派模型被破坏。按历史反正可以分为下面三种情况:
第一次:
由于双亲委派模型是JDK1.2引入的,ClassLoader
是在第一个版本就存在了的,并且加载的核心代码在loadClass
中(可参考《JVM》P284),所以为了兼容用户已经自定义类加载器的情况,双亲委派在实现中做出了妥协,在loadClass
方法中加了一个protected
修饰的findClass
方法,并引导用户使用findClass
。
第二次:
第二次破坏是基于双亲委派的模型自身的缺陷,双亲委派很好的解决了基础类型一致性的问题,但是对于基础类型需要回调用户的代码,双亲委派无能为力。这个时候引入线程上下文类加载器
。!待深入研究
第三次:
这次破坏基于对代码热替换,模块热部署的追求。例如OSGi。!待深入研究
参考资料:
《深入理解Java虚拟机》
Tomcat类加载器破坏双亲委派
从JDBC看“破坏”双亲委派模型
真正理解线程上下文类加载器
服务发现机制