前言
Java程序具有 " Write Once , Run Anywhere ." 的跨平台特性。实现这样的目的,Java的方案是:半编译 + 半解释,即 .Class + JVM 。
1、源程序内容会被编译为.Class文件,.Class文件具有严格规定如何从中提取信息,可以理解为 “中间码”,约定使用者如何理解文件内容 2、理解了程序内容,各个平台根据自身特色不同,实现各自的JVM用来解释(翻译).Class文件,变成真正的本地可执行指令。
如此实现了Java跨平台的特性。因此,跨平台的基础为.Class,实现为JVM。
本文的目的为:读懂.Class,悉知编写的程序代码在JVM眼中是什么样子。而在理解了.Class之后,对于理解JVM、理解字节码插桩等有进一步帮助。
基础知识
字节码
字节码是一种包含执行程序,由数据对组成的二进制文件,是一种中间码。一般来说,一字节占用8位,即包含八位的二进制。
文章所指的.Class文件为字节码文件,因每字节占8位,故使用16进制表示,易于阅读,数值范围 00 ~ FF (0 ~ 255).
无符号数基本类型
无符号数可以用来描述数字、索引引用、数值量或按照 UTF-8 编码构成字符串。u1、u2、u4、u8分别代表 1个字节、2个字节、4个字节和8个字节的无符号数。
字面量
字面量是一种固定值的表示法,本身没有含义,需要场景来为它赋予含义,如何理解?比如 007 没有含义,但是用来表示詹姆斯·邦德,你就知道007代表一个很厉害的特工。在程序中,int x = 10、String s = "10" 让字面量 10 具有了不同的意义。
全限定名
将一个类的全限定名是将类全名的.全部替换为/,如java.lang.String替换为java/lang/String
描述符
描述符用来描述字段的数据类型、方法的参数类表和返回值,每种符号对应不同数据类型
| 标识字符 | 含义 |
|---|---|
| B | byte |
| C | char |
| D | double |
| F | float |
| I | int |
| J | long |
| S | short |
| Z | boolean |
| V | void |
| L | 对象类型,如String表示为 Ljava/lang/String; |
Class文件
Java文件包含了一个类的所有信息,以下是一个Java类:
import java.io.Serializable;
public class TestClass implements Serializable{
private int m = 123;
private static int x = 10;
private static final int y = 20;
public int increace(){
return m+1;
}
public void m() throws Exception{
// 具体逻辑不写
}
public static String hello(){
return "hello word";
}
}
此Java文件中,所包含的信息有:
- 类明为TestClass并可被外部访问,实现了Serializable接口
- 拥有类变量 x和y,拥有成员变量 m
- 拥有可被外部访问的类函数 hello(),拥有可被外部访问的成员函数increace() 和 m()
note: 如无特殊说明,文章所说.Class文件均由此Java文件编译得来
这些信息在被编译后将在.Class文件中进行表达。通过命令
Javac fileName.java
可将Java文件编译成对应的.Class文件。.Class文件为字节码文件,可借助对应编辑器阅读。 本文使用的编辑器为 “010” ,Windows 和 Mac 都有, 自行下载。
.Class文件使用字节码表达信息,各数据间紧凑,不包含任何分隔符,因此整个.Class文件中存储的内容几乎是全部程序运行时的必要数据。如何解析字节码数据,就需要制定规则来解读,严格遵守。
.Class 文件风格采用类似于C语言结构的伪结构来存储数据。可以将.Class文件看成多张表的集合,通过表索引,能找到对应的数据。可以理解为,数据存在的相对位置,决定了它被赋予的涵义。
.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 | field_count | 字段表集合 |
| u2 | methods_count | 1 | 方法数 |
| method_info | methods | methods_count | 方法表集合 |
| u2 | attributes_count | 1 | 属性数 |
| attribute_info | attributes | attributes_count | 属性表结合 |
有些数据信息是定长的,有些视具体情况而定,但都会有相应的约束告知具体长度。各信息对应已在.Class实例文件图标出,剩下的是逐层去解析类信息。
常量池
常量池中主要存放两大类:字面量和符号引用。符号引用包括:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
与C和C++不同,Java代码编译后没有“连接”的步骤,在JVM加载Class文件的时候进行动态连接。.Class文件不会保存各方法、字段的最终内存布局信息,因为不能经过运行期转换无法得到真正的内存入库地址,无法被JVM使用。在JVM运行时,从常量池中拿到对应的符号引用,解析、翻译到具体的内存地址中再进行使用,这些信息也就存于JVM的方法区中。
常量池所占用长度不定,需要 0x0008 ~ 0x0009 提供常量数量统计,再根据常量池里的具体常量类型推算出具体总占用的长度。
但这比较繁琐,每一种常量类型对应一份表,需要根据表的不同查阅具体的表结构来获取信息。常量类型的第一位 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 | 表示一个动态方法调用点 |
除必要外,本文不打算列出各个对应的表结构,具体结构可参考 Class类文件结构之常量表
这里抛砖引玉。
第一个常量类型由 0x000A 处标出,值为0A,十进制为10,查表,类型为CONSTANT_Methodref_info(不得不说现在的编辑器很强大,没有对应功能的话就只能慢慢查了)。表中数据类型为u1、u2、u2 共占 5 个字节(具体表信息和内容含义后文再续)。如果是CONSTANT_Utf8_info类型,还会有 length 属性表明字面量占用字节长度,需要加上此长度。则第二个常量类型由 0x000F 处标出,值为0F,十进制为09,表类型为CONSTANT_Fieldref_info。以此类推...
这样一步一步查找对应常量也是比较麻烦的,好在Java内置类工具——javap可对.Class文件字节码进行分析,通过命令
javap -verbose fileName
能得到下图信息 :(仅展示了常量池部分)
常量池数量值在 0x0008 ~ 0x0009 为 23,转换十进制为35,表示常量池索引范围为 1~35。观察上两张图,前者索引从0开始,后者索引从1开始。
若摸不着门路,常量池的分析着实让人头大,个人看来,常量池里的信息是在 “搭积木” 。
本例子中,常量池涉及到的常量类型为:
- CONSTANT_Methodref_info
- CONSTANT_Fieldref_info
- CONSTANT_String_info
- CONSTANT_Class_info
- CONSTANT_Interger
- CONSTANT_NameAndType
- CONSTANT_Utf8
暂时抛开具体表结构,以上表类型结构关系如示:
上面仅画出了当前例子涉及到的常量类型的组成关系,任意类型的常量,不断拆分,最后都会指向基本类型的常量CONSTANT_Utf8,或自身就为基本类型如CONSTANT_Interger。可以理解为, 基本常量类型CONSTANT_Utf8本身没有过多意义,其它的类型为场景,为CONSTANT_Utf8赋予了意义。
CONSTANT_Utf8_info可以算是最基本的类型,结构为
// 伪代码
{
// 常量类型
u1 tag;
// 字节长度
u2 length;
// UTF-8缩略编码
bytes[length];
}
在遇到CONSTANT_Utf8_info类型的常量时,将bytes逐个按照UTF-8缩略编码即可得到对应的字面量
类级信息
定义的类为
public class TestClass implements Serializable
其中包含的信息为:
- 类本身: TestClass
- 访问标志:public
- 实现接口Serializable
- 父类为:Object
从.Class文件格式表中,在常量池后仅接着的数据,就是类级数据
从 0x0143 ~ 0x014C :
- access_flags(u2): 十六进制值为 0x0021
- this_class(u2): 十进制值为5,指向常量池第5个常量, 类型为CONSTANT_Class_info,类为 TestClass
- super_class(u2): 十进制值为5,指向第6个常量,类型为CONSTANT_Class_info,类为 java/lang/Object
- interface_count(u2): 实现接口数量 1 个
- interface[0] : 指向常量池第7个常量,类型为CONSTANT_Class_info,接口名诶 java/io/Serializable
CONSTANT_Class_info 常量表结构如下
// 伪代码
{
// 常量类型
u1 tag ;
// 指向常量池偏移量为name_index,类型为CONSTANT_Utf8_info类型的索引,
//代表类或接口的权限定名
u2 name_index;
}
与之前所说常量池里在搭积木的说法一致,后面涉及到的常量池里的类型依然如此。
访问标志使用标志位来表示,各个标志含义如表
| 标志名称 | 标志值 | 含义 |
|---|---|---|
| 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 | 标识这是一个枚举 |
当前情况为 0x0001 | 0x0020 = 0x0021
attribute(属性表)
属性表比较特殊,.Class文件、字段表、方法表等都可以携带自己的属性表集合,用来描述专有的场景,也因此将此表做前置说明。
属性表的特点为:
- 规则较宽松,不要求严格的顺序、长度、内容
- 只要不与已有属性表重复,任何编译器都可以向属性表中写入自定义的属性信息,JVM会忽略掉不认识的属性。
属性表结构为
// 伪代码
{
// 指向常量池类型为CONSTANT_Utf8_info的常量,代表属性名
u2 attribute_name_index ;
// 属性表info占用长度
u4 attribute_length;
// 这需要具体实现的结构,长度为attribute_length
Info info;
}
因此一个属性表的长度为 u2 + u4 + attribute_length。
Java与定义来很多属性表,文章检出涉及到的做后续说明,其它在实际需要时自行查阅
| 属性名称 | 使用位置 | 含义 |
|---|---|---|
| Code | 方法表 | Java代码编译成的字节码指令 |
| ConstantValue | 字段表 | final 关键字定义的常量值 |
| Exceptions | 方法表 | 方法抛出的异常 |
| LineNumberTable | Code属性 | Java源码的行号与字节码指令对应的关系 |
| SourceFile | 类文件 | 记录源文件名称 |
字段表与方法表携带的属性表暂未涉及,当前节点涉及到类型为SourceFile的.Class携带的属性表。
范围为 0x025A ~ 0x0262 共占 u2 + u4 + attibute_length = 8 字节 SourceFile属性结构如下
伪代码
{
// 指向常量池类型为CONSTANT_Utf8_info的常量,代表属性名
u2 attribute_name_index ;
// 属性表内容占用长度
u4 attribute_length;
// 指向常量池类型为CONSTANT_Utf8_info的常量,代表源文件名
u2 sourcefile_index;
}
因此通过此SourceFile属性表,得知源文件名为TestClass.java
字段表
查阅.Class文件格式表,接口表之后,就是字段数量已经字段数量表
从 0x014D ~ 0x016E , 其中 0x014D ~ 0x014E 表示字段数, 值为 0x0003,表示字段数为3,随便就是紧挨着的字段表。字段表结构如下
// 伪代码
{
// 访问标志
u2 access_flags
// 指向常量池类型为CONSTANT_Utf8_info的常量,表示字段名
u2 name_index
// 指向常量池类型为CONSTANT_Utf8_info的常量,用描述符表示
// 字段类型
u2 descriptor_index
// 属性表数量
attributes_count
// 属性表内容
attribuite_info
}
字段表和.Class一样,能携带自己的属性表处理特殊场景,attribuite_info是非必须的。当attributes_count的值为0时, 说明无需attribuite_info。字段也拥有访问标志来对字段做进一步约束。字段名则用name_index指向常量池的常量来表示,字段类型则用描述符来表示, 比如 Int 表示为 I (忘了往前看基础知识)。
当前例子定义的字段如下:
private int m = 123;
private static int x = 10;
private static final int y = 20;
定义了成员变量 m 和 类变量 x ,y。 举例 y 来看
位置为 0x015F ~ 0x016E,其中:
- 访问标志: 0x001A
- 字段名索引: 0x000B,十进制值为11,指向常量池第11个常量,为y
- 描述符索引为:0x0009,指向常量池第9个常量,为I
- 属性表数为: 0x0001,数量为1
- 属性表总占用长度为:u2 + u4 + 2字节 共10位,即 0x0167 ~ 0x016E
字段访问标志位含义如表:
| 标志名称 | 标志位 | 含义 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 是否为public |
| ACC_PRIVATE | 0x0002 | 是否为private |
| ACC_PROTECTED | 0x0004 | 是否为protected |
| ACC_STATIC | 0x0008 | 是否为static |
| ACC_FINAL | 0x0010 | 是否为final |
| ACC_VOLATILE | 0x0040 | 是否为volatile |
| ACC_TRANSIENT | 0x0080 | 是否为transient |
| ACC_SYNTHETIC | 0x1000 | 是否是由编译器自动产生 |
| ACC_ENUM | 0x4000 | 字段是否enum |
当前为 private static final ,即 0x0001 | 0x0008 | 0x0010 , 为 0x001A。 通过访问标志、字段索引、描述符信息,就可以拿到 -> private static final int y 这一信息。
对于使用 final和static修饰的并且时基本数据类型的变量,会使用属性表ConstantVulue来进行赋值。属性表 ConstantVulue 除了约定的基本数据外,还有类型为 u2 的ConstantValue_index 索引来表示指向常量池中的常量用来初始化数据。值位于 0x016D ~ 0x016E,为 0x000D,指向的常量表索引13处的值为整型的20;
而实例中的变量m,类变量x则在成员初始函数、类初始函数中进行赋值,下文做说明。
方法表
字段表之后,紧挨着的是方法数量与方法表集合
范围为 0x016F ~ 0x0258,方法数值为 0x0005 共 5 个方法。除了示例自定义的 increace() ,m() 和 hello() 外,还有实例构造方法()v,类构造器()方法。
方法表结构为:
伪代码
{
// 访问标志
u2 access_flags;
// 方法名索引,指向常量池类型为CONSTANT_Utf8_info的常量
u2 name_index;
// 方法返回值描述符索引,指向常量池类型为CONSTANT_Utf8_info的常量
u2 descriptor_index;
// 属性表数量
u2 attributes_count;
// 属性表内容
Info info;
}
方法也可以携带属性表来描述专有场景。通过上述结构以及具体字节码,可以推算出方法所包含的内容。其中,访问标志含义如表:
| 标志名称 | 标志值 | 含义 |
|---|---|---|
| ACC_PUBLIC | 0x0001 | 是否为public |
| ACC_PRIVATE | 0x0002 | 是否为private |
| ACC_PROTECTED | 0x0004 | 是否为protected |
| ACC_STATIC | 0x0008 | 是否为static |
| ACC_FINAL | 0x0010 | 是否为final |
| ACC_SYNCHRONIZED | 0x0020 | 是否为synchronized |
| ACC_BRIDGE | 0x0040 | 是否有编译器产生的桥接方法 |
| ACC_VARARGS | 0x0080 | 是否接受不定参数 |
| ACC_NATIVE | 0x0100 | 是否为native |
| ACC_ABSTRACT | 0x0400 | 是否为abstract |
| ACC_STRICTFP | 0x0800 | 是否为strictfp |
| ACC_SYNTHETIC | 0x1000 | 是否由编译器自动产生 |
取构造实例方法方法()做示例来看:
信息为:
- access_flags: 0x0001 ,为public
- name_index: 0x000E,为14,对应常量池得到
- descriptor_index: 0x000F,为15,对应常量池得到 ()V
- attributes_count: 0x0001,为1,属性表数量为1
可以根据信息反推得到函数信息,实例化函数被表达为 public ()V,依次为访问标志位、函数名、返回值。
一个函数方法,更重要的如何表述它所提供的功能。本质上来说,函数体里所有的代码段都是在进行运算操作,因此,只要将函数体里的代码段转换为字节码指令即可,函数执行时根据执行即可,再次之上再几率一下关键信息,就能得出函数执行、栈深度、局部变量数、字节码占用文件大小。
在attributes_count之后,从 0x0179 ~ 0x01A5 是属性表包含的内容。根据之前所说的属性表约定的格式 0x0179 ~ 0x0180 位置值为 0x0010,十进制为16,查找啊常量池知属性表为Code类型。
Code类型属性表结构如下:
// 伪代码
{
// 属性表名称索引,指向常量表类型为CONSTANT_Utf8_info的常量
u2 attribute_name_index;
// 属性表长度
u4 attribute_length;
// 栈深
u2 max_stack;
// 局部变量数
u2 max_locals;
// 字节码指令长度
u4 code_length;
// 字节码指令
u1 code code_length;
// 异常表数量
u2 exception_table_length;
// 异常表
exception_info exception_table;
// 属性表数量
u2 attributes_count;
// 属性表
attribute_info attributes;
}
获取方法信息不仅能直接通过阅读字节码文件,通过
javap -verbose className
也可以拿到,一起贴了
红圈为字节码指令集,黄圈为每一条字节码指令,绿圈为Code属性表的基本信息,蓝圈为javap工具解析出的实例函数信息。
首先,最大栈深为2,在函数执行的任意时刻都不会超过这个操作数栈深度的最大值;然后局部变量数为1,表示局部变量表所需的存储空间,单位为Slot,此单位是JVM为局部变量分配内存所使用的最小单位。蓝圈里还有args_sige表示方法接受的参数数,这里为1,也就是 this。最后就是函数代码块里转成的字节码指令里。
字节码指令不在文章的讨论范围内,不妨简单了解。
字节码指令代表着某种特定操作,由一个字节长度代表其操作含义,后面可以跟随0到多个所需操作数。
本例子中的初始化函数被翻译成了: 2A B7 00 01 2A 10 7B B5 00 02 B1 也就是上图蓝圈处的:
{
// 将 this 入栈
0: aload_0
// 唤醒父类实例化函数
1: invokespecial #1
4: aload_0
// 将 123 入栈
5: bitpush 123
// 访问字段 m,将123存入
7: putfield #2
// 方法返回
10: return
}
这里只说明 putfield #2,对应的字节码为 B5 00 02,其中 B5 代表操作执行 00 02 为操作所需参数,值为 0x0002,表示指向常量池类型为 CONSTANT_Fieldref_info 的常量。 CONSTANT_Fieldref_info 结构为
// 伪代码
CONSTANT_Fieldref_info
{
u1 tag;
// 指向常量池类型为CONSTANT_ClassInfo_info的常量
// 代表字段所属类
u2 class_index;
// 指向常量池类型为CONSTANT_NameAndType_info的常量
// 代表字段名和类型
u2 name_and_type_index;
}
CONSTANT_ClassInfo_info
{
u1 tag;
// 指向常量池尾CONSTANT_Utf8_info的常量
// 代表类名
u2 name_index;
}
CONSTANT_NameAndType_info
{
u1 tag;
// 指向常量池尾CONSTANT_Utf8_info的常量
// 代表 名称
u2 name_index;
// 指向常量池尾CONSTANT_Utf8_info的常量
// 代表 所属类型
u2 descriptor_index;
}
当前为指向第二个常量,对照信息:
能知道字节码操作 B5 00 02 是将栈中的 123 给 TestClass.m 进行赋值 ,也就是例子中定义的 private int m = 123 的赋值操作。
而 x 的赋值则在类构造器中进行,inicrea()和m()函数也可以用相同的方式进行分析。不再陈述,点到为止。
总结
至此,了解.Class文件的如何,通过.Class文件格式表可以解析出文件内容的基本信息。.Class文件可以看成多张表的集合,根据表制定的规则,顺藤摸瓜,自然能找出对应信息。.Class文件在如何表达信息上不难理解,难的是有耐心去缕清这些琐碎的索引关系,尤其常量池和属性表部分。属性表部分则提供了足够的发挥空间,根据场景提供更多内容。
文章仅了解了解析.Class文件的基本规则,更进一步的解析规则感兴趣或需要时再了解即可,方法不变。
参考
《深入理解Java虚拟机》 —— 第6章