【JVM】JVM 字节码指令集

前言

曾经:源代码 -> 经过编译 -> 本地机器码

Java:源代码 -> 经过编译 -> 字节码 -> 解释器 -> 本地机器码

image-20210508090007130

字节码:与操作系统和机器指令集无关的,平台中立的程序编译后的存储格式

字节码是无关性的基石

平台无关性的基石:

  • 所有平台都统一支持字节码
  • 不同的Java虚拟机都可以执行平台无关的字节码

因此实现了一次编译,到处运行

语言无关性的基石:

  • Java虚拟机
  • 字节码

Java虚拟机不是只可以执行Java源代码编译而成的字节码,只要符合要求(安全…)的字节码,它都可以执行。因此Kotlin等语言也可以运行在Java虚拟机上。

Class 字节码文件结构

文件格式存取数据的类型

  • 无符号数 : u1,u2,u4,u8代表1,2,4,8个字节的无符号数(可以表示数字,UTF-8的字符串,索引引用…)
  • 表: 由n个无符号数或n个表组成(命名以_info结尾)

初识 Class 文件格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Test {
private int m;
private final int CONSTANT=111;

public int inc() throws Exception {
int x;
try {
x = 1;
return x;
}catch (Exception e){
x = 2;
return x;
}finally{
x = 3;
}
}
}

使用可视化工具classpy查看反编译的结果

image-20201107172118033

每个集合前都有一个计数器来统计集合中元素的数量

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声明的常量
  • 符号引用
    • 全限定名
    • 方法或字段的简单名称和描述符

image-20210512225657765

图中的常量有我们代码中熟悉的常量也有很多没有显示出现在代码中的常量

访问标志

访问标志:用于识别类或接口的访问信息

  • 是否是一个接口,枚举,模块,注解…
  • 是否被final(public,abstract…)修饰

image-20201107175942225

  • ACC_PUBLIC:被public修饰
  • ACC_SUPER: 允许使用invokespecial字节码指令

类索引,父类索引与接口索引集合

类索引

用于确定本类的全限定名

image-20201107180153111

类索引指向常量池中表示该类的符号引用

父类索引

用于确定父类的全限定名

image-20201107180509860

父类索引指向常量池中表示该类父类的符号引用。除了Object外,所有类的父类索引都不为0

接口索引集合

描述这个类实现了哪些接口

我们的例子中没有实现接口,就没有(接口索引集合计数器为0)

总结

Class 文件由“类索引,父类索引,接口索引集合”来确定该类的继承关系

字段表集合

描述类声明的字段,字段包括类变量和成员变量(实例变量),不包括局部变量

image-20201107181355881

