【Android】JVM 学习之 Class 文件


JVM 与 Class

提到 Class 文件我们先来讨论一下 Java 的跨平台性。Java 是一种『一次编写,到处运行』的语言,也就是说它具有跨平台性。这与 C/C++ 『一次编写,处处编译』的跨平台性不同,Java 的跨平台性是依赖于 Java 虚拟机(以下简称JVM)的。JVM 是对 Java 这种跨平台性的具体实现。具体体现在虚拟机提供商会发布针对各类平台的 JVM, 这些 JVM 都能载入同一种与平台无关的二进制数据——字节码。这样,只要这个平台能够运行 JVM,就能够支持 Java 的这种跨平台特性。而前面提到的这种与平台无关的二进制数据字节码,就是存储在这篇文章的主角——Class 文件中的。

JVM 虽然名字里带有 Java,但实际上不仅仅是面向 Java 语言的,它不和包括 Java 在内的任何语言相绑定,只与 Class 这种二进制文件相关联。Java 虚拟机规范中对 Class 文件中的许多语法和结构进行了约束。只要其他语言的编译器能将其编译为 Class 文件,那么它也能很好地支持这种跨平台特性。比如 Kotlin、 Groovy 等语言就是在 JVM 上运行的。

具体过程如下图:

image-20190113100821118

Class 文件结构

Class 文件是一种二进制文件,以 8 位作为 1 个基本单位——字节。Class 文件内部的数据是没有任何分隔符的,它们都是紧凑地按照顺序排列于 Class 文件中。如果遇到了高于 8 位的数据,则会将其以『大端』(高位字节存放在低位地址)的方式将其分割为多个 8 位进行存储。

Class 文件采用了类似于 C 语言中结构体的方式进行存储,这种结构中只有两种数据——无符号数

  • 无符号数用于描述数字、索引引用、数量值及按照 UTF-8 编码构成的字符。它分别用 u1、u2、u3、u4 来代表 1 个字节、2 个字节、4 个字节及  8 个字节的无符号数。
  • 表则是由多个无符号数或其他表作为数据项构成的复合数据。一般以 "_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
attributes_info attributes attributes_count

无论是无符号数还是表,描述同类型但数量不定的数据时,都会使用一个前置容量计数器加上若干数据项形式,这样的连续的某一类型的数据为这一类型的集合

魔数及 Class 版本号

Class 文件的前面 4 个字节被称为魔数(Magic Number),用于确定这个 Class 文件是否能被虚拟机所接受。由于文件扩展名可以随意改动,使用魔数而不是扩展名验证文件是否有效可以保证一定的安全性。

Class 文件的魔数很有意思,制定文件格式的作者设计了一个很皮的魔数——0xCAFEBABE(咖啡宝贝)。这个魔数可能也为后来 Java 商标的出现作出了铺垫。

魔数之后紧接着的 4 个字节是 Class 文件版本号,前 2 个字节是次版本号(Minor Version),而后 2 个字节则是主版本号(Major Version)。JDK1.1 后每个 JDK 大版本主版本号+1,高版本的 JDK 文件可以根据 Class 文件的版本号向下兼容,但不能运行高版本的 Class 文件,即使内容没有改变。

常量池

版本号后面紧接着的是常量池的入口。常量池是 Class 文件的资源仓库。它是占用 Class 文件最大的空间的部分之一,也是第一个出现的表类型数据。

常量池的数据量是不确定的,因此在常量池入口处放置了一个 u2 类型的数据(constant_pool_count),代表了常量池的容量计数。这里有一个比较特殊的地方,常量池的容量计数是从 1 而不是 0 开始的。设计者这个第 0 个常量空出来是为了让某些常量池的索引值数据在特定情况下能够表达『不引用任何常量池项目』的含义。此时把索引置为 0 即可。

常量池主要存储了两类常量——字面量符号引用。字面量接近 Java 语言中常量的概念,而符号引用则属于编译原理方面的概念,有下面三类常量:

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

Java 代码的编译过程不包含连接的过程,而是在 JVM 加载 Class 文件时进行动态连接。Class 文件不会保存各个方法、字段的最终的内存布局信息。因此在虚拟机运行的时候需要从常量池获取对应的符号引用,再在类创建或运行的时候解析翻译到具体内存地址中。

