本篇是 《深刻探索编译插桩技术》系列文章 的第三篇,相比前两篇文章来讲,难度上升了不止一个档次,因此含金量比较高。而且,拥有扎实的 JVM 字节码基础能让咱们更好地掌握 ASM 这个强大的编译插桩工具,而灵活地运用 ASM 能让咱们的我的以及项目团队的生产力有质的提高,这一点,不管是在中小型公司,仍是在一二线的大公司来讲,都是能极大地提高自身的生产力以及我的与团队的价值。所以,掌握 JVM 字节码即是一件火烧眉毛的事情了。html
下面👇,让咱们扬帆起航开始咱们的 JVM 字节码探索之旅 吧。java
本文吸收了市面上绝大部分经典 JVM 著做与优秀博文的优点之处,将其中易于理解的部分从新编排并精心处理,相比于仅仅阅读一本 JVM 书籍来讲,能让咱们在更短的时间内去理解更多对咱们重要的知识。注意:标 🔥 的章节为重点章节,建议多多复习,加深对其的理解。git
“与平台无关” 的理想最终实如今操做系统的应用层面上:众多虚拟机厂商发布了许多能够运行在各类不一样平台上的虚拟机,而这些虚拟机均可以载入和执行同一种与平台无关的字节码,从而实现了程序的 “一次编写,处处运行”。github
而 字节码(ByteCode)正是构成其平台无关性的基石。Java 虚拟机不和包括 Java 在内的任何语言绑定,它 只与 “Class文件” 这种特定的二进制文件格式所关联,Class 文件中包含 了 Java 虚拟机指令集和符号表以及若干其余辅助信息。json
虚拟机并不关心 Class 的来源是何种语言,有了字节码,也解除了 Java 虚拟机和 Java 语言之间的耦合。数组
Java 语言中的各类变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,所以,字节码命令所能提供的语义描述能力确定会比 Java 语言自己更增强大。因此,有一些 Java 语言自己没法有效支持的语言特性不表明字节码自己就没法有效地支持,这也为其余语言实现一些有别于 Java 的语言特性提供了基础。缓存
字节码文件是由 十六进制值组成 的,对于 JVM 来讲,在读取数据的时候,它会 以两个十六进制值为一组,即 一个字节 进行读取。在 Java 中,咱们一般会采用 javac 命令将源代码编译成字节码文件,下面这幅 Java 官方图展现了一个 .java 文件从编译到运行的过程,以下所示:安全
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎 所有是程序运行的必要数据,没有空隙存在。微信
当遇到须要占用 8 位字节以上空间的数据项 时,则会按照高位在前的方式分割成若干个 8 位字节进行存储。(高位在前指 ”Big-Endian",即指最高位字节在地址最低位,最低位字节在地址最高位的顺序来存储数据,而 X86 等处理器则是使用了相反的 “Little-Endian” 顺序来存储数据)数据结构
根据 JVM 规范的规定,Class 文件格式采用了一种相似于 C 语言结构体的伪结构来存 储数据,而这种伪结构中有且只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,以 u一、u二、u四、u8 来分别表明 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数能够用来 描述数字、索引引用、数量值或者按照UTF-8 码构成字符串值。
表是 由多个无符号数或者其余表做为数据项构成的复合数据类型,全部表都习惯性地以 “_info”
结尾。表用于 描述有层次关系的复合结构的数据,而整个 Class 文件其本质上就是一张表。
对比 Linux、Windows 上的可执行文件(例如 ELF)而言,Class 文件能够看作是 JVM 的可执行文件。其 表格式 以下所示:
u4:表示可以保存4个字节的无符号整数,u2同理。
ClassFile {
u4 magic; // 魔法数字,代表当前文件是.class文件,固定0xCAFEBABE
u2 minor_version; // 分别为Class文件的副版本和主版本
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]; // 各类属性
}
复制代码
对于 Class 表结构而言,其 前 8 个字节 依次是以下 三个元素:
magic
:每一个 Class 文件的头 4 个字节被称为魔数(Magic Number),它的惟一做用是肯定这个文件是否为一个能被虚拟机所接受的 Class 文件。不少文件存储标准中都使用魔数来进行身份识别, 譬如图片格式,如 gif 或者 jpeg 等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,由于文件扩展名能够随意地改动。而且,Class 文件的魔数得到颇有 “浪漫气息”,值为:0xCAFEBABE(咖啡宝贝)。minor_version
:2 个字节长,表示当前 Class 文件的次版号。major_version
:2 个字节长,表示当前 Class 文件的主版本号。(Java 的版本号是从 45 开始 的,JDK 1.1 以后的每一个 JDK 大版本发布会在主版本号向上加 1(JDK 1.0~1.1 使用了 45.0~45.3 的版本号),例如 JDK 1.8 就是 52.0)。须要注意的是,虚拟机会拒绝执行超过其版本号的 Class 文件。而后,咱们再来简单地了解下 其它元素 的含义:
constant_pool_count
:常量池数组元素个数。constant_pool
:常量池,是一个存储了 cp_info 信息的数组,每个 Class 文件都有一个与之对应的常量池。(注意:cp_info 数组的索引从 1 开始)access_flags
:表示当前类的访问权限,例如:public、private。this_class 和 super_class
:存储了指向常量池数组元素的索引,this_class 中索引指向的内容为当前类名,而 super_class 中索引则指向其父类类名。interfaces_count 和 interfaces
:同上,它们存储的也只是指向常量池数组元素的索引。其内容分别表示当前类实现了多少个接口和对应的接口类类名。fields_count 和 fields
:表示成员变量的数量和其信息,信息由 field_info 结构体表示。methods_count 和 methods
:表示成员函数的数量和它们的信息,信息由 method_info 结构体表示。、attributes_count 和 attributes
:表示当前类的属性信息,每个属性都有一个与之对应的 attribute_info 结构。常见的属性信息如调试信息,它须要记录某句代码对应源代码的哪一行,此外,如函数对应的 JVM 字节码、注解信息也是属性信息。须要注意的是,Class 表的结构不像 XML 等描述语言,因为它没有任何分隔符号,因此在上面中的这些数据项,不管是顺序仍是数量,甚至于数据存储的字节序(Byte Ordering,Class 文件中字节序为 Big-Endian)这样的细节,都是被严格限定的。
对于上面的各个属性来讲,有很多属性是咱们须要重点掌握的,而 常量池能够被认为是 Class 表结构中的重中之重。下面👇,咱们就先来了解下常量池。
常量池能够理解为 Class 文件之中的资源仓库,其它的几种结构或多或少都会最终指向到这个资源仓库之中。
此外,常量池是 Class 文件结构中与其余项 关联最多 的数据类型,也是 占用 Class 文件空间最大 的数据项之一,同时它仍是 在 Class 文件中第一个出现的表类型数据项。所以,若是没有充分了地解常量池,后面其它的 Class 表类型数据项的学习会变得举步维艰。
假设一个常量池的容量(偏移地址:0x00000008)为十六进制数 0x0016,即十进制的 22,这就表明常量池中有 21 项常量,索引值范围为 1~21。在 Class 文件格式规范制定之时,设计者将第 0 项常量空出来是有特殊考虑的,这样作的目的在 于知足后面某些指向常量池的索引值的数据在特定状况下须要表达 “不引用任何一个常量池项”的含义。
而常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)
。
字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。
而 符号引用 则属于编译原理方面的概念,包括了 三类常量,以下所示:
此外,在虚拟机加载 Class 文件的时候会进行动态连接,由于其字段、方法的符号引用不通过运行期转换的话就没法获得真正的内存入口地址,也就没法直接被虚拟机使用。当虚拟机运行时,须要从常量池得到对应的符号引用,再在类建立或运行时进行解析,并翻译到具体的内存地址之中。
connstant_pool 中存储了一个一个的 cp_info 信息,而且每个 cp_info 的第一个字节(即一个 u1 类型的标志位)标识了当前常量项的类型,其后才是具体的常量项内容。
下面👇,咱们看看有哪些具体的 常量项的类型,以下表所示:
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_Utf8_info | 1 | 用于存储UTF-8编码的字符串,它真正包含了字符串的内容。 |
CONSTANT_Integer_info | 3 | 表示int型数据的信息 |
CONSTANT_Float_info | 4 | 表示float型数据的信息 |
CONSTANT_Long_info | 5 | 表示long型数据的信息 |
CONSTANT_Double_info | 6 | 表示double型数据的信息 |
CONSTANT_Class_info | 7 | 表示类或接口的信息 |
CONSTANT_String_info | 8 | 表示字符串,但该常量项自己不存储字符串的内容,它仅仅只存储了一个索引值 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 描述类的成员域或成员方法相关的信息 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄信息,其和反射相关 |
CONSTANT_MethodType_info | 16 | 标识方法类型,仅包含方法的参数类型和返回值类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点,用于 invokeDynamic 指令,Java 7引入 |
而后,咱们须要了解其中涉及到的重点常量项类型。这里咱们须要先明白 CONSTANT_String 和 CONSTANT_Utf8 的区别。
CONSTANT_Utf8
:真正存储了字符串的内容,其对应的数据结构中有一个字节数组,字符串便酝酿其中。CONSTANT_String
:自己不包含字符串的内容,但其具备一个指向 CONSTANT_Utf8 常量项的索引。咱们必需要了解的是,在全部常见的常量项之中,只要是须要表示字符串的地方其实际都会包含有一个指向 CONSTANT_Utf8_info 元素的索引。而一个字符串最大长度即 u2 所能表明的最大值为 65536,可是须要使用 2 个字节来保存 null 值,因此一个字符串的最大长度为 65534。
对于常见的常量项来讲通常能够细分为以下 三个维度。
常量项 Utf8 的数据结构以下所示:
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
复制代码
其元素含义以下所示:
tag
:值为 1,表示是 CONSTANT_Utf8_info 类型表。length
:length 表示 bytes 的长度,好比 length = 10,则表示接下来的数据是 10 个连续的 u1 类型数据。bytes
:u1 类型数组,保存有真正的常量数据。常量项 Class、Filed、Method、Interface、String 的数据结构分别以下所示:
CONSATNT_Class_info {
u1 tag;
u2 name_index;
}
CONSTANT_Fieldref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_MethodType_info {
u1 tag;
u2 descriptor_index;
}
CONSTANT_InterfaceMethodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_String_info {
u1 tag;
u2 string_index;
}
CONSATNT_NameAndType_info {
u1 tag;
u2 name_index;
u2 descriptor_index
}
复制代码
其元素含义以下所示:
name_index
:指向常量池中索引为 name_index 的常量表。好比 name_index = 6,代表它指向常量池中第 6 个常量。class_index
:指向当前方法、字段等的所属类的引用。name_and_type_index
:指向当前方法、字段等的名字和类型的引用。name_index
:指向某字段或方法等的名称字符串的引用。descriptor_index
:指向某字段或方法等的类型字符串的引用。常量项 Integer、Long、Float、Double 对应的数据结构以下所示:
CONSATNT_Integer_info {
u1 tag;
u4 bytes;
}
CONSTANT_Long_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_Float_info {
u1 tag;
u4 bytes;
}
CONSTANT_Float_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
复制代码
能够看到,在每个非基本类型的常量项之中,除了其 tag 以外,最终包含的内容都是字符串。正是由于这种互相引用的模式,才能有效地节省 Class 文件的空间。(ps:利用索引来减小空间占用是一种行之有效的方式)
对于 JVM 来讲,其 采用了字符串的形式来描述数据类型、成员变量及成员函数 这三类。所以,在讨论接下来各个的 Class 表项以前,咱们须要了解下 JVM 中的信息描述规则。下面,咱们来一一对此进行探讨。
数据类型一般包含有 原始数据类型、引用类型(数组),它们的描述规则分别以下所示:
byte、char、double、float、int、long、short、boolean
=> "B"、"C"、"D"、"F"、"I"、"J"、"S"、"Z"
。String => Ljava/lang/String
。int 数组 => "[I",String 数组 => "[Ljava/lang/Sting",二维 int 数组 => "[[I"
。在 JVM 规范之中,成员变量即 Field Descriptor 的描述规则以下所示:
FiledDescriptor:
# 一、仅包含 FieldType 一种信息
FieldType
FiledType:
# 二、FiledType 的可选类型
BaseType | ObjectType | ArrayType
BaseType:
B | C | D | F | I | J | S | Z
ObjectType:
L + 全路径ClassName;
ArrayType:
[ComponentType:
# 三、与 FiledType 的可选类型同样
ComponentType:
FiledType
复制代码
在注释1处,FiledDescriptor 仅仅包含了 FieldType 一种信息;注释2处,能够看到,FiledType 的可选类型为3中:BaseType、ObjectType、ArrayType,对于每个类型的规则描述,咱们在 数据类型 这一小节已详细分析过了。而在注释3处,这里 ComponentType
是一种 JVM 规范中新定义的类型,不过它是 由 FiledType 构成,其可选类型也包含 BaseType、ObjectType、ArrayType 这三种。此外,对于字节码来说,若是两个字段的描述符不一致, 那字段重名就是合法的。
在 JVM 规范之中,成员函数即 Method Descriptor 的描述规则以下所示:
MethodDescriptor:
# 一、括号内的是参数的数据类型描述,* 表示有 0 至多个 ParameterDescriptor,最后是返回值类型描述
( ParameterDescriptor* ) ReturnDescriptor
ParameterDescriptor:
FieldType
ReturnDescriptor:
FieldType | VoidDescriptor
VoidDescriptor:
// 二、void 的描述规则为 "V"
V
复制代码
在注释1处,MethodDescriptor 由两个部分组成,括号内的是参数的数据类型描述,表示有 0 至多个 ParameterDescriptor,最后是返回值类型描述。注释2处,要注意 void 的描述规则为 "V"。例如,一个 void hello(String str)
的函数 => (Ljava/lang/String;)V
。
了解了信息的描述规则以后,咱们就能够来看看 Class 表中的其它重要的表项:filed_info 与 method_info。
字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但 不包括在方法内部声明的局部变量。
filed_info 与 method_info 数据结构的伪代码以下所示:
field_info {
u2 access_flags;
u2 name
u2 descriptor_index
u2 attributes_count
attribute_info attributes[attributes_count]
}
method_info {
u2 access_flags;
u2 name
u2 descriptor_index
u2 attributes_count
attribute_info attributes[attributes_count]
}
复制代码
能够看到,filed_info 与 method_info 都包含有 访问标志、名字引用、描述信息、属性数量与存储属性 的数据结构。对于 method_info 所描述的成员函数来讲,它的内容通过编译以后获得的 Java 字节码会保存在属性之中。
注意:类构造器为 “< clinit >” 方法,而实例构造器为 “< init >” 方法。
下面,咱们就来了解下 access_flags 的相关知识。
access_flag 的取值类型在 Class、Filed、Method 之中都是不一样的,咱们分别来看看。
access_flags 中一共有 16 个标志位可使用,当前只定义了其中 8 个(JDK 1.5 增长了后面 3 种),没有使用到的标志位要求一概为 0。Class 的 access_flags 取值类型以下表示:
标志名 | 标志值 | 标志含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | public类型 |
ACC_FINAL | 0x0010 | final类型 |
ACC_SUPER | 0x0020 | 使用新的invokespecial语义 |
ACC_INTERFACE | 0x0200 | 接口类型 |
ACC_ABSTRACT | 0x0400 | 抽象类型 |
ACC_SYNTHETIC | 0x1000 | 该类不禁用户代码生成 |
ACC_ANNOTATION | 0x2000 | 注解类型 |
ACC_ENUM | 0x4000 | 枚举类型 |
例如一个 “public Class JsonChao” 的类所对应的 access_flags 为 0021(0X0001 和 0X0020 相结合)。下面的 Filed 与 Method 的计算也是同理。
接口之中的字段必须有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL 标志,这些都是由 Java 自己的语言规则所决定的。Filed 的 access_flag 取值类型以下表所示:
名称 | 值 | 描述 |
---|---|---|
ACC_PUBLIC | 0x0001 | public |
ACC_PRIVATE | 0x0002 | private |
ACC_PROTECTED | 0x0004 | protected |
ACC_STATIC | 0x0008 | static |
ACC_FINAL | 0x0010 | final |
ACC_VOLATILE | 0x0040 | volatile |
ACC_TRANSIENT | 0x0080 | transient,不能被序列化 |
ACC_SYNTHETIC | 0x1000 | 由编译器自动生成 |
ACC_ENUM | 0x4000 | enum,字段为枚举类型 |
Method 的 access_flag 取值以下表所示:
名称 | 值 | 描述 |
---|---|---|
ACC_PUBLIC | 0x0001 | public |
ACC_PRIVATE | 0x0002 | private |
ACC_PROTECTED | 0x0004 | protected |
ACC_STATIC | 0x0008 | static |
ACC_FINAL | 0x0010 | final |
ACC_SYNCHRONIZED | 0x0020 | synchronized |
ACC_BRIDGE | 0x0040 | bridge,方法由编译器产生 |
ACC_VARARGS | 0x0080 | 该方法带有变长参数 |
ACC_NATIVE | 0x0100 | native |
ACC_ABSTRACT | 0x0400 | abstract |
ACC_STRICT | 0x0800 | strictfp |
ACC_SYNTHETIC | 0x1000 | 方法由编译器生成 |
须要注意的是,当 Method 的 access_flags 的取值为 ACC_SYNTHETIC
时,该 Method 一般被称之为 合成函数。此外,当内部类访问外部类的私有成员时,在 Class 文件中也会生成一个 ACC_SYNTHETIC 修饰的函数。
只要不与已有属性名重复,任何人 实现的编译器均可以向属性表中写入本身定义的属性信息,Java 虚拟机运行时会忽略掉它所不认识的属性。
attribute_info 的数据结构伪代码以下所示:
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
复制代码
attribute_info 中的各个元素的含义以下所示:
attribute_name_index
:为 CONSTANT_Utf8 类型常量项的索引,表示属性的名称。attribute_length
:属性的长度。info
:属性具体的内容。attribute_name_index 所指向的 Utf8 字符串即为属性的名称,而 属性的名称是被用来区分属性的。全部的属性名称以下所示(其中下面👇 标红的为重要属性):
ConstantValue
:仅出如今 filed_info 中,描述常量成员域的值,通知虚拟机自动为静态变量赋值。对于非 static 类型的变量(也就是实例变量)的赋值是在实例构造器方法中进行的;而对 于类变量,则有两种方式能够选择:在类构造器方法中或者使用 ConstantValue 属性。若是变量没有被 final 修饰,或者并不是基本类型及字 符串,则将会选择在方法中进行初始化。Code
:仅出现 method_info 中,描述函数内容,即该函数内容编译后获得的虚拟机指令,try/catch 语句对应的异常处理表等等。StackMapTable
:在 JDK 1.6 发布后增长到了 Class 文件规范中,它是一个复杂的变长属性。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用,目的在于代替之前比较消耗性能的基于数据流 分析的类型推导验证器。它省略了在运行期经过数据流分析去确认字节码的行为逻辑合法性的步骤,而是在编译阶 段将一系列的验证类型(Verification Types)直接记录在 Class 文件之中,经过检查这些验证类型代替了类型推导过程,从而大幅提高了字节码验证的性能。这个验证器在 JDK 1.6 中首次提供,并在 JDK 1.7 中强制代替本来基于类型推断的字节码验证器。StackMapTable 属性中包含零至多个栈映射帧(Stack Map Frames),其中的类型检查验证器会经过检查目标方法的局部变量和操做数栈所须要的类型来肯定一段字节码指令是否符合逻辑约束。Exceptions
:当函数抛出异常或错误时,method_info 将会保存此属性。Signature
:JDK 1.5 中新增的属性,用于支持泛型状况下的方法签名,因为 Java 的泛型采用擦除法实现,在为了不类型信息被擦除后致使签名混乱,须要这个属性记录泛型中的相关信息。SourceFile
:包含一个指向 Utf8 常量项的索引,即 Class 对应的源码文件名。LineNumberTable
:Java 源码的行号与字节码指令的对应关系。LocalVariableTable
:局部变量数组/本地变量表,用于保存变量名,变量定义所在行。LocalVariableTypeTable
:JDK 1.5 中新增的属性,它使用特征签名代替描述符,是为了引入泛型语法以后能描述泛型参数化类型而添加。在上述表格中,咱们能够发现,不一样类型的属性可能会出如今 ClassFile 中不一样的成员里,当 JVM 在解析 Class 文件时会校验 Class 成员应该禁止携带有哪些类型的属性。此外,属性也能够包含子属性,例如:"Code" 属性中包含有 "LocalVariableTable"。
首先,要注意 并不是全部的方法表都必须存在这个属性,例如接口或者抽象类中的方法就不存在 Code 属性。
Code_attribute 的数据结构伪代码以下所示:
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];
}
复制代码
Code_attribute 中的各个元素的含义以下所示:
attribute_name_index、attribute_length
:attribute_length 的值为整个 Code 属性减去 attribute_name_index 和 attribute_length 的长度。max_stack
:为当前方法执行时的最大栈深度,因此 JVM 在执行方法时,线程栈的栈帧(操做数栈,operand satck)大小是能够提早知道的。每个函数执行的时候都会分配一个操做数栈和局部变量数组,而 Code_attribure 须要包含它们,以便 JVM 在执行函数前就能够分配相应的空间。max_locals
:为当前方法分配的局部变量个数,包括调用方式时传递的参数。long 和 double 类型计数为 2,其余为 1。max_locals 的单位是 Slot,Slot 是 虚拟机为局部变量分配内存所使用的最小单位。局部变量表中的 Slot 能够重用,当代码执行超出一个局部变量的做用域时,这个局部变量 所占的 Slot 能够被其余局部变量所使用,Javac 编译器会根据变量的做用域来分配 Slot 给各个 变量使用,而后计算出 max_locals 的大小。code_length
:为方法编译后的字节码的长度。exception_table_length
:表示 exception_table 的长度。exception_table
:每一个成员为一个 ExceptionHandler,而且一个函数能够包含多个 try/catch 语句,一个 try/catch 语句对应 exception_table 数组中的一项。start_pc、end_pc
:为异常处理字节码在 code[] 的索引值。当程序计数器在 [start_pc, end_pc) 内时,表示异常会被该 ExceptionHandler 捕获。handler_pc
:表示 ExceptionHandler 的起点,为 code[] 的索引值。catch_type
:为 CONSTANT_Class 类型常量项的索引,表示处理的异常类型。若是该值为 0,则该 ExceptionHandler 会在全部异常抛出时会被执行,能够用来实现 finally 代码。当 catch_type 的值为 0 时,表明任意异常状况都须要转向到 handler_pc 处进行处理。此外,编译器使用异常表而不是简单的跳转命令来实现 Java 异常及 finally 处理机制。attributes_count 和 attributes
:表示该 exception_table 拥有的 attribute 数量与数据。在 Code_attribute 携带的属性中,"LineNumberTable"
与 "LocalVariableTable"
对咱们 Android 开发者来讲比较重要,因此,这里咱们将再单独来说解一下它们。
LineNumberTable 属性 用于 Java 的调试,可指明某条指令对应于源码哪一行。
LineNumberTable 属性的结构以下所示:
LineNumberTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 line_number_table_length;
{ u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}
复制代码
其中最重要的是 line_number_table
数组,该数组元素包含以下 两个成员变量:
start_pc
:为 code[] 数组元素的索引,用于指向 Code_attribute 中 code 数组某处指令。line_number
:为 start_pc 对应源文件代码的行号。须要注意的是,多个 line_number_table 元素能够指向同一行代码,由于一行 Java 代码极可能被编译成多条指令。LocalVariableTable 属性用于 描述栈帧中局部变量表中的变量与 Java 源码中定义的变量之间的关系,它也不是运行时必需的属性,但默认会生成到 Class 文件之中。
LocalVariableTable 的数据结构以下所示:
LocalVariableTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{
u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length];
}
复制代码
其中最重要的元素是 local_variable_table
数组,其中的 start_pc
与 length
这两个参数 决定了一个局部变量在 code 数组中的有效范围。
须要注意的是,每一个非 static 函数都会自动建立一个叫作 this 的本地变量,表明当前是在哪一个对象上调用此函数。而且,this 对象是位于局部变量数组第1个位置(即 Slot = 0),它的做用范围是贯穿整个函数的。
此外,在 JDK 1.5 引入泛型以后,LocalVariableTable 属性增长了一个 “姐妹属性”: LocalVariableTypeTable,这个新增的属性结构与 LocalVariableTable 很是类似,仅仅是把记录 的字段描述符的 descriptor_index 替换成了字段的特征签名(Signature),对于非泛型类型来 说,描述符和特征签名能描述的信息是基本一致的,可是泛型引入以后,因为描述符中泛型的参数化类型被擦除掉,描述符就不能准确地描述泛型类型了,所以出现了 LocalVariableTypeTable。
JVM 在调用一个函数的时候,会建立一个局部变量数组(即 LocalVariableTable),而 Slot 则表示当前变量在数组中的位置。
在上面,咱们了解了 常量池、属性、field_info、method_info 等等一系列的源码文件组成结构,它们是仅仅是一种静态的内容,这些信息并不能驱使 JVM 执行咱们在源码中编写的函数。
从前可知,Code_attribute 中的 code 数组存储了一个函数源码通过编译后获得的 JVM 字节码,其中仅包含以下 两种 类型的信息:
JVM 指令码
:用于指示 JVM 执行的动做,例如加操做/减操做/new 对象。其长度为 1 个字节,因此 JVM 指令码的个数不会超过 255 个(0xFF)。JVM 指令码后的零至多个操做数
:操做数能够存储在 code 数组中,也能够存储在操做数栈(Operand stack)中。一个 Code 数组里指令和参数的组织格式 以下所示:
1字节指令码 0或多个参数(N字节,N>=0)
能够看到,Java 虚拟机的指令由一个字节长度的、表明着某种特定操做含义的数字(称为操做 码,Opcode)以及跟随其后的零至多个表明此操做所需参数(称为操做数,Operands)而构成。此外,大多数的指令都不包含操做数,只有一个操做码。
字节码指令集是一种具备鲜明特色、优劣势都很突出的指令集架构,因为限制了 Java 虚拟机操做码的长度为一个字节(即 0~255),这意味着指令集的操做码总数不可能超过 256 条。
若是不考虑异常处理的话,那么 Java 虚拟机的解释器可使用下面这个伪代码当作 最基本的执行模型 来理解,以下所示:
do {
自动计算PC寄存器的值加1;
根据PC寄存器的指示位置,从字节码流中取出操做码;
if(字节码存在操做数)从字节码流中取出操做数;
执行操做码所定义的操做;
} while (字节码流长度>0);
复制代码
因为 Java 虚拟机的操做码长度只有一个字节,因此,Java 虚拟机的指令集 对于特定的操做只提供了有限的类型相关指令去支持它。例如 在 JVM 中,大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。所以,咱们在处理 boolean、byte、short 和 char 类型的数组时,须要转换为与之对应的 int 类型的字节码指令来处理。
众所周知,JVM 是基于栈而非寄存器的计算模型,而且,基于栈的实现可以带来很好的跨平台特性,由于寄存器指令每每和硬件挂钩。可是,因为栈只是一个 FILO 的结构,须要频繁地压栈与出栈,所以,对于一样的操做,基于栈的实现须要更多指令才能完成。此外,因为 JVM 须要实现跨平台的特性,所以栈是在内存实现的,而寄存器则位于 CPU 的高速缓存区,所以,基于栈的实现其速度速度相比寄存器的实现要慢不少。要深刻了解 JVM 的指令集,咱们就必须先从 JVM 运行时的栈帧讲起。
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟 机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。
栈帧中存储了方法的 局部变量表、操做数栈、动态链接和方法返回地址、帧数据区 等信息。每个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
一个线程中的方法调用链可能会很长,不少方法都同时处于执行状态。对于 JVM 的执行引擎来 说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的全部 字节码指令都只针对当前栈帧进行操做,而 栈帧的结构 以下图所示:
Java 中当一个方法被调用时会产生一个栈帧(Stack Frame),而此方法便位于栈帧以内。而Java方法栈帧 主要包括三个部分,以下所示:
帧数据区,即常量池引用在前面咱们已经深刻地了解过了,可是还有两个重要部分咱们须要了解,一个是操做数栈,另外一个则是局部变量区。一般来讲,程序须要将局部变量区的元素加载到操做数栈中,计算完成以后,而后再存储回局部变量区。
咱们可使用 jclasslib 这个字节码工具去查看字节码,使用效果以下图所示,代码编译后在菜单栏 ”View” 中选择 ”Show Bytecode With jclasslib”,能够很直观地看到当前字节码文件的类信息、常量池、方法区等信息。
下面👇,咱们就先来看看操做数栈是怎么运转的。
操做数栈是为了 存放计算的操做数和返回结果。在执行每一条指令前,JVM 要求该指令的操做数已经被压入到操做数栈中,而且,在执行指令时,JVM 会将指令所需的操做数弹出,并将计算结果压入操做数栈中。
对于操做数栈相关的操做指令有以下 三类:
dup
:复制栈顶元素,经常使用于复制 new 指令所生成的未初始化的引用。pop
:舍弃栈顶元素,经常使用于舍弃调用指令的返回结果。wap
:交换栈顶的两个元素的值。须要注意的是,当值为 long 或 double 类型时,须要占用两个栈单元,此时须要使用 dup2/pop2 指令替代 dup/pop 指令。
对于 int(boolean、byte、char、short)
类型来讲,有以下三类经常使用指令:
iconst
:用于加载 [-1 ,5] 的 int 值。biconst
:用于加载一个字节(byte)所能表明的 int 值即 [-128-127]。sipush
:用于加载两个字节(short)所能表明的 int 值即 [-32768-32767]。而对于 long、float、double、reference
类型来讲,各个类型都仅有一类,其实就是相似于 iconst 指令,即 lconst、fconst、dconst、aconst
。
ldc
:用于加载常量池中的常量值,如 int、long、float、double、String、Class 类型的常量。例如 ldc #35 将加载常量池中的第 35 项常量值。正常状况下,操做数栈的压入弹出都是一条条指令完成。惟一的例外是在抛异常时,JVM 会清除操做数栈的全部内容,而后将异常实例压入操做数栈中。
局部变量区通常用来 缓存计算的结果。实际上,JVM 会把局部变量区当成一个 数组,里面会依次缓存 this 指针(非静态方法)、参数、局部变量。
须要注意的是,同操做数栈同样,long 和 double 类型的值将占据两个单元,而其它的类型仅仅占据一个单元。
而对于局部变量区来讲,它经常使用的操做指令有 三种,以下所示:
int(boolean、byte、char、short)
:iloadlong
:lloadfloat
:floaddouble
:dloadreference
:aloadint(boolean、byte、char、short)
:istorelong
:lstorefloat
:fstoredouble
:dstorereference
:astore这里须要注意的是,局部变量的加载与存储指令都须要指明所加载单元的下标,例如:iload_0 就是加载普通方法局部变量区中的 this 指针。
能够看到,上面两种类型的指令操做都须要操做局部变量区和操做数栈,那么,有没有 仅仅只做用在局部变量区的指令呢?
它就是 iinc M N(M为负整数,N为整数),它会将局部变量数组中的第 M 个单元中的 int 值增长 N,经常使用于 for 循环中自增量的更新,如 i++/i--。
了解了以上 JVM 的基础指令以后,咱们来看一个具体的栗子🌰,代码和其对应的 JVM 指令以下所示:
public static int bar(int i) {
return ((i + 1) - 2) * 3 / 4;
}
// 对应的字节码以下:
Code:
stack=2, locals=1, args_size=1
0: iload_0
1: iconst_1
2: iadd
3: iconst_2
4: isub
5: iconst_3
6: imul
7: iconst_4
8: idiv
9: ireturn
复制代码
这里咱们解释下上面的几处字节码的含义,以下所示:
Code
:JVM 字节码。stack
:表示该方法须要的操做数栈空间为 2。locals
:表示该方法须要的局部变量区空间为 1。args_size
:表示方法的参数大小为 1。最后,咱们来看看 每条指令执行先后局部变量区和操做数栈的变化状况,以下图所示:
了解了指令在操做数栈与局部变量区之间的转换规律,咱们下面再回过头来系统地了解下如下 九类按用途分类的字节码指令。
加载和存储指令用于 将数据在栈帧中的局部变量表和操做数栈之间来回传输,其指令以下所示:
iload、iload_<n>、lload、lload_<n>、fload、fload_ <n>、dload、dload_<n>、aload、aload_<n>
。istore、istore_<n>、lstore、lstore_<n>、 fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>
。bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、 iconst_m一、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
。wide
。相似于 iload_,它表明了 iload_0、iload_一、iload_2 和 iload_3 这几条指令。这几组指令都是某个带有一个操做数的通用指令(例如iload,iload_0 的语义与操做数为 0 时的 iload 指令语义彻底一致)。
运算或算术指令用于 对两个操做数栈上的值进行某种特定运算,并把结果从新存入到操 做栈顶。大致上算术指令能够分为 两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令。其指令以下所示:
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
。类型转换指令能够 将两种不一样的数值类型进行相互转换,例如咱们能够将小范围类型向大范围类型的安全转换,其指令以下所示:
-1)、i2b、i2c、i2s
-2)、l2i
-3)、f2i、f2l
-4)、d2i、d2l、d2f
其指令以下所示:
new
。newarray、anewarray、multianewarray
。getfield、putfield、getstatic、putstatic
。baload、caload、saload、iaload、laload、 faload、daload、aaload
。bastore、castore、sastore、iastore、 fastore、dastore、aastore
。arraylength
。instanceof、checkcast
。用于 直接操做操做数栈 的指令,以下所示:
pop、pop2(用于操做 Long、Double)
。dup、dup二、dup_x一、dup2_x一、dup_x二、dup2_x2
。swap
。控制转移指令就是 在有条件或无条件地修改 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 类型的条件分支指令是最为丰富和强大的。
经常使用的有 5条 用于方法调用的指令。 以下所示:
invokevirtual
:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最多见的方法分派方式。invokeinterface
:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。invokespecial
:用于调用一些须要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。invokestatic
:用于调用类方法(static方法)。invokedynamic
:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。这里咱们须要着重注意 invokespecial
指令,它用于 调用构造器与方法,当调用方法时,会将返回值仍然压入操做数栈中,若是当前方法没有返回值则须要使用 pop 指令弹出。
除了 invokespecial 以外,其它方法调用指令所消耗的操做数栈元素是根据调用类型以及目标方法描述符来肯定的。
返回指令是区分类型的,以下所示,为不一样返回类型对应的返回指令:
void
:returnint(boolean、byte、char、short)
:ireturnlong
:lreturnfloat
:freturndouble
:dreturnreference
:areturn方法调用指令与数据类型无关,而 方法返回指令是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。
在 Java 程序中显式抛出异常的操做(throw语句)都由 athrow 指令来实现,在 Java 虚拟机中,处理异常是采用异常表来完成的。
Java 虚拟机能够 支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
方法级的同步是隐式的,即无须经过字节码指令来控制,它实如今方法调用和返回操做 之中。虚拟机能够从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法。
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,若是设置了,执行线程就要求先成功持有管程,而后才能执行方法,最后当方法完成(不管是正常完成仍是非正常完成)时会释放管程。
同步一段指令集序列 一般是由 Java 语言中的 synchronized 语句块 来表示的,Java 虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义,而正确实现 synchronized 关键字须要 Javac 编译器与 Java 虚拟机二者共同协做支持。
编译器必须确保不管方法经过何种方式完成,方法中调用过的每条 monitorenter 指令都必须执行其对应的 monitorexit 指令,而不管这个方法是正常结束仍是异常结束。而且,它会自动产生一个异常处理器,这个异常处理器被声明可处理全部的异常,它的目的就是用来执行 monitorexit 指令。
深刻学习 JVM 字节码无疑会对咱们的总体实力有 质的提高,若是对 JVM 字节码了解较深,那么,咱们在学习 Groovy、Kotlin 等这些基于 JVM 的语言时就可以 在较短的学习时间内进阶到语言的高级层面。此外,深刻了解 JVM 字节码,可以赋予咱们经过表象透析本质的能力,而这,也正是极客们真正所追求的一通百通的灵魂之力。
一、《深刻理解Java虚拟机 JVM高级特性与最佳实践》第6章 类文件结构、第8章 虚拟机字节码执行引擎
二、《深刻理解Android Java虚拟机ART》第2章 深刻理解Class文件格式
欢迎关注个人微信:
bcce5360
因为微信群已超过 200 人,麻烦你们想进微信群的朋友们,加我微信拉你进群。
2千人QQ群,Awesome-Android学习交流群,QQ群号:959936182, 欢迎你们加入~