简单名称和描述符

  • 简单名称
    • 字段: 没有描述字段类型的名称
    • 方法: 没有描述参数列表和返回类型的名称
  • 描述符
    • 字段: 描述字段的类型
    • 方法: 描述参数列表和返回值
    • 描述符字符含义(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
  • 描述符描述方法
    • 参数列表按照从左到右的顺序写在()
    • 返回类型写到最后。比如String method(long[], int, String[]) => ([JIL[java.lang.String)Ljava.lang.String

因此Class文件中字段描述符指向常量池中的#07 I 符号引用(的索引)

注意

  1. 字段表集合不会列出父类或父接口中声明的字段
  2. 只用 简单名称 来确定字段,所以不能有重名字段
  3. 用 简单名称 和 描述符 确定方法,所以方法可以重名(重载)
    • 字节码文件 规定 简单名称+描述符相同才是同一个方法
    • 但是 Java语法 规定 重载 = 简单名称相同 + 描述符的参数列表不同 + 描述符的返回类型不能不同

方法表集合

描述类声明的方法,与字段表集合类似

image-20201107182407009

注意

方法表集合中不会列出父类方法信息(不重写的情况)

属性表集合

属性比较多,这里只说明我们例子中出现的,其他的会总结。用于描述某些场景专有信息刚刚在字段,方法表集合中都可以看到属性表集合,说明属性表集合是可以被携带的。

怎么没看到Java源代码中的代码呢?

实际上它属于属性表集合中的Code属性

Code 属性

Java源代码中方法体中的代码经过编译后编程字节码指令存储在Code属性内

image-20201107184345952

其中的异常表集合代表 编译器为这段代码生成的多条异常记录,对应着可能出现的代码执行路径(程序在try中不抛出异常会怎么执行,抛出异常又会怎么执行…)

image-20201107184823648

Exceptions 属性

列举出方法中可能抛出的检查异常(Checked Exception),也就是方法声明throws关键字后面的列举异常

image-20201107185136111

LineNumberTable 属性

描述Java源码行号与字节码指令行号(字节码偏移量)对应关系

SourceFile 属性

记录生成此Class文件的源码名称

StackMapTable属性

虚拟机类加载验证阶段的字节码验证时,不需要再检验了,只需要查看StackMapTable属性中的记录是否合法

编译阶段将一系列的验证类型结果记录在StackMapTable属性中

image-20201107185712220

ConstantValue

在类加载的准备阶段,为静态变量(常量)赋值。只有类变量才有这个属性。

  • 实例变量的赋值: 在实例构造器
  • 类变量的赋值: 在类构造器或带有ConstantValue属性在类加载的准备阶段

如果类变量被final修饰(此时该变量是一个常量),且该变量数据类型是基本类型或字符串,就会生成ConstantValue属性,该属性指向常量池中要赋值的常量,在类加载的准备阶段,直接把在常量池中ConstantValue指向的常量赋值给该变量

image-20201107191419341

总结所有属性

属性名 作用
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

image-20210513195725462

常用

javap -v 基本上可以反编译出Class文件中的很多信息(常量池,字段集合,方法集合…)

但是它不会显示私有字段或方法的信息,所以可以使用javap -v -p

详解 javap -v -p

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JavapTest {
private int a = 1;
float b = 2.1F;
protected double c = 3.5;
public int d = 10;

private void test(int i){
i+=1;
System.out.println(i);
}

public void test1(){
String s = "test1";
System.out.println(s);
}
}

image-20210513200417243

image-20210513200532661

image-20210513200946912

字节码指令集

https://www.yuque.com/u21195183/jvm/bg6q2k

字节码与数据类型

在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类型的字节码指令)

字节码指令大致分为:

  1. 加载与存储指令
  2. 算术指令
  3. 类型转换指令
  4. 对象创建与访问指令
  5. 方法调用与返回指令
  6. 操作数栈管理指令
  7. 控制转义指令
  8. 异常处理指令
  9. 同步控制指令

在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_2iload_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类型的数据转换为其原本字节大小,然后再在操作数栈中进行运算。

img


image-20210514163308948

注意:编译时就知道了局部变量表应该有多少槽的位置和操作数栈的最大深度(为节省空间,局部变量槽还会复用)

局部变量压栈常用指令集

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_1fload_0aload_0等指令。其中aload_n表示将一个对象引用压栈。

指令xload通过指定参数的形式,把局部变量压入操作数栈,当使用这个命令时,表示局部变量的数量可能超过了4个,比如指令iloadfload等。

举例:

1
2
3
4
5
6
7
public void load(int num, Object obj, long count, boolean flag, short[] arr) {
System.out.println(num);
System.out.println(obj);
System.out.println(count);
System.out.println(flag);
System.out.println(arr);
}

字节码执行过程:

image-20211020211048622

算术指令

算术指令将操作数栈中的两个栈顶元素出栈作运算再将运算结果入栈

使用的是后缀表达式(逆波兰表达式),比如 3 4 + => 3 + 4

分类

大体上算术指令可以分为两种:对整型数据进行运算的指令与对浮点类型数据进行运算的指令。

在每一大类中,都有针对Java虚拟机具体数据类型的专用算术指令。但没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算,都使用int类型的指令来处理。此外,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。

image-20211021094724555

运算时的溢出

数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数。其实Java虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为0时会导致虚拟机抛出异常ArithmeticException

运算模式

  • 向最接近数舍入模式:JVM要求在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为零的;
  • 向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果;

NaN 值使用

当一个操作产生溢出时,将会使用有符号的无穷大表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。而且所有使用NaN值作为操作数的算术操作,结果都会返回NaN;

image-20211021094942783

注意

  1. 当除数是0时会抛出ArithmeticException异常(如果是0.0,结果是Infinity)
  2. 浮点数转整数向0取整
  3. 浮点数计算精度丢失
  4. Infinity 计算结果无穷大
  5. NAN 计算结果不确定计算值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void test1() {
double d1 = 10 / 0.0;
//Infinity
System.out.println(d1);

double d2 = 0.0 / 0.0;
//NaN
System.out.println(d2);

//向0取整模式:浮点数转整数
//5
System.out.println((int) 5.9);
//-5
System.out.println((int) -5.9);


//向最接近数舍入模式:浮点数运算
//0.060000000000000005
System.out.println(0.05+0.01);

//抛出ArithmeticException: / by zero异常
System.out.println(1/0);
}

算术指令集

算数指令 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

image-20210514173650867

算术指令举例

举例1

1
2
3
public static int bar(int i) {
return ((i + 1) - 2) * 3 / 4;
}

image-20211021095249945

举例2

1
2
3
4
5
public void add() {
byte i = 15;
int j = 8;
int k = i + j;
}

image-20211021095347090

image-20211021095357407

image-20211021095407259

ef9ac1fd-3b93-4167-8d55-14e13c287d54

举例3

1
2
3
4
5
6
7
public static void main(String[] args) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a + b);
}