常量池的每一项常量都是一个表,JDK1.7 之前有 11 种结构不同的表数据,而在 JDK1.7 知乎为了更好支持动态语言调用,额外增加了 3 种:CONSTANT_MethodHandle_info、CONSTANT_MethodType_info 和 CONSTANT_InvokeDynamic_info。

这 14 种表有个共同特点——开始的第一位为一个 u1 类型的标志位,代表了这个常量属于哪种常量类型。下面是这个标志位代表的具体类型:

类型 标志位 描述
CONSTANT_Utf8_info 1 UTF-8 编码
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类/接口的符号引用
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Fieldref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的部分符号引用
CONSTANT_MethodHandle_info 15 方法句柄
CONSTANT_MethodType_info 16 方法类型
CONSTANT_InvokeDynamic_info 18 一个动态方法调用点

这 14 种常量类型分别有自己独有的结构。常量池中的第一项常量,它的标志位是 0x07,对应表中可以发现这个常量是 CONSTANT_Class_info 类型,它表示的是一个类或者接口的符号引用,结构如下:

类型 名称 数量
u1 tag 1
u2 name_index 1

tag 为标志位,用于区分常量类型。name_index 为一个索引,指向了常量池中一个 CONSTANT_Utf8_info 类型,代表了这个类或接口的全限定名。

下面我们看一下 CONSTANT_Utf8_info 的结构

类型 名称 数量
u1 tag 1
u2 length 1
u1 bytes length

length 说明了这个字符串有多少个字节,后面紧跟的长度为 length 的 bytes 存储了使用 UTF-8 缩略编码表示的字符串。这种缩略编码将从 '\u0001' 到 '\u007f' 之间的字符用一个字节表示,从 '\u0080' 到 '\u07ff' 的字符用两个字节表示,从 '\u0800' 到 '\uffff' 之间的字符用三个字节表示。

在 Class 文件中,方法、字段等都下需要 CONSTANT_Utf8_info 类型的常量来描述名称,因此 Java 中方法、字段名的最大值就是 length 的最大值。也就是如果定义了名字超过 64KB 的方法或字段就会导致无法编译。

Oracle 提供了一个分析 Class 文件字节码的工具——javap。通过javap可以帮助我们分析 Class 文件字节码。

尝试使用 javap

下面我们尝试使用 javap 进行java文件的分析。我们编写了一个名为 JavapDemo.java 的文件

然后通过 javac JavapDemo.java 将其编译为 Class 文件

javac JavapDemo.java

之后通过 javap -verbose JavapDemo.class 对它的 Class 文件进行分析

javap -verbose JavapDemo.class

我们只关注常量池部分,因此这里只贴出常量池部分的结果:

Constant pool:

#1 = Methodref #6.#21 // java/lang/Object."":()V

#2 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream;

#3 = Class #24 // JavapDemo

#4 = Methodref #3.#25 // JavapDemo.add:(II)I

#5 = Methodref #26.#27 // java/io/PrintStream.println:(I)V

#6 = Class #28 // java/lang/Object

#7 = Utf8 CONST

#8 = Utf8 I

#9 = Utf8 ConstantValue

#10 = Integer 20

#11 = Utf8

#12 = Utf8 ()V

#13 = Utf8 Code

#14 = Utf8 LineNumberTable

#15 = Utf8 add

#16 = Utf8 (II)I

#17 = Utf8 main

#18 = Utf8 ([Ljava/lang/String;)V

#19 = Utf8 SourceFile

#20 = Utf8 JavapDemo.java

#21 = NameAndType #11:#12 // "":()V

#22 = Class #29 // java/lang/System

#23 = NameAndType #30:#31 // out:Ljava/io/PrintStream;

#24 = Utf8 JavapDemo

#25 = NameAndType #15:#16 // add:(II)I

#26 = Class #32 // java/io/PrintStream

#27 = NameAndType #33:#34 // println:(I)V

#28 = Utf8 java/lang/Object

#29 = Utf8 java/lang/System

#30 = Utf8 out

