类文件结构和Java虚拟机类加载机制

“代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。”

概述

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

在 Java 语言中,类的加载、连接和初始化过程都是在程序运行期间完成的,为 Java 应用程序提供了高度的灵活性。

Class 类文件的结构

任何一个 Class 文件都对应着唯一一个类或者接口的定义信息。Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在 Class 文件之中,中间没有任何分隔符,所以整个 Class 文件中存储的内容几乎全是程序运行的必要数据。,遇到占用 8 位字节以上空间的数据项目时,会按照高位在前的方式分割成若干个 8 位字节进行存储。

Class 文件中只有两种数据类型:无符号数

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性的以_info结尾

Class 文件格式如下:

类型 名称 数量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count - 1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

无符号数属于基本的数据类型,用 u1、u2、u4、u8 里奥表示 1 个字节、2 个字节、4 个字节、8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值和按照 UTF-8 编码构成的字符串值。

魔数与 Class 文件的版本

每个 Class 文件的开头 4 个字节称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件,值为0xCAFEBABE

紧接着魔数的 4 个字节存储的是 Class 文件的版本号,第 5 和第 6 字节是次版本号,第 7 和第 8 字节是主版本号,虚拟机必须拒绝执行超过其版本号的 Class 文件

常量池

紧接着主次版本号之后的是常量池入口,常量池可以理解为 Class 文件之中的资源仓库,他是 Class 文件结构中与其他项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一,同时还是在 Class 文件中第一个出现的表类型数据项目。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值,这个容量计数是从1开始的,其他的集合类型都是从 0 开始的。因为将常量池索引置为 0 被设计用来表示不引用任何一个常量池项目

常量池主要存放两大类常量:字面量符号引用

字面量比较接近 Java 语言层面的常量概念,如文本字符串声明为final的常量值

符号引用属于编译原理方面的概念,包括以下三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址中。

常量中每一项常量都是一个表,目前一共有 14 种常量类型,他们均有各自的结构。

可以通过javap -verbose命令查看 Class 文件的字节码内容。

访问标志

在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别以下类或者接口层次的访问信息。

标志名称 标志值 含义
ACC_PUBLIC 0x0001 是否为 public 类型
ACC_FINAL 0x0010 是否被声明为 final,只有类可设置
ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码指令的新语意,JDK1.0.2 之后编译出来的类此标志都必须为真
ACC_INTERFACE 0x0200 标识这是一个接口
ACC_ABSTRACT 0x0400 是否为 abstract 类型,对于接口或抽象类来说,此标志值为真
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生的
ACC_ANNOTATION 0x2000 标识这是一个注解
ACC_ENUM 0x4000 标识这是一个枚举

access_flags 的值由上述标志值求或操作获得。

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

类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合(interfaces)是一组 u2 类型的数据集合,Class 文件中由这三项数据来确定这个类的继承关系。

类索引用于确定这个类的全限定名。

父类索引用于确定这个类的父类的全限定名。

接口索引集合用来描述这个类实现了哪些接口,按照 implements 语句后的接口顺序从左到右排列在接口索引集合中。

类索引和父类索引引用两个 u2 类型的索引值表示,他们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过 CONSTANT_Class_info 类型的常量中的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。

字段表集合

字段表(field_info)用于描述接口或者类中声明的变量,字段(field)包括类级变量以及实例级变量,但不包括局部变量。

字段表结构如下:

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count

字段修饰符放在 access_flags 项目中,他与类中的 access_flags 项目非常类似,结构如下:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 字段是否为 public
ACC_PRIVATE 0x0002 字段是否为 private
ACC_PROTECTED 0x0004 字段是否为 private
ACC_STATIC 0x0008 字段是否为 static
ACC_FINAL 0x0010 字段是否为 final
ACC_VOLATILE 0x0040 字段是否为 volatile
ACC_TRANSIENT 0x0080 字段是否为 transient
ACC_SYNTHETIC 0x1000 字段是否由编译器自动产生
ACC_ENUM 0x4000 字段是否枚举

