写点什么

JVM- 技术专题 -Class 文件加载虚拟机

发布于: 2021 年 03 月 29 日
JVM-技术专题-Class文件加载虚拟机

1.问题分享

  • Class 文件,在加载进 JVM 的过程中,究竟经历了些什么?

  • Class 文件 加载进 JVM 之后又会以什么样的形式呈现?

  • Class.forName 究竟是怎么获取 Class 对象的,Class 对象又是什么?

  • Class 文件是如何被加载到 JVM 里面的?

  • 类变量是存在堆中还是存在方法区中?

  • 类构造器<clinit>方法什么时候执行?

2.加载 Class 文件的过程

其中,如果是动态绑定或者晚期绑定,解析阶段不会再准备阶段后立刻执行。接下来我们就来看看是如何按照这个流程加载一个 Class 文件的。

2.1.加载阶段

JVM 规范并没有规定 java.lang.Class 类的实例要放到 Java 堆中,对于 HotSpot 虚拟机,是放到方法区里面的。

class 对象作为程序访问方法区中的这些类型数据的外部接口。

如上图,加载阶段主要做以下事情:

  1. 通过类全限定名获取定义此类的二进制字节流;

  2. 将字节流代表的静态存储结构转换为方法区的运行时数据结构;

  3. 内存中生成此类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

2.1.1、如何触发加载 Class 文件

如上图,当以下任何一种情况发生的时候,会触发加载 Class 文件:

遇到 new、getstatic、putstatic 或者 invokestatic 字节码指令的时候,如果类还没有初始化。

场景为: new 一个对象; 读取或者设置一个类的静态字段; 调用类的静态方法的时候;初始化类的时候,如果父类还没有初始化,则触发父类初始化;

虚拟机器启动时,main 方法所在的类会首先进行初始化;

JDK1.7 中使用动态语言支持的时候,如果一个 java.lang.invoke.MethodHandler 实例最后解析为:REF_getStatic,REF_putStatic,REF_invokeStatic 方法句柄的时候,并且句柄所对应的类没有进行过初始化。

这个时候通过类的全限定名称获取类的二进制字节流。

此时这个字节流为静态存储结构,需要转换为方法区的运行时数据结构。结构如上图方法区中所示。

每个类生成对应的结构,结构里面的信息详细介绍参考此文:The Java Virtual Machine 其中:

ClassLoader 的引用指的是加载这个 Class 文件的 ClassLoader 实例的引用;

Class 实例引用指的是类加载器在加载类信息并放到方法区之后,然后创建对应的 Class 类型的实例,并把该实例的引用保存到 Class 实例引用中。

2.1.2、获取二进制流的方式

如上图描述的,JVM 规范 5.3. Creation and Loading 并没有指定 class 文件二进制流需要从哪里以什么方式获取,目前主要有以下几种获取方式:

zip 包,延伸为 JAR、EAR、WAR 包;

网络,如 Applet;

动态代理;

JSP 生成;

数据库获取;

2.1.3、验证二进制字节流

如上图所示,在加载阶段就已经开始做部分验证工作了,但是验证还是属于连接阶段的动作,下面介绍验证阶段。

2.2.链接阶段

如上图:连接阶段包括:验证,准备,解析

2.2.1、验证阶段

验证阶段,做什么事情为了解释这一步的作用,我们先来做一个实验。

有如下一个类:

我们把 Java 文件编译为 class 文件,并执行之:

java com.itzhai.jvm.loadclass.TestVerify

可以发现输出:

现在我们使用前面 Class 文件 16 进制背后的秘密介绍的十六进制编辑方法,对 class 文件进行随意编辑,这里我们可以把常量池计数器故意调小一点,保存之后再次执行 class 文件:

可以发现抛出了异常:非法的常量池索引 33,这正是验证阶段干的事情。

验证阶段干什么事情

我们知道,class 文件是可以被认为篡改的,虚拟机如果直接拿来执行,可能会把系统给搞崩溃了,所以一定要先对 Class 文件做严格的验证。验证阶段主要完成以下检测动作:

2.2.1.1、文件格式验证

主要按照 Class 文件 16 进制背后的秘密文章中的阐述的格式,严格的进行校验。

2.2.1.2、元数据验证

主要是语义校验,保证不存在不符合 Java 语言规范的元数据信息,如:没有父类,继承了 final 类,接口的非抽象类实现没有完整实现方法等。

2.2.1.3、字节码验证

主要对数据流和控制流进行分析,确定成行语义是否合法,符合逻辑。不合法的例子:

