JVM 类加载机制详解

原文章来自我的语雀知识库
该文档总结自《深入理解Java虚拟机》第6章、第7章

类文件结构

虚拟机与字节码——平台无关性与语言无关性的基石

C语言程序编译后的产物是与特定机器指令集相关的二进制机器码,由于不同机器的硬件体系结构不同、操作系统不同,所以其指令集也是不同的,所以机器码是平台相关的。
Java诞生之时的宣传口号“一次编写,处处运行”喊出了当时软件开发人员对于冲破平台界限的渴求。要实现平台无关性,必须借助建立在特定硬件体系结构和操作系统之上的应用层,Java语言依赖Java虚拟机实现了这一点。Java虚拟机可以在多个硬件平台上使用,但这些Java虚拟机都能加载、执行与平台无关的字节码。
除此之外,虚拟机带来的另一种中立特性——语言无关性越来越受开发者重视。因为Java虚拟机并不与Java语言绑定,只要某种编程语言能编译成符合《Java虚拟机规范》的字节码文件,就能载入JVM运行。JVM并不关心字节码文件的来源,字节码文件不必编译自Java语言(例如JRuby、Groovy、Kotlin、JPython语言都可以编译成字节码文件),不必从磁盘读入、也可以从网络、数据库读入,也可以动态生成。

从上到下看Class类文件的结构

《Java虚拟机规范》定义了Class类文件的结构。只有符合规范的字节码文件才能被JVM加载。

魔数与Class文件的版本

魔数的作用是确定这个文件能否作为Class文件被JVM接受。有点类似与文件后缀,例如.txt、.jpg、.gif等等。与文件后缀名不同的是,魔数更加安全,不能被随意改动。字节码文件的魔数的值为0xCAFEBABE。
紧接着是字节码文件的次版本号和主版本号,版本号在JVM类加载过程的验证阶段会被用到,主要是说JVM不能接受比它版本更高的字节码文件,但可以兼容版本更低的字节码文件。

常量池

常量池可以比喻为字节码文件的资源仓库。在字节码的其它位置,有很多数据都指向常量池。
常量池主要存放了两类常量——字面量和符号引用。字面量有文本字符串、被声明为final的常量值、整数、浮点数等等。符号引用有类、接口的全限定名、字段的名称和描述符、方法的名称和描述符等等。
符号引用是与JVM的内存布局无关的,是静态的。在字节码文件被JVM加载中的解析阶段,符号引用会被替换为直接引用,可以理解为从静态的符号变成了动态的指针,可以说是符号引用被JVM运行时解析成了JVM中的具体的内存地址。
常量池中包含的项目有:UTF-8编码的字符串、整型字面量、浮点型字面量、长整型字面量、双精度浮点型字面量、类或接口的符号引用、字符串类型字面量、字段的符号引用、类中方法的符号引用、字段的方法引用等等。

访问标志

包含了这个类或接口的描述信息。包括:这个Class是类还是接口、是否是public、是否是抽象类、是否被声明为final类等等。

类索引、父类索引、接口索引集合

字节码文件由这三项数据来确定该类型的继承关系。
类索引确定了当前类的全限定名、父类索引确定了其父类的全限定名(Java中除了Object类的所有类都有一个直接父类。Object比较特殊,它没有父类。)、接口索引集合描述了这个类实现了哪些接口。

字段表集合

字段表存放了当前类声明的类变量和实例级变量(成员变量),保存了描述这些变量的信息,包括访问修饰符、static、final、volatile、transient(可否被序列化)、数据类型(数组、基本数据类型、对象)、字段名称。然而字段名称是可变长的,所以它只能引用常量池中的常量来描述。

方法表集合

与字段表集合的描述方式很类似。包括了方法的访问标志、名称索引、描述符索引、属性表集合。
其中方法体的代码被编译成字节码之后,被保存在了方法属性表集合中的"Code"属性中。

属性表集合