跟随 access_flags 标志的是两项索引值:name_index 和 descriptor_index,他们都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符。

方法表集合

Class 文件存储格式中对方法的描述与对字段的描述几乎一致。

方法表结构如下:

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count

方法表的访问标志结构如下:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 方法是否为 public
ACC_PRIVATE 0x0002 方法是否为 private
ACC_PROTECTED 0x0004 方法是否为 private
ACC_STATIC 0x0008 方法是否为 static
ACC_FINAL 0x0010 方法是否为 final
ACC_SYNCHRONIZED 0x0020 方法是否为 synchronized
ACC_BRIDGE 0x0040 方法是否为编译器产生的桥接方法
ACC_VARARGS 0x0080 方法是否接收不定参数
ACC_NATIVE 0x0100 方法是否为 native
ACC_ABSTRACT 0x0200 方法是否为 abstract
ACC_STRICTFP 0x0400 方法是否接为 stricttfp
ACC_SYNTHETIC 0x1000 方法是否由编译器自动产生

属性表集合

在 Class 文件、字段表、方法表都可以携带自己的属性表集合。总共有 21 项预定义的属性,如下:

类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载共 7 个阶段。其中验证、准备和解析 3 个部分统称为连接。如下图所示:

其中,加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始,因为这些阶段通常都是相互交叉地混合式进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段。

对于初始化阶段,严格规定了有且只有5 种情况必须立即对类进行初始化:

  1. 遇到 new、getstatic、putstatic 和 invokestatic 这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这四条指令的最常见的 Java 代码场景是:
  • 使用 new 关键字实例化对象的时候;
  • 读取或设置一个类的静态字段(被 final 修饰,已在编译器把结果放入常量池的静态字段除外)的时候;
  • 调用一个类的静态方法的时候。
  1. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
  2. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  3. 当虚拟机启动时,用户需要制定一个要执行的主类(包含 main()方法的类),虚拟机会先初始化这个主类
  4. 当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化

这 5 种场景中的行为被称为对一个类进行主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用

接口的初始化与类只在第 3 种场景有区别:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口都完成了初始化,只有在真正使用到父接口时才会初始化。

类加载的过程

加载

加载是类加载过程中的一个阶段,在加载阶段,虚拟机完成以下 3 件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

一个非数组类在加载阶段,即可以使用系统提供的引导类加载器去控制字节流的获取方式,也可以有用户自定义的类加载器去完成

数组类本身不通过类加载器创建,而是由 Java 虚拟机直接创建的,其(简称 C)创建过程遵循以下规则:

  • 如果数组的组件类型(指数组去掉一个维度的类型)是引用类型,递归采用加载过程区加载这个组件类型,数组 C 将在加载该组件类型的类加载器的类名称空间上被标识
  • 如果数组的组件类型不是引用类型,Java 虚拟机将会把数组 C 标记为与引导类加载器关联
  • 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,数组类的可见性默认为 public

加载阶段完成后,虚拟机外部的二进制字节流就会按照虚拟机所需的格式存储在方法区中,方法区中的数据存储格式由虚拟机实现自定义,然后会在内存中实例化一个 java.lang.Class 类的对象,HotSpot 中这个对象存放在方法区中,这个对象将作为程序访问方法区中类型数据的外部接口。

验证

验证是连接阶段的第一步,目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。

验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证元数据验证字节码验证符号引用验证

  1. 文件格式验证:主要验证字节流是否符合 Class 文件的格式规范
  2. 元数据验证:对字节码描述的信息进行语义分析,保证其描述的信息符合 Java 语言规范的要求
  3. 字节码验证:是最复杂的验证阶段,目的是通过数据流和控制流分析,确定程序语意是合法的、符合逻辑的
  4. 符号引用验证:发生在虚拟机将符号引用转化为直接引用时,目的是确保解析动作能正常进行

验证阶段是非常重要但是非必要的阶段,如果运行的全部代码都已经被反复使用和验证过,可以考虑使用-Xverify:none 参数关闭大部分类验证措施,缩短虚拟机类加载时间

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量将在方法区进行内存分配。