操作数栈放置了 int 类型数据,却当成 long 类型使用;

把父类对象赋值给了子类数据类型;

2.2.1.4、符号引用验证

解析阶段发生的验证,当把符号引用转化为直接引用的时候进行验证。这主要是对类自身以外的信息进行匹配性校验。主要包括:

  • 全限定名是否可以找到对应的类;

  • 指定类是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;

  • 校验类,字段和方法的可见性;

2.2.2、准备阶段

这个阶段还并没有开始执行类的构造方法,而只是为类变量分配内存并设置类变量初始值(零值)。这些变量所使用的内存都将在方法区中分配。

基本数据类型的零值:

Primitive Types and Values

这里只分配 static 变量,不包括实例变量。

注意:static final 类型的常量 value 会在准备阶段被初始化为常量指定的值。

静态变量存储在内存的 PremGen(方法区域)空间中,其值存储在 Heap 中


2.2.3、解析阶段

解析阶段主要将常量池内的符号引用替换为直接引用。

  • 符号引用:字面量,引用目标不一定已经加载到内存中;

  • 直接引用:直接指向目标的指针,或者相对偏移量,或是一个能简介定位到目标的句柄。直接引用和虚拟机实现的内存布局相关。


关于动态语言的支持:通过 invokedynamic 指令支持动态语言。该指令会对符号引用进行解析,但是不会缓存解析的结果,每次执行指令都需要重新解析。


解析主要针对以下七类符号引用进行:

类或接口 CONSTANT_Class_info

字段 CONSTANT_Fieldref_info

类方法 CONSTANT_Methodref_info

接口方法 CONSTANT_InterfaceMethodref_info

方法类型 CONSTANT_MethodType_info

方法句柄 CONSTANT_MethodHandle_info

调用限定符 CONSTANT_InvokeDynamic_info


常量池中的 14 种常量结构

符号引用解析的过程或校验的过程中,可能又会触发另一个类的加载。

2.3.初始化阶段


这阶段开始执行 Java 程序代码,这一步主要是执行类构造器<clinit>方法对类变量进行初始化的过程,注意,这个方法不是构造方法

下面就来介绍一下这个方法:

2.3.1、<clinit>方法

此方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的方法,主要是给类变量做初始化工作的方法。

生成<clinit>方法的实例

有如下代码:

这个类中有一个静态变量 DESC,并且在静态代码块中进行了赋值操作,我们看看其生成的汇编代码:


可以发现,生成了这样的一个方法。此方法既是生成的<clinit>方法。这里指令比较简单,主要是:拿到”hello world!!!“字符串的引用,把他设置到 DESC 类变量中。

关于<clinit>方法的注意事项

  • 顺序问题:静态语句块后面的静态变量,静态语句块中可以赋值,但不可以访问;

  • 继承执行顺序:无需显示调用,虚拟机会保证子类的<clinit>方法执行前,父类的<clinit>方法已经执行完毕;

  • 接口的<clinit>方法:虽然接口不能有静态语句块,但是可以给静态变量初始化值,所以也可以生成<clinit>方法;

  • 接口继承:除非使用到父接口的变量,否则执行子接口的<clinit>方法不需要先执行父接口的<clinit>方法;

  • 在并发场景,虚拟机会保证一个类的<clinit>方法只有一个线程执行,其他线程会阻塞,所以要确保静态代码块中不要写可能回到成进程阻塞的代码。


通过 new 创建的对象,而精致的是通过 ClassLoader 操作 .class 文件生成 Class 类,然后创建的对象。其实通过 new 或者反射创建实例,都需要 Class 对象。


类加载器加载 .class 文件主要分位三个步骤

检查类是否已经加载,如果有就直接返回

当前不存在该类,遵循双亲委派机制,加载 .class 文件

上面两步都失败,调用 findClass()

因为 ClassLoader 的 findClass 方法默认抛出异常,需要我们写一个子类重新覆盖它,比如:


defineClass 是通过字节码获取 Class 的方法,是 ClassLoader 定义的。我们具体不知道如何实现的,因为最终会调用一个 native 方法:


总结下类加载器加载 .class 文件的步骤:

  • 通过 ClassLoader 类中 loadClass() 方法获取 Class

  • 从缓存中查找,直接返回

  • 缓存中不存在,通过双亲委派机制加载

  • 上面两步都失败,调用 findClass()通过 IO 流从指定位置获取到 .class 文件得到字节数组调用类加载器 defineClass() 方法,由字节数组得到 Class 对象。

2.4 Class 类

.class 文件被类加载器加载到内存中并生成字节数组,JVM 根据字节数组创建了对应的 Class 对象。

