学习《深入理解Java虚拟机》总结
总览
每一个class字节码文件都唯一表示一个类、接口等,及时一个类是内部类,其在编译后也会有一个自己的class文件。字节码文件一般拥有以下几个部分组成:
- 头信息
- 常量池
- 访问标志
- 类索引、父类索引以及接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合(属性表并不与其他信息在一个层级,只不过很重要,所以放在这里)
通过这几部分就能完全映射java代码,还需要了解一个小点,就是class文件全部都是大头端解析。
声明一点,后文不做继续说明,所有出现类似于数组的存储结构,字节码文件都是事先在数组前声明数组数量,这样才能确定数组的开始和结尾信息。
头信息
头信息有两部分组成,一个是魔数,另一个就是版本号。
魔数就是为了表明这个文件是class文件的标识,如果魔数不是指定的话,那就不是字节码,不进行解析。这种技术运用比较广泛,很多协议,比如网络协议都拥有这个魔数(数值不相同罢了),这样就可以避免仅仅从文件后缀名带来的问题了(篡改了文件后缀,导致解析失败)。字节码的魔数是头4个字节,就是cafe babe(咖啡宝贝?)
版本号是在魔数后面的四个字节,前两个字节表示次序版本,如上图所示,0x0000表示的就是0版本,主版本号就是0x0036=54,表示的就是版本号大于等于jdk10.0(查询得到)的虚拟机可以进行加载,比这个小的虚拟机不能加载(JVM虚拟机版本号必须大于等于class文件的版本号)。
常量池
紧随头信息后面的是常量池(这个常量池不是JVM里面的常量池),所有程序里面显示声明的字符串、数值型数值、类型、接口、方法等信息都需要存储在常量池中,用到的时候直接通过索引(index)索取到相应的常量即可,有一种复用的思想,一个名称或者数值,我只需要存储一遍即可了(自己的理解)。
常量池的数据是可以互相进行索引的,比如一个CONSTANT_Class_info里面对于类名称的表示是用一个CONSTANT_Utf8_info来进行存储的,后者也是常量池的一个数据,前者只需要保留后者的索引位置即可。
为了便于演示,编译一个java代码,如下所示:
package com.blankchn.jvm.unit6;
/**
* @author BlankCHN
* @date 2019-03-13 14:27
*/
public class SimpleClass {
private int param1;
private int param2;
private void test(int p1, int p2){
param1 = p1;
param2 = p2;
System.out.println(param1+param2);
}
}
使用javap -verbose -p 阅读字节码:
public class com.blankchn.jvm.unit6.SimpleClass
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #6 // com/blankchn/jvm/unit6/SimpleClass
super_class: #7 // java/lang/Object
interfaces: 0, fields: 2, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #7.#19 // java/lang/Object."<init>":()V
#2 = Fieldref #6.#20 // com/blankchn/jvm/unit6/SimpleClass.param1:I
#3 = Fieldref #6.#21 // com/blankchn/jvm/unit6/SimpleClass.param2:I
#4 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #24.#25 // java/io/PrintStream.println:(I)V
#6 = Class #26 // com/blankchn/jvm/unit6/SimpleClass
#7 = Class #27 // java/lang/Object
#8 = Utf8 param1
#9 = Utf8 I
#10 = Utf8 param2
#11 = Utf8 <init>
#12 = Utf8 ()V
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 test
#16 = Utf8 (II)V
#17 = Utf8 SourceFile
#18 = Utf8 SimpleClass.java
#19 = NameAndType #11:#12 // "<init>":()V
#20 = NameAndType #8:#9 // param1:I
#21 = NameAndType #10:#9 // param2:I
#22 = Class #28 // java/lang/System
#23 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(I)V
#26 = Utf8 com/blankchn/jvm/unit6/SimpleClass
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (I)V
{
private int param1;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int param2;
descriptor: I
flags: (0x0002) ACC_PRIVATE
public com.blankchn.jvm.unit6.SimpleClass();
descriptor: ()V
flags: (0x0001) 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 7: 0
private void test(int, int);
descriptor: (II)V
flags: (0x0002) ACC_PRIVATE
Code:
stack=3, locals=3, args_size=3
0: aload_0
1: iload_1
2: putfield #2 // Field param1:I
5: aload_0
6: iload_2
7: putfield #3 // Field param2:I
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: aload_0
14: getfield #2 // Field param1:I
17: aload_0
18: getfield #3 // Field param2:I
21: iadd
22: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
25: return
LineNumberTable:
line 14: 0
line 15: 5
line 16: 10
line 17: 25
}
SourceFile: "SimpleClass.java"
可以注意到里面有一个部分是Constant pool,这个就是常量池,下面从#1到#33就是其中存储的各个数据。简单介绍一下出现的几个,
类型 | 显示 | 含义 |
---|---|---|
CONSTANT_Methodref_info | Methodref | 类中方法的符号引用 |
CONSTANT_Fieldref_info | Fieldref | 字段的符号引用 |
CONSTANT_Utf8_info | Utf8 | utf-8编码的字符串 |
CONSTANT_NameAndType | NameAndType | 字段或方法的部分符号引用 |
CONSTANT_Class_info | Class | 类或接口的符号引用 |
从字节码中可以看到常量池中每一种类型的常量都是有自己特有的结构,比如Methodref就有三部分组成,一部分是tag(这个结构所有种类常量都有,用一个字节表示这个常量的种类),第二部分是Class,表示这个方法是属于哪个类的,为了表示的清楚一些,我把上面用到的常量池数据单独放出来:
#1 = Methodref #7.#19 // java/lang/Object."<init>":()V
#7 = Class #27 // java/lang/Object
#11 = Utf8 <init>
#12 = Utf8 ()V
#19 = NameAndType #11:#12 // "<init>":()V
#27 = Utf8 java/lang/Object
上面字节码中,#1的Methodref的class对应的是#7, #7表示的#27代表的类,#27是一个字符串,java/lang/Object,所以这样#7就表示java/lang/Object这个类,第三个部分就是这个方法的名称和句柄类型使用#19常量表示的(NameAndType),#19表示的是方法名称#11,即,代表的是构造方法,#12表示的是参数列表和返回值,()表示没有参数,V表示Void即没有返回值,这样对于一个方法的引用就表示完了。可以看到这个表示的方法就是在编译器构造默认构造函数时引用的Object的构造函数。
通过上面一个例子可以看到每种常量都有不同的结构,并且通过常量之间的互相索引能够构造出所有的需要的数据(比如方法全限定名、参数列表、返回值、字段全限定名、类全限定名等等,所以常量池也是数量较多的一个部分)。
访问标志
访问标志用于表示这个类的访问权限,即对应的是public、private、protected、final、abstract、synthetic、annotation、enum等,对应表示这个类是否是公有的、私有的、保护的、不可扩展的、抽象的、由非用户代码生成的、注解、枚举类型。总共拥有16位,两个字节,现在仅仅用了8位,还有8位保留。
类索引、父类索引及接口索引集合
类索引和父类索引都是u2类型的数据(u2表示两个字节,u1表示一个字节,以此类推),比如上面例子中:
this_class: #6 // com/blankchn/jvm/unit6/SimpleClass
super_class: #7
interfaces: 0
···
#6 = Class #26 // com/blankchn/jvm/unit6/SimpleClass
#7 = Class #27 // java/lang/Object
#26 = Utf8 com/blankchn/jvm/unit6/SimpleClass
#27 = Utf8 java/lang/Object
类索引(this_class)表示本类的全限定名,此例中全限定名为#6的Class,指向#26的字符串,由此确定出此类的全限定名为:com/blankchn/jvm/unit6/SimpleClass。
父类索引(super_class)表示本类的父类全限定名,java中所有类除了Object以外都有父类,本类的父类为java/lang/Object(推理过程与类索引一样)。
接口索引(interfaces)表示本类中扩展的所有接口,其开头包含一个u2的接口计数器,用于记录扩展接口的数量,本例中接口数量为0,接下来,如果数量不为0,每一个对应一个CONSTANT_Class_info常量的索引,用于找到对应接口的全限定名。
字段表集合
字段表集合用于描述接口或者类中的字段,本例中如下图:
private int param1;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int param2;
descriptor: I
flags: (0x0002) ACC_PRIVATE
上面代码显示的是javap工具编排后的情况,在class文件中,字段表包含了5部分,如下表所示:
类型 | 名称 | 数量 | 意义 |
---|---|---|---|
u2 | access_flags | 1 | 字段修饰符 |
u2 | name_index | 1 | 字段的简单名称 |
u2 | descriptor_index | 1 | 字段的描述符 |
u2 | attributes_count | 1 | 属性表中属性的数量 |
u2 | attributes | attributes_count | 属性 |
字段的描述符、简单名称、全限定名:
描述符就是字段的数据类型,比如int数据就是I,double数据就是d,char数据就是c。
简单名称就是没有类型的字段名称,比如上面的int param1的简单名称就是param1
全限定名就是包含了包名的名称,但是把类名中的".“替换为”/",比如com.blankchn.jvm.unit6.SimpleClass.param1的全限定名就是com/blankchn/jvm/unit6/SimpleClass.param1
字段修饰符就是private、public、protected、static、final、volatile、enum、transient、synthetic等,其每一个都有唯一的标志,即为access_flags的值。
属性表后面会讲到。
方法表集合
方法表集合就是用于存储类中的方法,本例中的方法表集合如下:
public com.blankchn.jvm.unit6.SimpleClass();
descriptor: ()V
flags: (0x0001) 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 7: 0
private void test(int, int);
descriptor: (II)V
flags: (0x0002) ACC_PRIVATE
Code:
stack=3, locals=3, args_size=3
0: aload_0
1: iload_1
2: putfield #2 // Field param1:I
5: aload_0
6: iload_2
7: putfield #3 // Field param2:I
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: aload_0
14: getfield #2 // Field param1:I
17: aload_0
18: getfield #3 // Field param2:I
21: iadd
22: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
25: return
LineNumberTable:
line 14: 0
line 15: 5
line 16: 10
line 17: 25
方法表的结构如下:
类型 | 名称 | 数量 | 意义 |
---|---|---|---|
u2 | access_flags | 1 | 访问标志 |
u2 | name_index | 1 | 名称索引 |
u2 | descriptor_index | 1 | 描述符索引 |
u2 | attributes_count | 1 | 属性集数量 |
attribute_info | attributes | attributes_count | 属性 |
方法的简单名称、描述符、全限定名
- 简单名称就是只带有基本名称,比如例子中的test(),其简单名称就是test
- 描述符就是表示方法参数和返回值的符号,比如private void test(int p1, int p2)对应的描述符就是(II)V,表示参数为两个int型数据,输出为Void类型(输入数输出类型如果是数组的话,用“["表示
- 全限定名同字段的一样。
那么方法的代码都去哪了?这就是下面要降到的属性表集合了。
方法表中通过方法名和方法描述符和访问标志构造出了诸如:private int test(int p1, int p2)的形式,再通过属性表中的Code属性和Exceptions属性构造了诸如:
private int test(int p1, int p2) throws Exception{
//Code
}
因为这个方法是位于这个class文件的,通过class文件的类索引可以获取这个类的全限定名,这样这个方法相当于是在指定作用域范围内能够被看到了。
属性表集合
对于类、字段、方法都可以携带属性表,属性表中放置有描述相应对象的信息,比如方法表中的执行代码就放在方法表中的属性表中的Code属性中了,本例中,类中也携带有属性,如下所示:
···
interfaces: 0, fields: 2, methods: 2, attributes: 1
···
SourceFile: "SimpleClass.java"
就是这个SourceFile属性,其表示的就是这个字节码对应的java文件名称。
属性是字节码文件能够保持良好的可扩展性的重要基础,一些新特性可以以属性的形式附加于属性表中。
Code属性
Code属性是最为重要的属性了,其表示的是方法的所有代码,以test方法作为例子:
Code:
stack=3, locals=3, args_size=3
0: aload_0
1: iload_1
2: putfield #2 // Field param1:I
5: aload_0
6: iload_2
7: putfield #3 // Field param2:I
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: aload_0
14: getfield #2 // Field param1:I
17: aload_0
18: getfield #3 // Field param2:I
21: iadd
22: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
25: return
Code属性结构
类型 | 名称 | 数量 | 意义 |
---|---|---|---|
u2 | attribute_name_index | 1 | 属性名称索引 |
u4 | attribute_length | 1 | 属性长度 |
u2 | max_stack | 1 | 操作数栈最大深度 |
u2 | max_locals | 1 | 局部变量表所需要的存储空间 |
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_info | attributes | attributes_count | 属性 |
max_stack代表的是操作数栈最大深度,之前章节里面提到的栈帧的大小在编译的时候就确定好了,这就是体现,这个操作数栈大小指的就是栈帧的操作数栈。
max_locals代表的是局部变量表所需要的存储空间,虚拟机运行时为局部变量分配内存空间是以slot进行分配,一个字节的变量(byte、char、bool、short、int、float等)都是分配一个slot单位,像double、long等数据都是以两个slot进行分配。同时根据局部变量的作用域部分slot可以进行重用,所以max_locals的大小并不是所有局部变量的大小。
但是注意到本例中test方法只有两个参数,可是max_locals和args_size都是3,这是因为有一个this参数。
code就是字节指令,字节码用一个字节代表一个操作指令,所以公有256个操作指令,每个方法字节码的长度是有code_length决定的,虽然它是一个4个字节长度,但是jvm规范规定只允许使用两个字节,所以每个方法的字节码指令不能超过65535。每个字节码还可以拥有自己的参数比如本例中的:
···
#2 = Fieldref #6.#20 // com/blankchn/jvm/unit6/SimpleClass.param1:I
#6 = Class #26 // com/blankchn/jvm/unit6/SimpleClass
#8 = Utf8 param1
#9 = Utf8 I
#20 = NameAndType #8:#9 // param1:I
#26 = Utf8 com/blankchn/jvm/unit6/SimpleClass
···
2: putfield #2 // Field param1:I
这里面字节码指令putfield 使用的参数就是常量池的#2,也就是com.blankchn.jvm.unit6.SimpleClass的param1的int型数据。
异常表是用于程序中try…catch…finally块的,异常表结构如下:
类型名称 | 名称 | 数量 | 意义 |
---|---|---|---|
u2 | start_pc | 1 | try开始的位置 |
u2 | end_pc | 1 | try结束的位置 |
u2 | handler_pc | 1 | 对于捕获特定异常的类型处理的位置 |
u2 | catch_type | 1 | 需要捕获的异常类型 |
在编译字节码的同时,就已经将try、catch、finally对应的字节码编译进去了,比如下面这个代码:
int x ;
try{
x = 1;
return x;
}catch (Exception e) {
x = 2;
return x;
}finally{
x = 3;
}
这个代码不出现异常的结果是1,出现异常结果是2,字节码在编译的时候在try中的return x后面放入了finally中的代码,紧跟着后面是catch的代码,catch中的return x后面放的还是finally代码,之后还要再放一遍这个finally代码,目的是在catch中如果又发生了异常,则跳转到finally中去,在这个finally的最后还会将异常进行抛出。
- 当不出现异常时,try中的return语句执行完毕后(return的意思是将x的副本放入本地变量表中,作为返回值使用),虽然finally中对x进行了重新赋值,可是返回的是之前x的副本,所以返回的不是3而是1。如果这里用的不是int而是一个Object,那么返回的就是finally对这个Object操作后的结果了,因为return存入的副本依然还是对象的引用,finally对这个引用所指向的对象进行了修改,所以修改结果能够进行返回。int是存放于栈中的而不是堆的。
- 当出现异常后,比如x=1发生了异常,那么虚拟机会根据异常表查看在这个start_pc和end_pc发生了catch_type类型的异常的处理位置(handler_pc),也就是catch块的开头,然后执行catch的逻辑,如果catch依然发生了异常,则执行finally逻辑(因为编译的时候就编译器在变量表中声明了catch出现异常要跳转到catch中)。
Exceptions 属性
异常表属性,这个异常表不是Code属性中的异常表,这里面放的是一个方法后面throws跟随的异常。
LineNumberTable
行号表,表示的java文件中的每一行对应的所有字节码的开始位置,如下:
LineNumberTable:
line 14: 0
line 15: 5
line 16: 10
line 17: 25
比如根据行号表中line 14: 0表示java文件的第14行代表Code中的第0行开始,line15: 5表示java文件的第15行是从Code中的第5行开始。这个属性主要是在调试设置断点的时候以及抛出异常的时候堆栈中的出错的行号。
局部变量表
用于描述栈帧中局部变量表中的变量与java文件中定义变量的名称关系,这样我们就可以知道class文件中的这个方法的参数名称在java文件中是什么样子的了。我们在IDE中看到方法参数的名称就是依靠这个局部变量表,如果没有的话,就会显示args0 args1之类的了。
ConstantValue属性
这个属性是用于通知虚拟机对有ConstantValue的静态变量进行赋值。
对于实例字段(也就是非static字段)在构造方法进行赋值。
对于类字段(也就是static字段),如果没有用final进行修饰,则在构造方法中进行赋值,如果使用了final并且还是八种基本类型和String的话,那么使用Constant进行赋值,Constant含有一个constantvalue_index,这是对常量池数据的索引,意义就是final static的字段所要进行赋值用到的数据。