暴力突破 Android 编译插桩(八)- class 字节码

专栏:暴力突破 Android 编译插桩系列

一、Class 类文件结构


Java 虚拟机当初被设计出来的目的就不单单是只运行 Java 这一种语言。目前 Java 虚拟机已经可以支持很多除 Java 语言以外的其他语言了,如 Groovy、JRuby、Jython、Scala 等。之所以可以支持其他语言,是因为这些语言经过编译之后也可以生成能够被 JVM 解析并执行的字节码文件(class 文件)。而虚拟机并不关心字节码是由哪种语言编译而来的。

为了让 Java 语言具有良好的跨平台能力,Java 提供了一种可以在所有平台上都能使用的一种中间代码 - 字节码类文件(.class文件)。无论是哪种平台(如:Mac、Windows、Linux 等),只要安装了虚拟机都可以直接运行字节码。并且也解除了 Java 虚拟机和 Java 语言之间的耦合。

暴力突破 Android 编译插桩(八)- class 字节码_第1张图片

class 文件里只有两种数据结构:无符号数和表。

  • 无符号数:属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者字符串(UTF-8 编码)。每个字节的无符号数都是由两个 16 进制数值组成。
  • 表:表是由多个无符号数或者其他表作为数据项构成的复合数据类型,class文件中所有的表都以“_info”结尾。其实,整个 Class 文件本质上就是一张表。

这两者之间的关系可以用下面这张图来表示:

暴力突破 Android 编译插桩(八)- class 字节码_第2张图片

这些无符号数和表就组成了 class 中的各个结构。这些结构按照预先规定好的顺序紧密的从前向后排列,相邻的项之间没有任何间隙。如下图所示:

当 JVM 加载某个 class 文件时,JVM 就是根据上图中的结构去解析 class 文件,加载 class 文件到内存中,并在内存中分配相应的空间。具体某一种结构需要占用大多空间,可以参考下表: 

字段 含义
u4   magic_number 魔数
u2   minor_version 副版本号
u2   major_version 主版本号
u2   constant_pool_count 常量池大小
cp_info[constant_pool_count]   constant_pool 常量池
u2   access_flag 访问标志
u2   this_class 当前类索引
u2   super_class 父类索引
u2   interfaces_count 接口索引集合大小
u2[interfaces_count]   interfaces 接口索引集合
u2   fields_count 字段索引集合大小
field_info[fields_count]   fields 字段索引集合
u2   methods_count 方法索引集合大小
method_info[methods_count]   methods 方法索引集合
u2   attributes_count 属性索引集合大小
attribute_info[attributes_count]   attributes 属性索引集合

下面我们通过一个 Java 代码实例,来看一下上面这几个结构的详细情况:

import java.io.Serializable;
 
public class Test implements Serializable, Cloneable{
      private int num = 1;
 
      public int add(int i) {
          int j = 10;
          num = num + i;
          return num;
     }
}

通过 javac 将其编译,生成 Test.class 字节码文件。然后使用 16 进制编辑器打开 class 文件,显示内容如下所示:

暴力突破 Android 编译插桩(八)- class 字节码_第3张图片

接下来我们根据这个 class 字节码文件逐步分析 JVM 是如何解析它们。

1.1 魔数

根据上表可知在 class 文件开头的四个字节是 class 文件的魔数,它是一个固定的值 - 0XCAFEBABE。魔数是 class 文件的标志,也就是说它是判断一个文件是不是 class 格式文件的标准, 如果开头四个字节不是 0XCAFEBABE, 那么就说明它不是 class 文件,不能被 JVM 识别或加载。

1.2 版本号

紧跟着魔数后面的 4 个字节是版本号,前 2 个字节是副版本号,后 2 个字节是主版本号。也就是说当前 class 文件的综合版本号是 52.0,也就是 jdk1.8.0。

1.3 常量池