第一项就是Code属性,保存了Java代码编译成的字节码指令。之后还有很多项,不具体说明了。
Code属性中的max_stack记录了这个方法在执行的任意时刻申请的栈深度都不会超过这个值。
max_locals记录了这个方法的局部变量表所需的存储空间。

类加载过程

总的来说,类加载的过程分为加载、验证、准备、解析、初始化、使用、卸载。这些过程实际上并非是线性进行的,在某个阶段的执行过程中,会调用、激活另一个阶段,所以不如说这些过程是互相穿插进行的。

加载

加载阶段先后完成3件事:

  1. 通过类加载器和类的全限定名获取到定义该类的二进制字节流这个步骤是通过类加载器来完成的,涉及到双亲委派机制,这个之后再说。这一步需要注意的是,字节码文件的来源不一定是磁盘,也可能是ZIP包、JAR包、WAR包、网络、JDK动态代理、CGLib动态代理等,用户可以通过重写类加载器的loadClass()方法或者findClass()方法,从不同的地方获取字节码文件。
  2. 将静态存储的二进制字节流转化为方法区中的运行时数据结构因为二进制字节流中存放的符号引用需要在运行时被解析成直接引用,因为直接引用跟JVM运行时的内存布局相关联。
  3. 在堆内存中生成一个对应的java.lang.Class类型的对象,作为访问方法去类型数据的入口
验证

这一步是为了保证加载的二进制字节流满足《Java虚拟机规范》中的所有约束,很明显,验证阶段与加载阶段是穿插进行的,JVM需要在读取二进制字节流之后、把二进制字节流载入JVM内存之前,对二进制字节流进行验证。这一步主要是防止加载包含恶意代码的字节码文件,这一步在类加载过程中占了相当的比重,而且这一步是否严谨直接决定了JVM能否承受恶意代码的攻击。
需要验证阶段的原因主要是因为,字节码文件不一定是由Java代码编译而来的,在一般情况下,例如错误的类型强制转换、访问数组边界以外的数据、跳转到不存在的代码行等恶意操作无法使用Java代码实现、或者在一般的编译器中就会报错导致无法编译。但字节码文件不一定是Java代码编译来的,字节码文件甚至可以在二进制编辑器中用0和1直接敲出来,上述Java代码无法做到的事在字节码层面上都可以实现,如果在JVM中运行这类恶意代码,可能导致发生一些不可预知的错误。
验证阶段的流程:

  1. 文件格式验证:主要验证魔数、版本信息(JVM只能向下兼容,不能兼容更高版本的字节码)、类型信息是否合法、字符的编码方式是否合法。这个阶段主要保证输入的字节流能被正确地解析并放入方法区内,这个步骤完成后,字节码文件就被载入到方法区中了,之后的步骤都是在内存中完成的。逻辑上来看,这一步应该紧接着ClassLoader找到字节流文件之后进行。
  2. 元数据验证:对字节码进行语义层面上的验证,验证的点主要有:这个类是否有父类(除了Object,其余类都必须有一个父类)、是否继承了不允许继承的类(如被final修饰的类)、如果不是抽象类,那么它必须实现其父类或接口中的所有抽象方法。
  3. 字节码验证:这是整个验证过程中最复杂的部分,需要验证程序语义不会做出危害虚拟机的行为。例如,必须保证任何跳转指令都不允许跳转到方法体之外的字节码指令上、不允许出现把一个对象转换为一个与之毫不相关的类型等。
  4. 符号引用验证:主要是为了保证解析阶段可以正常运作。主要验证通过常量池中的符号引用能否找到对应的资源、以及当前类能否访问对应的资源。如果访问权限不足,则在加载当前类时会抛出java.lang.IllegalAccessError,如果找不到资源,则会抛出类似于java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等错误。
准备

