【JVM】JVM 字节码指令集
前言
曾经:源代码 -> 经过编译 -> 本地机器码
Java:源代码 -> 经过编译 -> 字节码 -> 解释器 -> 本地机器码
字节码:与操作系统和机器指令集无关的,平台中立的程序编译后的存储格式
字节码是无关性的基石
平台无关性的基石:
- 所有平台都统一支持字节码
- 不同的Java虚拟机都可以执行平台无关的字节码
因此实现了一次编译,到处运行
语言无关性的基石:
- Java虚拟机
- 字节码
Java虚拟机不是只可以执行Java源代码编译而成的字节码,只要符合要求(安全…)的字节码,它都可以执行。因此Kotlin等语言也可以运行在Java虚拟机上。
Class 字节码文件结构
文件格式存取数据的类型
- 无符号数 : u1,u2,u4,u8代表1,2,4,8个字节的无符号数(可以表示数字,UTF-8的字符串,索引引用…)
- 表: 由n个无符号数或n个表组成(命名以
_info
结尾)
初识 Class 文件格式
1 | public class Test { |
使用可视化工具classpy查看反编译的结果
每个集合前都有一个计数器来统计集合中元素的数量
Class文件格式的描述
数据类型 | 名称 | 数量 | 对应图中名字 | 作用 |
---|---|---|---|---|
u4 | magic | 1 | 魔数 | 确定这个文件是否是一个能被虚拟机接受的Class文件 |
u2 | minor_version | 1 | 次版本号 | 虚拟机必须拒绝执行超过其版本号的Class文件 |
u2 | major_version | 1 | 主版本号 | 虚拟机必须拒绝执行超过其版本号的Class文件 |
u2 | constant_pool_count | 1 | 常量池容量计数器 | 统计常量数量 |
cp_info | constant_pool | constant_pool_count - 1 | 常量池 | 存放常量 |
u2 | access_flags | 1 | 访问标志 | 识别类(类,接口)的访问信息 |
u2 | this_class | 1 | 类索引 | 确定类的全限定名 |
u2 | super_class | 1 | 父类索引 | 确定父类的全限定名 |
u2 | interfaces_count | 1 | 接口计数器 | 统计该类实现接口数量 |
u2 | interfaces | interfaces_count | 接口索引集合 | 描述该类实现了的接口 |
u2 | fields_count | 1 | 字段表集合计数器 | 统计类的字段数量 |
field_info | fields | fields_count | 字段表集合 | 描述类声明的字段(类变量,实例变量) |
u2 | methods_count | 1 | 方法表集合计数器 | 统计类的方法数量 |
method_info | methods | methods_count | 方法表集合 | 描述类声明的方法 |
u2 | attribute_count | 1 | 属性表集合计数器 | 统计属性数量 |
attribute_info | attributes | attributes_count | 属性表集合 | 描述属性 |
魔数与主次版本号
- 魔数:确定这个文件是否为一个能被虚拟机接受的有效Class文件
- 主次版本号:虚拟机拒绝执行超过其版本号的Class文件
- 不同版本的Java前端编译器编译生成对应的Class文件主次版本号不同
- 支持高版本JVM执行低版本前端编译器生成的Class文件(向下兼容)
- 拒绝低版本JVM执行高版本前端编译器生成的Clsss文件
常量池
常量池包含两大常量: 字面量和符号引用
符号引用与直接引用
- 符号引用
- 使用一组符号描述引用(为了定位到目标引用)
- 与虚拟机内存布局无关
- 还是符号引用时目标引用不一定被加载到内存
- 直接引用
- 直接执行目标的指针,相对偏移量或间接定位目标引用的句柄
- 与虚拟机内存布局相关
- 解析直接引用时目标引用已经被加载到内存中
字面量与符号引用
- 字面量
- 文本字符串
- 被final声明的常量
- 符号引用
- 全限定名
- 方法或字段的简单名称和描述符
图中的常量有我们代码中熟悉的常量也有很多没有显示出现在代码中的常量
访问标志
访问标志:用于识别类或接口的访问信息
- 是否是一个接口,枚举,模块,注解…
- 是否被final(public,abstract…)修饰
ACC_PUBLIC
:被public修饰ACC_SUPER
: 允许使用invokespecial
字节码指令
类索引,父类索引与接口索引集合
类索引
用于确定本类的全限定名
类索引指向常量池中表示该类的符号引用
父类索引
用于确定父类的全限定名
父类索引指向常量池中表示该类父类的符号引用。除了Object外,所有类的父类索引都不为0
接口索引集合
描述这个类实现了哪些接口
我们的例子中没有实现接口,就没有(接口索引集合计数器为0)
总结
Class 文件由“类索引,父类索引,接口索引集合”来确定该类的继承关系
字段表集合
描述类声明的字段,字段包括类变量和成员变量(实例变量),不包括局部变量
简单名称和描述符
- 简单名称
- 字段: 没有描述字段类型的名称
- 方法: 没有描述参数列表和返回类型的名称
- 描述符
- 字段: 描述字段的类型
- 方法: 描述参数列表和返回值
- 描述符字符含义(long,boolean,对象类型是J,Z,L 其他都是首字母大写)
标识字符 | 含义 |
---|---|
B | byte |
C | char |
D | double |
F | float |
I | int |
J | long |
S | short |
Z | boolean |
V | void |
L | 对象类型,如Ljava/lang/Object |
- 描述符描述n维数组
- 在前面先写n个
[
再写标识字符
比如java.lang.Integer[ ]
=>[Ljava.lang.Integer
- 在前面先写n个
- 描述符描述方法
- 参数列表按照从左到右的顺序写在
()
中 - 返回类型写到最后。比如
String method(long[], int, String[])
=>([JIL[java.lang.String)Ljava.lang.String
- 参数列表按照从左到右的顺序写在
因此Class文件中字段描述符指向常量池中的#07 I 符号引用(的索引)
注意
- 字段表集合不会列出父类或父接口中声明的字段
- 只用 简单名称 来确定字段,所以不能有重名字段
- 用 简单名称 和 描述符 确定方法,所以方法可以重名(重载)
- 字节码文件 规定 简单名称+描述符相同才是同一个方法
- 但是 Java语法 规定 重载 = 简单名称相同 + 描述符的参数列表不同 + 描述符的返回类型不能不同
方法表集合
描述类声明的方法,与字段表集合类似
注意
方法表集合中不会列出父类方法信息(不重写的情况)
属性表集合
属性比较多,这里只说明我们例子中出现的,其他的会总结。用于描述某些场景专有信息刚刚在字段,方法表集合中都可以看到属性表集合,说明属性表集合是可以被携带的。
怎么没看到Java源代码中的代码呢?
实际上它属于属性表集合中的Code属性
Code 属性
Java源代码中方法体中的代码经过编译后编程字节码指令存储在Code属性内
其中的异常表集合代表 编译器为这段代码生成的多条异常记录,对应着可能出现的代码执行路径(程序在try中不抛出异常会怎么执行,抛出异常又会怎么执行…)
Exceptions 属性
列举出方法中可能抛出的检查异常(Checked Exception),也就是方法声明throws
关键字后面的列举异常
LineNumberTable 属性
描述Java源码行号与字节码指令行号(字节码偏移量)对应关系
SourceFile 属性
记录生成此Class文件的源码名称
StackMapTable属性
虚拟机类加载验证阶段的字节码验证时,不需要再检验了,只需要查看StackMapTable属性中的记录是否合法
编译阶段将一系列的验证类型结果记录在StackMapTable属性中
ConstantValue
在类加载的准备阶段,为静态变量(常量)赋值。只有类变量才有这个属性。
- 实例变量的赋值: 在实例构造器
- 类变量的赋值: 在类构造器或带有ConstantValue属性在类加载的准备阶段
如果类变量被final修饰(此时该变量是一个常量),且该变量数据类型是基本类型或字符串,就会生成ConstantValue属性,该属性指向常量池中要赋值的常量,在类加载的准备阶段,直接把在常量池中ConstantValue指向的常量赋值给该变量
总结所有属性
属性名 | 作用 |
---|---|
Code | 方法体内的代码经过编译后变为字节码指令存储在Code属性中 |
Exceptions | 列举出方法可能抛出的检查异常(Checked Exception) |
LineNumberTable | Java源码行号与字节码偏移量(字节码行号)对应关系 |
LocalVariableTable | Java源码定义的局部变量与栈帧中局部变量表中的变量对应关系(局部变量名称,描述符,局部变量槽位置,局部变量作用范围等) |
LocalVariableTypeTable | 与LocalVariableTable 相似,只是把LocalVariableTable 的描述符换成了字段的特征签名(完成对泛型的描述) |
SourceFile | 记录生成这个Class文件的源码文件名称 |
SourceDebugExtension | 用于存储额外的代码调式信息 |
ConstantValue | 在类加载的准备阶段,为静态变量(常量)赋值 |
InnerClasses | 记录内部类与宿主类之间的关系 |
Deprecated | 用于表示某个字段,方法或类已弃用 (可以用注解@deprecated表示) |
Synthetic | 用于表示某字段或方法不是由Java源代码生成的,而是由编译器自行添加的 |
StackMapTable | 虚拟机类加载验证阶段的字节码验证时,不需要再检验了,只需要查看StackMapTable属性中的记录是否合法 |
Signature | 记录泛型签名信息 |
BootstrapMethods | 保存动态调用(invokeeddynamic)指令引用的引导方法限定符 |
MethodParameters | 记录方法的各个形参名称与信息 |
javap 解析 Class 文件
关于 javac
javac xx.java
编译Java源文件,不会生成对应的局部变量表javac -g xx.java
编译Java源文件,生成对应的局部变量表
IDEA中编译Java源文件使用的是javac -g
关于 javap
常用
javap -v
基本上可以反编译出Class文件中的很多信息(常量池,字段集合,方法集合…)
但是它不会显示私有字段或方法的信息,所以可以使用javap -v -p
详解 javap -v -p
1 | public class JavapTest { |
字节码指令集
字节码与数据类型
在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:
- i代表对int类型的数据操作
- l代表long
- s代表short
- b代表byte
- c代表char
- f代表float
- d代表double
也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的。
大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend)为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型。
指令分析
大部分指令先以i(int),l(long),f(float),d(double),a(引用)开头。其中byte,char,short,boolean在hotspot中都是转成int去执行(使用int类型的字节码指令)。
字节码指令大致分为:
- 加载与存储指令
- 算术指令
- 类型转换指令
- 对象创建与访问指令
- 方法调用与返回指令
- 操作数栈管理指令
- 控制转义指令
- 异常处理指令
- 同步控制指令
在hotspot中每个方法对应的一组字节码指令实际上就是在该方法所对应的栈帧中的局部变量表和操作数栈上进行操作。
字节码指令包含字节码操作指令和操作数 (操作数可能是在局部变量表上,也可能在常量池中,还可能就是常数)
(说在前面)在做值相关操作时:
- 一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用中等取得数据,这些数据(可能是值,可能是对象的引用)被压入操作数栈。
- 一个指令,也可以从操作数栈中取出一到多个值(pop多次),完成赋值、加减乘除、方法传参、系统调用等等操作。
加载与存储指令
加载
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。(可以从局部变量表,常量池中加载到操作数栈)
加载与存储相关指令(重要):
- 局部变量压栈指令:将一个局部变量加载到操作数栈:
xload、xload_<n>
(其中x为i、l、f、d、a,n为0到3) - 常量入栈指令:将一个常量加载到操作数栈:
bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>)、fconst_<f>、dconst_<d>
- 栈中元素装入局部变量表指令:将一个数值从操作数栈存储到局部变量表:
xstore、xstore_<n>
(其中x为i、l、f、d、a,n为0到3);xastore
(其中x为i、l、f、d、a、b、c、s) - 扩充局部变量表的访问索引的指令:
wide
。
上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如iload_<n>
)。这些指令助记符实际上代表了一组指令(例如iload_<n>
代表了iload_0、iload_1、iload_2
和iload_3
这几个指令)。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这若干组特殊指令来说,它们表面上没有操作数,不需要进行取操作数的动作,但操作数都隐含在指令中。
除此之外,它们的语义与原生的通用指令完全一致(例如iload_0
的语义与操作数为0时的iload指令语义完全一致)。在尖括号之间的字母指定了指令隐含操作数的数据类型,<n>
代表非负的整数,<i>
代表是int类型数据,<l>
代表long类型,<f>
代表float类型,<d>
代表double类型。
操作byte、char、short和boolean类型数据时,用int类型的指令来表示。
字节码指令操作byte、char、short和boolean类型数据时,都会将其转换为int类型,因为局部变量表里一个槽占用4个字节,所以1或2字节的数据放进去就会自动变成四个字节。
但是在从局部变量表读取该数据到操作数栈前,会使用相应的窄化类型转换指令(例如i2b,int -> byte)将局部变量表里int类型的数据转换为其原本字节大小,然后再在操作数栈中进行运算。
注意:编译时就知道了局部变量表应该有多少槽的位置和操作数栈的最大深度(为节省空间,局部变量槽还会复用)
局部变量压栈常用指令集
xload_n | xload_0 | xload_1 | xload_2 | xload_3 |
---|---|---|---|---|
iload_n | iload_0 | iload_1 | iload_2 | iload_3 |
lload_n | lload_0 | lload_1 | lload_2 | lload_3 |
fload_n | fload_0 | fload_1 | fload_2 | fload_3 |
dload_n | dload_0 | dload_1 | dload_2 | dload_3 |
aload_n | aload_0 | aload_1 | aload_2 | aload_3 |
局部变量压栈指令剖析
局部变量压栈指令将给定的局部变量表中的数据压入操作数栈。这类指令大体可以分为:
xload_<n>
(x为i、l、f、d、a,n为0到3)xload
(x为i、l、f、d、a)
说明:在这里,x的取值表示数据类型。
指令xload_n
表示将第n个局部变量压入操作数栈,比如iload_1
、fload_0
、aload_0
等指令。其中aload_n
表示将一个对象引用压栈。
指令xload
通过指定参数的形式,把局部变量压入操作数栈,当使用这个命令时,表示局部变量的数量可能超过了4个,比如指令iload
、fload
等。
举例:
1 | public void load(int num, Object obj, long count, boolean flag, short[] arr) { |
字节码执行过程:
算术指令
算术指令将操作数栈中的两个栈顶元素出栈作运算再将运算结果入栈
使用的是后缀表达式(逆波兰表达式),比如 3 4 + => 3 + 4
分类
大体上算术指令可以分为两种:对整型数据进行运算的指令与对浮点类型数据进行运算的指令。
在每一大类中,都有针对Java虚拟机具体数据类型的专用算术指令。但没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算,都使用int类型的指令来处理。此外,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。
运算时的溢出
数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数。其实Java虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为0时会导致虚拟机抛出异常ArithmeticException
。
运算模式
- 向最接近数舍入模式:JVM要求在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为零的;
- 向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果;
NaN 值使用
当一个操作产生溢出时,将会使用有符号的无穷大表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。而且所有使用NaN值作为操作数的算术操作,结果都会返回NaN;
注意
- 当除数是0时会抛出
ArithmeticException
异常(如果是0.0,结果是Infinity) - 浮点数转整数向0取整
- 浮点数计算精度丢失
- Infinity 计算结果无穷大
- NAN 计算结果不确定计算值
1 | public void test1() { |
算术指令集
算数指令 | int(boolean,byte,char,short) | long | float | double | |
---|---|---|---|---|---|
加法指令 | iadd | ladd | fadd | dadd | |
减法指令 | isub | lsub | fsub | dsub | |
乘法指令 | imul | lmul | fmul | dmul | |
除法指令 | idiv | ldiv | fdiv | ddiv | |
求余指令 | irem | lrem | frem | drem | |
取反指令 | ineg | lneg | fneg | dneg | |
自增指令 | iinc | ||||
按位或指令 | ior | lor | |||
按位与指令 | iand | land | |||
按位异或指令 | ixor | lxor | |||
比较指令 | lcmp | fcmpg / fcmpl | dcmpg / dcmpl |
算术指令举例
举例1
1 | public static int bar(int i) { |
举例2
1 | public void add() { |
举例3
1 | public static void main(String[] args) { |
自增指令
1、i++
:
1 | public static void main(String[] args) { |
上述代码的字节码解析:
iconst_0
:将数字 0 入操作数栈istore_1
:弹出操作数栈顶元素 0 并存储到局部变量表索引位置为1的变量(此时操作数栈为空)iload_1
:从局部变量表中加载索引位置1的元素(0)到操作数栈(目的是备份一下i 自增前的值,待自增结束后再赋值给 a)iinc 1 by 1
:首先读取局部变量表中 i 的值,再进行加一操作,最后存储加一后的值1到索引位置 1,重新赋给了 i(该指令没有原子性)istore_2
:将操作数栈顶的元素 0(第三步存储的)赋值给局部变量表中的 a
所以最终结果就是,a 的值等于 0。而到这这一结果的根本原因是因为:在给 a 赋值前先从局部变量表中 load
了 i 加一前的值,并在最后将该值又赋给了 a,所以造成了 i++
后 a 的值等于 i 加一前的值。
注意:iinc 1 by 1
这条指令虽然在JVM层面只是一条命令,但是其在最底层的CPU层面却是包含了三个步骤:
- 读取:从局部变量表中读取 i 的值
- 累加:将该值进行加一
- 保存:将加一后的值保存回局部变量表中
其中的一条指令可以保证是原子操作,但是3条指令合在一起却不是,这就导致了i++
语句不是原子操作。
如果在读取操作进行后,当前线程失去了执行权,那么在该线程再一次获取到执行权后,就不会再做一次读取操作,而是直接使用线程切换前读取到的值进行加一,这就导致了高并发下 i++
操作的结果异常
情景:两个线程同时对 static int i = 0
进行各自连续100次的 i++
操作,理想情况下结果为 200,但最极端的情况下,结果为 2。该过程图解:
2、i--
:
1 | public static void main(String[] args) { |
对比 ++i
和 i++
,可以看到二者的区别是:
++i
是先执行iinc 1 by 1
,将 i 的值增加,然后再iload_1
读取了加一后的 i 值,将该值赋给 ai++
则是在iinc 1 by 1
执行前就先把 i 原本的值备份了一份,然后 i 自增后,再将之前备份的值赋给 a
补充:只有局部变量才使用 iinc 指令,如果是调用的成员变量属性++,则不会使用 iinc 指令,其指令更加复杂。先从局部变量表里 load 该值,然后再加一,最后存回局部变量表里去。
类型转换指令
类型转换指令可以分为宽化类型转换和窄化类型转换(对应基本类型的非强制转换和强制转换)
类型转换指令集
byte | char | short | int | long | float | double | |
---|---|---|---|---|---|---|---|
int | i2b | i2c | i2s | ○ | i2l | i2f | i2d |
long | l2i i2b | l2i i2c | l2i i2s | l2i | ○ | l2f | l2d |
float | f2i i2b | f2i i2c | f2i i2s | f2i | f2l | ○ | f2d |
double | d2i i2b | d2i i2c | d2i i2s | d2i | d2l | d2f | ○ |
宽化类型转换指令
- 转换规则
Java虚拟机直接支持以下数值的宽化类型转换( widening numeric conversion,小范围类型向大范围类型的安全转换)。也就是说,并不需要指令执行,包括
- 从int类型到long、float或者 double类型。对应的指令为:
i21
、i2f
、i2d
- 从long类型到float、 double类型。对应的指令为:
i2f
、i2d
- 从float类型到double类型。对应的指令为:
f2d
简化为:int -> long -> float -> double
- 精度损失问题
- 宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从int转换到long,或者从int转换到double,都不会丢失任何信息,转换前后的值是精确相等的。
- 从int、long类型数值转换到float,或者long类型数值转换到double时,将可能发生精度丢失一一可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据IEEE754最接近含入模式所得到的正确整数值。
尽管宽化类型转换实际上是可能发生精度丢失的,但是这种转换永远不会导致Java虚拟机抛出运行时异常
补充说明:
从byte、char和 short类型到int类型的宽化类型转换实际上是不存在的。对于byte类型转为int,拟机并没有做实质性的转化处理,只是简单地通过操作数栈交換了两个数据。而将byte转为long时,使用的是i2l,可以看到在内部,byte在这里已经等同于int类型处理,类似的还有 short类型,这种处理方式有两个特点:
一方面可以减少实际的数据类型,如果为 short和byte都准备一套指令,那么指令的数量就会大増,而虚拟机目前的设计上,只愿意使用一个字节表示指令,因此指令总数不能超过256个,为了节省指令资源,将 short和byte当做int处理也在情理之中。
另一方面,由于局部变量表中的槽位固定为32位,无论是byte或者short存入局部变量表,都会占用32位空间。从这个角度说,也没有必要特意区分这几种数据类型。
窄化类型转换指令
- 转换规则
Java虚拟机也直接支持以下窄化类型转换:
- 从主int类型至byte、 short或者char类型。对应的指令有:
i2b
、i2c
、i2s
- 从long类型到int类型。对应的指令有:
l2i
- 从float类型到int或者long类型。对应的指令有:
f2i
、f2l
- 从double类型到int、long或者float类型。对应的指令有:
d2i
、d2l
、d2f
- 精度损失问题
窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度。
尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常
补充说明:
当将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,将遵循以下转换规则:
- 如果浮点值是NaN,那转换结果就是int或long类型的0.
- 如果浮点值不是无穷大的话,浮点值使用IEEE754的向零含入模式取整,获得整数值v。如果v在目标类型T(int或long)的表示范围之内,那转换结果就是v。否则,将根据v的符号,转换为T所能表示的最大或者最小正数
当将一个double类型窄化转换为float类型时,将遵循以下转换规则,通过向最接近数舍入模式舍入一个可以使用float类型表示的数字。最后结果根据下面这3条规则判断:
- 如果转换结果的绝对值太小而无法使用float来表示,将返回float类型的正负零
- 如果转换结果的绝对值太大而无法使用float来表示,将返回float类型的正负无穷大。
- 对于double类型的NaN值将按规定转换为float类型的NaN值。
注意: long转换为float或double时可能发生精度丢失
1 | public void test2(){ |
NaN和Infinity的特殊情况:
- NaN转为整型会变成0
- 正无穷或负无穷转为整型会变成那个类型的最大值或最小值
1 | public void test3(){ |
对象创建与访问指令
对象创建与访问指令: 创建指令,字段访问指令,数组操作指令,类型检查指令
创建指令
new
: 创建实例newarray
: 创建一维基本类型数组anewarray
: 创建一维引用类型数组multianewarray
: 创建多维数组
注意:这里的创建可以理解为分配内存,当多维数组只分配了一维数组时使用的是anewarray
字段访问指令
getstatic
: 对静态字段进行读操作putstatic
: 对静态字段进行写操作getfield
: 对实例字段进行读操作putfield
: 对实例字段进行写操作
读操作:把要进行读操作的字段入栈
写操作:把要写操作的值出栈再写到对应的字段
举例:以getstatic
指令为例,它含有一个操作数,为指向常量池的Fieldref索引,它的作用就是获取Fieldref指定的对象或者值,并将其压入操作数栈。
1 | public void sayHello() { |
对应的字节码指令:
1 | 0 getstatic #8 <java/lang/System.out> |
图示:
数组操作指令
b/c/s/i/l/f/d/a aload
:表示将数组中某索引元素入栈 (读)- 需要的参数从栈顶依次向下:索引位置,数组引用
b/c/s/i/l/f/d/a astore
:表示将某值出栈并写入数组某索引元素 (写)- 需要的参数从栈顶依次向下:要写入的值,索引位置,数组引用
注意:b开头的指令对byte和boolean通用
arraylength
:先将数组引用出栈再将获得的数组长度入栈
类型检查指令
instanceof
: 判断某对象是否为某类的实例checkcast
: 检查引用类型是否可以强制转换
方法调用与返回指令
方法调用指令
非虚方法:静态方法、私有方法、父类中的方法、被final修饰的方法、实例构造器
与之对应不是非虚方法的就是虚方法了。
-
普通调用指令
invokestatic
:调用静态方法invokespecial
:调用私有方法、父类中的方法、实例构造器方法、final方法invokeinterface
:调用接口方法invokevirtual
:调用虚方法- 使用
invokestatic
和invokespecial
指令的一定是非虚方法 - 使用
invokeinterface
指令一定是虚方法(因为接口方法需要具体的实现类去实现) - 使用
invokevirtual
指令可能是虚方法
-
动态调用指令
invokedynamic
: 动态解析出需要调用的方法再执行
jdk 7 出现
invokedynamic
,支持动态语言。
测试虚方法代码
父类
1 | public class Father { |
接口
1 | public interface TestInterfaceMethod { |
子类
1 | public class Son extends Father{ |
方法返回指令
方法返回指令: 方法结束前,将栈顶元素(最后一个元素)出栈,返回给调用者
根据方法的返回类型划分多种指令:
方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的。
- 包括
ireturn
(当返回值是boolean、byte、char、short和int 类型时使用)、lreturn
、freturn
、dreturn
和areturn
- 另外还有一条
return
指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
通过ireturn
指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃。如果当前返回的是synchronized方法,那么还会执行一个隐含的monitorexit
指令,退出临界区。
最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者。
举例:
1 | public int methodReturn() { |
图示:
操作数栈管理指令
通用型指令,不区分类型
- 出栈
pop/pop2
出栈1个/2个栈顶元素
- 入栈
dup/dup2
复制栈顶1个/2个slot并重新入栈dup_x1
复制栈顶1个slot并插入到栈顶开始的第2个slot下dup_x2
复制栈顶1个slot并插入到栈顶开始的第3个slot下dup2_x1
复制栈顶2个slot并插入到栈顶开始的第3个slot下dup2_x2
复制栈顶2个slot并插入到栈顶开始的第4个slot下- 插入到具体的slot计算:dup的系数 +
_x
的系数
- 插入到具体的slot计算:dup的系数 +
控制转义指令
条件跳转指令
通常先进行比较指令,再进行条件跳转指令
比较指令比较结果-1,0,1再进行判断是否要跳转
条件跳转指令: 出栈栈顶元素,判断它是否满足条件,若满足条件则跳转到指定位置
注意: 这种跳转指令一般都"取反",比如代码中第一个条件语句是d>100,它第一个条件跳转指令就是ifle
小于等于0,满足则跳转,不满足则按照顺序往下走
比较条件跳转指令
比较条件跳转指令 类似 比较指令和条件跳转指令 的结合体
多条件分支跳转指令
多条件分支跳转指令是为了switch-case提出的
tableswitch
用于case值连续的switch多条件分支跳转指令,效率好lookupswitch
用于case值不连续的switch多条件分支跳转指令(虽然case值不连续,但最后会对case值进行排序)
tableswitch
lookupswitch
对于String类型是先找到对应的哈希值再equals比较确定走哪个case的
无条件跳转指令
无条件跳转指令就是跳转到某个字节码指令处
goto
经常使用jsr,jsr_w,ret
不怎么使用了
异常处理指令
throw抛出异常对应athrow
:清除该操作数栈上所有内容,将异常实例压入调用者操作数栈上
使用try-catch/try-final/throws
时会产生异常表:异常表保存了异常处理信息 (起始,结束位置,字节码指令偏移地址,异常类在常量池中的索引等信息)
athrow
异常表
异常还会被压入栈或者保存到局部变量表中
同步控制指令
synchronized作用于方法时,方法的访问标识会有ACC_SYNCHRONIZED表示该方法需要加锁。synchronized作用于某个对象时,对应着 monitorentry
加锁字节码指令和 monitorexit
解锁字节码指令。
Java中的synchronized默认是可重入锁。
当线程要访问需要加锁的对象时 (执行monitorentry
):
- 先查看对象头中加锁次数,如果为0说明未加锁,获取后,加锁次数自增
- 如果不为0,再查看获取锁的线程是不是自己,如果是自己就可以访问,加锁次数自增
- 如果不为0且获取锁线程不是自己,就阻塞
当线程释放锁时 (执行monitorexit
)会让加锁次数自减:
为什么会有2个monitorexit
?
程序正常执行应该是一个monitorentry
对应一个monitorexit
的。但如果程序在加锁的代码中抛出了异常,没有释放锁,那不就会造成其他阻塞的线程永远也拿不到锁了吗?所以在程序抛出异常时(跳转PC偏移量为15的指令)继续往下执行,抛出异常前要释放锁。