紧跟在版本号之后的是一个叫作常量池的表(cp_info)。在常量池中保存了类的各种相关信息,比如类的名称、父类的名称、类中的方法名、参数名称、参数类型等,这些信息都是以各种表的形式保存在常量池中的。常量池中的每一项都是一个表,其项目类型常用的共有 14 种,如下表所示:

常量类型 类型描述 表结构(按顺序) 结构描述
CONSTANT_Utf8_info UTF-8 编码的字符串 u1   tag 值为 1
u2   length UTF-8 编码的字符串占用字节数
u1[length]   bytes 长度为 length 的 UTF-8 编码的字符串
CONSTANT_Integer_info 整形常量表 u1   tag 值为 3
u4   bytes 按照高位在前存储的 int 值
CONSTANT_Float_info 浮点型常量表 u1   tag 值为 4
u4   bytes 按照高位在前存储的 float 值
CONSTANT_Long_info 长整型常量表 u1   tag 值为 5
u8   bytes 按照高位在前存储的 long 值
CONSTANT_Double_info 双精度浮点型常量表 u1   tag 值为 6
u8   bytes 按照高位在前存储的 double 值
CONSTANT_Class_info 类或接口 引用表 u1   tag 值为 7
u2   index 指向全限定名常量项的索引
CONSTANT_String_info 字符串常量表 u1   tag 值为 8
u2   index 指向字符串字面量的索引
CONSTANT_Fieldref_info 字段引用表 u1   tag 值为 9
u2   index 指向此字段所属类的 CONSTANT_CLASS_info 的索引项
u2   index 指向此字段的名称和类型的 CONSTANT_NameAndType 的索引项
CONSTANT_Methodref_info 类中方法引用表 u1   tag 值为 10
u2   index 指向此方法所属类的 CONSTANT_CLASS_info 的索引项
u2   index 指向此方法的名称和类型的 CONSTANT_NameAndType 的索引项
CONSTANT_InterfaceMethodref_info 接口中方法引用表 u1   tag 值为 11
u2   index 指向此方法所属接口的 CONSTANT_Class_Type 的索引项
u2   index 指向此方法的名称和类型的 CONSTANT_NameAndType 的索引项
CONSTANT_NameAndType_info 字段或方法的名称和类型表

u1   tag

值为 12
u2   index 指向该字段或方法名称的常量项的索引
u2   index 指向该字段或方法类型的常量项的索引
CONSTANT_MethodHandle_info 表示方法句柄 u1   tag 值为 15
u1   reference_kind 值必须在1至9之间,它决定了方法句柄的类型。方法句柄类型的值表示方法句柄的字节码行为。
u2   reference_index 值必须是对常量池的有效索引
CONSTANT_MethodType_info 表示方法类型 u1   tag 值为 16
u2   descriptor_index 值必须是对常量池的有效索引,常量池在该索引处的项必须是 CONSTANT_Uft8_info 结构,表示方法的描述符
CONSTANT_InvokeDynamic_info 动态方法调用表 u1   tag 值为 18
u2 bootstrap_method_attr_index 值必须是对当前 Class 文件中引导放发表的 bootstrap_methods[] 数组的有效索引
u2   name_and_type_index 值必须是对当前常量池的有效索引,常量池在该索引处的项必须是 CONSTANT_NameAndType_info 结构,表示方法名和方法描述符

可以看出常量池中的每一项起始的第一位是一个 u1 大小的 tag 值。tag 值是表的标识,JVM 解析 class 文件时,通过这个值来判断当前数据结构是哪一种表。不难看出,在常量池内部的表中也有相互之间的引用(这样可以有效地节省 Class 文件的空间),就以 CONSTANT_String_info 和 CONSTANT_Utf8_info 这两张表举例说明,CONSTANT_Utf8_info 表具体结构如下:

table CONSTANT_utf8_info {
    u1 tag;//tag值为1,表示是 CONSTANT_Utf8_info 类型表。
    u2 length;//length 表示 u1[] 的长度,比如 length=5,则表示接下来的数据是 5 个连续的 u1 类型数据。
    u1[length] bytes;//u1 类型数组,长度为上面第 2 个参数 length 的值。
}