#31 = Utf8 Ljava/io/PrintStream;

#32 = Utf8 java/io/PrintStream

#33 = Utf8 println

#34 = Utf8 (I)V

访问标志

常量池知乎紧跟的 2 个字节表示了访问标志,用于识别一些类或接口层次的访问信息,包括了 Class 是类还是接口、是否定义为 public 类型、是否定义为 abstract 类型,如果是类的话,是否被标明为 final 等等,具体含义如下:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 是否为 public
ACC_FINAL 0x0010 是否为 final(仅限类)
ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码指令的新语义(JDK 1.0.2 之后进行过改变)
ACC_INTERFACE 0x0200 标识为一个接口
ACC_ABSTRACT 0x0400 是否为 abstract 类型
ACC_SYNTHETIC 0x1000 标识这个类非由用户代码生成
ACC_ANNOTATION 0x2000 标识这是一个注解
ACC_ENUM 0x4000 标识这是一个枚举

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

类索引和父类索引都是 u2 类型的数据,接口索引组合则是一组 u2 类型的数据的集合。Class 文件根据这三项数据确定类的继承关系。类索引用于确定类的全限定名,父类索引用于确定类的父类全限定名。由于除了 java.lang.Object 外所有类都有父类,因此除了它之外所有类的父类索引都不为0。而接口索引集合则是将接口按 implements 语句的顺序从左到右排列。

类索引和父类索引各自指向了一个 CONSTANT_Class_info 类型的常量,通过它可以找到类的全限定名字符串。

而接口索引集合的第一项则是一个接口计数器,用于表示索引表容量,之后紧跟的则是接口索引表。

字段表集合

字段表描述了接口或者类中声明的变量。包括了类级变量及实例级变量(不包括方法中的局部变量)。

它的结构如下:

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

字段修饰符放在 access_flags 项目中,与类中的 access_flags 十分类似,均为 u2 类型。

name_index 和 descriptor_index 均是对常量池的引用,分别代表了字段的简单名称和方法的描述符。

下面解释一下简单名称、描述符以及全限定名的含义:

  • 全限定名:将类全名中的 '.' 替换为 '/' ,在最后加上 ';'
  • 简单名称则是指没有类型和参数修饰的方法或字段名称。
  • 而描述符其实就对应了我之前关于 NDK 的博客中的方法签名,详细的可以参考这篇文章

字段表的固定数据到 descrpiptor_index 就结束了。在它之后跟了一个属性表集合来存储一些额外信息。

方法表集合

方法表和字段表的内容几乎完全一致,仅仅在访问标志和属性表集合的可选项中有区别。

因为 volatile 和 transient 关键字不能修饰方法,所以方法表访问标志中没有了 ACC_VOLATILE 和 ACC_TRANSIENT 标志。

同时因为 synchronized、native、strictfp 和 abstract 可以修饰方法,因此在访问标志中增加了 ACC_STYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 标志。

属性表集合

在 Class 文件、字段表、方法表都可以携带自己的属性表集合,来描述某些场景专有的信息。

属性表不再要求各个属性有严格的顺序,只要不与已有属性名重复,任何人实现的编译器都可以向属性表写入自己定义的属性信息。JVM 运行时会忽略不认识的属性。在早期的《Java 虚拟机规范》中预定义了 9 项虚拟机应当能识别的属性,而如今在最新的规范中,预定义属性已经扩张到了 21 项。下面会解释其中关键常用的几项。

Code 属性

Java 方法体中的代码经过 Javac 编译后,会变为字节码指令存储于 Code 属性中。Code 属性存储于方法表的属性表集合中,但不是所有方法表都具有 Code 属性(如接口、抽象类中的方法)。

Code 属性的结构如下:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 max_stack 1
u2 max_locals 1
u4 code_length 1
u1 code code_length
u2 exception_table_length 1
exception_info exception_table exception_table_length
u2 attributes_count 1
attribute_info attributes attributes_count

attribute_name_index 为一个指向了 CONSTANT_Utf8_info 类型的索引。它的常量值固定为 "Code"。

attribute_length 指示了属性值的长度,固定为整个属性表长度减去 6 字节(attribute_name_index 及 attribue_length)。