如果类字段的字段属性表中存在 ConstantValue 属性,准备阶段变量会被初始化为 ConstantValue 属性所指定的值。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量

直接引用:直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄

初始化

类初始化阶段才真正开始执行类中定义的 Java 程序代码,是执行类构造器()方法的过程。

  1. ()方法是有编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的
  2. ()方法与类的构造函数不同,他不需要显式调用父类构造器,虚拟机会保证父类的()方法先执行,所以第一个被执行的()方法一定是 java.lang.Object
  3. 由于父类()方法先执行,所以父类中定义的静态语句块要优先于子类变量的赋值操作
  4. ()方法对于类或接口来说不是必需的,如果类中没有静态语句块和对变量的赋值操作,则不为这个类生成()方法
  5. 接口的()方法不需要先执行父类的()方法,只有当父接口中定义的变量使用时,父接口才会初始化。接口的实现类初始化时也不会执行接口的()方法
  6. 虚拟机会保证一个类的()方法在多线程环境中被正确的加锁、同步

类加载器

类加载阶段的“通过一个类的全限定名来获取定义此类的二进制字节流”这个动作被设计放到 Java 虚拟机外部实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为类加载器

类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

双亲委派模型

从 Java 虚拟机的角度来讲,只存来两种不同的类加载器:启动类加载器其他类加载器

从 Java 开发人员的角度来看,大部分 Java 程序会使用一下三种系统提供的类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):将存放在/lib 目录中的,或被-Xbootclasspath 参数指定路径中的,且被虚拟机是别的类库加载到虚拟机内存中
  2. 扩展类加载器(Extension ClassLoader):将存放在/lib/ext 目录中的,或呗 java.ext.dirs 系统变量指定路径中的所有类库加载到虚拟机内存中
  3. 应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上所指定的类库

图中展示的类加载器之间的层次关系,称为类加载器的双亲委派模型,该模型要求除了启动类加载器外,其余的类加载器都应当有自己的父类加载器。类加载器的父子关系都是使用组合关系来复用父加载器的

双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,他首先不会自己尝试去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的家在请求最终都应该传送到顶层的启动类加载器中,只有当父加载器在其搜索范围内没有找到所需的类时,子加载器才会尝试去加载。

双亲委派模型的代码都集中在 java.lang.ClassLoader 的 loadClass()方法中,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
protected synchronized Class<?> loadClass(String name,boolean resolve)throws ClassNotFoundException{
// 首先检查请求的类是否应经被加载过了
Class c = findLoadedClass(name);
if(c == null){
try{
if(parent != null){
c = parent.loadClass(name,false);
}else{
c = findBootstrapClassOrNull(name);
}
}catch(ClassNotFoundException e){
// 如果父类加载器抛出ClassNotFoundException
// 说明父类无法完成加载请求
}
if(c == null){
// 在父类加载器无法加载的时候
// 再调用本身的findClass()方法来进行类加载
c = findClass(name);
}
}
if(resolve){
resolveClass(c);
}
return c;
}

代码逻辑为:先检查是否已经被加载过,若没有加载则调用父加载器的 loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出 ClassNotFoundException 后,再调用自己的 findClass()方法进行类加载。

破坏双亲委派模型

双亲委派模型不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的类加载器实现方式。到目前为止,出现过 3 次较大规模的“被破坏”情况。

  1. 双亲委派模型在 JDK1.2 之后引入,为了向前兼容,在 java.lang.ClassLoader 添加了一个新的 protected 方法 findClass()
  2. 为了解决基础类需要调用会用户代码的问题,引入了一个不太优雅的设计:线程上下文类加载器,父类加载器可以通过他请求子类加载器去完成类加载动作
  3. 由于用户对程序动态性的追求而导致,例如 OSGi 的网状结构

只要有足够的理由和意义,突破已有的原则就可以认为是一种创新。

参考

1、周志明,深入理解 Java 虚拟机:JVM 高级特性与最佳实践,机械工业出版社

2、xiedacon,《深入理解 java 虚拟机》-类文件结构