而我们在 java 代码中声明的 String 字符串最终在 class 文件中的存储格式就是 CONSTANT_utf8_info。因此一个字符串最大长度也就是 u2 所能代表的最大值 65536 个,但是需要使用2个字节来保存 null 值,因此一个字符串的最大长度为 65536 - 2 = 65534。

CONSTANT_String_info {
    u1 tag;
    u2 string_index;
}

此外,在虚拟机加载 Class 文件的时候会进行动态链接,因为其字段、方法的符号引用不经过运行期转换的话就无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时进行解析,并翻译到具体的内存地址之中。

接下来就看一下实例代码的解析过程。因为开发者平时定义的 Java 类各式各样,类中的方法与参数也不尽相同。所以常量池的元素数量也就无法固定,因此 class 文件在常量池的前面使用 2 个字节的容量计数器,用来代表当前类中常量池的大小。如下图所示:

也就是说常量计数器的值 OX17 为 23。其中下标为 0 的常量被 JVM 留作其他特殊用途,在 Class 文件格式规范制定之时,设计者将第 0 项常量空出来是有特殊考虑的,这样做的目的在于如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为 0 来表示。Class 文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、 字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始。因此 Test.class 中实际的常量池大小为这个计数器的值减 1,也就是 22个。

紧跟着的是第一个常量的 tag 值:

 tag 值 OX0A 是 10,根据上面的表可知是一个 CONSTANT_Methodref_info,因此之后的 2 个字节指向这个方法是属于哪个类,紧接的 2 个字节指向这个方法的名称和类型。它们的值分别是 4 和 17,表示指向常量池中的第 4 和第 17 个常量。

剩下的 21 个常量的解析过程也大同小异,这里就不一一解析了。实际上我们可以借助 javap 命令来帮助我们查看 class 常量池中的内容:

暴力突破 Android 编译插桩(八)- class 字节码_第4张图片

正如我们刚才分析的一样,常量池中第一个常量是 Methodref 类型,指向下标 4 和下标 17 的常量。其中下标 17 的常量类型为 NameAndType,而下标在 17 的 NameAndType 的 名称index 和 类型_index 分别指向了 9 和 10,也就是 “” 和 “()V”。仔细解析层层引用,最后我们可以看出,Test.class 文件中常量池的第 1 个常量保存的是 Object 中的默认构造器方法。

这里我们介绍一种可以在 Android Studio 中看 class 文件的插件 - jclasslib,代码编译后在菜单栏 "View" 中选择 "Show Bytecode With jclasslib",可以很直观地看到当前字节码文件的类信息、常量池、方法区等信息:

暴力突破 Android 编译插桩(八)- class 字节码_第5张图片

 Java 类型描述符

暴力突破 Android 编译插桩(八)- class 字节码_第6张图片

1.4 访问标志

紧跟在常量池之后的常量是访问标志,占用两个字节,如下图所示:

访问标志代表类或者接口的访问信息,比如:该 class 文件是类还是接口,是否被定义成 public,是否是 abstract,如果是类,是否被声明成 final 等等。各种访问标志如下所示:

暴力突破 Android 编译插桩(八)- class 字节码_第7张图片

我们定义的 Test.java 是一个普通 Java 类,不是接口、 枚举或注解,并且被 public 修饰但没有被声明为 final 和 abstract,因此它所对应的 access_flags 为 0021(0X0001 | 0X0020 = 0X0021)。

1.5 当前类索引、父类索引与接口索引计数器

在访问标志后的 2 个字节就是类索引,类索引后的 2 个字节就是父类索引

可以看出类索引指向常量池中的第 3 个常量,父类索引指向常量池中的第 4 个常量。从 javap 生成的内容中可以看出,第 3 个常量和第 4 个常量均为 CONSTANT_Class_info 表类型,并且代表的类分别是 “Test” 和 “Object”。父类索引后的 2 个字节则是接口索引计数器。如下图所示:

可以看出这个类实现了 2 个接口。查看在接口计数器之后的 4 个字节分别为:

  • 0005:指向常量池中的第 5 个常量,从图中可以看出第 5 个常量值为 "Serializable"。
  • 0006:指向常量池中的第 6 个常量,从图中可以看出第 6 个常量值为 "Cloneable"。

综上所述,可以得出如下结论:当前类为 Test 继承自 Object 类,并实现了 “Serializable” 和 “Cloneable” 这两个接口。

1.6 字段表

紧跟在接口索引集合后面的就是字段表了,字段表的主要功能是用来描述类或者接口中声明的变量。这里的字段包含了类级别变量以及实例变量,但是不包括方法内部声明的局部变量。同样, 一个类中的变量个数是不固定的,因此在字段表集合之前还是使用一个计数器来表示变量的个数,如下所示:

表示类中声明了 1 个变量(在 class 文件中叫字段),字段计数器之后会紧跟着 1 个字段表的数据结构。字段表的具体结构如下:

类型 名称
u2 access_flags 字段的访问标志
u2 name_index 字段的名称索引(也就是变量名)
u2 descriptor_index 字段的描述索引(也就是变量的类型)
u2 attributes_count 属性计数器
attribute_info[attributes_count]  attributes  

继续解析 Text.class 中的字段表,紧跟着字段计数器之后的是字段访问标志、变量名索引、变量类型索引:

我们先来看看字段访问标志,对于 Java 类中的变量,也可以使用 public、private、final、static 等标识符进行标识。因此解析字段时,需要先判断它的访问标志,字段的访问标志如下所示:

暴力突破 Android 编译插桩(八)- class 字节码_第8张图片

字段表结构图中的访问标志的值为 0002,代表它是 private 类型。变量名索引指向常量池中的第 7 个常量,变量名类型索引指向常量池中第 8 个常量。第 7 和第 8 个常量分别为“num”和“I”。因此可以得知类中有一个名为 num,类型为 int 类型的变量。

需要注意的是字段表集合中不会列出从父类或者父接口中继承而来的字段。内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

1.7 方法表

字段表之后跟着的就是方法表常量。相信你应该也能猜到了,方法表常量应该也是以一个计数器开始的,因为一个类中的方法数量是不固定的,如图所示:

上图表示 Test.class 中有两个方法,但是我们只在 Test.java 中声明了一个 add 方法,这是为什么呢?这是因为默认构造器方法也被包含在方法表常量中。方法表的结构如下所示:

可以看到,方法也是有自己的访问标志,具体如下:

暴力突破 Android 编译插桩(八)- class 字节码_第9张图片

根据方法表的结构可知方法表中后面的内容:

暴力突破 Android 编译插桩(八)- class 字节码_第10张图片

  • 0001:访问标志,代表 ACC_PUBLIC。
  • 0009:方法名称索引,指向常量池中的第9个常量,值为""
  • 000A:方法类型索引,指向常量池中的第10个常量,值为"()V"
  • 0001:方法属性个数为1,也就是说后面又一个属性表(attribute_info)。
  • 从000B开始是属性表。也就是 Java 代码编译成的字节码指令集合,下面讲属性表的时候详细说。

由上可知这是 Test.java 中的构造方法。

接着看下一个方法:

暴力突破 Android 编译插桩(八)- class 字节码_第11张图片

  • 0001:访问标志,代表 ACC_PUBLIC。
  • 000D:方法名称索引,指向常量池中的第13个常量,值为"add"
  • 000E:方法类型索引,指向常量池中的第13个常量,值为"(I)I"
  • 0001:方法属性个数为1,也就是说后面又一个属性表(attribute_info)。
  • 从000B开始是属性表。也就是 Java 代码编译成的字节码指令集合,下面讲属性表的时候详细说。

由上可知这是 Test.java 中的 "public int  add(int i)" 方法。