max_stack 表示操作数栈深度最大值,虚拟机运行时需要根据这个值来分配栈帧中的操作栈深度。

max_locals 表示局部变量表需要的存储空间,单位是 Slot。Slot 是虚拟机为局部变量分配内存的最小单位,对于 byte、char 等等这些不超过 32 位的数据类型,占用 1 Slot。而对于 double、long 这两种 64 位的数据类型则需要 2 Slot。另外,并不是方法中用到多少局部变量,就把它们所占 Slot 之和作为 max_locals 的值。因为局部变量表中 Slot 可以复用,当代码执行超出一个局部变量作用域时,这个局部变量占的 Slot 就可以被其他局部变量使用。Javac 会根据变量作用域来分配 Slot,之后计算出 max_locals 的大小。

code_lengthcode 用于存储 Java 源码编译后的字节码指令。由于 code 是一个 u1 类型的值,也就是说 JVM 最多可以表达 256 条指令。

而关于 code_length, 由于它是一个 u4 类型的值,也就是说明理论上最大值为 2^32-1。但由于 Java 虚拟机规范中限制了一个方法不能超过 65535 条字节码指令,因此它实际只使用了 u2 的长度。

之后的 exception_table_lengthexception_table 是方法的显式异常处理表集合。对于 Code 属性并不是必须存在的。它包含四个 u2 类型的字段,分别是 start_pc、end_pc、handler_pc、catch_pc。具体含义为:如果当前的字节码在 start_pc 行到 end_pc 行之间出现了 catch_pc 类型或者其子类的异常,则转到第 handler_pc 行继续处理。如果 catch_pc 的值为 0,则任意异常情况都会跳转到 handler_pc 进行处理。

可以发现,我们常用的 Java 异常及 finally 处理机制就是通过异常表来实现的。

Exceptions 属性

此处的 Exceptions 属性是与 Code 平级的一种属性,列举出了方法中可能抛出的受查异常,也就是 throws 后面列举的异常

LineNumberTable 属性

LinerNumberTable 属性描述了 Java 源码行号与字节码行号的对应关系。并不是运行时必需的属性,但会默认生成到 Class 文件中。可以在 Javac 中用 -g:none 或者 -g:lines 选项取消或要求生成此信息。

如果不生成此信息,则抛出异常时,堆栈中不会显示出错行号。并且调试程序时无法按源码行进行断点的设置。

LocalVariableTable 属性

LocalVariableTable 属性描述了栈帧中局部变量表中的变量与 Java 源码中定义的变量间的关系,也不是必须的属性。可以通过 -g:none 或 -g:vars 来取消或要求生成此信息。

如果不生成此信息,则其他人引用这个方法时,所有参数名称会消失。编译器会用 arg0、arg1 这类占位符进行代替。对代码编写带来了较大不便,且调试时无法根据参数名获取到具体参数值。

SoureceFile 属性

SourceFile 用于记录生成 Class 文件的源码文件名称,也是可选的。可通过 -g:none 或 -g:source 来取消或要求生成此信息。

如果不生成此信息,抛出异常时,堆栈中不会显示出错代码所属文件名。

ConstantValue 属性

ConstantValue 属性作用是通知虚拟机自动为静态变量赋值。被 static 修饰的变量才可以使用此属性。

对于非 static 类型的变量,赋值是在实例构造器的 <init> 方法中进行的。

而对于类变量,有两种方式选择:在类构造器 <clinit> 方法中或者使用 ConstantValue 属性。

InnerClasses 属性

InnerClasses 属性用于记录内部类与宿主类间的关联。若一个类定义了内部类,则编译器会为它及所包含的内部类生成 InnerClasses 属性。

Deprecated 及 Synthetic 属性

这两个属性都属于布尔属性,只存在有或没有的概念,没有属性值的概念。

Deprecated 属性用于表示某个类、字段或者方法已被作者定位不再建议使用。可以用 @Deprecated 注解来进行设置。

Synthetic 属性表示此字段或者方法不是 Java 源码生成,而是由编译器自己添加的。

参考资料

参考资料:《深入理解 Java 虚拟机(第二版)》


Android Developer in GDUT