大家好,这里是追風者。今天我们来聊一聊 JVM 的类加载机制。
类的整个生命周期分为七个阶段:加载、验证、准备、解析、初始化、使用和卸载。其中 验证、准备、解析三部分统称为连接。
上面七阶段中,解析和使用两个阶段的顺序是不确定的。解析阶段在某些情况下,可能会在初始化阶段结束后才开始,这是为了支持 Java 语言的运行时动态绑定特性。
必须进行初始化的情况:
-
遇到 new、getstatic、putstatic 和 invokestatic 这四条字节码时,如果类没有初始化就触发其初始化阶段。
-
使用 new 关键字实例化对象。
-
读取或设置一个类型的静态字段。
-
调用一个类的静态方法的时候。
-
-
使用 java.lang.reflect 包的方法对类型进行反射调用的时,如果类型没有进行初始化就先触发其初始化阶段。
-
当初始化类时,发现父类未进行初始化,则先初始化父类。
-
当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机先初始化这个主类。
-
当使用 JDK7 加入的动态语言支持时,如果一个 java.lang.MethodHadnle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始胡,则需要先触发器初始化。
-
当一个接口定义了 JDK8 新加入的默认方法时,如果有这个接口的实现类发生了初始化,那么该接口在其之前被初始化。
当一个类初始化时,要求其父类全部被初始化过了,但是接口在初始化时,并不需要其父类接口全部都完成初始化,只有在真正使用到父接口的时候才会初始化。
类加载主要包括了类的 加载、验证、准备、解析和初始化 五个阶段,下面对这五个阶段进行详细描述。
# 加载
加载阶段,JVM 主要完成以下三件事:
-
通过一个类的全限定名来获取定义此类的二进制字节流。
-
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
-
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
数组类不通过类加载器创建,由 JVM 直接在内存中动态构建出来的。
数组类创建过程遵循以下规则:
-
如果数组的组件类型(指的是数组去掉一个维度的类型)是引用类型,那就递归采用本届定义的加载过程去加载这个组件类型,数组 C 将被标识在加载该组件类型的类加载器的类名称空间上。
-
如果数组的组件类型不是引用类型,JVM 将会把数组 C 标记为与引导类加载器关联。
-
数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组的可访问性将默认为 public。
加载阶段与连接阶段部分动作是交叉进行的,加载阶段尚未完成,连接阶段可能已将开始。
# 验证
验证阶段的目的是确保 Class 温江的字节流中包含的信息符合 《Java 虚拟机规范》的全部约束要求,确保这些信息被当作代码时不会危害虚拟机自身安全。
验证阶段从大体上会完成四个阶段的检验动作:文本格式检验、元数据检验、字节码检验和符号引用检验。
-
文件格式检验:该阶段主要目的是保证输入的字节流能正确地解析并存储于方法去中,格式上符合一个 Java 类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过该阶段验证后,这段字节流才被允许进入 JVM 内存的方法区中进行存储。后面的三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
-
元数据验证:对字节码描述的信息进行语义分析,进行语义校验。
-
字节码验证:通过数据流分析和控制流分析,确定程序的语义收是合法的、符合逻辑的。在第二阶段对元数据验证完毕后,该阶段就对类的方法体进行校验分析。方法体 Code 属性表中有一项 StackMapTable 的属性,该属性描述了方法体所有基本块开始时本地变量表和操作栈应有的状态,在字节码验证期间,JVM 只需要检查 StackMapTable 中的纪录是否合法即可。
-
符号引用验证:该阶段验证行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段中发生。该阶段目的是确保解析行为能正常执行。
# 准备
准备阶段是正式为类中定义的变量分配内存并设置类变量初始值的阶段。这些变量使用的内存都在方法区中被分配(逻辑上)。
此阶段是对类变量进行内存分配,而不是对实例对象进行内存分配,实例对象会在对象实例化时随着对象一起分配在 Java 堆中。
# 解析
解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。
-
符号引用:符号引用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
-
直接引用:直接以引用就是指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
解析动作主要针对 类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 这七类符号引用进行。
## 类或接口的解析
D 为代码所处的类,要将 符号引用 N 解析为 类或接口的 C 的直接引用,有以下步骤:
-
如果 C 不是一个数组类型,那么 JVM 将会把代表 N 的全限定名传递给 D 的类加载器去加载类 C。加载过程中(是类加载过程,而不是加载阶段),由于元数据验证、字节码验证的需要,有可能触发其他相关类的加载,例如加载这个类的父类或实现的接口等。
-
如果 C 是一个数组类型,并且数组元素类型为对象,那就会按照第一步进行解析元素类对象。
-
如果上面两个步骤没有出现异常,那么 C 在 JVM 中已经是一个有效的类或接口了,但在解析完成前还需要进行符号引用验证,确定 D 对 C 有访问权限。
如果说 D 对 C 拥有访问权限,那么以下三个规则至少有一条成立:
-
被访问类 C 是 public 的,并且与访问类 D 处在同一模块下。
-
被访问类 C 是 public 的,不予访问类 D 处在同一模块下,但是被访问类 C 的模块允许访问类 D 的模块进行访问。
-
被访问类 C 不是 public 的,但与访问类 D 在同一包下。
## 字段解析
解析字段符号引用,首先对常量池字段表结构中 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,也就是对字段所属的类或接口的符号引用进行解析。
如果对这个类或接口的符号引用解析成功,记作 C 表示,按照以下步骤对 C 进行后续字段的搜索:
-
如果 C 本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
-
如果 C 中实现了接口,将会按照继承关系从下往上递归搜索各个杰克和他的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
-
如果 C 不是 Object 的话,将会按照继承关系从下到上递归搜索其父类,如果父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
-
查找失败,抛出 java.lang.NoSuchFieldError 异常。
如果查找成功返回引用,将会对这个字段继续宁权限验证,如果发现不具备对该字段的访问权限,则抛出 java.lang.IllegalAccessError 异常。
如果一个类实现的多个接口和继承的父类中有多个一致的字段,编译器会拒绝编译。
## 方法解析
方法解析的第一步与字段解析一样,需要先解析出该方法所属的类或接口的符号引用,如果解析成功,用 C 表示这个类,通过如下步骤进行方法搜索:
-
如果在类的方法表中发现 C 是一个接口的话,直接抛出 java.lang.IncompatibleClassChangeError 异常。
-
在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
-
在 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
-
在类 C 实现的接口列表以及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有匹配的方法,说明 C 是一个抽象类,查找结束,抛出 java.lang.AbstractMethodError 异常。
-
宣告查找失败,抛出 java.lang.NoSuchMethodError 异常。
查找成功后,对方法进行权限验证,不通过就抛出 java.lang.IllegalAccessError 异常。
## 接口方法解析
接口方法解析也是需要先解析出接口方法表的 class_index 项中索引的方法所属的接口的符号引用,如果解析成功,依然用 C 表示这个接口,通过以下步骤进行后续的接口方法搜索:
-
如果接口方法表中发现按 C 是一个类,直接抛出 java.lang.IncompatibleClassChangeError 异常。
-
在接口 C 中查找简单名称和描述符都与目标匹配的方法,如果有则返回该方法的直接引用,查找结束。
-
在接口 C 的父接口中递归查找,直到 Object 类为止,看是否有简单名称和描述符都与目标匹配的方法,如果有则返回该方法的直接引用,查找结束。
-
对于步骤 3,由于 Java 的接口允许多继承,如果 C 的不同父接口中存在多个相同简单名称和描述符都与目标匹配的方法,那么会从这些方法中选取一个返回并结束查找,有些虚拟机会拒绝编译。
-
查找失败,抛出 java.lang.NoSuchMethodError 异常。
初始化
类的初始化阶段是加载过程的最后一个步骤。在这个阶段,JVM 才将真正执行类中编写的代码,将主导权移交给程序。
在准备阶段,已经为变量进行一次系统级要求的初始零值了,在初始化阶段,开始为变量根据程序制定的计划对这些变量进程初始化。或者说,初始化阶段就是执行类构造器 () 方法的过程。
-
() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的位置顺序决定的,静态语句块中只能反问道定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
-
() 方法与类的构造函数(即 JVM 视角中的实例构造器 ())不同,它不需要显示地调用父类构造器,JVM 会保证子类的 () 方法执行前,父类的 () 方法已经执行完毕,因此 JVM 中第一个被执行的 () 方法的类型一定为 Object 对象。
-
由于父类的 () 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
-
() 方法对于雷火接口并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为该类生成 () 方法。
-
接口中不能使用静态语句块,但仍然可以有变量初始化的赋值操作,因此接口与类一样,都会生成 () 方法。但接口的 ()方法不需要先执行父接口的 () 方法,因为只有父接口的变量被使用时,父接口才会被初始化。同理,接口的实现类在初始化时也一样不会执行接口的 () 方法。
-
JVM 必须保证一个类的 () 方法在多线程环境下被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 () 方法,直到活动线程执行完毕 () 方法。
# 类加载器
类加载器目的是通过一个类的全限定名来获取描述该类的二进制字节流的来进行类加载。
类加载器只用于实现类的加载动作。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 JVM 的唯一性,每个类加载器都拥有一个独立的类名称空间。即如何要比较两个类是否相等,这两个类必须是由同一个类加载器加载的,如果不是同一个类加载器加载的,结果一定是不一样的。
## 双亲委派模型
JVM 角度来看,只存在两种类加载器,一种为启动类加载器(Bootstrap Class Loader),这个类加载器使用C++实现的,是虚拟机的一部分;零一种就是其他所有的类加载器,这些类加载器是由 Java 语言实现的,独立存在于虚拟机外部,并且全部继承自抽象类 ClassLoader。
绝大数 Java 程序都会使用到下面三个系统提供的类加载器加载:
-
启动类加载器(Bootstrapt Class Loader):该类加载器赋值加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 JVM 能够识别的类库加载到虚拟机内存中。
-
扩展类加载器(Extension Class Loader):该类加载器在 sun.misc.Launcher$ExtClassLoader 中以 Java 代码实现的。它负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。
-
应用程序类加载器(Application Class Loader):该类加载器由 sun.misc.Launcher$AppClassLoader 来实现。由于该类加载器是 ClassLoader 类中的 getSystemClassLoader() 方法的返回值,所以也称为 “系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以在代码中直接使用这个类加载器。
此时,Java 中类加载器的层次关系就是 “双亲委派模型(Parents Delegation Model)”。双亲委派模型要求除去顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。但这里的父子关系一般是使用组合关系来复用父加载器代码的,而不是继承关系。
双亲委派的工作原理:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时候,子加载器才会尝试自己完成加载。
双亲委派模型使 Java 的类根据类加载器有了优先级的层次关系,保证了基础构建的类在任何情况下都会是同一个类(因为都会传递到顶层类加载器加载)。
注
如果文章有任何错误欢迎各位斧正,编程心得就是需要不断的交流才会拓宽视野,感谢各位。