阅读《深入理解Java虚拟机》第六章后,记录下使用WinHex工具分析二进制文件的过程。
源码沿用书中的例子:
public class Hex {
private int m;
public int inc(){
return m+1;
}
}
使用WinHex打开编译后的.class文件:
首先需要知道的是:Class文件中只有两种数据类型:无符号数和表。无符号数属于基本的数据类型,以u1,u2,u4,u8来代表占用1个字节,2个字节,4个字节和8个字节。表则为多个无符号数或其他表构成的复合数据类型,惯以“_info”结尾来表示表结构。所以一个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 | fields_count |
u2 | methods_count | 1 |
method_info | methods | methods_count |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
开始分析各数据项:
魔数(magic)
魔数的唯一作用就是用来识别文件,并且不是Class文件所独有,其他诸如gif或者jpeg图片文件头中都有魔数。在Class文件中,魔数的作用就是告诉虚拟机,我这个文件是Class文件,使得虚拟机能够接受该文件。
Class文件的魔数值为CAFEBABE
,占用4个字节。“咖啡宝贝”?可能这就是Java用咖啡作为商标的原因吧~
次版本号(minor_version)和主版本号(major_version)
各占用两个字节,表示Class文件是由哪个JDK版本编译输出
高版本JDK可以向下兼容低版本,但不能运行更高版本的Class文件,只要超过虚拟机所能接受的版本,即使Class内容无任何变化,虚拟机都会拒绝执行。这里我使用的JDK次版本号为0x0000=0
,主版本号为0x0034=52
,所以该Class文件是由JDK1.8.0所编译的,其他版本号对应的JDK可以在某度找到。
常量池
constant_pool_count
:常量池容量计数值,就是指常量池中一共有多少项常量,用两个字节表示。值得注意的是,这里的计数是从1开始的,而不是我们习惯的从0开始,原因为当某些数据需要表达“不引用任何一个常量池项目”的含义时,就可以将索引值置为0。
0x0016转为十进制是22,表示池中共有21项常量,索引值为1~21。
常量池中主要存放字面量和符号引用,字面量近似于Java中所说的常量概念,如文本字符串String s="123"
,声明为final的常量值static final int V=123
。而符号引用相对比较抽象,个人理解为虚拟机在加载Class文件时,会依据符号引用找到真正指向内存的引用或者句柄。符号引用包括三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
常量池中共有14项常量,每项常量都是结构互不相同的表结构数据,这里列出常量池中的项目类型:
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_Utf8_info | 1 | URF-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 | 表示一个动态方法调用点 |
每一项常量表中,都有一项类型为u1的标志位tag,用于标识属于哪一种常量类型,我们继续分析Class文件:
0x0A=10,从表中我们查找标志为10的常量项,找到CONSTANT_Methodref_info
:类中方法的符号引用。它的表结构为:
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u1 | tag | 1 | 值为10 |
u2 | index | 1 | 指向声明方法的类描述符CONSTANT_Class_info的索引项 |
u2 | index | 1 | 指向名称及类型描述符CONSTANT_NameAndType_info的索引项 |
index索引的值表示常量池中的第几个常量, 0x0004
和0x0012
分别表示第4项常量和第18项常量。继续分析Class文件: tag=0x09
对应的常量项为:CONSTANT_Fieldref_info
,它的表结构:
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u1 | tag | 1 | 值为9 |
u2 | index | 1 | 指向声明字段的类或者接口描述符CONSTANT_Class_info的索引项 |
u2 | index | 1 | 指向字段描述符CONSTANT_NameAndType_info的索引项 |
红蓝两项常量都为CONSTANT_Class_info
,表结构为:
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u1 | tag | 1 | 值为7 |
u2 | index | 1 | 指向全限定名常量项索引项 |
接下来到CONSTANT_Utf8_info
常量项,表结构
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u1 | tag | 1 | 值为1 |
u2 | length | 1 | 指向声明字段的类或者接口描述符CONSTANT_Class_info的索引项 |
u1 | bytes | 1 | 指向字段描述符CONSTANT_NameAndType_info的索引项 |
补充说明,引用书中原话:
length值说明了这个UTF-8编码的字符串长度是多少字节,后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。UTF-8缩略编码与普通的缩略编码区别是:从 ‘\u0001’ 到 ‘u\007f’ 之间的字符(相当于1~127的ASCII码)的缩略编码使用一个字节表示,从 ‘\u0080’ 到 ‘\u07ff’ 之间的所有字符的缩略编码用两个字节表示,从 ‘\u0800’ 到 ‘\uffff’ 之间的所有字符的缩略编码就按照普通UTF-8编码规则使用三个字节表示。
为了演示,我将源码private int m;
改为private int 你;
,再看看Class文件:
长度由一个字节变成三个字节,m:'\u006d'
你:'\u4f60'
。篇幅有限,剩余的常量项可以照这个思路理解,遇到新的常量项可以在某度找到相关的表结构。
访问标志
常量池结束后,紧接着的两个字节就是访问标志(access_flags),访问标志用于标识该Class文件的访问信息,详情参考下表:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为public类型 |
ACC_FINAL | 0x0010 | 是否被声明final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语意,invokespecial指令的语意在JDK 1.0.2发生过改变,为了区别这条指令使用哪种语意,JDK 1.0.2之后编译出来的类的这个标志都必须为真 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,此标志值为真,其他类值为假 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
那么,由源码可以知道,除ACC_PUBLIC
和ACC_SUPER
标志为真之外,其余都为假,所以最终这两个字节的值为:0x0001|0x0020=0x0021,查看Class文件,确实如此:
类索引、父类索引与接口索引集合
类索引和父类索引都是一个u2类型的数据,接口索引集合是一组u2类型的数据的集合,一个Class文件就是由这三项数据来确定类的继承关系。
类索引和父类索引都是指向CONSTANT_Utf8_info
类型常量,而接口索引入口的第一项是u2类型的接口计数器,表示索引表的容量,源码并没有实现任何接口,所以这里计数器为0。
字段表集合
用于描述接口或者类中声明的变量,注意这里的变量只包括类变量和实例变量,而不包括方法中的局部变量。回想一下Java中的变量,无非就包含几种信息:字段作用域(public、private……),字段修饰符(static、final、volatile……),字段类型(基本数据类型,对象,数组),字段名称。看看字段表结构:
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u2 | access_flags | 1 | 字段访问标志 |
u2 | name_index | 1 | 字段简单名称 |
u2 | descriptor_index | 1 | 描述符 |
u2 | attributes_count | 1 | 属性数量 |
u2 | attributes | attributes_count | 属性值 |
字段访问标志:
标志名称 | 标志值 | 含义 |
---|---|---|
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 |
我们结合Class文件来分析:
0x0001
是Class文件的fields_count
数据项,忘记了可以查看本文的第一张表,代表共有多少个变量,源码中只有一个,所以这里的值为1。0x0002
字段的访问标志,明显我们只用了private
修饰符,所以除ACC_PRIVATE
以外的访问标志都为0。0x0005
字段的简单名称,指向常量池中索引值为5的常量,值是m
。0x0006
字段的描述符,指向常量池中索引值为6的常量,值是I
,表示int类型,其他数据类型网上都有介绍。0x0000
是字段表的倒数第二项,代表属性数量,属性之后再介绍,这里属性数量为0,故不存在之后的属性内容。
方法表
方法表结构和字段表结构一样,不同在于表中数据项的值有所变化。对于访问标志,volatile
和transient
关键字无法修饰方法,去除;相对地,增加了synchronized
、native
、strictfp
、abstract
关键字。而方法中的代码,存放在code
属性中,属性之后介绍。我们继续看看Class文件:
0x0002
表示共有两个方法,分别为默认无参构造方法和inc()成员方法。0x0001
第一个方法的访问标志,因为只有ACC_PUBLIC
为真,所以值等于1。0x0007
指向构造器<init>
。0x0008
指向()V
,其中V表示特殊类型void。0x0001
属性数量,代表包含1个属性。
另外,需要注意的是,除非子类重写了父类中的方法,子类方法表中才会出现父类方法的信息。并且,除了父类方法外,编译器也有可能自动添加方法,例如类构造器<cinit>
方法和实例构造器<init>
方法。
属性表
在《Java虚拟机规范(Java SE 7)》版中,预定义属性共有21项,每项属性只能在规定的范围内使用,例如Code
属性只能在方法表中使用,Deprecated
属性可以在“类,方法表,字段表”中使用 。一个符合规则的属性表应该满足以下结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
在讲述方法表时,最后提到<init>
方法有一个属性,我们接着分析:
该属性的值为0x0009
,指向常量池中的第9项Code
,表示属性名为
Code。Code属性是Class文件中最重要的一个属性,用于描述方法中的代码。先看看Code属性表结构:
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u2 | attribute_name_index | 1 | 属性名称 |
u4 | attribute_length | 1 | 属性长度 |
u2 | max_stack | 1 | 最大操作数栈深度,在方法中涉及大量栈的压入和弹出指令,虚拟机运行时根据这个参数来分配栈帧 |
u2 | max_locals | 1 | 局部变量表所需的存储空间,Slot为单位,对于不超过32位的数据类型,占用一个Slot;而类似double,long就要占用两个Slot。用于存放方法参数,显示异常处理参数,方法体定义的局部变量。 |
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 | attributes | attributes_count | 属性 |
关于max_locals
比较疑惑,大多数都会认为这里的存储空间就是局部变量数量相加,其实不然,每个Slot都可以复用,举个栗子:
//占用一个Slot
void inc1(String s){}
//占用两个Slot
void inc2(String s){s+=this.toString();}
首先需要知道的是,在每个实例方法中,this
都会作为方法参数隐式地传入,也就是说每个方法至少需要一个Slot。那为什么inc1方法只占用一个Slog,不是另外传入了参数s
么?原因很简单,inc1方法中没有一个操作同时需要s
和this
参与,一个Slot可以被两者复用,而inc2方法中完成s+=this.toString();
需要this
和s
的同时参与,所以占用两个Slot。
code
代表字节码指令,也是我们分析Class文件的重点关注对象。占用一个字节,每个字节代表一条字节码指令,每条字节码指令都有不同的含义,例如在上面的截图可以看到共有五条指令:
2A aload_0
将第一个引用类型本地变量推送至栈顶B7 invokespecial
调用超类构造方法,实例初始化方法,私有方法00 nop
什么都不做,还记得之前说过常量池是从1开始计数,需要保留0来表示不引用任何一个常量池项目01 aconst_null
将int型-1推送至栈顶B1 return
从当前方法返回void
这五条字节码指令描述了本文实例代码中inc方法的执行过程。目前Java虚拟机规范大概定义了200条指令,具体分析时可以通过查表获取各指令含义。之后就是异常处理表相关项,和属性表相关项,相关表结构可自行查阅资料。另外,配合javap命令可以更方便分析Class文件。