image-20211021095659729

image-20211021095708757

自增指令

1、i++

1
2
3
4
public static void main(String[] args) {
int i = 0;
int a = i++;
}

上述代码的字节码解析:

  • 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 加一前的值。

image-20211021142230633

注意:iinc 1 by 1这条指令虽然在JVM层面只是一条命令,但是其在最底层的CPU层面却是包含了三个步骤:

  1. 读取:从局部变量表中读取 i 的值
  2. 累加:将该值进行加一
  3. 保存:将加一后的值保存回局部变量表中

其中的一条指令可以保证是原子操作,但是3条指令合在一起却不是,这就导致了i++语句不是原子操作。

如果在读取操作进行后,当前线程失去了执行权,那么在该线程再一次获取到执行权后,就不会再做一次读取操作,而是直接使用线程切换前读取到的值进行加一,这就导致了高并发下 i++ 操作的结果异常


情景:两个线程同时对 static int i = 0 进行各自连续100次的 i++ 操作,理想情况下结果为 200,但最极端的情况下,结果为 2。该过程图解:

幻灯片1


2、i--

1
2
3
4
public static void main(String[] args) {
int i = 0;
int a = ++i;
}

image-20211021163147335

对比 ++ii++,可以看到二者的区别是:

  • ++i 是先执行 iinc 1 by 1,将 i 的值增加,然后再 iload_1 读取了加一后的 i 值,将该值赋给 a
  • i++ 则是在 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

宽化类型转换指令

  1. 转换规则

Java虚拟机直接支持以下数值的宽化类型转换( widening numeric conversion,小范围类型向大范围类型的安全转换)。也就是说,并不需要指令执行,包括

  • 从int类型到long、float或者 double类型。对应的指令为:i21i2fi2d
  • 从long类型到float、 double类型。对应的指令为:i2fi2d
  • 从float类型到double类型。对应的指令为:f2d

简化为:int -> long -> float -> double

  1. 精度损失问题
  • 宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从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位空间。从这个角度说,也没有必要特意区分这几种数据类型。

窄化类型转换指令

  1. 转换规则

Java虚拟机也直接支持以下窄化类型转换:

  • 从主int类型至byte、 short或者char类型。对应的指令有:i2bi2ci2s
  • 从long类型到int类型。对应的指令有:l2i
  • 从float类型到int或者long类型。对应的指令有:f2if2l
  • 从double类型到int、long或者float类型。对应的指令有:d2id2ld2f
  1. 精度损失问题

窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度。

尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是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
2
3
4
5
6
7
8
9
10
11
12
13
14
public void test2(){
long l1 = 123412345L;
long l2 = 1234567891234567899L;

float f1 = l1;
//结果: 1.23412344E8 => 123412344
// l1 = 123412345L
System.out.println(f1);

double d1 = l2;
//结果: 1.23456789123456794E18 => 1234567891234567940
// l2 = 1234567891234567899L
System.out.println(d1);
}