1.8 属性表

在之前解析字段和方法的时候,在它们的具体结构中我们都能看到有一个叫作 attributes_info 的表,这就是属性表。属性表并没有一个固定的结构,各种不同的属性只要满足以下结构即可:

name_index 所指向的 Utf8 字符串即为属性的名称,而 属性的名称是被用来区分属性的。所有的属性名称如下所示(其中下面加粗的为重要属性):

属性名称 使用位置 含义
Code 方法表

描述函数内容,Java 代码编译成的字节码指令

ConstantValue 字段表

由 final 关键字定义的常量值

Deprecated 类、方法表、字段表 被声明为 deprecated 的方法和字段
Exceptions 方法表 方法抛出的异常列表
EnclosingMethod 类文件 仅当一个类为局部类或者匿名类时才能拥有这个属性,这个属性用于标示这个类所在的外围方法
InnerClasses 类文件 内部类列表
LineNumberTable Code 属性 Java 源码的行号与字节码指令的对应关系
LocalVariableTable Code 属性 方法的局部变量描述
StackMapTable Code 属性 JDK 6 中新增的属性,供新的类型检查验证器检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配
Signature 类、方法表、字段表 JDK 1.5 中新增的属性,用于支持泛型情况下的方法签名,由于 Java 的泛型采用擦除法实现,在为了避免类型信息被擦除后导致签名混乱,需要这个属性记录泛型中的相关信息
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 用于存储额外的调试信息
Synthetic 类、方法表、字段表 标识方法或字段为编译器自动生成的
LocalVariableTypeTable JDK 1.5 中新增的属性,它使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加。
RuntimeVisibleAnnotations 类、方法表、字段表  
RuntimeInvisibleAnnotations 类、方法表、字段表  
RuntimeVisibleParameterAnotations 方法表  
RuntimeinvisibleParameterAnnotaions 方法表  
AnnotationDefault 方法表  
BootstrapMethods 类文件  

我们可以接着刚才解析方法表 add() 方法中属性表的思路继续往下分析:

暴力突破 Android 编译插桩(八)- class 字节码_第12张图片

0X0001 是属性计数器,代表只有一个属性。0X000B 是属性表类型索引,通过查看常量池可以看出它是一个 Code 属性表,如下所示:

暴力突破 Android 编译插桩(八)- class 字节码_第13张图片

Code 属性表的结构如下: 

表结构项 含义
u2  attribute_name_index

属性名称索引。是一项指向 CONSTANT_Utf8_info 型常量的索引, 此常量值固定为“Code”, 它代表了该属性的属性名称。

u4  attribute_length 属性值的长度。 由于属性名称索引与属性长度一共为6个字节, 所以属性值的长度固定为整个属性表长度减去6个字节。
u2  max_stack 操作数栈深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。 虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。
u2  max_locals 局部变量表所需的存储空间。max_locals的单位是变量槽(Slot),Slot是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long这两种64位的数据类型则需要两个变量槽来存放。方法参数(包括实例方法中的隐藏参数“this”) 、显式异常处理程序的参数(Exception Handler Parameter, 就是try-catch语句中catch块中所定义的异常) 、 方法体中定义的局部变量都需要依赖局部变量表来存放。 注意, 并不是在方法中用了多少个局部变量, 就把这些局部变量所占变量槽数量之和作为max_locals的值, 操作数栈和局部变量表直接决定一个该方法的栈帧所耗费的内存, 不必要的操作数栈深度和变量槽数量会造成内存的浪费。 Java虚拟机的做法是将局部变量表中的变量槽进行重用, 当代码执行超出一个局部变量的作用域时, 这个局部变量所占的变量槽可以被其他局部变量所使用, Javac编译器会根据变量的作用域来分配变量槽给各个变量使用, 根据同时生存的最大局部变量数量和类型计算出max_locals的大小。
u4  code_length 为方法编译后的字节码的长度
u1[code length]  code

