JVM入门到入土-Java虚拟机寄存器指令集与栈指令集
HotSpot虚拟机中的任何操作都需要入栈和出栈的步骤。
由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
参考资料
- Java虚拟机规范(JavaSE8)
- 深入理解Java虚拟机
JVM的两大指令集特点
基于栈式架构的特点
设计和实现更简单,适用于资源受限的系统(HotSpot虚拟机就基于此):
- 避开了寄存器的分配难题: 使用零地址指令方式分配。
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。
- 指令集更小编译器容易实现。
- 不需要硬件支持,可移植性更好,更好实现跨平台
基于寄存器架构的特点
典型的应用是x86的二进制指令集: 比如传统的PC以及Android的Davlik虚拟机。
- 指令集架构则完全依赖硬件,可移植性差
- 性能优秀和执行更高效,花费更少的指令去完成一项操作。
- 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。
获取栈指令集-指令javap
注意:如果你使用JDK17,可能会出现找不到控制台指令的问题(因为默认安装不会配置该指令),去JDK17的安装目录将bin添加到环境变量即可
先编译一个简单的1+2
程序:
java
public class StackOneTest {
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a+b;
}
}
然后使用javap -c class文件全名
,我们将在控制台得到如下内容:
shell
D:\CodeProjects\Eclipse\StackTest\bin\testjava>javap -c StackOneTest.class
Compiled from "StackOneTest.java"
public class testjava.StackOneTest {
public testjava.StackOneTest();
Code:
0: aload_0
1: invokespecial #8 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
}
由于手里没有基于寄存器的指令集实验环境,可自行查阅查看方法
栈指令集字节码的语义
字节码指令集设计概述
在 Java 虚拟机的指令集中,大多数的指令都包含了其所操作的数据类型信息。例如,
iload
指令用于从局部变量表中加载int
类型的数据到操作数栈中,而ad
指加载的则是float
类型的数据。这两个指令的操作可能会是由同一段代码来实现的,但它们必须拥有各自独立的操作码。对于大部分与数据类型相关的字节码指令来说,它们的操作码助记符中都有特殊的字符来表明该指令为哪种数据类型服务:i
代表对int
型的数据操作,l
代表long
,s
代表short
,b
代表byte
,c
代表char
,f
代表float
,d
代表double
,a
代表reference
。也有一些指令的助记符没有明确用字母指明数据类型,例如arraylength
指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,例如,无条件跳转指令goto
则是与数据类型无关的。因为 Java 虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码给指令集的设计带来了很大的压力。如果每一种与数据类型相关的指令都支持 Java 虚拟机的所有运行时数据类型,那恐怕就会超出一个字节所能表示的数量范围了。因此,Java 虚拟机的指令集对于特定的操作只提供了有限的类型相关指令,换句话说,指令集将会故意设计成非完全独立的(not orthogonal,即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可支持的类型。
表 2-2 列举了 Java 虚拟机所支持的字节码指令集。用数据类型列所代表的特殊字符替换
opcode
列的指令模板中的T
,就可以得到一个具体的字节码指令。如果在表中指令模板与数据类型两列共同确定的单元格为空,则说明虚拟机不支持对这种数据类型执行这项操作。例如,load
指令有操作int
类型的iload
,但是没有操作byte
类型的同类指令。请注意,从表 2-2 中可以看出,大部分的指令都没有支持整数类型byte
、char
和short
,甚至没有任何指令支持boolean
类型。编译器会在编译期或运行期将byte
和short
类型的数据带符号扩展(sign-extend)为相应的int
类型数据,将boolean
和char
类型数据零位扩展(zero-extend)为相应的int
类型数据。与之类似,在处理boolean
、byte
、short
和char
类型的数组时,也会转换为使用对应的int
类型的字节码指令来处理。因此,操作数的实际类型为boolean
、byte
、char
及short
的大多数操作,都可以用操作数的运算类型(computational type)为int
的指令来完成。from : Java虚拟机规范(JavaSE8)
附表:
实际类型与关系映射表:
加载与存储指令集
加载本地变量到操作数栈的指令:
指令 | 描述 |
---|---|
iload |
将 int 类型加载到操作数栈 |
iload <n> |
将指定索引的 int 类型加载到操作数栈 |
lload_<n> |
将指定索引的 long 类型加载到操作数栈 |
fload |
将 float 类型加载到操作数栈 |
fload <n> |
将指定索引的 float 类型加载到操作数栈 |
dload |
将 double 类型加载到操作数栈 |
dload <n> |
将指定索引的 double 类型加载到操作数栈 |
aload |
将引用类型加载到操作数栈 |
aload <n> |
将指定索引的引用类型加载到操作数栈 |
存储操作数栈到局部变量表的指令:
指令 | 描述 |
---|---|
istore |
将 int 类型存储到局部变量表 |
istore <n> |
将 int 类型存储到指定索引的局部变量 |
lstore <n> |
将 long 类型存储到指定索引的局部变量 |
fstore |
将 float 类型存储到局部变量表 |
fstore <n> |
将 float 类型存储到指定索引的局部变量 |
dstore |
将 double 类型存储到局部变量表 |
dstore <n> |
将 double 类型存储到指定索引的局部变量 |
astore |
将引用类型存储到局部变量表 |
astore_<n> |
将引用类型存储到指定索引的局部变量 |
加载常量到操作数栈的指令:
指令 | 描述 |
---|---|
bipush |
将带符号的 byte 常量(-128~127)加载到操作数栈 |
sipush |
将带符号的 short 常量(-32768~32767)加载到操作数栈 |
ldc |
将 int, float 或 String 类型的常量加载到操作数栈 |
ldc_w |
与 ldc 类似,但用于更大的常量池索引 |
ldc2_w |
将 long 或 double 类型的常量加载到操作数栈 |
aconst_null |
将 null 加载到操作数栈 |
iconst_m1 |
将整数 -1 加载到操作数栈 |
iconst <i> |
将整数常量加载到操作数栈 |
lconst <1> |
将长整数常量1加载到操作数栈 |
fconst <f> |
将浮点数常量加载到操作数栈 |
dconst <d> |
将双精度浮点数常量加载到操作数栈 |
扩充局部变量表的访问索引或立即数的指令:
指令 | 描述 |
---|---|
wide |
扩展下一条指令使用的局部变量索引的宽度 |
算数指令集
算术指令:
操作类型 | 指令 |
---|---|
加法 | iadd 、ladd 、fadd 、dadd |
减法 | isub 、Isub 、fsub 、dsub |
乘法 | imul 、mul 、fmul 、dmul |
除法 | idiv 、ldiv 、fdiv 、ddiv |
求余 | irem 、Irem 、frem 、drem |
逻辑位运算指令:
操作类型 | 指令 |
---|---|
按位或 | ior 、lor |
按位与 | iand 、land |
按位异或 | ixor 、lxor |
其他运算指令:
操作类型 | 指令 |
---|---|
求负值 | ineg 、Ineg 、fneg 、dneg |
移位 | ishl 、ishr 、iushr 、Ishl 、Ishr 、lushr |
局部变量自增 | iinc |
比较 | dcmpg 、dcmpl 、fcmpg 、fcmpl 、Icmp |
栈指令集字节码分析
main方法中的算数字节码
shell
D:\CodeProjects\Eclipse\StackTest\bin\testjava>javap -c StackOneTest.class
Compiled from "StackOneTest.java"
public class testjava.StackOneTest {
public testjava.StackOneTest();
Code:
0: aload_0
1: invokespecial #8 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
}
从上述字节码不难看出,JVM
先执行了StackOneTest
对象的构造,然后将这个对象变量加载到了操作数栈(默认构造器),我们重点来看main
中的指令集:
shell
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
先将两个常量1
,2
加载到操作数栈,并且进行了存储,接下来:
shell
4: iload_1
5: iload_2
6: iadd
7: istore_3
加载了两个变量a
,b
到操作数栈,然后使用算数指令进行相加,最后进行存储结果(这里的第三个变量未使用,变量加载可能被编译器优化掉了),我们稍加修改:
可以发现在使用了第三个变量后,JVM
进行了正确的变量加载
含方法的字节码分析
我们增加一个方法来进一步分析:
java
public class StackOneTest {
public static int add(int a, int b) {
return a+b;
}
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = add(a, b);
System.out.println(c);
}
}
从这里可知 invokestatic
应该为调用方法的指令集,其运行也是和上一个大同小异,不过需要注意不同返回值类型的Treturn
指令(T是借用泛型里的一个代号)
多次入栈字节码分析
修改代码:
java
public class StackOneTest {
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = 3;
int d = a+b;
int e = a+c;
}
}
相同方法进行分析:
可以发现其字节码也是逐行进行操作的,在变量d
,e
位置的处理和第一处也是类似的,只不过a
进行了重复入栈