NaN和Infinity的特殊情况:

  • NaN转为整型会变成0
  • 正无穷或负无穷转为整型会变成那个类型的最大值或最小值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void test3(){
double d1 = Double.NaN;
double d2 = Double.POSITIVE_INFINITY;

int i1 = (int) d1;
int i2 = (int) d2;
//0
System.out.println(i1);
//true
System.out.println(i2==Integer.MAX_VALUE);

long l1 = (long) d1;
long l2 = (long) d2;
//0
System.out.println(l1);
//true
System.out.println(l2==Long.MAX_VALUE);

float f1 = (float) d1;
float f2 = (float) d2;
//NaN
System.out.println(f1);
//Infinity
System.out.println(f2);
}

对象创建与访问指令

https://www.yuque.com/u21195183/jvm/bg6q2k#153d0652

对象创建与访问指令: 创建指令,字段访问指令,数组操作指令,类型检查指令

创建指令

  • new: 创建实例
  • newarray: 创建一维基本类型数组
  • anewarray: 创建一维引用类型数组
  • multianewarray: 创建多维数组

注意:这里的创建可以理解为分配内存,当多维数组只分配了一维数组时使用的是anewarray

image-20210514230240390

字段访问指令

  • getstatic: 对静态字段进行读操作
  • putstatic: 对静态字段进行写操作
  • getfield: 对实例字段进行读操作
  • putfield: 对实例字段进行写操作

读操作:把要进行读操作的字段入栈

写操作:把要写操作的值出栈再写到对应的字段

image-20210515093103500

举例:以getstatic指令为例,它含有一个操作数,为指向常量池的Fieldref索引,它的作用就是获取Fieldref指定的对象或者值,并将其压入操作数栈。

1
2
3
public void sayHello() {
System.out.println("hel1o");
}

对应的字节码指令:

1
2
3
4
0 getstatic #8 <java/lang/System.out>
3 ldc #9 <hello>
5 invokevirtual #10 <java/io/PrintStream.println>
8 return

图示:

image-20211021141445947

数组操作指令

  • b/c/s/i/l/f/d/a aload表示将数组中某索引元素入栈 (读)
    • 需要的参数从栈顶依次向下:索引位置,数组引用
  • b/c/s/i/l/f/d/a astore表示将某值出栈并写入数组某索引元素 (写)
    • 需要的参数从栈顶依次向下:要写入的值,索引位置,数组引用

image-20210515094055002

注意:b开头的指令对byte和boolean通用

  • arraylength先将数组引用出栈再将获得的数组长度入栈

image-20210515095603614

类型检查指令

  • instanceof: 判断某对象是否为某类的实例
  • checkcast: 检查引用类型是否可以强制转换

image-20210515095311597

方法调用与返回指令

方法调用指令

非虚方法:静态方法、私有方法、父类中的方法、被final修饰的方法、实例构造器

与之对应不是非虚方法的就是虚方法了。

  • 普通调用指令

    • invokestatic:调用静态方法
    • invokespecial:调用私有方法、父类中的方法、实例构造器方法、final方法
    • invokeinterface:调用接口方法
    • invokevirtual:调用虚方法
    • 使用invokestaticinvokespecial指令的一定是非虚方法
    • 使用invokeinterface指令一定是虚方法(因为接口方法需要具体的实现类去实现)
    • 使用invokevirtual指令可能是虚方法
  • 动态调用指令

    • invokedynamic: 动态解析出需要调用的方法再执行

    jdk 7 出现invokedynamic,支持动态语言。

测试虚方法代码

父类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Father {
public static void staticMethod(){
System.out.println("father static method");
}

public final void finalMethod(){
System.out.println("father final method");
}

public Father() {
System.out.println("father init method");
}

public void overrideMethod(){
System.out.println("father override method");
}
}

接口

1
2
3
public interface TestInterfaceMethod {
void testInterfaceMethod();
}