接下来我们来分析下 Class 对象。

Class 中肯定有保存这些信息的字段

Class 类中用 ReflectionData 里面的字段来与 .class 的内容映射,分别映射了字段、方法、构造器和接口。

通过 annotaionData 映射了注解数据,其它的就不展示了,大家可以自行打开 IDEA 查看下 Class 的源码。那我们看看 Class 类的方法

2.4.1 构造器

Class 类的构造器是私有的,只能通过 JVM 创建 Class 对象。所以就有了上面通过类加载器获取 Class 对象的过程。

2.4.2 Class.forName

Class.forName() 方法还是通过类加载器获取 Class 对象。

2.4.3 newInstance

newInstance() 的底层是返回无参构造函数。

我们来梳理下前面的知识点:

  • 反射的关键点就是获取 Class 类,那系统是如何获取到 Class 类?

  • 是通过类加载器 ClassLoader 将 .class 文件通过字节数组的方式加载到 JVM 中,JVM 将字节数组转换成 Class 对象。那类加载器是如何加载的呢?

通过 ClassLoader 的 loadClass() 方法

从缓存中查找,直接返回,缓存中不存在,通过双亲委派机制加载

上面两步都失败,调用 findClass()通过 IO 流从指定位置获取到 .class 文件得到字节数组调用类加载器 defineClass() 方法,由字节数组得到 Class 对象

Class 类的构造器是私有的,所以需要通过 JVM 获取 Class

Class.forName() 也是通过类加载器获取的 Class 对象。newInstance 方法的底层也是返回的无参构造函数。

3.Class 文件基本结构

ClassFile {    u4             magic; // 魔数    u2             minor_version; // 副版本号    u2             major_version; // 主版本号    u2             constant_pool_count; // 常量池计数器    cp_info        constant_pool[constant_pool_count-1]; // 常量池数据区    u2             access_flags; // 访问标志    u2             this_class; // 类索引    u2             super_class; // 父类索引    u2             interfaces_count; // 接口计数器    u2             interfaces[interfaces_count]; // 接口表    u2             fields_count; // 字段计数器    field_info     fields[fields_count]; // 字段表    u2             methods_count; // 方法计数器    method_info    methods[methods_count]; // 方法表    u2             attributes_count; // 属性计数器    attribute_info attributes[attributes_count]; // 属性表}
复制代码

Class 文件是一组以 8 位字节为基础单位的二进制流。以上类结构只有两种数据类型:

  • 无符号数:无符号数属于基本属性类型,用 u1, u2, u4, u8 分别代表 1 个字节,2 个字节,4 个字节和 8 个字节的无符号数,可以用它描述数字、索引引用、数量值或者 utf8 编码的字符串值;

  • 表:由多个无符号数或者其他表作为数据项构成的复合数据类型,以命名_info 结尾。

根据以上的 Class 文件结构,我们可以梳理出以下的 Class 文件结构图:

3.1 魔数 magic

用于标识这个文件的格式,Class 文件格式的魔数为 0xCAFEBABE。

3.2 副版本号 minor_version,主版本号 major_version

minorversion 和 majorversion 项目的值是此类文件的次要版本号和主要版本号。 主版本号和次版本号共同决定了类文件格式的版本。 如果类文件的主版本号为 M,次版本号为 m,则将其类文件格式的版本表示为 M.m。 因此,可以按字典顺序对类文件格式版本进行排序,例如 1.5 <2.0 <2.1。

3.3 常量池计数器 constantpoolcount

常量池描述着整个 Class 文件中所有的字面量信息。常量池计数器(constantpoolcount)的值等于常量池(constant_pool)表中的条目数加一。

如果 constantpool 索引大于零且小于 constant pool_count,则该索引被视为有效

3.4、常量池表 constant_pool[]

constantpool[]是一个结构表,表示各种字符串常量,类和接口名称,字段名称以及在 ClassFile 结构及其子结构中引用的其他常量。 constantpool 表条目的格式由其第一个“标签”字节指示。

所有类型的常量池表项目有以下通用的格式:

cp_info {    u1 tag;    u1 info[];}
复制代码

constant_pool 表的索引从 1 到 constant_pool_count-1。

未完待续... ...


用户头像

我们始于迷惘,终于更高的迷惘。 2020.03.25 加入

一个酷爱计算机技术、健身运动、悬疑推理的极客狂人,大力推荐安利Java官方文档:https://docs.oracle.com/javase/specs/index.html

评论

发布
暂无评论
JVM-技术专题-Class文件加载虚拟机