深入浅出 JVM 之字节码-指令集简介
文章目录
!版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。
系统环境:
- JDK 1.8
参考地址:
- Oracle Java 虚拟机规范
- JVM从入门到精通-字节码指令集
- 深入理解Java虚拟机: JVM高级特性与最佳实践(第3版)
深入浅出 JVM 系列文章
- 01.深入浅出 JVM 之 Java 虚拟机
- 02.深入浅出 JVM 之 Class 字节码文件
- 03.深入浅出 JVM 之字节码-常量池常量分析
- 04.深入浅出 JVM 之字节码-指令集简介
- 05.深入浅出 JVM 之类加载-类加载流程
- 06.深入浅出 JVM 之类加载-类加载器
- 07.深入浅出 JVM 之运行时数据区
- 08.深入浅出 JVM 之运行时数据区-堆
- 09.深入浅出 JVM 之运行时数据区-方法区
- 10.深入浅出 JVM 之运行时数据区-虚拟机栈
- ......
一、字节码指令集简介
1.1 字节码指令集简介
Java 虚拟机指令由一个字节长度的操作码 (opcode) 构成,这个操作码代表着某种特定的操作。大多数指令后面可能跟随零至多个操作数 (operand),这些操作数是执行操作所必需的参数。由于 Java 虚拟机采用的是面向操作数栈的架构,而非面向寄存器的架构,因此大部分指令仅包含操作码,而相应的参数则存储在操作数栈中。这种设计使得指令集简洁而高效。
1.2 字节码执行模型
Java 虚拟机的中的解释器执行过程,可以通过下面的一个执行循环操作的伪代码,来协助进行理解:
1do {
2 计算PC寄存器的当前位置并从该位置取出操作码;
3 if (操作码后存在操作数) {
4 取出操作数;
5 }
6 执行操作码所定义的操作;
7} while (处理下一次请求);
1.3 字节码与数据类型
Java 虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload
指令用于从局部变量表中加载 int
型的数据到操作数栈中,而 fload
指令加载的则是 float
类型的数据。
每种与数据类型相关的字节码指令,在其操作码助记符中,都包含了表示特定数据类型的特殊字符。在下面表格中列出了这些字符及其代表的数据类型:
名称 | 指令集 |
---|---|
i | int 类型的数据操作 |
l | long 类型数据操作 |
s | short 类型数据操作 |
b | byte 类型数据操作 |
c | char 类型数据操作 |
f | float 类型数据操作 |
d | double 类型数据操作 |
二、常用字节码指令集
2.1 加载与存储指令
Java 虚拟机通过 加载 和 存储 指令,实现了数据在 栈帧 的 局部变量表 和 操作数栈 之间的传递。下面的表格中,列出了几种主要的加载和存储的指令及其描述:
名称 | 描述 | 指令集 |
---|---|---|
局部变量压栈指令 | 将一个局部变量加载到操作数栈 | iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n> |
常量入栈指令 | 将一个常量加载到操作数栈 | bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d> |
出栈装入局部变量表指令 | 将一个数值从操作数栈存储到局部变量表 | istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n> |
扩充局部变量表访问索引指令 | 扩展指令,使其能处理更大索引的局部变量访问 | wide |
特殊形式的指令,如 iload_<n>
,代表了一系列指令 (如 iload_0 至 iload_3),这些指令是通用指令 iload
的特殊形式,其中操作数隐含在指令中,无需显式提取。这些指令的语义与原始的通用指令完全一致,例如,iload_0
的功能与操作数为 0
时的 iload
指令完全相同。
在这些指令中,尖括号内的字符表示隐含的操作数类型,如 <n>
表示非负整数,<i>
代表 int 类型,<l>
代表 long 类型,<f>
代表 float 类型,而 <d>
代表 double 类型。操作涉及 byte、char、short 和 boolean 类型数据时,通常使用 int 类型的指令进行处理。
2.2 运算指令
在 Java 虚拟机中 算术指令 用于对 操作数栈 上的两个值执行特定的运算,并将结果推回操作栈顶。这些运算指令大致可以分为两类:
- ① 整型数据运算指令: 对整型数据执行的运算;
- ② 浮点型数据运算指令: 对浮点型数据执行的运算;
对于整数和浮点数的运算,当出现溢出或被零除时,它们的行为也不相同。在 Java 虚拟机中,除了直接支持整型数 int 之外,并没有针对 byte、short、char 和 boolean 类型的专用算术指令,因此这些类型的数据运算需使用 int 类型的指令代替。
下面是一些常见的算术指令列表:
名称 | 指令集 |
---|---|
加法指令 | 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 |
数据运算溢出处理
在整型运算中,溢出可能导致非预期的结果,如两个大的正整数相加得到一个负数,这种情况在数学中是不可能出现的,属于溢出现象。根据 《Java虚拟机规范》,在整型运算中,除了除法和求余指令会在除数为零时抛 ArithmeticException
外,其余情况下不应抛出运行时异常。
运算模式和规范
在处理浮点数时,按照 《Java虚拟机规范》,Java 虚拟机必须遵守 IEEE 754 标准,这包括对非正规浮点数和逐级下溢的明确处理规则。这些规则将会使某些数值算法处理起来变得非常明确,不会出现模棱两可的情况。比如 Java 虚拟机处理浮点数时,通常有以下两种模式:
- ① 向最接近数舍入模式: 所有非精确运算结果应舍入到最接近的精确值。
- ② 向零舍入模式: 浮点数转为整数时采用,舍入到最接近且不大于原值的整数。
如果运算操作产生了无明确数学定义的结果,如无穷大或 NaN,虚拟机将分别使用有符号的无穷大或 NaN 来表示。此外,任何涉及 NaN 的运算都将返回 NaN。
2.3 类型转换指令
Java 虚拟机中的 类型转换指令 用于实现代码中的 显示类型转换,它分为俩种不同数值类型的转换,分别为 宽化类型转换 和 窄化类型转换。
宽化类型转换 (Widening Numeric Conversions)
宽化类型转换涉及小范围类型向大范围类型的安全转换。在 Java 虚拟机中,执行这类转换时通常不需要显式指令,因为它们是安全的。支持的宽化类型转换包括:
- int 转换为 long、float 或者 double;
- long 转换为 float 或 double;
- float 转换为 double;
窄化类型转换 (Narrowing Numeric Conversion)
窄化类型转换指的是大范围类型向小范围类型的转换,这需要使用显式的转换指令来完成。涉及的转换指令包括 i2b
、i2c
、i2s
、l2i
、f2i
、f2l
、d2i
、d2l
和 d2f
,具体如下:
- int 转换为 byte、short 或 char (指令:i2b、i2c、i2s);
- long 转换为 int (指令:l2i);
- float 转换为 int 或 long (指令:f2i、f2l);
- double 转换为 int、long 或 float (指令:d2i、d2l、d2f);
窄化类型转换可能会导致正负号、数量级的改变,以及数值精度的丢失。但是,根据《Java虚拟机规范》,窄化类型转换指令永远不会导致虚拟机抛出运行时异常。
遵循规则:
(1) 将浮点值窄化转换为整数类型(int 或 long)时的规则如下:
- 如果浮点值是 NaN,那么转换结果就是 int 或 long 类型的 0;
- 如果浮点值非无穷大且非 NaN,则应使用 IEEE754 向零舍入模式取整。如果取整后的值在目标类型的范围内,则结果为该整数值;如果超出范围,根据值的符号转换为能表示的最大或最小正数;
(2) 将 double 类型窄化转换为 float 时的规则如下:
- 通过向最接近数舍入模式舍入一个可以使用 float 类型表示的数字。最后结果根据下面这 3 条规则判断:
- 如果转换结果的绝对值太小而无法使用 float 来表示,将返回 float 类型的正负零。
- 如果转换结果的绝对值太大而无法使用 float 来表示,将返回 float 类型的正负无穷大。
- 对于 double 类型的 NaN 值将按规定转换为 float 类型的 NaN 值。
2.4 对象创建与访问指令
Java 虚拟机从字节码层面就对面向对象做了深层次的支持,有一系列专门用于 操作对象 的指令。不过对象也分类型,比如 类实例 和 数组 都是对象,但 Java 虚拟机对类实例和数组的创建与操作,使用了不同的字节码指令。这些指令包括如下:
名称 | 指令集 |
---|---|
创建类实例指令 | 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 |
这些指令集可以说是 Java 虚拟机处理对象的强大工具,使得 Java 语言能够灵活地支持面向对象编程的核心特性。
2.5 操作数栈管理指令
操作数栈管理指令 如同操作一个普通数据结构中的堆栈那样,用于直接对 操作数栈 进行操作。这些指令包括:
名称 | 指令集 |
---|---|
使操作数栈的栈顶一个或两个元素出栈 | pop、pop2 |
复制栈顶1或2个数值,并将复制值或双份的复制值重新压入栈顶 | dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2 |
使栈最顶端的两个数值互换 | swap |
这些指令属于通用型,对栈的压入或者弹出无需指明数据类型。
说明:
- 不带 _x 的指令用于复制栈顶数据并压入栈顶,其包括俩个 dup 和 dup2 俩个指令。dup 的系数代表要复制的 slot 个数,dup 开头的指令用于复制 1 个 slot 的数据。
- 带 _x 的指令是复制栈顶数据并插入栈顶以下的某个位置,其包括 dup_x1、dup2_x1、dup_x2、dup2x2 四个指令。对于带 x 的复制插入指令,只要将指令的 dup 和 x 的系数相加,结果即为需要插入的位置。
- pop 指令用于将栈顶的 1 个 slot 数值出栈。
- pop2 指令用于将栈顶的 2 个 slot 数值出栈。
2.6 控制转移指令
控制转移指令 可以让 Java 虚拟机有条件或无条件地从指定位置指令的下一条指令继续执行程序。从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改 PC 寄存器的值。这些指令包括:
名称 | 指令集 |
---|---|
比较指令 | lcmp、fcmpl、fcmpg、dcmpl、dcmpg |
条件跳转指令 | ifeq、ifne、iflt、ifge、ifgt、ifle |
比较条件分支指令 | if_icmpne、if_icmplt、if_icmpge、if_icmpgt、if_icmple、ifnull、ifnonnull、if_acmpeq、if_acmpne |
多条件分支跳转指令 | tableswitch、lookupswitch |
无条件跳转指令 | goto、goto_w |
① 比较指令
名称 | 指令集 |
---|---|
比较 long 类型值 | lcmp |
比较 float 类型值(当遇到 NaN 时返回 -1) | fcmpl |
比较 float 类型值(当遇到 NaN 时返回 1) | fcmpg |
比较double类型值(当遇到 NaN 时返回 -1) | dcmpl |
比较double类型值(当遇到 NaN 时返回 1) | dcmpg |
② 条件跳转指令
名称 | 指令集 |
---|---|
如果等于 0 则跳转 | ifeq |
如果不等于 0 则跳转 | ifne |
如果小于 0 则跳转 | iflt |
如果大于等于 0 则跳转 | ifge |
如果大于 0 则跳转 | ifgt |
如果小于等于 0 则跳转 | ifle |
③ 比较条件分支指令
名称 | 指令集 |
---|---|
如果两个 int 值相等则跳转 | if_icmpeq |
如果两个 int 类型值不相等则跳转 | if_icmpne |
如果一个 int 类型值小于另外一个 int 类型值则跳转 | if_icmplt |
如果一个 int 类型值大于或者等于另外一个 int 类型值则跳转 | if_icmpge |
如果一个 int 类型值大于另外一个 int 类型值则跳转 | if_icmpgt |
如果一个 int 类型值小于或者等于另外一个 int 类型值则跳转 | if_icmple |
如果等于 null 则跳转 | ifnull |
如果不等于 null 则跳转 | ifnonnull |
如果两个对象引用相等则跳转 | if_acmpeq |
如果两个对象引用不相等则跳转 | if_acmpne |
④ 多条件分支跳转指令
名称 | 指令集 |
---|---|
通过索引访问跳转表并跳转 | tableswitch |
通过键值匹配访问跳转表并执行跳转操作 | lookupswitch |
⑤ 无条件跳转指令
名称 | 指令集 |
---|---|
无条件跳转 | goto |
无条件跳转 (宽索引) | goto_w |
2.7 方法调用和返回指令
方法调用指令 和 返回指令 分别用在方法调用和返回时。不同的方法调用指令用于操作不同类型的方法,不同的方法返回指令用于返回不同类型返回值。
方法调用指令
名称 | 指令集 |
---|---|
运行时按照对象的类来调用实例方法 | invokcvirtual |
根据编译时类型来调用实例方法 | invokespecial |
调用类方法 | invokestatic |
调用接口方法 | invokcinterface |
方法返回指令
名称 | 指令集 |
---|---|
从方法中返回 int 类型的数据 | ireturn |
从方法中返回 long 类型的数据 | lreturn |
从方法中返回 float 类型的数据 | freturn |
从方法中返回 double 类型的数据 | dreturn |
从方法中返回引用类型的数据 | areturn |
从方法中返回,返回值为 void | return |
2.8 异常处理指令
在 Java 程序中,显式抛出异常的操作都由 athrow
指令来实现,如 throw
语句。除了用 throw
语句显式抛出异常的情况之外,在 《Java虚拟机规范》 中还规定了,许多运行时异常会在其它 Java 虚拟机指令检测到异常状况时自动抛出。例如,前面介绍整数运算中,当除数为零时,虚拟机会在 idiv
或 ldiv
指令中抛出 ArithmeticException
异常。
而在 Java 虚拟机中,处理异常 (catch 语句) 不是由字节码指令来实现的,而是采用异常表来完成。
很久之前处理异常曾经使用 jsr 和 ret 指令来实现,不过现在已经过时。
比如,如果一个方法定义了一个 try-catch
或者 try-finally
的异常处理,就会创建一个异常表,它包含了每个异常处理或者 finally
块的信息,如下:
- 起始位置
- 结束位置
- 程序计数器记录的代码处理的偏移地址
- 被捕获的异常类在常量池中的索引
当异常发生时,Java 虚拟机会在当前方法中寻找匹配的异常处理器。如果未找到,方法将结束并且当前栈帧被弹出,异常将被抛给调用者。如果在所有调用栈中都未找到处理器,且异常发生在最后一个非守护线程中,如主线程,那么 Java 虚拟机将终止。
2.9 同步指令
Java 虚拟机中既支持方法级的同步,也支持代码块级的同步,这两种同步结构都是使用管程 (Monitor,常被称为锁) 来实现的。
方法级同步
方法级的同步是隐式的,不需要通过字节码指令来进行控制,获取管程与释放管程操作已经融入到方法的调用和方法返回之中。
在 Java 中可以使用 synchronized
关键字修饰方法,被修饰的方法转换为字节码后,如果你仔细观察的话会发现在方法字节码区域后会多出一个 ACC_SYNCHRONIZED
访问标志,存在该标志的方法 Java 虚拟机会认为该方法是同步方法。
当方法调用时会检查方法的 ACC_SYNCHRONIZED
访问标志是否被设置,如果存在该标志,那么方法执行时会先获取管程 (锁),然后再执行方法,并且当方法执行完成后无论是否发生异常,管程 (锁) 都将被释放掉。
在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程 (锁) 将在异常抛到同步方法外时自动释放。
方法级同步示例
给出如下示例代码 SyncExample1.java
文件,里面创建一个使用 synchronized
修饰的 a()
方法:
1public class SyncExample1 {
2
3 public synchronized void a() {
4 System.out.println("hello world!");
5 }
6
7}
8
先使用 javac SyncExample1
命令将 Java 文件转换为字节码文件,然后再使用 javap -verbose SyncExample1.class
观察字节码文件内容,可以看到在 a()
方法的 flags
中存在 ACC_SYNCHRONIZED
标志,如下:
1...
2{
3 public club.mydlq.SyncExample1();
4 descriptor: ()V
5 flags: ACC_PUBLIC
6 Code:
7 stack=1, locals=1, args_size=1
8 0: aload_0
9 1: invokespecial #1 // Method java/lang/Object."<init>":()V
10 4: return
11 LineNumberTable:
12 line 3: 0
13 LocalVariableTable:
14 Start Length Slot Name Signature
15 0 5 0 this Lclub/mydlq/SyncExample1;
16
17 public synchronized void a();
18 descriptor: ()V
19 flags: ACC_PUBLIC, ACC_SYNCHRONIZED
20 Code:
21 stack=2, locals=1, args_size=1
22 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
23 3: ldc #3 // String hello world!
24 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
25 8: return
26 LineNumberTable:
27 line 6: 0
28 line 7: 8
29 LocalVariableTable:
30 Start Length Slot Name Signature
31 0 9 0 this Lclub/mydlq/SyncExample1;
32}
代码块级同步
代码块级同步是显式的,在编写的 Java 代码中需要使用 synchronized {xxx}
来表示同步代码块,其中 {}
中的代码就是同步代码块中的代码逻辑,线程在执行时,可以保证代码块中的代码执行时的线程安全。
而在 Java 代码编译为字节码指令后,将会发现在同步代码块前后分别多出 monitorenter
和 monitorexit
俩个指令,线程在执行到 monitorenter
时会获取管程,在执行到 monitorexit
时会释放管程。
代码块级同步示例
给出如下示例代码 SyncExample2.java
文件,里面创建一个引入 synchronized
代码块的 b()
方法:
1public class SyncExample2 {
2
3 public void b() {
4 synchronized (this) {
5
6 }
7 }
8
9}
先使用 javac SyncExample2
命令将 Java 文件转换为字节码文件,然后再使用 javap -verbose SyncExample2.class
观察字节码文件内容,可以看到在 b()
方法的 flags
中存在 monitorenter
和 monitorexit
指令,如下:
1...
2{
3 public club.mydlq.SyncExample2();
4 descriptor: ()V
5 flags: ACC_PUBLIC
6 Code:
7 stack=1, locals=1, args_size=1
8 0: aload_0
9 1: invokespecial #1 // Method java/lang/Object."<init>":()V
10 4: return
11 LineNumberTable:
12 line 3: 0
13 LocalVariableTable:
14 Start Length Slot Name Signature
15 0 5 0 this Lclub/mydlq/SyncExample2;
16
17 public void b();
18 descriptor: ()V
19 flags: ACC_PUBLIC
20 Code:
21 stack=2, locals=3, args_size=1
22 0: aload_0
23 1: dup
24 2: astore_1
25 3: monitorenter
26 4: aload_1
27 5: monitorexit
28 6: goto 14
29 9: astore_2
30 10: aload_1
31 11: monitorexit
32 12: aload_2
33 13: athrow
34 14: return
35 Exception table:
36 from to target type
37 4 6 9 any
38 9 12 9 any
39 LineNumberTable:
40 line 6: 0
41 line 8: 4
42 line 9: 14
43 LocalVariableTable:
44 Start Length Slot Name Signature
45 0 15 0 this Lclub/mydlq/SyncExample2;
46 StackMapTable: number_of_entries = 2
47 frame_type = 255 /* full_frame */
48 offset_delta = 9
49 locals = [ class club/mydlq/SyncExample2, class java/lang/Object ]
50 stack = [ class java/lang/Throwable ]
51 frame_type = 250 /* chop */
52 offset_delta = 4
53}
---END---
!版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。