者 | 东升的思考
责编 | Elle
不啰嗦,直接从最最简单的一段Java源代码开启Java整体字节码分析之旅。
package com.dskj.jvm.bytecode;
public class MyTest1 {
private int a = 1;
public intgetA {
return a;
}
public voidsetA(int a) {
this.a = a;
}
}Java字节码文件
IDEA工具编译代码后,Terminal 终端控制台,进入到生成class文件的目录下。
执行如下命令:
javap -verbose com.dskj.jvm.bytecode.MyTest1
生成字节码文件内容:
Classfile
/.../classes/com/dskj/jvm/bytecode/MyTest.class
Last modified Jul 31, 2018; size 489 bytes
MD5 checksum bdb537edd2d216ea99d6ce529073ee42
Compiled from "MyTest1.java"
public class com.dskj.jvm.bytecode.MyTest
minor version: 0
major version: 52 # JDK最大版本号
flags: ACC_PUBLIC, ACC_SUPER
Constant pool: #
#1 = Methodref #4.#20 // java/lang/Object."<init>":V
#2 = Fieldref #3.#21 // com/dskj/jvm/bytecode/MyTest1.a:I
#3 = Class #22 // com/dskj/jvm/bytecode/MyTest1
#4 = Class #23 // java/lang/Object
#5 = Utf8 a
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/dskj/jvm/bytecode/MyTest1;
#14 = Utf8 getA
#15 = Utf8 I
#16 = Utf8 setA
#17 = Utf8 (I)V
#18 = Utf8 SourceFile
#19 = Utf8 MyTest1.java
#20 = NameAndType #7:#8 // "<init>":V
#21 = NameAndType #5:#6 // a:I
#22 = Utf8 com/dskj/jvm/bytecode/MyTest1
#23 = Utf8 java/lang/Object
{
public com.dskj.jvm.bytecode.MyTest1;
descriptor: V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":V
4: aload_0
5: iconst_1
6: putfield #2 // Field a:I
9: return
LineNumberTable:
line 6: 0
line 8: 4
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 this Lcom/dskj/jvm/bytecode/MyTest1;
public int getA;
descriptor: I
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field a:I
4: ireturn
LineNumberTable:
line 11: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/dskj/jvm/bytecode/MyTest1;
public void setA(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: iload_1
2: putfield #2 // Field a:I
5: return
LineNumberTable:
line 15: 0
line 16: 5
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Lcom/dskj/jvm/bytecode/MyTest1;
0 6 1 a I
}
SourceFile: "MyTest1.java”
Java字节码十六进制
Mac操作系统下建议使用 Hex Fiend 工具查看 MyTest1.class 文件的十六进制格式。
十六进制文本如下,便于后续分析使用:
CA FE BA BE 00 00 00 34 00 18 0A 00 04 00 14 09 00 03 00 15 07 00 16 07 00 17 01 00 01 61 01 00 01 49 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65 01 00 12 4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61 62 6C 65 01 00 04 74 68 69 73 01 00 1F 4C 63 6F 6D 2F 64 73 6B 6A 2F 6A 76 6D 2F 62 79 74 65 63 6F 64 65 2F 4D 79 54 65 73 74 31 3B 01 00 04 67 65 74 41 01 00 03 28 29 49 01 00 04 73 65 74 41 01 00 04 28 49 29 56 01 00 0A 53 6F 75 72 63 65 46 69 6C 65 01 00 0C 4D 79 54 65 73 74 31 2E 6A 61 76 61 0C 00 07 00 08 0C 00 05 00 06 01 00 1D 63 6F 6D 2F 64 73 6B 6A 2F 6A 76 6D 2F 62 79 74 65 63 6F 64 65 2F 4D 79 54 65 73 74 31 01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74 00 21 00 03 00 04 00 00 00 01 00 02 00 05 00 06 00 00 00 03 00 01 00 07 00 08 00 01 00 09 00 00 00 38 00 02 00 01 00 00 00 0A 2A B7 00 01 2A 04 B5 00 02 B1 00 00 00 02 00 0A 00 00 00 0A 00 02 00 00 00 06 00 04 00 08 00 0B 00 00 00 0C 00 01 00 00 00 0A 00 0C 00 0D 00 00 00 01 00 0E 00 0F 00 01 00 09 00 00 00 2F 00 01 00 01 00 00 00 05 2A B4 00 02 AC 00 00 00 02 00 0A 00 00 00 06 00 01 00 00 00 0B 00 0B 00 00 00 0C 00 01 00 00 00 05 00 0C 00 0D 00 00 00 01 00 10 00 11 00 01 00 09 00 00 00 3E 00 02 00 02 00 00 00 06 2A 1B B5 00 02 B1 00 00 00 02 00 0A 00 00 00 0A 00 02 00 00 00 0F 00 05 00 10 00 0B 00 00 00 16 00 02 00 00 00 06 00 0C 00 0D 00 00 00 00 00 06 00 05 00 06 00 01 00 01 00 12 00 00 00 02 00 13
前面都是铺垫,来到重磅分析的一节。
Java字节码整体结构如下图所示,以下图示以不同纬度展示了字节码结构中所包含的关键内容。
Java字节码整体结构图:
完整的Java字节码结构图:
接下来结合十六进制格式的 class 文件,参照 Java字节码文件来剖析下都包含了哪些内容。
1)4个字节,Magic Number
魔数,值为0xCAFEBABE,这是Java创始人James Gosling制定
2)2+2个字节,Version
包括minor_version和major_version,major_version:1.1(45),1.2(46),1.3(47),1.4(48),1.5(49),1.6(50),1.7(51),1.8(52),1.9(53),1.10(54)
3)2+n个字节,Constant Pool
包括字符串常量、数值常量等
4)2个字节,Access Flags
访问标记,标记当前的类是public、final、abstract等等,是不是满足某些特定要求。
5)2个字节,This Class Name
当前类的名字
6)2个字节,Super Class Name
当前类所属父类的名字
7)2+n个字节,Interfaces
当前类所实现的接口
8)2+n个字节,Fields
字段表,描述了当前类的字段的各种各样的信息
9)2+n个字节,Methods
方法表,当前类所定义的方法,这部分内容相对比以上字节结构是比较不容易理解
因为在我们一个类的定义当中方法是最常见的,方法里面包含若干的重要信息,包含签名、访问修饰符、名字、方法的执行代码逻辑、返回值等等。
这些方法也是以信息的形式存储在编译之后的字节码class文件当中,接下来,JVM去执行字节码文件时,当你调用某个特定方法时,JVM才能根据你所编写的源代码的意图去执行字节码里的指令。
对于这个方法来说,在JVM中最终是形成一条条指令的去执行的,也就是说在字节码里形成的每一条指令对应源码文件中的每一行源代码。
这些指令也可以称作为助记符,比如aload_0,iload_1等。
10)2+n个字节,Attributes
附加属性
Class字节码中有两种数据类型:
字节数据直接量:这是基本的数据类型。共细分为u1、u2、u4、u8四种,分别代表连续的1个字节、2个字节、4个字节、8个字节组成的整体数据。
表(数组),是一种复合的数据结构,表是由多个基本数据或其他表,按照既定顺序组成的大的数据集合。表是有结构的,它的结构体现在:组成表的成分所在的位置和顺序都是已经严格定义好的。
接下来,我们使用 javap -verbose 命令分析一个字节码文件时,将会分析该字节码文件的魔数、版本号、常量池、访问标记、类信息、类变量、类的成员变量、类的构造方法与类中的方法信息等信息。
魔数:所有的.class字节码文件的前4个字节都是魔数,文件中魔数为:CA FE BA BE,魔数值为固定值:0xCAFEBABE(咖啡宝贝?),这个值的获得很有“浪漫气息”,其作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
版本号:魔数之后的4个字节为Class文件版本信息,前两个字节表示minor version(次版本号),后两个字节表示major version(主版本号)。
这里的版本号为00 00 00 34,换算成十进制(3 * 16的1次方 + 4 = 52),表示次版本号为0,主版本号为52。
所以,该文件的版本号为:1.8.0。可以通过java -version命令来验证这一点。Java的版本号是从45开始的,JDK1.0之后大的主版本号线上加1,如JDK1.1(45)、JDK1.2(46)以此类推JDK1.8(52)。
常量池(constant pool):紧接着主版本号之后的就是常量池入口。
一个Java类中定义的很多信息都是由常量池来维护和描述的,可以将常量池看作是Class文件的资源仓库,比如说Java类中定义的方法与变量信息,都是存储在常量池中。由于常量池中常量的数量是不固定的,故在常量池入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。
这里的容量计数是从1开始的,十六进制数为:00 18,转换为十进制为24,代表常量池中有24项常量,索引值范围1~24。
常量池数组中元素的个数 = 常量池数 - 1(其中0暂时不使用),所以Java字节码文件中constant_pool中只看到了23项目常量。那为什么容量计数不从0开始呢?具体原因下一节说明。
常量池中主要存储两类常量:
字面量:字面量如文本字符串,Java中声明为final的常量值等。
符号引用:类和接口的全局限定名,字段的名称和描述符,方法的名称和描述符等。
Java类所对应的常量池主要由常量池数量与常量池数组(常量表)这两部分共同构成。
常量池数量紧跟在主版本号后面,占据2个字节;常量池数组紧跟在常量池数量之后。常量池数组与一般的数组不同的是,常量池数组中元素的类型、结构都是不同的,长度当然也就不同;但是,每一种元素第一个数据都是一个u1类型,该字节是个标志位,占据1个字节。
JVM在解析常量池时,会根据这个u1类型来获取元素的具体类型。值得注意的是,常量池数组中元素的个数 = 常量池数 - 1(其中0暂时不使用),目的是满足某些常量池索引值的数据在特定情况下需要表达「不引用任何一个常量池」的含义;根本原因在于,索引为0也是一个常量(保留常量),只不过它不位于常量表中,这个常量就对应值;所以,常量池的索引从1而非0开始。
Class文件结构中常量池中实际是有14种数据类型的,12~14种数据类型是在JDK1.7之后添加进来的(新增三种类型分别为:CONSTANT_MethodHandle_info、CONSTANT_MethodType_info、CONSTANT_InvokeDynamic_info),主要是为了更好的支持动态语言调用的。但是,最常用如下所列的列出了11种常规的数据类型:
上述常量都是以「CONSTANT」开头,以「info」结尾的常量名。每一个常量包含的信息的段都是不同的,我们可以根据每一个段自身的起始和结束位置是什么来进行分析。
抽出两个代表性的常量进行解析:
CONSTANT_Utf8_info
如果这个tag的值为1,占1个字节,它就表示的UTF-8编码的字符串;length,占2个字节,比如length值是4,表示的是从length的下后面读取4个字节长度的字符串。
这个就表示CONSTANT_Utf8_info的具体的文本内容。就是说根据length就能够知道接下来我要读取多少个字节才能读完,这些字节是由bytes来表示的。
CONSTANT_Fieldref_info
tag是U1类型,值为9。有两个index值,都是U2类型的,第一个index代表的是指向声明字段的类或接口描述符CONSTANT_Class_info的索引项,第二个index代表的指向字段描述符CONSTANT_NameAndType_info的索引项。
具体可以理解为当我们定义一个字段时,一定是附属在某一个类上的,所以要先索引到类信息上,可以具体看下CONSTANT_Class_info,其tag是U1类型,值为7,它的index代表指向全限定名常量项的索引,很好理解了。
然后再找到这个字段的描述符,这里指向了会索引到CONSTANT_NameAndType_info,其tag是U1类型,值为12,根据两个index的描述可以理解为要有字段或方法的名称以及字段或方法的描述符即可找到源码中对应的字段和方法。
接下来,我们以上述Java字节码结构总表为依据分析下Java字节码十六进制对应到Java字节码文件中的constant_pool常量池。
Java字节码十六进制:
从第9位开始的十六进制
0A 00 04 00 14 0A表示值为10,从字节码结构总表中找到值为10的是CONSTANT_Methodref_info,有两个index值,第一个index占用的字节 00 04 转换为十进制为4,第二个index占用的字节00 14 转化为十进制为20。
从Java字节码文件中Constant pool定义可看到:
Constant pool: #
#1 = Methodref #4.#20 // java/lang/Object."<init>":V
索引到位置#4和#20,从常量池中找到这两个索引项如下:
#4 = Class #23 // java/lang/Object
#20 = NameAndType #7:#8 // "<init>":V
这两个索引正好可以跟结构总表中对应上。其中,#4表示的类全限定名为java/lang/Object,而索引20位置又引用了#7:#8。继续找到#7和#8:
#7 = Utf8 <init>
#8 = Utf8 V
从第16位开始的十六进制
09 00 03 00 15 这个标志位值为09,从字节码结构总表中找到值为9的常量为CONSTANT_Fieldref_info,其后面跟着两个index,对应十六进制转换为十进制为3和21。
#2 = Fieldref #3.#21 // com/dskj/jvm/bytecode/MyTest1.a:I
对应有两个索引项#3和#21,如下所示:
#3 = Class #22 // com/dskj/jvm/bytecode/MyTest1
#21 = NameAndType #5:#6 // a:I
索引项#3引用了索引项#22,索引项#21引用了索引项#5:#6
#22 = Utf8 com/dskj/jvm/bytecode/MyTest1
#5 = Utf8 a
#6 = Utf8 I
根据以上,#5表示的变量名为a,#6表示的变量a的返回类型是I,即int类型的。也就知道了#2 = Fileldref,对应的是com/dskj/jvm/bytecode/MyTest1.a:I。
对应到MyTest1类的变量:
private int a = 1;
从第21位开始的十六进制
07 00 16 标志位为07,值为7字节码结构总表中对应常量CONSTANT_Class_info,索引占用2个字节,对应转换为十进制为22。
#3 = Class #22 // com/dskj/jvm/bytecode/MyTest1
#22 = Utf8 com/dskj/jvm/bytecode/MyTest1
从第27位开始的十六进制
十六进制字节码文件:
01 00 01 61 01 00 01 49 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65
查找标志位为01 ,值为1的结构总表常量为CONSTANT_Utf8-info,length的占用2个字节十六进制为 00 01 ,那么length长度就是1(转换为十进制的值,即0 * 16的一次方 + 1),后面找到1个字节为61,通过HexFiend工具也能看到指向了a。
所以,找到的十六进制:01 00 01 61
常量池中进一步印证下:
#6 = Utf8 I
十六进制字节码文件:
01 00 01 49 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65...
继续查找标志位为01 ,值为1的结构总表常量为CONSTANT_Utf8-info,length的占用2个字节十六进制为 00 01 ,那么length长度就是1,后面找到1个字节为49,通过HexFiend工具也能看到指向了I。
所以,找到的十六进制:01 00 01 49
常量池中进一步印证下:
#6 = Utf8 I
十六进制字节码文件:
01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65...
继续查找标志位为01 ,值为1的结构总表常量为CONSTANT_Utf8-info,length的占用2个字节十六进制为 00 06 ,那么length长度就是6(转换为十进制的值,即0 * 16的一次方 + 6),后面找到6个字节为 3C 69 6E 69 74 3E,通过HexFiend工具也能看到指向了<init>。
所以,找到的十六进制:01 00 06 3C 69 6E 69 74 3E
常量池中进一步印证下:
#7 = Utf8 <init>
以此类推,最终都能通过十六进制字节码并结合字节码结构总表分析在常量池中找到对应的字节码内容。
访问标志信息包括该Class文件是类还是接口,是否被定义成public,是否是abstract,如果是类,是否被声明成final。通过上面的MyTest1源代码,我们知道该文件是类并且是public的。
Access_Flag访问标志结构表:
上述MyTest1类十六进制字节码中的位置:0x 00 21
这个 0x 00 21 是访问标志结构表中的 0x 00 20 和 0x 00 01 的并集,表示 ACC_PUBLIC 与 ACC_SUPER。
public class com.dskj.jvm.bytecode.MyTest1
...
flags: ACC_PUBLIC, ACC_SUPER
访问标志之后的是This Class Name,对应十六进制为 0x 00 03
在常量池项目类型中查找:
#3 = Class #22 // com/dskj/jvm/bytecode/MyTest1
This Class Name之后的是Super Class Name,对应十六进制为 0x 00 04
在常量池项目类型中查找:
#4 = Class #23 // java/lang/Object
Interfaces
接口包括两部分,第一个是interfaces_count(接口个数),第二部分interfaces(接口名)。
当前这个类对应的十六进制:00 00 转换为十进制仍然是0,说明当前这个类是没有实现任何接口的。
因此,这个interfaces接口表就不会再出现了。如果接口数量interfaces_count大于等于1的话,那么这个interfaces接口表是存在的。
Fields
字段包括两部分,第一个是fields_count(字段个数),第二部分fields(字段名)。
当前这个类对应的十六进制:00 01 转换为十进制值为1,说明这个类内部有一个字段。
字段表集合
字段表用于描述类和接口中声明的变量。这里的字段包含了类级别变量以及实例变量,但是不包括方法内部声明的局部变量。
字段表结构:
第一个是access_flags访问标志符,如public、private、protected、final、abstract等等。
第二个name_index和第三个descriptor_index两个构成一个字段结构的完整信息。
attributes_count是字段的独有的信息,如果值是0,后面的attributes也就不存在了。
具体结构示例:
当前类字段对应的十六进制如下所示:
field_info {
u2 access_flags; 0002
u2 name_index; 0005
u2 descriptor_index; 0006
u2 attributes_counts; 0000
attribute_info attributes[attributes_count];
}
0x0002在访问标志结构表中对应的是ACC_PRIVATE。
名称索引 0x0005 与 描述符索引 0x0006 转换为十六进制为 5 和 6,从 常量池结构表中查找结果:
#5 = Utf8 a
#6 = Utf8 I
附加属性的数量为0x0000,转换为十进制为0,后面的附加属性attributes也就不会出现了。
00 03 // methods_count
00 01 // access_flags
00 07 // name_index
00 08 // descriptor_index
00 01 // attributes_count
00 09 // attribute_name_index
00 00 00 38 // attribute_length
00 02 // 附加属性的 max_stacks
00 01 // 附加属性的 max_locals
00 00 00 0A // 附加属性的 code_length
2A B7 00 01 2A 04 B5 00 02 B1 // code_lengthc长度的字节,具体执行的字节码指令
00 00 00 02 00 0A 00 00 00 0A 00 02 00 00 00 06 00 04 00 08 00 0B 00 00 00 0C 00 01 00 00 00 0A 00 0C 00 0D 00 00 00 01 00 0E 00 0F 00 01 00 09 00 00 00 2F 00 01 00 01 00 00 00 05 2A B4 00 02 AC 00 00 00 02 00 0A 00 00 00 06 00 01 00 00 00 0B 00 0B 00 00 00 0C 00 01 00 00 00 05 00 0C 00 0D 00 00 00 01 00 10 00 11 00 01 00 09 00 00 00 3E 00 02 00 02 00 00 00 06 2A 1B B5 00 02 B1 00 00 00 02 00 0A 00 00 00 0A 00 02 00 00 00 0F 00 05 00 10 00 0B 00 00 00 16 00 02 00 00 00 06 00 0C 00 0D 00 00 00 00 00 06 00 05 00 06 00 01 00 01 00 12 00 00 00 02 00 13
Methods
方法包括两部分,第一个是methods_count(方法个数),第二部分methods(方法名)。
当前这个类对应的十六进制:00 03转换为十进制值为3,说明这个类内部有三个方法。
三个方法为:
setA、getA,以及默认无参的构造方法。
方法表结构:
具体含义类似于上述的字段表结构。
access_flags 对应的十六进制:00 01 在标志结构表中查找为ACC_PUBLIC。
name_index名称索引对应十六进制 00 07 descriptor_index描述符索引对应十六进制 00 08
分别转换为十进制为 7 和 8,在常量池中查找结果:
#7 = Utf8 <init> // 表示这个类的构造方法
#8 = Utf8 V // 表示不接收任何参数的不返回结果的描述符
attributes_count对应十六进制:00 01 ,其个数为1,表示会有一个附加属性。也说明了有一个attributes。
方法的属性结构构成:
方法中的每一个属性都是一个atrribute_info结构。
atrribute_info {
u2 atrribute_name_index;
u4 attribute_length;
u1 info[atrribute_length];
}
attribute_name_index对应十六进制为 00 09,在常量池结构表中查找结果:
#9 = Utf8 Code
从字节码中每一个方法中都能体现出来,比如默认构造方法:
public com.dskj.jvm.bytecode.MyTest1;
descriptor: V
flags: ACC_PUBLIC
Code:
...
然后根据 atrribute_length 对应十六进制为 00 00 00 38 转换为十进制为3 * 16的一次方 + 8 = 56
说明在这个十六进制后面找到56个字节作为Code这个属性的具体的值。
方法表结构:
前三个字段和field_info一样。
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count]
}
方法的属性结构:
JVM预定了部分atrribute,但是编译器自己也可以实现自己的atrribute写入class文件里,供运行时使用。
不同的attribute通过attribute_name_index来区分。
Code结构:
Code attribute的作用是保存该方法的结构,如所对应的字节码。
attribute_length表示attribute所包含的字节数,不包含atrribute_name_index和attribute_length字段。
max_stack表示这个方法运行的任何时刻所能达到的操作数栈的最大深度。// 00 02
max_locals表示方法执行期间创建的局部变量的数目,包含用来表示传入的参数的局部变量的。// 00 01
code_length表示该方法所包含的字节码的字节数以及具体的指令码。也即助记符。// 00 00 00 0A 转换为十进制值为10,即跟着后面的10个字节 2A B7 00 01 2A 04 B5 00 02 B1 这些是字节码具体指令,对应到构造方法的字节码:
那么,这些十六进制是怎么和下面的助记符对应的呢?
我们通过jclasslib工具(字节码查看工具,支持IDEA插件形式安装)查看时,点击助记符的链接会跳到Oracle官网可查看具体详细解释。第一个助记符: 0: aload_0 打开链接可以看到:
链接地址:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.aload_n
具体解释内容所示:
aload_<n>
Operation
Load reference from local variable
Format
aload_<n>
Forms
aload_0 = 42 (0x2a) // 通过这里就能直接看到 aload_0 对应的十进制是42,转换为十六进制就是 0x2a,对应字节码文件中的 2A
aload_1 = 43 (0x2b)
aload_2 = 44 (0x2c)
aload_3 = 45 (0x2d)
Description
The <n> must be an index into the local variable array of the current frame (§2.6). The local variable at <n> must contain a reference. The objectref in the local variable at <n> is pushed onto the operand stack.
这个<n>必须是一个到当前栈帧局部变量数组的一个索引,位于<n>位置上的局部变量会包含一个引用,位于<n>位置上的局部变量的这个引用会被推送到栈顶(准备进行操作)。
第二个助记符:
1: invokespecial #1 // Method java/lang/Object."<init>":V
连接地址:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.invokespecial
invokespecial
Operation
Invoke instance method; special handling for superclass, private, and instance initialization method invocations
Format
invokespecial
indexbyte1
indexbyte2
Forms
invokespecial = 183 (0xb7)
Operand Stack
..., objectref, [arg1, [arg2 ...]] →
...
具体字节码即是该方法被调用时,虚拟机所执行的字节码。
exception_table,这里存放的是处理异常的信息。
每个exception_table表项由start_pc,end_pc,handler_pc,catch_type组成。
start_pc和end_pc表示在code数组中的从start_pc到end_pc处(包含start_pc,不包含end_pc)的指令抛出的异常会由这个表项来处理。
handler_pc表示处理异常的代码的开始处。catch_type表示会被处理的异常类型,它指向常量池里的一个异常类。当catch_type为0时,表示处理所有的异常。
附加属性
LineNumberTable:这个属性用来表示code数组中的字节码和Java代码行数之间的关系。这个属性可以用来在调试的时候定位代码的执行行数。
LocalVariableTable:局部变量表,当前类中只有唯一的局部变量,而这个局部变量就是this当前对象。
局部变量表属性类似于行号表属性。
请注意:
Java源代码角度:Java类中的实例方法中可以直接使用this。
Java字节码角度: Java类中的非静态方法,即实例方法中的这个this实际是通过编译器隐示的作为方法的第一个参数传递进来(有点类似于Python中的方法,其方法中的第一个参数都会传递一个self变量,表示当前对象本身)。这样使得每一个实例方法内部都可以很顺利的访问this。换句话说针对类的实例方法它至少会有一个LocalVariable局部变量,这个变量就是this。
在JVM规范中,每个变量/字段都有描述信息,描述信息主要的作用是描述字段的数据类型、方法的参数列表(包括数量、类型与顺序)与返回值。根据描述符规则,基本数据类型和代表无返回值的void类型都用一个大写字符来表示,对象类型则使用字符L加对象的全限定名称来表示。为了压缩字节码文件的体积,对于基本数据类型,JVM都只使用一个大写字母来表示,如下所示:
B - byte,C - char,D - double,F - float,I - int,J - long,S - short,Z - boolean,V - void,L - 对象类型,如Ljava/lang/String;
数组类型: 针对数组类型来说,每一个维度使用一个前置的[来表示,如:
int数组被记录[I,String[]二维数组被记录为[[Ljava/lang/String;
方法描述符
用描述符描述方法时,按照先参数列表,后返回值的顺序来描述。参数列表按照参数的严格顺序放在一组之内,如方法:
String getInfoByIdAndName(int id, String name),该方法的描述符为:(I, Ljava/lang/String;)Ljava/lang/String;
Java字节码文件的工具推荐:
https://github.com/ingokegel/jclasslib
声明:本文为作者投稿,版权归作者个人所有。
掌握jvm 字节码,最关键的是学习class文件格式以及字节码指令集等细节,今天我们来学习class字节码文件格式(jdk8版本)。
Java代码经过javac编译器编译成class文件,JVM虚拟机读取class文件执行其中的代码。
通过JVM虚拟机规范,实现了jvm跨平台、跨语言的能力,JVM规范中非常重要的一部分就是class字节码文件格式。
class文件的整体结构如下图所示,其中u1,u2,u4分别表示1个、2个、4个字节长度的无符号数据,无符号byte数据按照具体的场景可以用来表示数字、字符等。 结构中还可以使用复合结构,比如cp_info, cp_info结构也会在规范中进行定义。
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
魔法字符串,固定为0xCAFEBABE
分别是class文件的小版本号和大版本号,jvm规范要求运行的jvm版本必须大于等于(更严格说是能支持,不过目前大于等于即可)class文件的major_version才能运行,否则抛出异常。
常量池数量,是下面的常量池表的长度加一,因为index=0的常量引用没有使用。
常量池表,每个常量池的结构cp_info如下,常量池可以表示字符串常量、类名、接口名、方法等信息,这些常量池会在class文件中其他地方进行引用(比如字段中字段类型、字段名等)。 常量通过index进行引用,常量之间也可以通过index进行引用。
cp_info中的tag字段用来标识当前的常量类型,不同的常量类型有不同的子结构,然后就可以用具体的结构来解析info[]这个byte数组。 常量的结构有,
cp_info {
u1 tag;
u1 info[];
}
Constant type | tag value |
CONSTANT_Class | 7 |
CONSTANT_Fieldref | 9 |
CONSTANT_Methodref | 10 |
CONSTANT_InterfaceMethodref | 11 |
CONSTANT_Integer | 3 |
CONSTANT_Float | 4 |
CONSTANT_Long | 5 |
CONSTANT_Double | 6 |
CONSTANT_NameAndType | 12 |
CONSTANT_Utf8 | 1 |
CONSTANT_MethodHandle | 15 |
CONSTANT_MethodType | 16 |
CONSTANT_InvokeDynamic | 18 |
我们提前查看一下各个常量类型的结构,给后面介绍Field, Method做铺垫。
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
CONSTANT_Class_info表示类或接口
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
tag: 是CONSTANT_Class对应的值(7) name_index: name_index是这个类或接口的类名的字符串常量的index
CONSTANT_Fieldref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_InterfaceMethodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
字段引用、方法引用、接口方法引用这三个结构比较类似,都是各自的tag以及class_index和name_and_type_index
class_index: 这个字段、方法所在类的class的常量的index
name_and_type_index: 这个字段的名称和类型结构常量CONSTANT_NameAndType_info的index。name分别是字段名和方法名,类型是字段、方法的descriptor描述符。
字符串常量结构
string_index: 指向CONSTANT_Utf8_info的index
CONSTANT_String_info {
u1 tag;
u2 string_index;
}
整数和浮点数常量结构,对应的数值占用4个字节。
CONSTANT_Integer_info {
u1 tag;
u4 bytes;
}
CONSTANT_Float_info {
u1 tag;
u4 bytes;
}
这两个常量结构分别存储long和double类型的数值,大小占用8个字节 high_bytes和low_bytes分别表示高位和低位的数据,以long为例,对应值为((long) high_bytes << 32) + low_bytes
CONSTANT_Long_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_Double_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_NameAndType_info常量用来表示名称和类型,在前面的CONSTANT_Fieldref_info, CONSTANT_Methodref_info, CONSTANT_InterfaceMethodref_info 常量中有使用,结构如下
CONSTANT_NameAndType_info {
u1 tag;
u2 name_index;
u2 descriptor_index;
}
name_index: 指向对应名称的utf8常量的CONSTANT_Utf8_info的index descriptor_index: 指向类型描述符的CONSTANT_Utf8_info的index。
在jvm中,数据分为primitive type(基本类型,比如int, long)和reference type(引用类型),类型的描述符规则如下
类型 | 描述符 |
byte | B |
char | C |
double | D |
float | F |
int | I |
long | J |
short | S |
boolean | Z |
reference,引用类型 | LClassName; |
数组 | [ |
引用类型的ClassName是/间隔的字符串,比如java.lang.String的描述符为Ljava/lang/String; 数组是在对应的类型前加[,比如int[]描述符为[I, String[]描述符为[Ljava/lang/String;, 多维数组距离 int[][]描述符为[[I
Field Descriptor是对应字段的类型的描述符 Method Descriptor为( {ParameterDescriptor} ) ReturnDescriptor,比如public String test(int a, Long b)的方法描述符为(ILjava/lang/Long)Ljava/lang/String;,如果返回值是void,则使用V
CONSTANT_Utf8_info常量存储utf8编码的字符串内容,包含一个字符串长度字段和对应长度的byte数组。
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
access_flags用来表示当前类的一些bit信息(类似bitmap),这样用2个字节的空间就可以表示16个标记信息。
Flag Name | Value | 表头 |
ACC_PUBLIC | 0x0001 | 表示当前类/接口是否是public |
ACC_FINAL | 0x0010 | 是否声明了final |
ACC_SUPER | 0x0020 | 都是true, 为了兼容旧版本的字节码的标记 |
ACC_INTERFACE | 0x0200 | 是否是接口 |
ACC_ABSTRACT | 0x0400 | 是否是抽象类,接口也是抽象类 |
ACC_SYNTHETIC | 0x1000 | 表示不是代码中生成的类,比如jdk为实现lambda表达式在运行时生成的一些类 |
ACC_ANNOTATION | 0x2000 | 是否是@interface这样的注解类 |
ACC_ENUM | 0x4000 | 是否枚举类 |
Fields是field_info的数组,每个field_info结构如下。
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
access_flags: 字段的access_flags,和class的access_flags类似,用来描述字段的public,private,volatile等等标识信息。
Flag Name | Value | 描述 |
ACC_PUBLIC | 0x0001 | 是否是public字段 |
ACC_PRIVATE | 0x0002 | 是否是private字段 |
ACC_PROTECTED | 0x0004 | 是否是static字段 |
ACC_STATIC | 0x0008 | 是否是static字段 |
ACC_FINAL | 0x0010 | 是否是final字段 |
ACC_VOLATILE | 0x0040 | 是否是volatile字段 |
ACC_TRANSIENT | 0x0080 | 是否是transient字段 |
ACC_SYNTHETIC | 0x1000 | 单元格 |
ACC_ENUM | 0x4000 | 单元格 |
name_index: 字段名称的CONSTANT_Utf8_info常量index descriptor_index: 字段类型描述符的CONSTANT_Utf8_info常量index attributes_count: 字段的属性数量 attributes: 字段的属性,结构为attribute_info,比如ConstantValue,描述常量字段的常量值,属性的结构稍后介绍。
类中所有的方法包括构造函数(<init>)、静态初始化方法(<clinit>),都使用method_info结构,在一个类中,方法名称和方法签名联合起来必须唯一
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
access_flags: 方法的标识数据,包括public, private, synchronized等等信息
Flag Name | Value | 描述 |
ACC_PUBLIC | 0x0001 | public方法 |
ACC_PRIVATE | 0x0002 | private方法 |
ACC_PROTECTED | 0x0004 | protected方法 |
ACC_STATIC | 0x0008 | static方法 |
ACC_FINAL | 0x0010 | final方法 |
ACC_SYNCHRONIZED | 0x0020 | synchronized方法(方法维度的synchronized声明,不同于synchronized代码块的monitor_enter和monitor_exit) |
ACC_BRIDGE | 0x0040 | 是否是transient字段 |
ACC_VARARGS | 0x0080 | 有可变参数的方法 |
ACC_NATIVE | 0x0100 | native方法 |
ACC_ABSTRACT | 0x0400 | 抽象方法 |
ACC_STRICT | 0x0800 | 浮点数模式是FT-strict的,这个很少见 |
ACC_SYNTHETIC | 0x1000 | 是否是合成方法,即不再源代码中的方法 |
name_index: 指向方法名的CONSTANT_Utf8_info常量 descriptor_index: 指向方法描述符的CONSTANT_Utf8_info常量 attributes_count: 方法的属性数量 attributes[]: 方法的各个属性,其中比较关键的是名字为Code的属性,包含的是方法体的字节码指令。
Attributes属性在classfile, field_info, method_info中都有使用,结构如下
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
attribute_name_index: 指向属性的名称的CONSTANT_Utf8_info常量 attribute_length: 属性信息的字节长度,即info的长度 info[]: 属性的具体信息,每种属性有自己的结构
属性有ConstantValue,Code,StackMapTable,Exceptions,BootstrapMethods等等很多种属性,我们这里重点介绍一下ConstantValue和Code。
常量值属性用来表示常量字段的常量值,数值(int,long,float等)和字符串字段能够声明成常量。
ConstantValue_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;
}
attribute_name_index: 指向"ConstantValue"的CONSTANT_Utf8_info attribute_length: 2,因为constantvalue_index是两个byte长度的index constantvalue_index: 指向具体的常量池中的常量,按照类型不同分为CONSTANT_Long,CONSTANT_Float,CONSTANT_Double,CONSTANT_Integer(int, short, char, byte, boolean都用CONSTANT_Integer),CONSTANT_String,
Code属性用来表示方法体中的代码字节码。
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
attribute_name_index: 指向"Code"的CONSTANT_Utf8_info常量 attribute_length: 后面所有的字段信息的字节数 max_stack: 方法的字节码指令执行过程中需要的操作数栈的最大栈层数,关于方法字节码指令的执行,在字节码指令文章中进行介绍。 max_locals: 方法的字节码指令执行过程中需要的本地变量表的最大长度(注意局部变量表的元素长度是4字节,long和double变量在局部变量表中占两个位置) code_length: 方法体的字节码的长度 code[]: 方法体的字节码 exception_table_length: 异常表的长度 exception_table[]: 异常表数组,每个异常表包含start_pc,end_pc,handler_pc,catch_type。pc是指code[]数组中的索引,也就是从code[]字节码数组start_pc(包含)到end_pc(不包含)中的字节码执行时出现catch_type(指向异常类的CONSTANT_Class_info常量)异常,则转到code[]的handler_pc位置来处理异常。 attributes_count: Code属性的数量 attributes[]: Code属性数组,比如LineNumberTable,LocalVariableTable, LocalVariableTypeTable, StackMapTable
其他的属性可以参考jvm规范
假如我们现在有一个class文件,想去查看其中的Java源代码,该如何实现呢?有如下几种方法。
javap是jdk里自带的反编译工具,可以打印出更加可读的class字节码信息。
javap -c -cp /Users/liuzhengyang/Code/work/code-test/target/classes/ test.Test
Compiled from "Test.java"
public class test.Test {
public test.Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public java.lang.String hello();
Code:
0: ldc #2 // String hello world
2: areturn
}
javap参数说明
参数 | 说明 |
-cp | 指定classpath, javap需要到classpath中寻找class文件 |
-p | 默认情况下javap不打印出private的方法、字段,通过-p可以打印全部信息 |
-c | 默认情况下javap不打印出方法的body字节码,通过-c可以打印 |
-v | 打印最全的信息,包括常量池、方法stack size、方法本地变量表等等 |
把class文件拖动到IDEA中即可查看到反编译的java代码结果,相比javap更加易读。
如果要查看运行中的程序中使用到的代码,可以使用arthas的[jad](https://arthas.aliyun.com/doc/jad.html)命令。
更详细的资料包括java语言规范、java虚拟机规范可以在[Java Language and Virtual Machine Specifications](https://docs.oracle.com/javase/specs/index.html)中找到
本篇文章介绍了class文件的结构,包括常量池、字段、方法、属性等,详细了解了每个数据的结构,最后了解查看class文件的几种方式。
ava 中的字节码,英文名为 bytecode, 是 Java 代码编译后的中间代码格式。JVM 需要读取并解析字节码才能执行相应的任务。
从技术人员的角度看,Java 字节码是 JVM 的指令集。JVM 加载字节码格式的 class 文件,校验之后通过 JIT 编译器转换为本地机器代码执行。 简单说字节码就是我们编写的 Java 应用程序大厦的每一块砖,如果没有字节码的支撑,大家编写的代码也就没有了用武之地,无法运行。也可以说,Java 字节码就是 JVM 执行的指令格式。
那么我们为什么需要掌握它呢?
不管用什么编程语言,对于卓越而有追求的程序员,都能深入去探索一些技术细节,在需要的时候,可以在代码被执行前解读和理解中间形式的代码。对于 Java 来说,中间代码格式就是 Java 字节码。 了解字节码及其工作原理,对于编写高性能代码至关重要,对于深入分析和排查问题也有一定作用,所以我们要想深入了解 JVM 来说,了解字节码也是夯实基础的一项基本功。同时对于我们开发人员来时,不了解平台的底层原理和实现细节,想要职业进阶绝对不是长久之计,毕竟我们都希望成为更好的程序员, 对吧?
任何有实际经验的开发者都知道,业务系统总不可能没有 BUG,了解字节码以及 Java 编译器会生成什么样的字节码,才能说具备扎实的 JVM 功底,会在排查问题和分析错误时非常有用,也能更好地解决问题。
而对于工具领域和程序分析来说, 字节码就是必不可少的基础知识了,通过修改字节码来调整程序的行为是司空见惯的事情。想了解分析器(Profiler),Mock 框架,AOP 等工具和技术这一类工具,则必须完全了解 Java 字节码。
4.1 Java 字节码简介
有一件有趣的事情,就如名称所示, Java bytecode 由单字节(byte)的指令组成,理论上最多支持 256 个操作码(opcode)。实际上 Java 只使用了 200 左右的操作码, 还有一些操作码则保留给调试操作。
操作码, 下面称为 指令, 主要由类型前缀和操作名称两部分组成。
例如,'i' 前缀代表 ‘integer’,所以,'iadd' 很容易理解, 表示对整数执行加法运算。
根据指令的性质,主要分为四个大类:
此外还有一些执行专门任务的指令,比如同步(synchronization)指令,以及抛出异常相关的指令等等。下文会对这些指令进行详细的讲解。
4.2 获取字节码清单
可以用 javap 工具来获取 class 文件中的指令清单。 javap 是标准 JDK 内置的一款工具, 专门用于反编译 class 文件。
让我们从头开始, 先创建一个简单的类,后面再慢慢扩充。
package demo.jvm0104;
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode obj = new HelloByteCode();
}
}
代码很简单, main 方法中 new 了一个对象而已。然后我们编译这个类:
javac demo/jvm0104/HelloByteCode.java
使用 javac 编译 ,或者在 IDEA 或者 Eclipse 等集成开发工具自动编译,基本上是等效的。只要能找到对应的 class 即可。
javac 不指定 -d 参数编译后生成的 .class 文件默认和源代码在同一个目录。
注意: javac 工具默认开启了优化功能, 生成的字节码中没有局部变量表(LocalVariableTable),相当于局部变量名称被擦除。如果需要这些调试信息, 在编译时请加上 -g 选项。有兴趣的同学可以试试两种方式的区别,并对比结果。
JDK 自带工具的详细用法, 请使用: javac -help 或者 javap -help 来查看; 其他类似。
然后使用 javap 工具来执行反编译, 获取字节码清单:
javap -c demo.jvm0104.HelloByteCode
# 或者:
javap -c demo/jvm0104/HelloByteCode
javap -c demo/jvm0104/HelloByteCode.class
javap 还是比较聪明的, 使用包名或者相对路径都可以反编译成功, 反编译后的结果如下所示:
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode {
public demo.jvm0104.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
}
OK,我们成功获取到了字节码清单, 下面进行简单的解读。
4.3 解读字节码清单
可以看到,反编译后的代码清单中, 有一个默认的构造函数 public demo.jvm0104.HelloByteCode(), 以及 main 方法。
刚学 Java 时我们就知道, 如果不定义任何构造函数,就会有一个默认的无参构造函数,这里再次验证了这个知识点。好吧,这比较容易理解!我们通过查看编译后的 class 文件证实了其中存在默认构造函数,所以这是 Java 编译器生成的, 而不是运行时JVM自动生成的。
自动生成的构造函数,其方法体应该是空的,但这里看到里面有一些指令。为什么呢?
再次回顾 Java 知识, 每个构造函数中都会先调用 super 类的构造函数对吧? 但这不是 JVM 自动执行的, 而是由程序指令控制,所以默认构造函数中也就有一些字节码指令来干这个事情。
基本上,这几条指令就是执行 super() 调用;
public demo.jvm0104.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
至于其中解析的 java/lang/Object 不用说, 默认继承了 Object 类。这里再次验证了这个知识点,而且这是在编译期间就确定了的。
继续往下看 c,
public static void main(java.lang.String[]);
Code:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
main 方法中创建了该类的一个实例, 然后就 return 了, 关于里面的几个指令, 稍后讲解。
4.4 查看 class 文件中的常量池信息
常量池 大家应该都听说过, 英文是 Constant pool。这里做一个强调: 大多数时候指的是 运行时常量池。但运行时常量池里面的常量是从哪里来的呢? 主要就是由 class 文件中的 常量池结构体 组成的。
要查看常量池信息, 我们得加一点魔法参数:
javap -c -verbose demo.jvm0104.HelloByteCode
在反编译 class 时,指定 -verbose 选项, 则会 输出附加信息。
结果如下所示:
Classfile /XXXXXXX/demo/jvm0104/HelloByteCode.class
Last modified 2019-11-28; size 301 bytes
MD5 checksum 542cb70faf8b2b512a023e1a8e6c1308
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 HelloByteCode.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 demo/jvm0104/HelloByteCode
#15 = Utf8 java/lang/Object
{
public demo.jvm0104.HelloByteCode();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
LineNumberTable:
line 5: 0
line 6: 8
}
SourceFile: "HelloByteCode.java"
其中显示了很多关于 class 文件信息: 编译时间, MD5 校验和, 从哪个 .java 源文件编译得来,符合哪个版本的 Java 语言规范等等。
还可以看到 ACC_PUBLIC 和 ACC_SUPER 访问标志符。 ACC_PUBLIC 标志很容易理解:这个类是 public 类,因此用这个标志来表示。
但 ACC_SUPER 标志是怎么回事呢? 这就是历史原因, JDK 1.0 的 BUG 修正中引入 ACC_SUPER 标志来修正 invokespecial 指令调用 super 类方法的问题,从 Java 1.1 开始, 编译器一般都会自动生成ACC_SUPER 标志。
有些同学可能注意到了, 好多指令后面使用了 #1, #2, #3 这样的编号。
这就是对常量池的引用。 那常量池里面有些什么呢?
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
......
这是摘取的一部分内容, 可以看到常量池中的常量定义。还可以进行组合, 一个常量的定义中可以引用其他常量。
比如第一行: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V, 解读如下:
同学们可以试着解析其他的常量定义。 自己实践加上知识回顾,能有效增加个人的记忆和理解。
总结一下,常量池就是一个常量的大字典,使用编号的方式把程序里用到的各类常量统一管理起来,这样在字节码操作里,只需要引用编号即可。
4.5 查看方法信息
在 javap 命令中使用 -verbose 选项时, 还显示了其他的一些信息。 例如, 关于 main 方法的更多信息被打印出来:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
可以看到方法描述: ([Ljava/lang/String;)V:
还可以看到执行该方法时需要的栈(stack)深度是多少,需要在局部变量表中保留多少个槽位, 还有方法的参数个数: stack=2, locals=2, args_size=1。把上面这些整合起来其实就是一个方法:
public static void main(java.lang.String[]);
注:实际上我们一般把一个方法的修饰符+名称+参数类型清单+返回值类型,合在一起叫“方法签名”,即这些信息可以完整的表示一个方法。
稍微往回一点点,看编译器自动生成的无参构造函数字节码:
public demo.jvm0104.HelloByteCode();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
你会发现一个奇怪的地方, 无参构造函数的参数个数居然不是 0: stack=1, locals=1, args_size=1。 这是因为在 Java 中, 如果是静态方法则没有 this 引用。 对于非静态方法, this 将被分配到局部变量表的第 0 号槽位中, 关于局部变量表的细节,下面再进行介绍。
有反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args); 有JavaScript编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);
4.6 线程栈与字节码执行模型
想要深入了解字节码技术,我们需要先对字节码的执行模型有所了解。
JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈(JVM stack),用于存储栈帧(Frame)。每一次方法调用,JVM都会自动创建一个栈帧。栈帧 由 操作数栈, 局部变量数组 以及一个class 引用组成。class 引用 指向当前方法在运行时常量池中对应的 class)。
我们在前面反编译的代码中已经看到过这些内容。
局部变量数组 也称为 局部变量表(LocalVariableTable), 其中包含了方法的参数,以及局部变量。 局部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节。操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。
有一些操作码/指令可以将值压入“操作数栈”; 还有一些操作码/指令则是从栈中获取操作数,并进行处理,再将结果压入栈。操作数栈还用于接收调用其他方法时返回的结果值。
4.7 方法体中的字节码解读
看过前面的示例,细心的同学可能会猜测,方法体中那些字节码指令前面的数字是什么意思,说是序号吧但又不太像,因为他们之间的间隔不相等。看看 main 方法体对应的字节码:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
间隔不相等的原因是, 有一部分操作码会附带有操作数, 也会占用字节码数组中的空间。
例如, new 就会占用三个槽位: 一个用于存放操作码指令自身,两个用于存放操作数。
因此,下一条指令 dup 的索引从 3 开始。
如果将这个方法体变成可视化数组,那么看起来应该是这样的:
每个操作码/指令都有对应的十六进制(HEX)表示形式, 如果换成十六进制来表示,则方法体可表示为HEX字符串。例如上面的方法体百世成十六进制如下所示:
甚至我们还可以在支持十六进制的编辑器中打开 class 文件,可以在其中找到对应的字符串:
(此图由开源文本编辑软件Atom的hex-view插件生成)
粗暴一点,我们可以通过 HEX 编辑器直接修改字节码,尽管这样做会有风险, 但如果只修改一个数值的话应该会很有趣。
其实要使用编程的方式,方便和安全地实现字节码编辑和修改还有更好的办法,那就是使用 ASM 和 Javassist 之类的字节码操作工具,也可以在类加载器和 Agent 上面做文章,下一节课程会讨论 类加载器,其他主题则留待以后探讨。
4.8 对象初始化指令:new 指令, init 以及 clinit 简介
我们都知道 new是 Java 编程语言中的一个关键字, 但其实在字节码中,也有一个指令叫做 new。 当我们创建类的实例时, 编译器会生成类似下面这样的操作码:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
当你同时看到 new, dup 和 invokespecial 指令在一起时,那么一定是在创建类的实例对象!
为什么是三条指令而不是一条呢?这是因为:
由于构造函数调用不会返回值,所以如果没有 dup 指令, 在对象上调用方法并初始化之后,操作数栈就会是空的,在初始化之后就会出问题, 接下来的代码就无法对其进行处理。
这就是为什么要事先复制引用的原因,为的是在构造函数返回之后,可以将对象实例赋值给局部变量或某个字段。因此,接下来的那条指令一般是以下几种:
在调用构造函数的时候,其实还会执行另一个类似的方法 <init> ,甚至在执行构造函数之前就执行了。
还有一个可能执行的方法是该类的静态初始化方法 <clinit>, 但 <clinit> 并不能被直接调用,而是由这些指令触发的: new, getstatic, putstatic or invokestatic。
也就是说,如果创建某个类的新实例, 访问静态字段或者调用静态方法,就会触发该类的静态初始化方法【如果尚未初始化】。
实际上,还有一些情况会触发静态初始化, 详情请参考 JVM 规范: [http://docs.oracle.com/javase/specs/jvms/se8/html/]
4.9 栈内存操作指令
有很多指令可以操作方法栈。 前面也提到过一些基本的栈操作指令: 他们将值压入栈,或者从栈中获取值。 除了这些基础操作之外也还有一些指令可以操作栈内存; 比如 swap 指令用来交换栈顶两个元素的值。下面是一些示例:
最基础的是 dup 和 pop 指令。
还有复杂一点的指令:比如,swap, dup_x1 和 dup2_x1。
dup_x1 和 dup2_x1 指令看起来稍微有点复杂。而且为什么要设置这种指令呢? 在栈中复制最顶部的值?
请看一个实际案例:怎样交换 2 个 double 类型的值?
需要注意的是,一个 double 值占两个槽位,也就是说如果栈中有两个 double 值,它们将占用 4 个槽位。
要执行交换,你可能想到了 swap 指令,但问题是 swap 只适用于单字(one-word, 单字一般指 32 位 4 个字节,64 位则是双字),所以不能处理 double 类型,但 Java 中又没有 swap2 指令。
怎么办呢? 解决方法就是使用 dup2_x2 指令,将操作数栈顶部的 double 值,复制到栈底 double 值的下方, 然后再使用 pop2 指令弹出栈顶的 double 值。结果就是交换了两个 double 值。 示意图如下图所示:
dup、dup_x1、dup2_x1 指令补充说明
指令的详细说明可参考 JVM 规范:
dup 指令
官方说明是:复制栈顶的值,并将复制的值压入栈。
操作数栈的值变化情况(方括号标识新插入的值):
..., value →
..., value [,value]
dup_x1 指令
官方说明是:复制栈顶的值,并将复制的值插入到最上面 2 个值的下方。
操作数栈的值变化情况(方括号标识新插入的值):
..., value2, value1 →
..., [value1,] value2, value1
dup2_x1 指令
官方说明是:复制栈顶 1 个 64 位/或 2 个 32 位的值, 并将复制的值按照原始顺序,插入原始值下面一个 32 位值的下方。
操作数栈的值变化情况(方括号标识新插入的值):
# 情景 1: value1, value2, and value3 都是分组 1 的值(32 位元素)
..., value3, value2, value1 →
..., [value2, value1,] value3, value2, value1
# 情景 2: value1 是分组 2 的值(64 位,long 或double), value2 是分组 1 的值(32 位元素)
..., value2, value1 →
..., [value1,] value2, value1
Table 2.11.1-B 实际类型与 JVM 计算类型映射和分组
实际类型 | JVM 计算类型 | 类型分组 |
boolean | int | 1 |
byte | int | 1 |
char | int | 1 |
short | int | 1 |
int | int | 1 |
float | float | 1 |
reference | reference | 1 |
returnAddress | returnAddress | 1 |
long | long | 2 |
double | double | 2 |
4.10 局部变量表
stack 主要用于执行指令,而局部变量则用来保存中间结果,两者之间可以直接交互。
让我们编写一个复杂点的示例:
第一步,先编写一个计算移动平均数的类:
package demo.jvm0104;
//移动平均数
public class MovingAverage {
private int count = 0;
private double sum = 0.0D;
public void submit(double value){
this.count ++;
this.sum += value;
}
public double getAvg(){
if(0 == this.count){ return sum;}
return this.sum/this.count;
}
}
第二步,然后写一个类来调用:
package demo.jvm0104;
public class LocalVariableTest {
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
int num1 = 1;
int num2 = 2;
ma.submit(num1);
ma.submit(num2);
double avg = ma.getAvg();
}
}
其中 main 方法中向 MovingAverage 类的实例提交了两个数值,并要求其计算当前的平均值。
然后我们需要编译(还记得前面提到, 生成调试信息的 -g 参数吗)。
javac -g demo/jvm0104/*.java
然后使用 javap 反编译:
javap -c -verbose demo/jvm0104/LocalVariableTest
看 main 方法对应的字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=6, args_size=1
0: new #2 // class demo/jvm0104/MovingAverage
3: dup
4: invokespecial #3 // Method demo/jvm0104/MovingAverage."<init>":()V
7: astore_1
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
18: aload_1
19: iload_3
20: i2d
21: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
24: aload_1
25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D
28: dstore 4
30: return
LineNumberTable:
line 5: 0
line 6: 8
line 7: 10
line 8: 12
line 9: 18
line 10: 24
line 11: 30
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 ma Ldemo/jvm0104/MovingAverage;
10 21 2 num1 I
12 19 3 num2 I
30 1 4 avg D
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
请注意,store 之类的指令调用实际上从栈顶删除了一个值。 这就是为什么再次使用相同值时,必须再加载(load)一次的原因。
例如在上面的字节码中,调用 submit 方法之前, 必须再次将参数值加载到栈中:
12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
调用 getAvg() 方法后,返回的结果位于栈顶,然后使用 dstore 将 double 值保存到本地变量4号槽位,这里的d表示目标变量的类型为double。
24: aload_1
25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D
28: dstore 4
关于 LocalVariableTable 有个有意思的事情,就是最前面的槽位会被方法参数占用。
在这里,因为 main 是静态方法,所以槽位0中并没有设置为 this 引用的地址。 但是对于非静态方法来说, this 会将分配到第 0 号槽位中。
再次提醒: 有过反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args); 有JavaScript编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);
理解这些字节码的诀窍在于:
给局部变量赋值时,需要使用相应的指令来进行 store,如 astore_1。store 类的指令都会删除栈顶值。 相应的 load 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。
4.11 流程控制指令
流程控制指令主要是分支和循环在用, 根据检查条件来控制程序的执行流程。
一般是 If-Then-Else 这种三元运算符(ternary operator), Java中的各种循环,甚至异常处的理操作码都可归属于 程序流程控制。
然后,我们再增加一个示例,用循环来提交给 MovingAverage 类一定数量的值:
package demo.jvm0104;
public class ForLoopTest {
private static int[] numbers = {1, 6, 8};
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
for (int number : numbers) {
ma.submit(number);
}
double avg = ma.getAvg();
}
}
同样执行编译和反编译:
javac -g demo/jvm0104/*.java
javap -c -verbose demo/jvm0104/ForLoopTest
因为 numbers 是本类中的 static 属性, 所以对应的字节码如下所示:
0: new #2 // class demo/jvm0104/MovingAverage
3: dup
4: invokespecial #3 // Method demo/jvm0104/MovingAverage."<init>":()V
7: astore_1
8: getstatic #4 // Field numbers:[I
11: astore_2
12: aload_2
13: arraylength
14: istore_3
15: iconst_0
16: istore 4
18: iload 4
20: iload_3
21: if_icmpge 43
24: aload_2
25: iload 4
27: iaload
28: istore 5
30: aload_1
31: iload 5
33: i2d
34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V
37: iinc 4, 1
40: goto 18
43: aload_1
44: invokevirtual #6 // Method demo/jvm0104/MovingAverage.getAvg:()D
47: dstore_2
48: return
LocalVariableTable:
Start Length Slot Name Signature
30 7 5 number I
0 49 0 args [Ljava/lang/String;
8 41 1 ma Ldemo/jvm0104/MovingAverage;
48 1 2 avg D
位置 [8~16] 的指令用于循环控制。 我们从代码的声明从上往下看, 在最后面的LocalVariableTable 中:
那么中间的 2,3,4 号槽位是谁霸占了呢? 通过分析字节码指令可以看出,在 2,3,4 槽位有 3 个匿名的局部变量(astore_2, istore_3, istore 4等指令)。
如果我们的 JDK 版本再老一点, 则会在 2,3,4 槽位发现三个源码中没有出现的变量: arr$, len$, i$, 也就是循环变量。
循环体中的第一条指令用于执行 循环计数器与数组长度 的比较:
18: iload 4
20: iload_3
21: if_icmpge 43
这段指令将局部变量表中 4号槽位 和 3号槽位的值加载到栈中,并调用 if_icmpge 指令来比较他们的值。
【if_icmpge 解读: if, integer, compare, great equal】, 如果一个数的值大于或等于另一个值,则程序执行流程跳转到pc=43的地方继续执行。
在这个例子中就是, 如果4号槽位的值 大于或等于 3号槽位的值, 循环就结束了,这里 43 位置对于的是循环后面的代码。如果条件不成立,则循环进行下一次迭代。
在循环体执行完,它的循环计数器加 1,然后循环跳回到起点以再次验证循环条件:
37: iinc 4, 1 // 4号槽位的值加1
40: goto 18 // 跳到循环开始的地方
4.12 算术运算指令与类型转换指令
Java 字节码中有许多指令可以执行算术运算。实际上,指令集中有很大一部分表示都是关于数学运算的。对于所有数值类型(int, long, double, float),都有加,减,乘,除,取反的指令。
那么 byte 和 char, boolean 呢? JVM 是当做 int 来处理的。另外还有部分指令用于数据类型之间的转换。
算术操作码和类型
当我们想将 int 类型的值赋值给 long 类型的变量时,就会发生类型转换。
类型转换操作码
在前面的示例中, 将 int 值作为参数传递给实际上接收 double 的 submit() 方法时,可以看到, 在实际调用该方法之前,使用了类型转换的操作码:
31: iload 5
33: i2d
34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V
也就是说, 将一个 int 类型局部变量的值, 作为整数加载到栈中,然后用 i2d 指令将其转换为 double 值,以便将其作为参数传给submit方法。
唯一不需要将数值load到操作数栈的指令是 iinc,它可以直接对 LocalVariableTable 中的值进行运算。 其他的所有操作均使用栈来执行。
4.13 方法调用指令和参数传递
前面部分稍微提了一下方法调用: 比如构造函数是通过 invokespecial 指令调用的。
这里列举了各种用于方法调用的指令:
那么 invokevirtual 和 invokeinterface 有什么区别呢?这确实是个好问题。 为什么需要 invokevirtual 和 invokeinterface 这两种指令呢? 毕竟所有的接口方法都是公共方法, 直接使用 invokevirtual 不就可以了吗?
这么做是源于对方法调用的优化。JVM 必须先解析该方法,然后才能调用它。
使用 invokevirtual 和 invokeinterface 的区别不是那么明显。想象一下,类定义中包含一个方法定义表, 所有方法都有位置编号。下面的示例中:A 类包含 method1 和 method2 方法; 子类B继承A,继承了 method1,覆写了 method2,并声明了方法 method3。
请注意,method1 和 method2 方法在类 A 和类 B 中处于相同的索引位置。
class A
1: method1
2: method2
class B extends A
1: method1
2: method2
3: method3
那么,在运行时只要调用 method2,一定是在位置 2 处找到它。
现在我们来解释invokevirtual 和 invokeinterface 之间的本质区别。
假设有一个接口 X 声明了 methodX 方法, 让 B 类在上面的基础上实现接口 X:
class B extends A implements X
1: method1
2: method2
3: method3
4: methodX
新方法 methodX 位于索引 4 处,在这种情况下,它看起来与 method3 没什么不同。
但如果还有另一个类 C 也实现了 X 接口,但不继承 A,也不继承 B:
class C implements X
1: methodC
2: methodX
类 C 中的接口方法位置与类 B 的不同,这就是为什么运行时在 invokinterface 方面受到更多限制的原因。 与 invokinterface 相比, invokevirtual 针对具体的类型方法表是固定的,所以每次都可以精确查找,效率更高(具体的分析讨论可以参见参考材料的第一个链接)。
4.14 JDK7 新增的方法调用指令 invokedynamic
Java 虚拟机的字节码指令集在 JDK7 之前一直就只有前面提到的 4 种指令(invokestatic,invokespecial,invokevirtual,invokeinterface)。随着 JDK 7 的发布,字节码指令集新增了invokedynamic指令。这条新增加的指令是实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,同时也是 JDK 8 以后支持的 lambda 表达式的实现基础。
为什么要新增加一个指令呢?
我们知道在不改变字节码的情况下,我们在 Java 语言层面想调用一个类 A 的方法 m,只有两个办法:
这两个方法都需要显式的把方法 m 和类型 A 直接关联起来,假设有一个类型 B,也有一个一模一样的方法签名的 m 方法,怎么来用这个方法在运行期指定调用 A 或者 B 的 m 方法呢?这个操作在 JavaScript 这种基于原型的语言里或者是 C# 这种有函数指针/方法委托的语言里非常常见,Java 里是没有直接办法的。Java 里我们一般建议使用一个 A 和 B 公有的接口 IC,然后 IC 里定义方法 m,A 和 B 都实现接口 IC,这样就可以在运行时把 A 和 B 都当做 IC 类型来操作,就同时有了方法 m,这样的“强约束”带来了很多额外的操作。
而新增的 invokedynamic 指令,配合新增的方法句柄(Method Handles,它可以用来描述一个跟类型 A 无关的方法 m 的签名,甚至不包括方法名称,这样就可以做到我们使用方法 m 的签名,但是直接执行的时候调用的是相同签名的另一个方法 b),可以在运行时再决定由哪个类来接收被调用的方法。在此之前,只能使用反射来实现类似的功能。该指令使得可以出现基于 JVM 的动态语言,让 jvm 更加强大。而且在 JVM 上实现动态调用机制,不会破坏原有的调用机制。这样既很好的支持了 Scala、Clojure 这些 JVM 上的动态语言,又可以支持代码里的动态 lambda 表达式。
RednaxelaFX 评论说:
简单来说就是以前设计某些功能的时候把做法写死在了字节码里,后来想改也改不了了。 所以这次给 lambda 语法设计翻译到字节码的策略是就用 invokedynamic 来作个弊,把实际的翻译策略隐藏在 JDK 的库的实现里(metafactory)可以随时改,而在外部的标准上大家只看到一个固定的 invokedynamic。
参考材料
*请认真填写需求信息,我们会在24小时内与您取得联系。