本文以代码示例来学习 java 类文件的结构,其中对类文件结构的学习均来自周志明先生所著的 《深入理解 Java 虚拟机》一书,在此表示诚挚的感谢。
代码如下:
1 package com.reycg.jvm; 2 3 public class ReferenceCountingGC { 4 5 public Object instance = null; 6 7 public static void testGC() { 8 ReferenceCountingGC objA = new ReferenceCountingGC(); 9 ReferenceCountingGC objB = new ReferenceCountingGC(); 10 11 objA.instance = objB; 12 objB.instance = objA; 13 14 objA = null; 15 objB = null; 16 17 System.gc(); 18 } 19 20 21 public static void main(String[] args) { 22 ReferenceCountingGC.testGC(); 23 } 24 25 }
使用 winHex 打开对应的 class 文件,如下图显示
下面对这些数据进行分析
Magic Number 魔数
每个 Class 文件的头 4 个字节称为 magic number,它的作用就是确定这个文件是否是一个能够被 jvm 接受的 Class 文件。
0xCAFEBABE 咖啡宝贝,这个充满魔性的代号就是 class 文件的 magic number。
Class 文件的版本
紧接着 magic number 的四个字节就是 Class 文件的版本号,其中前两个字节表示次版本号,后两个字节表示主版本号。
00 00 00 33 就表示 JDK 1.7.0 版本。
常量池
上图中发灰的部分,也就是紧紧挨着主次版本号后的区域就是常量池的入口。
常量池可以理解为 Class 文件中的资源仓库。它在 class 文件中一般占用的空间最大。
常量池入口出放置了一个 u2 类型的数据,u2 也就是两个字节的意思,表示常量池容量的计数值。
从图中可看出数值为 0x0023,从 Data interpreter 中可看出十进制为 35。
与 Java 习惯语言不一致的是,这个容量计数是从 1 开始,而不是从 0 开始的,因此这就表示常量池中包含 34 个常量。
常量池中主要存放两大类常量:字面量 Literal 和 符号引用 Symbolic References。其中
- 字面量比较接近 Java 语言中的常量概念,包括
- 文本字符串
- 声明为 final 的常量值等
- 符号引用则属于编译原理方面的概念
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
class 文件中不会保存各个方法,字段的最终的内存信息,而是在 jvm 加载 class 文件的时候才会进行动态连接,翻译成具体的内存地址。
常量池的每一项常量都是一个表,在 JDK 1.7 中共有 14 种结构各不相同的结构数据。在 《深入理解 java 虚拟机》一书中有详细的介绍,此处不再赘述。
需要说明的是每个类型结构数据表结构,最开始都是一个 u1 类型的标志位 tag,可以根据这个 tag 直接对应到到底是哪个常量类型。
观察上图, 07 则表示 tag 标志位,通过它则可知道这个常量属于 CONSTANT_Class_info 类型,该类型表示一个类或者接口的符号引用。
CONSTANT_Class_info 的结构如下
类型 | 名称 | 数量 |
u1 | tag | 1 |
u2 | name_index | 1 |
该结构的 name_index 是一个索引值,指向常量池中一个 Constant_Utf8_info 类型常量,该常量则代表了这个类或者接口的全限定名称。
此处 name_index 则为 0x0002 也就是指向常量池中的第 2 项常量。
继续查找第二项常量,如上图,所有的灰色区域都是第二项常量的范围。
从 tag 为 01 上可得到该常量为 CONSTANT_Utf8_info 型常量。该常量描述为 UTF-8 编码的字符串。
该常量的结构为
类型 | 名称 | 数量 |
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
length 值则表示这个 UTF-8 编码的字符串长度是多少个字节。它后面会紧跟着长度为 length 字节的连续数据,该段连续数据是一个使用 UTF-8 缩略编码表示的字符串。
由于 length 值是 u2 类型的数据,因此它能表示的最大长度为 65535,因此字符串的最大长度也就是 65535 (64k),如果 Java 中定义了超过 64k 的英文字符的变量或者方法名,就会无法编译。
截止到现在,我们已经分析了常量池中 34 个常量中的两个,其余的 32 个常量都可以通过类似的方法计算出来。后面的常量方式也都可以通过这种方式计算出来,此处就不再一一分析。
实际上有现成的软件可以帮助我们完成这一过程, Oracle 公司专门有一个用于分析 Class 文件字节码的工具 javap。关于 javap 的 help 文档如下所示
下图列出了使用 javap 工具的 -verbose 参数输出的 ReferenceCountingGc.class 文件字节码内容,注意此处省略了常量池之外的其他信息。
访问标志
在常量池结束后,后面的两个字节就表示访问标志 access_flags, 这个标志用来标识一些 class 或者接口层次的访问信息。
通过页首的 java 代码可以看出 ReferenceCountingGC 的访问标志为 public class,其中
- public 标志名称为 ACC_PUBLIC, 值为 0x0001
- ACC_SUPER 标志在 JDK 编译出来后就必须为真,其值为 0x0020
其他的标志都为假,因此它的 access_flags 为 0x0001|0x0020=0x0021. 从上图看出确实为 0x0021
类索引,父类索引与接口索引集合
Class 文件由类索引,父类索引和接口索引,这 3 项数据来确定该 class 的继承关系,其中
- this_class 类索引
- 确定这个类的全限定名
- u2 类型
- super_class 父类索引
- u2 类型
- 确定这个类的父类的全限定名
- interface 接口索引集
- 是一组 u2 类型的数据集合
- implements 语句对应,从左至右依次排列
访问标志后的 3个 u2 数据依次为 0x0001, 0x0003, 0x0000。也就是说
- 类索引 0x0001
- 父类索引 0x0003
- 接口索引的大小 0x0000
通过查询 javap 命令计算出的常量池,找出对应的 class 和父 class 的常量
从图中对应得出
- 类索引指向 #1 // com/reycg/jvm/ReferenceCountingGC
- 父类索引 #3 //// java/lang/Object
字段表集合 field_info
字段表用来描述接口或者 class 中声明的变量。
字段 field 则包括
- 类级变量
- 实例级变量
此处需要总结下在 Java 中描述一个字段需要包括的信息
- 作用域 (public, private, protected 修饰符)
- 实例变量还是类变量 static 修饰符
- 可变性 final
- 并发可见性 volatile 修饰符,也就是是否强制从主内存读写
- 是否可以被序列化 transient 修饰符
- 字段类型数据(基本类型,对象,数组)
- 字段名称
根据上面的分析,下表就列出了字段表的最终格式
类型 | 名称 | 数量 |
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | description_index | 1 |
u2 | attribute_count | 1 |
attribute_info | attributes | attributes_count |
access_flags 就是访问修饰符,有些是强制的,有些不是强制的,依据具体情况而定。
name_index 和 description_index,都是对常量池的引用,但两者有所区别
- name_index 表示字段的简单名称
- description_index 表示字段和方法的描述符
简单名称,描述符以及限定名这 3 个字符串的概念
- 全限定名,如 com/reycg/jvm/ReferenceCountingGC
- 简单名称,指没有类型和参数修饰的方法或者字段名称,比如 testGC
- 方法和字段的描述符
- 作用是用来描述字段的数据类型,方法的参数列表和返回值
- 方法的参数列表中包含
- 数量
- 类型
- 顺序
描述符标识含义如下表所示
标识字符 | 含义 |
B | 基本类型 byte |
C | 基本类型char |
D | 基本类型 double |
F |
基本类型 float |
I | 基本类型 int |
J |
基本类型 long |
S | 基本类型 long |
Z | 基本类型 boolean |
V | 特殊类型 void |
L | 对象类型 如 Ljava/lang/object |
对于数组来说,每个维度都会使用一个前置的 "[" 字符来描述,如一维数组 java.lang.String[] 将会被记录为 "[java/lang/String"。
用描述符来描述方法时,会按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号内,如
- void inc() ---> ()V
- java.lang.String toString() --> ()Ljava/lang/String
- int indexOf(String str, int fromIndex) --> indexOf:(Ljava/lang/String;)I
现在结合前面字段表的最终格式表格,继续看 winhex class 数据
- 红色框中 u2 类型数据 0x0001 表示字段表的数量 field count ,说明这个 class 只有一个字段表数据
- 后面会紧跟着对这个字段表数据进行描述
- 0x0001 表示 access_flags public
- 0x0005 表示 name_index ,从前面的常量表中可查到 #5 是一个 utf8 字符串 instance
- 0x0006 表示 description_index, 指向常量表中的 #6 表示 L/java/lang/object
- 0x0000 表示 attributes_count 其值为 0, 那么后面也就不需要 attribute_info 字段了
由此可以推断出源代码定义字段为
private Object instance;
方法表集合
如果对字段表集合能够做到很好的理解,要理解方法表集合也就非常容易了。方法表的结构与字段表的结构非常类似,如下表所示。
类型 | 名称 | 数量 |
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attribute_count |
字段表集合和方法表集合最大的区别在于访问标志和属性表集合的可选项有所区别。
继续分析 class 文件
- 0x0003 表示集合中有两个方法
- 第一个方法集合数据
- access_flags 0x0001 : 方法为 public
- name_index 0x0007 : 指向常量表 #7 = Utf8 <init>
- descriptor_index 0x0008 #8 = Utf8 ()V
- attributes_count 0x0001 表示该方法的属性表集合中有一项属性
- attributes 属性名称索引 0x0009 对应常量是 Code,说明该属性是方法的字节码描述
有上面分析,第一个方法集合描述的是 <init> 方法,它是由编译器自动添加的方法,叫做实例构造器方法,会在编译器优化章节中涉及到。
属性表集合
属性表这个概念在前面已经出现了数次,字段表,class 文件,方法表都可以携带自己的属性表集合,用来描述某些场景下专有的信息。
一个符合负责的属性表应该满足下表中所定义的结构
类型 | 名称 | 数量 |
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | 1 |
在 java 虚拟机规范 Java SE 7 版中,预定义的属性有 21 项,下文会结合着 class 字节码对其中一些属性的关键部分进行讲解。
1. Code 属性
Java 程序方法体中的代码经过 Javac 编译器处理后,最终会变成字节码指令存储在 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 | attribute_count | 1 |
attribute_info | attributes | attributes_count |
结合下图 class 文件内容进行分析
上面讲到,方法 <init> 的 attribute 是 Code, 0x0009。
attribute_length 表示属性值的长度 0x0000003c 转换成十进制就是 60 字节
max_stack 0x0002 表示操作数栈 Operand Stacks 深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。
max_locals 0x0001 表示局部变量表所需要的存储空间。max_locals 的单位是 Slot。
Slot 是虚拟机为局部变量分配内存使用的最小单位。对于 byte, char, float,int, short,boolean 和 returnAddress 等长度不超过 32 位的类型数据,每个局部变量占用一个 slot,而 double 和 long 这两种 64 位的数据类型则需要两个 Slot 来存放。
code_length 和 code 用来存储 Java 源程序编译后生成的字节码指令。code_length 表示字节码长度 0x0000000A,表示 10 个字节。后面的 10 个字节,也就是蓝色方框中的数据就是 code 字节码。
code 是用来存储字节码指令的一系列字节流,叫做字节码指令。其中每个指令就是一个 u1 类型的单字节,当 jvm 读取到 code 中的一个字节码时,就会找到对应的字节码代表什么指令,并且可以知道该指令是否需要跟随参数,以及参数应当如何理解。
刚才分析到了 init 方法的 code 字节码,知道占10个字节,下面我们就操刀对这10个字节的 code进行操作分析
winhex class 数据如下
这里再补充一下,下面查询的字节码指令对应关系是从《深入理解 jvm 虚拟机》 附录B 得出。
jvm 翻译这些字节的过程如下
- 读入 2A,查表得到对应的指令为 a_load0, 表示将第 0 个 slot 中为 reference 类型的本地变量推送到操作数栈
- 读入 B7 ,对应指令 invokespecial 调用超类构造方法,实例初始化方法,私有方法。解释下就是将刚才推送的 reference 类型的本地变量 作为方法参数,调用此对象的实例构造器方法。invokespecial 指令本身有一个 u2 类型的参数,来说明具体调用哪个方法
- 读入 0x000A 就是 invokespecial 的参数 #10 从常量池中可以查到 #10 表示
- Methodref #3.#11 // java/lang/Object."<init>":()V
- 也就是实例构造器 <init> 方法的符号引用
- 读入 2A,a_load0
- 读入 01, aconst_null 将 null 推送到栈顶
- 读入 B5,对应指令为 putfield 为指定的类的实例域赋值,putfield 后面跟一个 u2 的参数
- 读入 00 0C,为 putfield 的参数,到常量表里对应得到 #13
- #13 = NameAndType #5:#6 // instance:Ljava/lang/Object;
- 对应到代码,就是 public Object instance = null;
- 读入 B1 查表得到对应的指令为 return,含义是返回此方法,并且返回值为 void
由此当前整个方法结束。
现在来看下使用 javap 命令将 Class 文件中的 init 方法计算出来得到的结果。
从图中可看出,与我们的计算结果能够精确的吻合在一起。
这里有个问题 locals = 1, args_size =1 表示参数为 1, 但是在 init 方法体中并没有定义任何局部变量和参数。这是为什么呢?
这是因为在任何实例方法里面,都可以通过 "this" 关键字访问到此方法所属的对象。 javac 编译器在编译的时候,会把对 this 关键字的访问转变为对一个普通方法参数的访问,然后再 jvm 调用此参数时自动传入此参数。
而如果方法声明为 static 类型就不会将 this 传入,看代码中的 testGC 就可以得到验证。
未完待续。。。