探探字节码文件

简介

java语言能够实现跨平台,完全依赖于字节码文件JVM虚拟机,其字节码文件是一段代码从编写到执行的中间产物,但是对于编写java语言的我们来说,可以不用去关心实现细节的。字节码文件规范其实也是一门艺术,所有的跨平台语言都是基于class文件进行跨平台,所以,这也是扩展的一个知识面。

本篇文章,我们将讨论class文件作为一个二进制字节流,class文件是如何表达源代码的思想的。

练习

我们通过一个简单的例子来学习一下字节码文件

1
2
3
4
5
package com.mashibing.jvm.c1_bytecode;

public class T0100_ByteCode01 {

}

这是一个简单的类,什么也没有,但是当反编译后,可以看到多了一个构造方法

1
2
3
4
5
6
package com.mashibing.jvm.c1_bytecode;

public class T0100_ByteCode01 {
public T0100_ByteCode01() {
}
}

javac命令编译到的二进制文件为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
cafe babe 0000 0034 0010 0a00 0300 0d07
000e 0700 0f01 0006 3c69 6e69 743e 0100
0328 2956 0100 0443 6f64 6501 000f 4c69
6e65 4e75 6d62 6572 5461 626c 6501 0012
4c6f 6361 6c56 6172 6961 626c 6554 6162
6c65 0100 0474 6869 7301 0030 4c63 6f6d
2f6d 6173 6869 6269 6e67 2f6a 766d 2f63
315f 6279 7465 636f 6465 2f54 3031 3030
5f42 7974 6543 6f64 6530 313b 0100 0a53
6f75 7263 6546 696c 6501 0015 5430 3130
305f 4279 7465 436f 6465 3031 2e6a 6176
610c 0004 0005 0100 2e63 6f6d 2f6d 6173
6869 6269 6e67 2f6a 766d 2f63 315f 6279
7465 636f 6465 2f54 3031 3030 5f42 7974
6543 6f64 6530 3101 0010 6a61 7661 2f6c
616e 672f 4f62 6a65 6374 0021 0002 0003
0000 0000 0001 0001 0004 0005 0001 0006
0000 002f 0001 0001 0000 0005 2ab7 0001
b100 0000 0200 0700 0000 0600 0100 0000
0300 0800 0000 0c00 0100 0000 0500 0900
0a00 0000 0100 0b00 0000 0200 0c

结构

接下来,我们分析一下字节码文件

魔数

深入理解计算机中我们知道所有的文件(不管是数据还是程序),在计算机里都是以二进制的格式进行保存的。那么如何区分文件的格式呢,我们可以打开png、jpg类型图片,可以发现前四位

1
2
-- png格式  8950 4E47
-- jpg格式 FFDB FFE0

同样格式的文件前四位都是一样的。在java语言规范中,标识为class文件的前四位为cafe babe,这个就叫做魔数,占四位字节

版本号

不同的源码,在不同版本的jdk上编译,得到的字节码也是不一样的,这是通过版本号来区分的。版本号四个字节,前面为副版本号,接着是主版本号

因此副版本号0x0000,主版本号ox0034,十进制组合为52.0版本,也就是jdk1.8.0

常量池

常量池是字节码设计最复杂的一部分,它的长度不固定,不同类型的常量结构也不一样。如下表:

注:此表格的类型的单位不对,不是bit,应该是byte(字节)。后面的同理。

常量池计数器

记录常量池的长度,占两个字节0x0010表示常量池的长度为16。注意,下标是从1开始的,为0的下标不指向内部引用,是为别的引用预备的。

常量池类型和结构

1
2
3
4
5
6
7
0a00 0300 0d07 000e 0700 0f01 0006 3c69 6e69 743e 0100 0328 2956 0100 0443 6f64 6501 000f 4c69
6e65 4e75 6d62 6572 5461 626c 6501 0012 4c6f 6361 6c56 6172 6961 626c 6554 6162 6c65 0100
0474 6869 7301 0030 4c63 6f6d 2f6d 6173 6869 6269 6e67 2f6a 766d 2f63 315f 6279 7465 636f
6465 2f54 3031 3030 5f42 7974 6543 6f64 6530 313b 0100 0a53 6f75 7263 6546 696c 6501 0015
5430 3130 305f 4279 7465 436f 6465 3031 2e6a 6176 610c 0004 0005 0100 2e63 6f6d 2f6d 6173
6869 6269 6e67 2f6a 766d 2f63 315f 6279 7465 636f 6465 2f54 3031 3030 5f42 7974 6543 6f64
6530 3101 0010 6a61 7661 2f6c 616e 672f 4f62 6a65 6374

这部分二进制描述的全部是常量池的信息,可以对造上面表格一一查找。

比如

0x0a00 0300 0d查找结构为0a | 0003 | 000d,即对应10,CONSTANT_Methodref_info类型,接着是方法的两个指针索引,指向下标为3和13的常量池。

这一一查找挺麻烦的,但是我们只要用

1
javap -verbose  T0100_ByteCode01.class

常量池类型和结构就全都出来了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Constant pool:
#1 = Methodref #3.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // com/mashibing/jvm/c1_bytecode/T0100_ByteCode01
#3 = Class #15 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Lcom/mashibing/jvm/c1_bytecode/T0100_ByteCode01;
#11 = Utf8 SourceFile
#12 = Utf8 T0100_ByteCode01.java
#13 = NameAndType #4:#5 // "<init>":()V
#14 = Utf8 com/mashibing/jvm/c1_bytecode/T0100_ByteCode01
#15 = Utf8 java/lang/Object

