字节码
Java的规范分为两种,一种是Java语言规范,一种是虚拟机规范。Java语言规范主要集中在语言的层面上,虚拟机规范集中在更为底层的层面上。
字节码属于Java虚拟机规范的一部分,我们知道在JVM上涌现出各种各样的语言,比如Scala、Groovy、kotlin等等,这些语言语法不尽相同,但编译后的字节码文件所遵循的规范却是一样的。
对于JVM来说,他并不关心源代码所使用的语言,他只关心所编译出来的字节码文件是否符合字节码的规范。如果符合字节码规范,就可以加载并运行。这样就给了很多JVM上其他语言很好的契机,他们可以在遵守字节码规范的情况下,实现一些Java语言所不具备的特性。
如果了解了字节码也会对很多深层次的东西有更直观的感受,比如Java中synchronized关键字,在他字节码文件中是怎样的一种体现。比如volatile,防止指令重排序还有可见性,它在字节码文件当中是怎样体现的。从根源上真正去理解了这些相关的助记符后,回到语言层面上,对语言的关键字就会有豁然开朗的感觉。
我们来下面一段代码:
package com.leolin.jvm.bytecode; public class MyTest1 { private int a = 1; public int getA() { return a; } public void setA(int a) { this.a = a; } }
这段代码非常简单,没有什么好解释的,我们重点关注在这段代码编译后的字节码文件。这里我们先来看下MyTest1.class文件出来的内容,笔者这里使用的是Notepad++这个工具,使用这个工具打开class文件,默认会看到一堆的乱码,要看字节码文件,需要将文件的内容转换成十六进制,这里需要安装Notepad++一个插件:插件→插件管理→搜索HEX-Editor,然后安装这个插件。安装完插件后:插件→HEX-Editor→View in HEX,这样就可以将class文件的内容以十六进制来显示了。Address那一列和行并非class文件的内容,仅仅是Notepad++为方便我们阅读增加的行和列。
上面的代码基本上是很难看懂的,不过没关系,机器能读懂就行,就像让只能识别0和1的机器来读我们人类的语言,它们也是读不懂的。下面,我们来逐行逐字的解析上面的字节码。
我们先来了解魔数的概念:大多数情况下,我们都是通过扩展名来识别一个文件的类型的,比如我们看到一个.txt类型的文件我们就知道他是一个纯文本文件。但是,扩展名是可以修改的,当一个文件的扩展名被修改过,怎么识别一个文件的类型呢?这就用到了我们提到的“魔数”。很多类型的文件,其起始的几个字节的内容是固定的。比如:class文件的前四个字节都是魔数,魔数值为固定值:0xCAFEBABE
魔数后面的4个字节是版本信息,前两个字节表示minor version(次版本号),后两个字节表示major version(主版本号),十六进制34=十进制52。所以该文件的版本号为1.8.0。可以通过java -version命令来验证这一点。
这里我们先用javap -verbose来反编译MyTest1,javap加上-verbose这个参数的输出会打印行号、本地变量表信息、反编译汇编代码,还会输出当前类用到的常量池等信息。
D:Fworkjava_spacejvm-lecture argetclasses>javap -v com.leolin.jvm.bytecode.MyTest1 Classfile /D:/F/work/java_space/jvm-lecture/target/classes/com/leolin/jvm/bytecode/MyTest1.class Last modified 2020-5-10; size 493 bytes MD5 checksum 128d7aa0a92c1f5c5d623499a75480f3 Compiled from "MyTest1.java" public class com.leolin.jvm.bytecode.MyTest1 SourceFile: "MyTest1.java" minor version: 0 major version: 51 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#20 // java/lang/Object."<init>":()V #2 = Fieldref #3.#21 // com/leolin/jvm/bytecode/MyTest1.a:I #3 = Class #22 // com/leolin/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/leolin/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/leolin/jvm/bytecode/MyTest1 #23 = Utf8 java/lang/Object { public com.leolin.jvm.bytecode.MyTest1(); 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 3: 0 line 4: 4 LocalVariableTable: Start Length Slot Name Signature 0 10 0 this Lcom/leolin/jvm/bytecode/MyTest1; public int getA(); flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #2 // Field a:I 4: ireturn LineNumberTable: line 7: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/leolin/jvm/bytecode/MyTest1; public void setA(int); 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 11: 0 line 12: 5 LocalVariableTable: Start Length Slot Name Signature 0 6 0 this Lcom/leolin/jvm/bytecode/MyTest1; 0 6 1 a I }
上面反编译出来的字节码信息,可以看到主版本号(major version)、次版本号(minor version),确实与我们之前分析的一致。
版本号之后的就是常量池入口,一个Java类定义的很多信息都是由常量池来维护和描述的,可以将常量池看作是class文件的资源仓库,比如Java类定义的方法和变量信息,都是存储在常量池中。常量池中主要存储两类常量:字面量和符号引用。字面量如文本字符串、Java中声明为final常量值等,而符号引用如类和接口的全局限定名,字段的名称和描述符,方法的名称和描述符等。
常量池的整体结构:Java类对应的常量池主要由常量池数量和常量池数组(常量表)两部分共同构成。常量池数量紧跟在主版本号后面,占据两个字节,而常量池数组在常量池数量之后。常量池数组与一般数组不同的是,常量池数组中元素的类型、结构都是不同的,长度当然也就不同,但是每一种元素的第一个数据都是一个u1类型标志位,占据一个字节。JVM在解析常量池时,就会根据这个u1类型的来获取对应的元素的具体类型。
上面的MyTest1版本号之后的两个字节是0x0018,转换成十进制是24个元素,但是我们看我们反编译出来的代码的常量池(Constant pool)只有23个元素。这里需要注意一点:常量池数组中元素的个数=常量池数-1,(其中0暂时不使用)。目的是满足某些常量池索引值的数据在特定的情况下需要表达不引用任何常量池的含义。根本原因在于索引为0也是一个常量(保留常量),只不过它不位于常量表中,这个常量就对应null,所以常量池的索引从1而非0开始。
在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 getUser(int id ,String name)的描述符为:(I,Ljava/lang/String;)Ljava/lang/String;
下表给出了常量池中11种数据类型的结构:
常量 | 项目 | 类型 | 描述 |
CONSTANT_Utf8_info | tag | u1 | 值为1 |
length | u2 | UF-8编码的字符串占用的字节数 | |
bytes | u1 | 长度为length的UTF-8编码的字符串 | |
CONSTANT_Integer_info | tag | u1 | 值为3 |
bytes | u4 | 按照高位在前存储的int值 | |
CONSTANT_Float_info | tag | u1 | 值为4 |
bytes | u4 | 按照高位在前存储的float值 | |
CONSTANT_Long_info | tag | u1 | 值为5 |
bytes | u8 | 按照高位在前存储的long值 | |
CONSTANT_Double_info | tag | u1 | 值为6 |
bytes | u8 | 按照高位在前存储的double值 | |
CONSTANT_Class_info | tag | u1 | 值为7 |
index | u2 | 指向全限定名常量项的索引 | |
CONSTANT_String_info | tag | u1 | 值为8 |
index | u2 | 指向字符串字面量的索引 | |
CONSTANT_Fieldref_info | tag | u1 | 值为9 |
index | u2 | 指向声明字段的类或接口描述符CONSTANT_Class_info的索引项 | |
index | u2 | 指向字段名称及类型描述符CONSTANT_NameAndType_info的索引项 | |
CONSTANT_Methodref_info | tag | u1 | 值为10 |
index | u2 | 指向声明方法的类描述符CONSTANT_Class_info的索引项 | |
index | u2 | 指向方法名称及类型描述符CONSTANT_NameAndType_info的索引项 | |
CONSTANT_InrerfaceMethodref_info | tag | u1 | 值为11 |
index | u2 | 指向声明方法的接口描述符CONSTANT_Class_info的索引项 | |
index | u2 | 指向方法名称及类型描述符CONSTANT_NameAndType_info的索引项 | |
CONSTANT_NameAndType_info | tag | u1 | 值为12 |
index | u2 | 指向字段或方法名称常量项目的索引 | |
index | u2 | 指向该字段或方法描述符常量项的索引 |
现在,我们来分析下上面字节码文件的常量池:
第一个元素的标志位是0a,对应的常量是CONSTANT_Methodref_info,这是元素所存储的是一个方法,它需要两个索引,长度分别为u2,第一个索引指向的是定义这个方法的类,第二个索引指向的是方法的名字和返回类型。第一个index是0x0004,第二个index是0x0014,索引值分别为4和20,整个元素的内容为:0a 0004 0014。这里我们先根据上面反编译的结果开一下上帝视觉,常量池索引值4指向索引值23,索引值23存储的是java/lang/Object,常量池索引值20会指向7和8,索引值7所存储的<init>代表的是构造方法,索引值8所存储的()V代表的这一个无返回的方法。在Java中,如果我们没有编写构造方法,编译器会默认帮我们生成一个构造方法。所以第一个元素其实代表是父类Object的构造方法。
第二个元素的标志位是09,对应的常量是CONSTANT_Fieldref_info,这里同样需要两个索引,长度都是u2,第一个索引指向定义这个字段的类,索引的值是0x0003,为常量池第三个元素。第二索引指向字段的类型和名称,索引的值是0x0015,为常量池第21个元素,整个元素的内容为:09 0003 0015。第三个元素根据反编译的结果是com/leolin/jvm/bytecode/MyTest1,而第21个元素指向了5和6的索引位置,第五个元素存储的是a,代表的是这个字段的名字,第六个元素存储的是I,代表这个元素的类型为int。
现在我们来分析第三个元素,之前我们说第三个元素是com/leolin/jvm/bytecode/MyTest1,我们看看根据字节码分析的结果,是否与我们反编译的结果一样。第三个元素的标志位是07,对应的常量是CONSTANT_Class_info,只有一个index,指向全限定名常量项的索引,长度为u2,索引的值为0x0016,十进制为22。整个元素的内容为:07 0016。所以这里我们还是只能先对第三个元素按下不表,等到分析第22个元素,看看是否和我们预期的结果一样,存储的是com/leolin/jvm/bytecode/MyTest1。
第四个元素的标志位依旧是07,整个元素的内容为0x07 0017,索引指向第23个元素,根据反编译的结果,第23个元素是java/lang/Object。
第五个元素的标志位是01,对应的常量为CONSTANT_Utf8_info,这个元素还存储了两个字段,length和bytes,分别为字符串的长度,其中length的长度为u2,这里是0x0001,十进制为1,代表这是长度为1的字符串,往后读一个字节是0x61,十进制为97,而ASCII为97所对应的字符为a,整体内容为:01 0001 61。正好之前常量池第二个元素引用的第21个元素,第21个元素代表a:I,其中的a正是引用了第五个元素。
第六个元素的标志位是01,整体元素:01 0001 49,而0x49的十进制是73,ASCII为73所对应的字符为I。
第七个元素的标志位是01,长度为0x0006,十进制为6,向后读取6个字节。0x3c 0x69 0x6e 0x69 0x74 0x3e,我们将每个字节转换成对应的十进制之后,每个ASCII码所对应的字符是<init>。
第八个元素的标志位是01,长度为0x0003,向后读取3个字节:0x28 0x29 0x56,这三个十六进制在转换成字符之后为()V。
第九个元素的标志位是01,长度为0x0004,向后读取4个字节:0x43 0x6f 0x64 0x65,转化为字符串之后为Code。
第十个元素的标志位是01,长度为0x000f,向后读取15个字节:0X4c 0X69 0X6e 0X65 0X4e 0X75 0X6d 0X62 0X65 0X72 0X54 0X61 0X62 0X6c 0X65,转化之后得到:LineNumberTable。
第十一个元素的标志位是01,长度为0x0012,向后读取18个字节:0X4c 0X6f 0X63 0X61 0X6c 0X56 0X61 0X72 0X69 0X61 0X62 0X6c 0X65 0X54 0X61 0X62 0X6c 0X65,转换之后得到:LocalVariableTable。
第十二个元素的标志位是01,长度为0x0004,向后读取4个字节:0X74 0X68 0X69 0X73,转换后得到:this。
第十三个元素的标志位是01,长度为0x0021,向后读取33个字节:
0X4c 0X63 0X6f 0X6d 0X2f 0X6c 0X65 0X6f 0X6c 0X69 0X6e 0X2f 0X6a 0X76 0X6d 0X2f 0X62 0X79 0X74 0X65 0X63 0X6f 0X64 0X65 0X2f 0X4d 0X79 0X54 0X65 0X73 0X74 0X31 0X3b
转后后得到:
Lcom/leolin/jvm/bytecode/MyTest1;
第十四个元素的标志位是01,长度为0x0004,向后读取4个字节:0X67 0X65 0X74 0X41,转换后得到:getA。第十五个元素的标志位是01,长度为0x0003,向后读取3个字节:0X28 0X29 0X49,转换后得到:()I。如果将十四和十五两个元素如果组合在一起,就代表我们的成员方法int getA()。
第十六个元素的标志位是01,长度为0x0004,向后读取4个字节:0X73 0X65 0X74 0X41,转换后得到:setA。第十七个元素的标志位是01,长度为0x0004,向后读取4个字节:0X28 0X49 0X29 0X56,转换后得到:(I)V。如果将十六和十七两个元素组合在一起,就代表我们的成员方法void SetA(int a)。
第十八个元素的标志位是01,长度为0x000a,向后读取10个字节:0X53 0X6f 0X75 0X72 0X63 0X65 0X46 0X69 0X6c 0X65,转换后得到:SourceFile。第十九个元素的标志位是01,长度为0x000c,向后读取12个字节:0X4d 0X79 0X54 0X65 0X73 0X74 0X31 0X2e 0X6a 0X61 0X76 0X61,转换后得到:MyTest1.java。将这两个元素组合在一起,可以知道当前的class文件是哪个java文件编译出来的。
第二十个元素的标志位是0c,对应常量是CONSTANT_NameAndType_info,包含两个u2长度的索引,第一个是0x0007,指向字段名或方法名。第二个是0x0008,指向字段或方法的类型,对应常量池第七个和第八个元素,即:<init>()V。
第二十一个元素的标志位是0c,包含两个u2长度的索引,第一个0x0005,第二个是0x0006,对应常量池第五个和第八个元素,即:aI。
第二十二个元素的标志位是01,长度为0x001f,向后读取31个字节:
0X63 0X6f 0X6d 0X2f 0X6c 0X65 0X6f 0X6c 0X69 0X6e 0X2f 0X6a 0X76 0X6d 0X2f 0X62 0X79 0X74 0X65 0X63 0X6f 0X64 0X65 0X2f 0X4d 0X79 0X54 0X65 0X73 0X74 0X31
转换后得到:com/leolin/jvm/bytecode/MyTest1。这是类的全局限定名,在字节码中,包名和类名之间、以及包名和包名之间不是用.来分割,而是用/分割的。
第二十三个元素的标志位是01,长度为0x0010,向后读取16个字节:
0X6a 0X61 0X76 0X61 0X2f 0X6c 0X61 0X6e 0X67 0X2f 0X4f 0X62 0X6a 0X65 0X63 0X74
转换后得到:java/lang/Object。
至此,我们常量池里的常量都分析完毕了。