用于存储字节码指令的一系列字节流。 既然叫字节码指令, 那顾名思义每个指令就是一个u1类型的单字节, 当虚拟机读取到code中的一个字节码时, 就可以对应找出这个字节码代表的是什么指令, 并且可以知道这条指令后面是否需要跟随参数, 以及后续的参数应当如何解析。一个u1数据类型的取值范围为0x00~0xFF, 对应十进制的0~255, 也就是一共可以表达256条指令。

u2 exception_table_length 表示 exception_table 的长度
exception_info[exception_table_length] exception_table 每个成员为一个 ExceptionHandler,并且一个函数可以包含多个 try/catch 语句,一个 try/catch 语句对应 exception_table 数组中的一项。
u2 attributes_count 表示该 exception_table 拥有的 attribute 数量
attribute_info[attributes_count] attributes attribute数据

紧跟着的 4 个字节 00000032 的值 50 是属性的长度(attribute_length)。Code 属性表中,最主要的就是一些列的字节码。通过 javap -v Test.class 之后,可以看到方法的字节码,如下图显示的是 add 方法的字节码指令: 

暴力突破 Android 编译插桩(八)- class 字节码_第14张图片

在 Code_attribute 携带的属性中,"LineNumberTable" 与 "LocalVariableTable" 对我们 Android 开发者来说比较重要,所以,这里我们将再单独来讲解一下它们。

我们来看看 LineNumberTable,LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量) 之间的对应关系。它并不是运行时必需的属性, 但默认会生成到Class文件之中, 它的属性的结构如下:

结构项
u2  attribute_name_index
u4  attribute_length
u2  line_number_table_length
line_number_info[line_number_table_length]  line_number_table

LineNumberTable 是一个数量为 line_number_table_length、 类型为 line_number_info 的集合,line_number_info 表包含 start_pc 和 line_number 两个u2类型的数据项, 前者是字节码行号, 后者是Java源码行号。

  • start_pc:为 code[] 数组元素的索引,用于指向 Code_attribute 中 code 数组某处指令。
  • line_number:为 start_pc 对应源文件代码的行号。需要注意的是,多个 line_number_table 元素可以指向同一行代码,因为一行 Java 代码很可能被编译成多条指令。

再来看看 LocalVariableTable,用于描述栈帧中局部变量表中的变量与 Java 源码中定义的变量之间的关系,它也不是运行时必需的属性,但默认会生成到 Class 文件之中。它的属性的结构如下:

结构项
u2   attribute_name_index
u4   attribute_length
u2   local_variable_table_length
local_variable_info[local_variable_table_length]   local_variable_table

其中 local_variable_info 项目代表了一个栈帧与源码中的局部变量的关联, 结构如表如下:

结构项
u2   start_pc
u2   length
u2   name_index
u2   descriptor_index
u2   index

start_pc 和 length 属性分别代表了这个局部变量的生命周期开始的字节码偏移量及其作用范围覆盖的长度, 两者结合起来就是这个局部变量在字节码之中的作用域范围。

name_index 和 descriptor_index 都是指向常量池中 CONSTANT_Utf8_info 型常量的索引, 分别代表了局部变量的名称以及这个局部变量的描述符。

index是这个局部变量在栈帧的局部变量表中变量槽的位置。 当这个变量数据类型是 64 位类型时(double和long) ,它占用的变量槽为 index 和 index+1 两个。

顺便提一下, 在 JDK 5 引入泛型之后, LocalVariableTable 属性增加了一个“姐妹属性”—— LocalVariableTypeTable。 这个新增的属性结构与 LocalVariableTable 非常相似, 仅仅是把记录的字段描述符的 descriptor_index 替换成了字段的特征签名(Signature) 。 对于非泛型类型来说, 描述符和特征签名能描述的信息是能吻合一致的, 但是泛型引入之后, 由于描述符中泛型的参数化类型被擦除掉, 描述符就不能准确描述泛型类型了。 因此出现了 LocalVariableTypeTable 属性, 使用字段的特征签名来完成泛型的描述。

 