子类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Son extends Father{

public Son() {
//invokespecial 调用父类init 非虚方法
super();
//invokestatic 调用父类静态方法 非虚方法
staticMethod();
//invokespecial 调用子类私有方法 特殊的非虚方法
privateMethod();
//invokevirtual 调用子类的重写方法 虚方法
overrideMethod();
//invokespecial 调用父类方法 非虚方法
super.overrideMethod();
//invokespecial 调用父类final方法 非虚方法
super.finalMethod();
//invokedynamic 动态生成接口的实现类 动态调用
TestInterfaceMethod test = ()->{
System.out.println("testInterfaceMethod");
};
//invokeinterface 调用接口方法 虚方法
test.testInterfaceMethod();
}

@Override
public void overrideMethod(){
System.out.println("son override method");
}

private void privateMethod(){
System.out.println("son private method");
}

public static void main(String[] args) {
new Son();
}
}

image-20210426234249850

方法返回指令

方法返回指令: 方法结束前,将栈顶元素(最后一个元素)出栈,返回给调用者

根据方法的返回类型划分多种指令:

image-20210515103425506

方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的。

  • 包括ireturn(当返回值是boolean、byte、char、short和int 类型时使用)、lreturnfreturndreturnareturn
  • 另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。

通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃。如果当前返回的是synchronized方法,那么还会执行一个隐含的monitorexit指令,退出临界区。

最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者。

举例:

1
2
3
4
5
6
7
public int methodReturn() {
int i = 500;
int j = 200;
int k = 50;

return (i + j) / k;
}

图示:

image-20211021141618931

操作数栈管理指令

通用型指令,不区分类型

  • 出栈
    • 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的系数

控制转义指令

条件跳转指令

通常先进行比较指令,再进行条件跳转指令

比较指令比较结果-1,0,1再进行判断是否要跳转

条件跳转指令: 出栈栈顶元素,判断它是否满足条件,若满足条件则跳转到指定位置

image-20210515164609270

image-20210515165351883

注意: 这种跳转指令一般都"取反",比如代码中第一个条件语句是d>100,它第一个条件跳转指令就是ifle小于等于0,满足则跳转,不满足则按照顺序往下走

比较条件跳转指令

比较条件跳转指令 类似 比较指令和条件跳转指令 的结合体

image-20210515180004587

image-20210515181000595

多条件分支跳转指令

多条件分支跳转指令是为了switch-case提出的

  • tableswitch用于case值连续的switch多条件分支跳转指令,效率好
  • lookupswitch用于case值不连续的switch多条件分支跳转指令(虽然case值不连续,但最后会对case值进行排序)

tableswitch

image-20210515182307183

lookupswitch

image-20210515183527055

对于String类型是先找到对应的哈希值再equals比较确定走哪个case的

无条件跳转指令

无条件跳转指令就是跳转到某个字节码指令处

  • goto经常使用
  • jsr,jsr_w,ret不怎么使用了

image-20210515183640270

异常处理指令

throw抛出异常对应athrow清除该操作数栈上所有内容,将异常实例压入调用者操作数栈上

使用try-catch/try-final/throws时会产生异常表:异常表保存了异常处理信息 (起始,结束位置,字节码指令偏移地址,异常类在常量池中的索引等信息)

athrow

image-20210515192750444

异常表

image-20210515193437666

异常还会被压入栈或者保存到局部变量表中

同步控制指令

synchronized作用于方法时,方法的访问标识会有ACC_SYNCHRONIZED表示该方法需要加锁。synchronized作用于某个对象时,对应着 monitorentry加锁字节码指令和 monitorexit解锁字节码指令

Java中的synchronized默认是可重入锁

当线程要访问需要加锁的对象时 (执行monitorentry):

  1. 先查看对象头中加锁次数,如果为0说明未加锁,获取后,加锁次数自增
  2. 如果不为0,再查看获取锁的线程是不是自己,如果是自己就可以访问,加锁次数自增
  3. 如果不为0且获取锁线程不是自己,就阻塞

当线程释放锁时 (执行monitorexit)会让加锁次数自减:

image-20210515195912727

为什么会有2个monitorexit?

程序正常执行应该是一个monitorentry对应一个monitorexit的。但如果程序在加锁的代码中抛出了异常,没有释放锁,那不就会造成其他阻塞的线程永远也拿不到锁了吗?所以在程序抛出异常时(跳转PC偏移量为15的指令)继续往下执行,抛出异常前要释放锁