完完整整地剖析 一个由java编译器
编译java源程序
所生成的.class文件的结构/内容
。
文章导航
- 1. 说明
- 2. Java源文件
- 3. 编译
- 4. Class文件格式
- 5. 文件头 ( 魔数+版本号 )
- 6. 常量池
- 7. 类的基本信息
- 8. 类的字段(Filed)
- 9. 类的方法信息(Method)
- 9.1 方法1(<init>)
- 9.2 方法2(getI)
- 9.3 方法3(setI)
- 9.3 方法4(main)
- 9.3 方法5(<clinit>)
- 10. 类的属性(Attribute)
- 11. 最后
1. 说明
注:为了日后研究以及查阅文章的便捷性,我将Test.class文件进行了备份,以便以后再进行分析时,只需要用二进制文件编辑器打开它即可,无需重新对源程序进行编译,Test.class文件的地址为:
Test.class。
我们将使用一个简单的程序Test.java
,我们将其编译成Test.class
文件,并且逐个字节地
分析此.class文件的二进制文件内容
,分析每个字节的含义,从而帮助我们理解Java编译器(javac)
将.java文件编译后生成.class文件,.class文件的结构,内容到底是怎样的。
- 这里使用到的IDE为
IDEA
,所以就让idea让我们编译源文件。 - 为了查看.class文件的二进制形式,我们需要一个
记事本NotePad++
,同时需要在此记事本中安装插件Hex-Editor
。 - 为了验证我们的分析结果是正确的,我们需要一个查看.class文件结构的工具:
jclasslib
,我们直接在IDEA
中安装插件jclasslib即可
至于怎么安装上述的一些软件/插件,网上有很多教程,相信很容易就能够学会的,这里集中精力分析字节码
注意,我们这里查看的16进制是以Hex-Editor默认的Big-endian大端模式查看的
2. Java源文件
下面给出一个非常简单的Java源程序Test.java,我们将其放在jvm.bytecode包
下。
我们现在可以简单地认为这个程序由如下元素构成:
三个字段,其中一个静态字段
三个函数,main函数,和字段i的getter,setter方法
package jvm.bytecode;
public class Test {
String str = "welcome";
private int i = 5;
public static Integer integer = 10;
public int getI() {
return i;
}
public void setI(int i) {
this.i = i;
}
public static void main(String[] args) {
Test test = new Test();
test.setI(8);
integer = 20;
}
}
3. 编译
现在,我们在IDEA
中build project
一下,确认安装好jclasslib
插件后,我们鼠标点击Test.java程序源代码处,然后按照图示,点击View菜单中的jclasslib
工具,能够得到右侧的面版。这个工具用来验证我们分析的字节码是否正确,等到我们分析完一遍字节码,对字节码的结构有着清晰的认识之后,我们就能够在日常生活中直接使用此工具来查看.class文件的底层结构/内容,但是我们现在必须得先分析一遍字节码,后续才能更好地使用此工具。
现在,我们在out目录
中找到编译后生成的字节码文件Test.class
,我们使用NotePad++打开
现在,我们的任务就是完完全全地分析下面列出地每一个字节,以让我们对.class文件的结构有着清晰的认识
4. Class文件格式
首先,我们必须要知道class文件的内容,结构/框架是什么样子的。这样我们才能够按照这个格式来分析其内容。下图就是Class文件的格式。
我们现在只需要知道类型
中的u1,u2,u4
其实是它在字节码中所占的字节数
分别为1,2,4
。
5. 文件头 ( 魔数+版本号 )
现在我们分析一下Class文件的前8个字节。
通过查看Class文件结构,我们将前8个字节称为文件头。
- 前4个字节为
magic
,译作魔数
- 后两个字节为
minor_version
,我们称之为Java的一个小版本号
- 最后的两个字节为
major_version
,我们称之为Java的一个大版本号
大版本号_小版本号
确定java的一个版本。我们可以看到小版本号为0,大版本号为0x34
,也就是52,通过查表,可以得到版本为JDK1.8
。(至于具体的数字对应的版本,可以查表,这里不详细解释。)
最后我们看一看前4个字节cafebabe(咖啡宝贝)
。
也就是说,Java虚拟机要运行class字节码,.class文件
必须要以·cafebabe
开头,随后包含java编译器生成的class文件版本
。
在这文件头之后,才是我们真正编写代码后生成的内容。
6. 常量池
紧接着文件头的是所谓的常量池(constant_pool)
。常量池部分的开头2字节表示有多少常量,随后就紧跟着这么多的的常量。
首先,我们看到常量的个数为0x31,也就是49个
现在我们来验证我们的想法是不是对的,我们查看IDEA Jclasslib面板的信息
通过Jclasslib,我们看到只有48个常量。下面是深入理解Java虚拟机给出的解释
设计者将第0项空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项”的含义
对于每一个常量,它的信息需要查找下面的表来确定。
常量信息的第一项是一个字节的Tag,用于表示常量的类型
,据此我们通过查表,可以找到第一个常量所占的字节如下所示,0x0a表示其为方法引用信息(Constant_Methodref_info)
所以通过查表我们现在能够确定常量池的所有常量,耐心查表,然后对照jclasslib,看自己的分析结果是否正确,下面我给出了我的分析结果,与jclasslib的结果一致:
7. 类的基本信息
按照Class文件的格式,我们现在应该开始分析类的一般信息了。这里的一般信息我们可以理解为对应class类{}外的信息
,比如:
package jvm.bytecode;
public class Test extends Base implements I1, I2{
// ...
}
我们现在研究的就是这部分内容:
package jvm.bytecode;
public class Test extends Base implements I1, I2
-
access_flag
表示类的访问修饰
-
this_class类索引
。用来确定类的全限定名
(这里的Test类的全限定名为jvm.bytecode.Test
) -
super_class
。用来确定父类的全限定名
(这里对应的应该是java.lang.Object
) -
interfaces_count和interfaces
。用来表示类所实现的接口信息。
现在我们接着常量池字节码的后面,开始分析
首先我们根据下表来分析访问标识:
- 访问标识符为
0x21 = 0x20 | 0x01
,所以访问标识为ACC_PUBLIC, ACC_SUPER
。 this_class
标识的索引为0x05
,通过查找常量池
,我们知道0x05代表的常量值为jvm/bytecode/Test
。也就是说this_class
也就是Test的全限定名为jvm/bytecode/Test
super_class
标识的索引为0x0a
,通过查找常量池
,我们知道0x0a代表的常量为java/lang/Object
。也就是说super_class
也就是Test父类的全限定名为java/lang/Object
interfaces_count
为0,根据Test的源代码,我们知道,Test类并没有实现任何接口,所以interfaces_count
确实为0。那么后面的interfaces字节码就不必分析了,它并不存在
。
到此为止,我们已经完成了Class的一般属性的分析
。其实还挺简单。
8. 类的字段(Filed)
根据Class文件的结构,我们现在要接着分析类的字段信息
了。
首先,紧跟着的是2个字节大小的数字,表示有多少个字段。而对于每个字段,它包含的字段信息由下表组成:
字段的信息包括访问标识access_flags
,名字索引name_index
,描述信息索引desc_index
,还有若干个属性
。其中,access_flags
可以通过查下面的表来确定其访问修饰信息,而name_index, desc_index
则要查找常量池的相关索引
来确定其信息。
通过查表,我们能够将各个字段的信息划分出来:
首先00 03
表示有3个字段信息,所以后面紧跟着3个字段信息。
- 第一个字段。
access_flags为00 00
,表示没有任何访问修饰符,根据我们的源代码,我们可以初步判断此字段就是String str = "welcome";
,因为我们并没有给它加任何访问修饰符。再看name_index为00 0b
,通过查找常量池
,可以知道其值为utf8字符串str
。接着是desc_index
值为00 0c
,同理通过查找常量池得到其值为Ljava/lang/String;
,表示此字段为一个引用String类型(L表示引用类型)
。紧接着的00 00
表示属性的个数为0,那么字段后面的属性信息我们就不需要考虑了。所以,通过这8个字节就确定了一个字段的信息为str : Ljava/lang/String
- 第二个字段。同理,我们快速得可以得到字段的信息为
ACC_PRIVATE i I
-
access_flags == 0x02
表示private
-
name_index == 0x0d
,查常量池表得i
-
desc_index == 0x0e
,查常量池表得I
(大写的i
),I是int
的简写。 -
attr_count == 0
,没有任何附加属性。
这里有必要说明一下各个数据类型在字段描述信息中的表示形式:
- 同理,我们能够得到第三个字段的信息:
ACC_PUBLIC ACC_STATIC integer Ljava/lang/Integer
-
access_flags == 0x09 == 0x08 | 0x01
表示ACC_PUBLIC,ACC_STATIC
-
name_index == 0x0f
,查常量池表得integer
-
desc_index == 0x10
,查常量池表得Ljava/lang/Integer
。 -
attr_count == 0
,没有任何附加属性。
9. 类的方法信息(Method)
类的方法,是Java程序的业务逻辑核心所在,所以以字节码形式保存的方法信息是我们必须要深刻理解的。
与字段结构类似,都是开头2个字节标识
信息的个数,这里是Method的个数
方法信息的结构,我们也需要知道:
我们可以看到,method_info方法结构与field_info字段结构是一一模一样。根据我们对字段信息的分析,似乎套用到Method信息上,有点不对劲。因为我们编写的方法,不仅仅只有一般的描述性信息,我们最重要的是函数中的代码,那么代码信息保存在哪里呢?答案是代码信息保存在attribute_info,也就是属性中。
我们另外,还需要知道方法的访问标志:
所以我们现在需要知道,属性项的内容到底是什么。下面列出了attr_info的构成:
我们现在开始分析method_info!!!
首先,我们根据methods_counts
,方法表结构
和属性表结构
,能够准确的找到5个模块的方法字节码
,如下图所示。
首先我们看到methods_count == 0x0005
,知道:有5个方法
。这似乎有点出乎我们的意料:在我们的源程序代码中,我们只编写了3个方法:getI,setI,main,但是经过编译后形成的字节码却有5个方法,不急,我们慢慢分析...
在分析之前我们需要给出一个Code属性表
,方法得Code属性中保存着代码方法的代码信息:
9.1 方法1(<init>)
第一个方法的完整信息就是由如下字节码构成。
-
access_flags == 0x01
,ACC_PUBLIC
,public方法
-
name_index == 0x11
,查找常量池对应索引:<init>
-
desc_index == 0x12
,查找常量池对应索引:()V
attr_count == 0x01
,一个属性
attr_name_index == 0x13
,查找常量池索引得属性名:Code
attr_length == 0x 00 00 00 42
,Code属性
内容长度为66
max_stack == 0x02
,操作数栈的最大深度为2max_locals == 0x01
,局部变量所需的存储空间(以slot为单位)code_length == 0x 00 00 00 10
,代码长度为16个字节code (代码)
2a
aload_0
b7 invokespecial 00 01 Methodref:java/lang/Object: <init>:()V)
2aaload_0
12 ldc 02CONSTANT_String_info: "welcome"
b5 putfield 00 03 Fieldref:jvm/bytecode/Test: str:Ljava/lang/String;
2aaload_0
08iconst_5
b5putfield
00 04 Fieldrefjvm/bytecode/Test: i:I
b1return
/*****************************************************/
//aload_0
将第一个引用类型本地变量推送至栈顶
//invokespecial
调用超类构造方法,实例初始化方法,私有方法
//ldc
将int/float/String常量值从常量池中推送至栈顶
//putfield
为指定类的实例域赋值
//iconst_5
将int型5推送至栈顶
//return
从当前方法返回void
- 我们能够从字节码中得到
<init>
方法做的事情如下:
public void init(){ // ACC_PUBLIC init<> ()V
// 由于Test并没有显示地继承自任何类,那么它调用地就是父类Object的<init>方法
super();// Object的<init>
this.str = "welcome";
this.i = 5;
}
我们可以将<init>理解为编译器自动添加的无参构造方法
exception_table_length == 0
由于<init>方法不会抛出异常,所以不考虑异常表
attr_count == 0x02
,Code属性中内嵌2个属性
①. attr_name_index == 0x14
,Code的第1个属性名为LineNumberTable
其中LineNumberTable属性的结构
如下所示,其中每个line_number_info包含4个字节,前2个字节为start_pc
,后2个字节为line_number
,前者为字节码行号,后者为Java源码行号。
LineNumber的作用就是将Java源码中的行号映射到与之对应的字节码行号。
-
attr_length == 0x0e
属性长度为14 -
line_number_table_length == 0x03
,3个行号映射信息 -
start_pc -> line_number
0x00 -> 0x03
0x04 -> 0x05
0x0a -> 0x06
②. attr_name_index == 0x15
,Code的第2个属性名LocalVariableTable
LocalVariableTable
的属性结构如下图所示。-
attr_length == 0x0c
,属性长度为12个字节,恰好就是剩下的12个字节。 -
local_var_table_length == 0x01
,一个local_var
表项 -
local_variable_info信息
start_pc == 0x00
,局部变量声明周期开始的字节码偏移量length == 0x10
局部变量的作为范围长度name_index == 0x16
变量名thisdesc_index == 0x17
变量类型Ljvm/bytecode/Test;
index == 0x00
局部变量在栈帧局部变量表中的slot位置
9.2 方法2(getI)
第2个方法的完整信息就是由如下字节码构成。
-
access_flags == 0x01
,ACC_PUBLIC
,public方法
-
name_index == 0x18
,查找常量池对应索引:getI
-
desc_index == 0x19
,查找常量池对应索引:()I
attr_count == 0x01
,一个属性
attr_name_index == 0x13
,查找常量池索引得属性名:Code
attr_length == 0x 00 00 00 2f
,Code属性
内容长度为47
max_stack == 0x01
,操作数栈的最大深度为1max_locals == 0x01
,局部变量所需的存储空间(以slot为单位)code_length == 0x 00 00 00 05
,代码长度为5个字节code (代码)
2a
aload_0
b4getfield
00 04// Fieldref: jvm/bytecode/Test i:I
acireturn
// 从当前方法返回int
- 我们再对照一下Java源码:
public int getI() {
return i; // this.i;
}
exception_table_length == 0x00
没有异常信息表attr_count == 0x02
Code有2个属性
。
-
①. attr_name_index == 0x14
,属性名为LineNumberTable
-
attr_length == 0x06
,属性长度为6个字节 -
line_number_table_length == 0x01
一个line_number_info
:start_pc->line_number : 0x00 -> 0x0b
-
②. attr_name_index == 0x15
,属性名为LocalVariableTable
-
attr_length == 0x0c
,属性长度为12个字节 -
local_var_table_length == 0x01
,一个local_var
表项 -
local_variable_info信息
start_pc == 0x00
,局部变量声明周期开始的字节码偏移量length == 0x05
局部变量的作为范围长度name_index == 0x16
变量名thisdesc_index == 0x17
变量类型Ljvm/bytecode/Test;
index == 0x00
局部变量在栈帧局部变量表中的slot位置
9.3 方法3(setI)
第3个方法的完整信息就是由如下字节码构成。
-
access_flags == 0x01
,ACC_PUBLIC
,public方法
-
name_index == 0x1a
,查找常量池对应索引:setI
-
desc_index == 0x1b
,查找常量池对应索引:(I)V
attr_count == 0x01
,一个属性
attr_name_index == 0x13
,查找常量池索引得属性名:Code
attr_length == 0x 00 00 00 3e
,Code属性
内容长度为62
max_stack == 0x02
,操作数栈的最大深度为2max_locals == 0x02
,局部变量所需的存储空间(以slot为单位)code_length == 0x 00 00 00 06
,代码长度为6个字节code (代码)
2a
aload_0
1biload_i
b5putfield
00 04// Fieldref: jvm/bytecode/Test i:I
b1return
- 我们可以对照一下Java源码:
public void setI(int i) {
this.i = i;
}
exception_table_length == 0x00
没有异常信息表attr_count == 0x02
Code有2个属性
。
-
①. attr_name_index == 0x14
,属性名为LineNumberTable
-
attr_length == 0x0a
,属性长度为10个字节 -
line_number_table_length == 0x02
2个line_number_info
:start_pc->line_number : [0x00 -> 0x0f],[0x05 -> 0x10]
-
②. attr_name_index == 0x15
,属性名为LocalVariableTable
-
attr_length == 0x16
,属性长度为22个字节 -
local_var_table_length == 0x02
,2个local_var
表项 -
1. )local_variable_info信息
start_pc == 0x00
,局部变量声明周期开始的字节码偏移量length == 0x06
局部变量的作为范围长度name_index == 0x16
变量名thisdesc_index == 0x17
变量类型Ljvm/bytecode/Test;
index == 0x00
局部变量在栈帧局部变量表中的slot位置 -
2. )local_variable_info信息
start_pc == 0x00
,局部变量声明周期开始的字节码偏移量length == 0x06
局部变量的作为范围长度name_index == 0x0d
变量名i
desc_index == 0x0e
变量类型I
index == 0x01
局部变量在栈帧局部变量表中的slot位置
9.3 方法4(main)
第4个方法的完整信息就是由如下字节码构成。
-
access_flags == 0x09 == 0x01 | 0x08
,ACC_PUBLIC
,即ACC_STATIC
,public static方法
-
name_index == 0x1c
,查找常量池对应索引:main
-
desc_index == 0x1d
,查找常量池对应索引:([Ljava/lang/String;)V
attr_count == 0x01
,一个属性
attr_name_index == 0x13
,查找常量池索引得属性名:Code
attr_length == 0x 00 00 00 57
,Code属性
内容长度为87
max_stack == 0x02
,操作数栈的最大深度为2max_locals == 0x02
,局部变量所需的存储空间(以slot为单位)code_length == 0x 00 00 00 17
,代码长度为23个字节code (代码)
new: 创建一个对象,并将其引用值压入栈顶
bbnew
00 05// Class_info: jvm/bytecode/Test
59dup
// 复制栈顶数值并将其压入栈顶
b7invokespecial
00 06// Methodref: jvm/bytecode/Test: <init>:()V
4castore_1
// 将栈顶引用型数值存入第2个本地变量
2baload_1
// 将第二个引用类型本地变量推送至栈顶
10bipush
// 将单字节(-128~127)常量值推送至栈顶
08
b6invokevirtual
00 07// Methodref: jvm/bytecode/Test: setI:(I)V
10bipush
14
b8invokestatic
00 08// java/lang/Integer: valueOf:(I)Ljava/lang/Integer;
b3putstatic
00 09// Fieldref: jvm/bytecode/Test: integer:Ljava/lang/Integer;
b1return
- 我们可以对照一下Java源码:
public static void main(String[] args) {
Test test = new Test();
test.setI(8);
integer = 20;
}
exception_table_length == 0x00
没有异常信息表attr_count == 0x02
Code有2个属性
。
-
①. attr_name_index == 0x14
,属性名为LineNumberTable
-
attr_length == 0x12
,属性长度为18个字节 -
line_number_table_length == 0x04
4个line_number_info
:start_pc->line_number : [0x00 -> 0x13],[0x08 -> 0x14],[0x0e, 0x15],[0x16, 0x16]
-
②. attr_name_index == 0x15
,属性名为LocalVariableTable
-
attr_length == 0x16
,属性长度为22个字节 -
local_var_table_length == 0x02
,2个local_var
表项 -
1. )local_variable_info信息
start_pc == 0x00
,局部变量声明周期开始的字节码偏移量length == 0x17
局部变量的作为范围长度name_index == 0x1e
变量名args
desc_index == 0x1f
变量类型[Ljava/lang/String;
index == 0x00
局部变量在栈帧局部变量表中的slot位置 -
2. )local_variable_info信息
start_pc == 0x08
,局部变量声明周期开始的字节码偏移量length == 0x0f
局部变量的作为范围长度name_index == 0x20
变量名test
desc_index == 0x17
变量类型Ljvm/bytecode/Test;
index == 0x01
局部变量在栈帧局部变量表中的slot位置
9.3 方法5(<clinit>)
第5个方法的完整信息就是由如下字节码构成。
-
access_flags == 0x08
,ACC_STATIC
,static方法
-
name_index == 0x21
,查找常量池对应索引:<clinit>
-
desc_index == 0x12
,查找常量池对应索引:()V
attr_count == 0x01
,1个属性
attr_name_index == 0x13
,查找常量池索引得属性名:Code
attr_length == 0x 00 00 00 21
,Code属性
内容长度为33
max_stack == 0x01
,操作数栈的最大深度为1max_locals == 0x00
,局部变量所需的存储空间(以slot为单位)code_length == 0x 00 00 00 09
,代码长度为9个字节code (代码)
10
bipush
0a
b8invokestatic
00 08// java/lang/Integer: valueOf:(I)Ljava/lang/Integer;
b3putstatic
00 09// Fieldref: jvm/bytecode/Test: integer:Ljava/lang/Integer;
b1return
- 很显然,
<clinit>
是编译器自动生成的
一个为静态成员初始化的函数。
public static Integer integer = 10;
exception_table_length == 0x00
没有异常信息表attr_count == 0x01
Code有1个属性
。
-
①. attr_name_index == 0x14
,属性名为LineNumberTable
-
attr_length == 0x06
,属性长度为6个字节 -
line_number_table_length == 0x01
1个line_number_info
:start_pc->line_number : [0x00 -> 0x08]
10. 类的属性(Attribute)
根据.class
文件的结构,我们现在已经分析到类文件的最后一部分了!!属性
首先我们查看一下剩下的仍未分析过的字节码
00 01 == attr_count
属性的个数为1个
00 22 == attr_name_index
属性的名字为"SourceFile"
00 00 00 02 == attr_length
属性的长度为2个字节
00 23
生成此class文件的源代码文件名称为Test.java
11. 最后
到目前为止,我们已经完完整整地分析完整个class文件的二进制含义
了。
其中常量池
,方法区
是最为重要的部分,分析过程非常繁琐。
分析的开始就是常量池,它为后面的方法表等各个表提供了具体的语义。
- 值得注意的是,当我们
没有为类指定构造函数
的时候,编译器会为我们自动生成一个<init>
方法,充当类的默认无参构造器
,实例字段会在此init方法中进行初始化
。 - 与之对应的,编译器同时也会自动一个
<clinit>方法
,用来初始化静态字段/代码块
。 - 分析方法的
汇编代码/助记符
是很有意思的,因为它不同于广为我们所知的基于寄存器的指令集
。java编译器生成的指令流,是基于栈的指令集
现在,我们可以说已经掌握了.class文件字节码的基本结构与分析方法,我们现在有足够的信心来使用class文件查看工具来提升我们的工作效率。