二、运行时的栈帧


我们先来回忆一下 jvm 运行时内存分布。JVM 的运行时内存结构中一共有两个“栈”和一个“堆”,分别是 Java 虚拟机栈和本地方法栈,以及“GC堆”和方法区,除此之外还有一个程序计数器。JVM 内存中只有堆和方法区是线程共享的数据区域,其它区域都是线程私有的。并且程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。用一张图来概括就是:

暴力突破 Android 编译插桩(八)- class 字节码_第15张图片

本节我们只重点学习虚拟机栈的内容。虚拟机栈是线程私有的,与线程的生命周期同步。在 Java 虚拟机规范中,对这个区域规定了两种异常状况:

  • StackOverflowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出。
  • OutOfMemoryError:当 Java 虚拟机动态扩展到无法申请足够内存时抛出。

在我们学习 Java 虚拟机的的过程当中,经常会看到一句话“JVM 是基于栈的解释器执行的,DVM 是基于寄存器解释器执行的”,这句话里的“基于栈”指的就是虚拟机栈。虚拟机栈的初衷是用来描述 Java 方法执行的内存模型,每个方法被执行的时候,JVM 都会在虚拟机栈中创建一个栈帧,接下来看下这个栈帧是什么。

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。我们可以这样理解:一个线程包含多个栈帧,而每个栈帧内部包含局部变量表、操作数栈、动态连接、返回地址等。如下图所示:

暴力突破 Android 编译插桩(八)- class 字节码_第16张图片

下面我们根据上面javap 生成的 add 方法的字节码指令来分析一下栈帧的内部结构。

暴力突破 Android 编译插桩(八)- class 字节码_第17张图片

局部变量表是变量值的存储空间,我们调用方法时传递的参数,以及在方法内部创建的局部变量都保存在局部变量表中。在 Java 编译成 class 文件的时候,就会在方法的 Code 属性表中的 max_locals 数据项中,确定该方法需要分配的最大局部变量表的容量。需要注意的是系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。上图中的 locals=3 就是代表局部变量表长度是 3,也就是说经过编译之后,局部变量表的长度已经确定为3,分别保存:i、j、num。

操作数栈也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候写入方法的Code属性表中的max_stacks数据项中。栈中的元素可以是任意Java数据类型,包括long和double。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈(比如:iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将结果重新压回到操作数栈中)。上图中的 stack=3 就是代表操作数栈的最大深度是 3。

动态链接的主要目的是为了支持方法调用过程中的动态连接。在一个 class 文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其所在内存地址中的直接引用,而符号引用存在于方法区中。Java 虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的就是为了支持方法调用过程中的动态连接(Dynamic Linking)。

返回地址。当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 正常退出:指方法中的代码正常完成,或者遇到任意一个方法返回的字节码指令(如return)并退出,没有抛出任何异常。
  • 异常退出:指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论当前方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。一般来说,方法正常退出时,调用者的 PC 计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

 

三、字节码指令


字节码指令用途分类汇总。

3.1 加载和存储指令

加载和存储指令用于 将数据在栈帧中的局部变量表和操作数栈之间来回传输,其指令如下所示:

  • 将一个局部变量加载到操作栈:iload、iload_、lload、lload_、fload、fload_ 、dload、dload_、aload、aload_
  • 将一个数值从操作数栈存储到局部变量表:istore、istore_、lstore、lstore_、 fstore、fstore_、dstore、dstore_、astore、astore_
  • 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、 iconst_m1、iconst_、lconst_、fconst_、dconst_
  • 扩充局部变量表的访问索引的指令:wide。

类似于 iload_,它代表了 iload_0、iload_1、iload_2 和 iload_3 这几条指令。这几组指令都是某个带有一个操作数的通用指令(例如iload,iload_0 的语义与操作数为 0 时的 iload 指令语义完全一致)。

3.2 运算指令