这一步骤主要是为当前类的类变量(静态变量)分配内存并设置初始值。从概念上讲,静态变量应该被分配在方法区,但方法区实际上是一个逻辑上的区域,JDK 7以后,类变量会随着Class对象一起被存放在堆区中。
上述的初始值一般是0,例如某个类变量被定义为public static int value = 123;那么在这一步骤,为value这个类变量分配内存空间,并将其初始值设置为0。将value设置为123的动作实际上会在初始化阶段,通过方法进行赋值。当然,也存在特殊情况,public static final int value = 123;这样定义value,那么value在这一步就会被赋值为123。

解析

解析阶段会将当前类的常量池中的符号引用解析为直接引用。
符号引用:实际上是用一组符号来描述所引用的目标,可以是任何形式的字面量,只要在解析阶段能无歧义地定位到目标即可。符号引用与JVM运行时的内存布局无关。
直接引用:可以是指向目标的指针、相对偏移量、句柄。直接引用与JVM的运行时内存布局相关。

类或接口的解析

假如我要将一个类C的符号引用解析为直接引用,如果类C还未加载,则JVM会先去加载类C,在类C加载到JVM内存中后,如果当前类有足够的权限访问类C,则解析完成。如果没有足够的权限,则会抛出错误java.lang.IllegalAccessError。(这其实就是上面验证阶段中的符号引用验证)
在JDK 9引入模块化之后,public类型不再意味着程序在任何位置都有访问它的权限,我们还必须检查模块间的访问权限。CGLib在JDK 9版本会因为权限不足而抛出java.lang.IllegalAccessError。

其它成分的解析

包括字段解析、方法解析、接口方法解析。

初始化

初始化阶段就是执行()方法,即类构造器的过程。
类构造器会自动收集类中的所有类变量的赋值动作、以及静态代码块(static块)中的语句,将他们合并产生的。收集的顺序按照语句在源文件中出现的顺序进行。static块中的语句只能访问到定义在它之前的类变量,因为这些变量的赋值动作已经完成。对于static块之后定义的类变量,static块只能对他们进行赋值操作,而不能访问它们。啥叫做访问呢?例如System.out.print(i);就是访问i这个类变量,如果它定义在static块后面,编译器会提示非法向前引用。
需要注意的点:

  1. 子类的类构造器执行之前,其父类的类构造器必定已经执行完毕。所以JVM中第一个被执行的类构造器必定是java.lang.Object的类构造器。
  2. image.png
  3. 类构造器对类和接口来说不是必须的,如果当前类没有对类变量的赋值操作、也没有static块,那么可以不为这个类生成类构造器。
  4. 为了保证线程安全性,如果多个线程同时初始化一个类,那么只会有一个线程实际地去执行类构造器,其它线程都需要阻塞等待,直到活动线程执行完毕类构造器。如果某个类的类构造器中有耗时很长的操作,就有可能导致多个线程阻塞。

双亲委派模型

双亲委派模型的实现仅有十多行代码,代码的逻辑十分简单,先通过类的全限定名来判断该类是否被加载过,若这个类还没有被加载就调用当前类加载器的父加载器去加载这个类如果当前类加载器的父加载器parent为空,则认为当前类加载器的父加载器就是启动类加载器。所以我们说,Java中除了启动类加载器以外的任何类加载器都有父加载器。,在此之后,如果这个类仍没有被加载,则调用当前类的findClass方法来进行类加载。
image.png
类加载器之间的父子关系不是用继承实现的,而是用一个成员变量parent,也就是用组合的形式来实现的。
image.png
可以看出,从定向下的类加载器分别为启动类加载器、扩展类加载器、应用程序类加载器。若没有特殊需求,在代码中用户可以使用应用程序类加载器从磁盘中加载字节码文件,如果字节码文件不来源于磁盘,用户也可以自定义类加载器,从网络、数据库等其它位置获得字节码的二进制流文件。
由于最顶层的类加载器是启动类加载器,所以这保证了Object、String这样属于核心类库的类一定从<JAVA_HOME>\lib目录下被加载。用户如果自己想自定义Object、String类,那么可以通过编译,但永远无法被加载。保证了任何环境下的核心类库、扩展类库都是同一个类,也就保证了Java中最基础的一些行为是稳定的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值