访问标志

常量池后面就是访问标志,用两个字节来表示,其标识了类或者接口的访问信息,比如:该Class文件是类还是接口,是否被定义成public,是否是abstract,如果是类,是否被声明成final等等。各种访问标志如下所示:

由于所有类都会继承Object类,所以访问标识ox0020都是满足的,因此访问标志为0x0021

当前类名

两个字节,类索引的值为ox0002,指向常量池第二项的索引。

1
#2 = Class              #14            // com/mashibing/jvm/c1_bytecode/T0100_ByteCode01

这里是通过类索引我们可以确定类的全限定名。

父类名

跟当前类名一样,两个字节,类索引的值为ox0003,指向常量池第三项的索引。

1
#3 = Class              #15            // java/lang/Object

从字节码的设计来看,这里只有两个字节存父类名的索引,因此,这里只能指向一个类。即从设计上看出,java语言类只能从一个类继承,但多个类可以多重继承。

接口

接口计数器

两个字节,记录了实现接口的个数。这里为0x0000,即没有实现接口,因此,接口索引就不占字节。

接口索引

一个接口,两个字节。通过类索引找到实现的接口全限定名。

字段表

字段计数器

两个字节,记录了字段的个数。由于没有,故字节码到ox0000就没了。

字段表结构

字段表作为一个表,同样有他自己的结构

我们知道,一个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected)、static修饰符、final修饰符、volatile修饰符等等。因此,其可像类的访问标志那样,使用一些标志来标记字段。字段的访问标志有如下这些:

方法表

方法计数器

两个字节,记录了方法的个数。这里为0x0001,由于该类没有自定义构造方法,编译后会生成一个构造方法,当初始化的时候会调用Object的构造方法。

方法表结构

方法表的结构实际跟字段表是一样的,方法表结构如下:

跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下:

对应字节码为:0001 0001 0004 0005 0001 0006 0000 002f 0001 0001 0000 0005 2ab7 0001 b1

1
2
3
0001  ||  0001   ||   0004    ||   0005  ||  0001     || 0006 
------------------------------------------------------------------------
计数器 || public || 索引<init> || 索引5 || 属性计数器 || 属性Code类型

Code属性表结构

1
2
3
4
5
0006 0000 002f 
0001 0001 0000 0005 2ab7 0001 b1
00 0000 02
00 0700 0000 0600 0100 0000 03
00 0800 0000 0c00 0100 0000 0500 0900 0a00 00

0x0006对应常量池索引6的下标,查看是Code类型

0x0000002f为属性的长度,这里的值是47,即往后47个字节都是Code的内容。

max_stack的值为0x0001,即操作数栈深度的最大值为1。

max_locals的值为0x0001,即局部变量表所需的存储空间为1;max_locals的单位是Slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。

code_length的值为0x000000005,即字节码指令的长度为5。

code的值为2ab7 0001 b1,这里的值就代表一系列的字节码指令。一个字节代表一个指令,一个指令可能有参数也可能没参数,如果有参数,则其后面字节码就是他的参数;如果没参数,后面的字节码就是下一条指令。

1
2
3
4
5
6
7
2a 指令,查表可得指令为aload_0,其含义为:将第0个Slot中为reference类型的本地变量推送到操作数栈顶。

b7 指令,查表可得指令为invokespecial,其含义为:将操作数栈顶的reference类型的数据所指向的对象作为方法接受者,
调用此对象的实例构造器方法、private方法或者它的父类的方法。其后面紧跟着的2个字节即指向其具体要调用的方法。
这里指向的是`0x0001`的索引,即调用的是Object的构造方法。

b1 指令,查表可得指令为return,其含义为:返回此方法,并且返回值为void。这条指令执行完后,当前的方法也就结束了。

exception_table_length的值为0x0000,即异常表长度为0,所以其异常表也没有;
attributes_count的值为0x0002,即code属性表里面还有两个其他的属性表,分别是0x00070x0008的索引。对应为LineNumberTable属性和LineVariableTable属性。关于这两个属性可以见下面,有特定的结构。

属性表

到这里,我们剩下的字节码还有00 0100 0b00 0000 0200 0c

属性计数器

两个字节,记录了属性的个数。这里是0x0001,即一个属性。

属性结构体

属性表的结构比较灵活,各种不同的属性只要满足以下结构即可:

0x000b对应常量池索引11的下标,查看是SourceFile类型,到这里我们需要查看SourceFile类型属性的结构

属性的长度为0x00000002,即两个字节,后面是0x000c指向索引为12的下标,即T0100_ByteCode01.java,表示源文件的名称

其他属性

其实,上面LineNumberTable、LineVariableTable也是类似SourceFile类型,也有特定的结构。Java虚拟机中预定义的属性有20多个,如下图所示

总结

字节码文件的设计真的是一门艺术,根据java语言的规范,将根据人的思想编写的高级语言经过编译器编译成字节码文件,通过二进制(十六进制)表达了源代码的思想。我们在学习的过程中可以使用JClassLib—IDEA插件协助我们看字节码文件,这样就不用面对枯燥的十六进制了。

使用工具查看很是方便,如图