运算或算术指令用于 对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操 作栈顶。大体上算术指令可以分为 两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令。其指令如下所示:

  • 加法指令:iadd、ladd、fadd、dadd。
  • 减法指令:isub、lsub、fsub、dsub。
  • 乘法指令:imul、lmul、fmul、dmul。
  • 除法指令:idiv、ldiv、fdiv、ddiv。
  • 求余指令:irem、lrem、frem、drem。
  • 取反指令:ineg、lneg、fneg、dneg。
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr。
  • 按位或指令:ior、lor。
  • 按位与指令:iand、land。
  • 按位异或指令:ixor、lxor。
  • 局部变量自增指令:iinc。
  • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。

3.3 类型转换指令

类型转换指令可以 将两种不同的数值类型进行相互转换,例如我们可以将小范围类型向大范围类型的安全转换,其指令如下所示:

  • i2b、i2c、i2s
  • l2i
  • f2i、f2l
  • d2i、d2l、d2f

3.4 对象创建与访问指令

其指令如下所示:

  • 创建类实例的指令:new。
  • 创建数组的指令:newarray、anewarray、multianewarray。
  • 访问类字段(static字段,或者称为类变量)和实例字段(非 static 字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic。
  • 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、 faload、daload、aaload。
  • 将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、 fastore、dastore、aastore。
  • 取数组长度的指令:arraylength。
  • 检查类实例类型的指令:instanceof、checkcast。

3.5 操作数栈管理指令

用于 直接操作操作数栈 的指令,如下所示:

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2(用于操作 Long、Double)。
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
  • 将栈最顶端的两个数值互换:swap。

3.6 控制转移指令

控制转移指令就是 在有条件或无条件地修改 PC 寄存器的值。其指令如下所示:

  • 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、 if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。
  • 复合条件分支:tableswitch、lookupswitch。
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret。

其中的 tableswitch 与 lookupswitch 含义如下:

  • tableswitch:条件跳转指令,针对密集的 case。
  • lookupswitch:条件跳转指令,针对稀疏的 case。

可以看到,Java 虚拟机提供的 int 类型的条件分支指令是最为丰富和强大的。

3.7 方法调用指令

常用的有 5条 用于方法调用的指令。 如下所示:

  • invokevirtual:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
  • invokeinterface:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
  • invokespecial:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic:用于调用类方法(static方法)。
  • invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。

这里我们需要着重注意 invokespecial 指令,它用于 调用构造器与方法,当调用方法时,会将返回值仍然压入操作数栈中,如果当前方法没有返回值则需要使用 pop 指令弹出。

除了 invokespecial 之外,其它方法调用指令所消耗的操作数栈元素是根据调用类型以及目标方法描述符来确定的。

3.8 方法返回指令

返回指令是区分类型的,如下所示,为不同返回类型对应的返回指令:

  • void:return
  • int(boolean、byte、char、short):ireturn
  • long:lreturn
  • float:freturn
  • double:dreturn
  • reference:areturn

方法调用指令与数据类型无关,而 方法返回指令是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。

3.9 异常处理指令

在 Java 程序中显式抛出异常的操作(throw语句)都由 athrow 指令来实现,在 Java 虚拟机中,处理异常是采用异常表来完成的。

3.10 同步指令

Java 虚拟机可以 支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。

方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作 之中。虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法。

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时会释放管程。

同步一段指令集序列 通常是由 Java 语言中的 synchronized 语句块 来表示的,Java 虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义,而正确实现 synchronized 关键字需要 Javac 编译器与 Java 虚拟机两者共同协作支持。

编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须执行其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束。并且,它会自动产生一个异常处理器,这个异常处理器被声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。

 

参考文献

《深入理解Java虚拟机 JVM高级特性与最佳实践》第6章-类文件结构、第8章-虚拟机字节码执行引擎

拉勾教育 - Android 工程师进阶34讲 1到4讲

深入探索编译插桩技术(三、解密 JVM 字节码)

你可能感兴趣的:(编译插桩)