JVM入门到入土-Java虚拟机寄存器指令集与栈指令集

  • HotSpot虚拟机中的任何操作都需要入栈和出栈的步骤。

  • 由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

参考资料

  • Java虚拟机规范(JavaSE8)
  • 深入理解Java虚拟机

JVM的两大指令集特点

基于栈式架构的特点

设计和实现更简单,适用于资源受限的系统(HotSpot虚拟机就基于此):

  • 避开了寄存器的分配难题: 使用零地址指令方式分配。
  • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。
  • 指令集更小编译器容易实现。
  • 不需要硬件支持,可移植性更好,更好实现跨平台

基于寄存器架构的特点

典型的应用是x86的二进制指令集: 比如传统的PC以及Android的Davlik虚拟机。

  • 指令集架构则完全依赖硬件,可移植性差
  • 性能优秀和执行更高效,花费更少的指令去完成一项操作。
  • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。

获取栈指令集-指令javap

注意:如果你使用JDK17,可能会出现找不到控制台指令的问题(因为默认安装不会配置该指令),去JDK17的安装目录将bin添加到环境变量即可

先编译一个简单的1+2程序:

public class StackOneTest {public static void main(String[] args) {int a = 1;int b = 2;int c = a+b;}}

然后使用javap -c class文件全名,我们将在控制台得到如下内容:

D:\CodeProjects\Eclipse\StackTest\bin\testjava>javap -c StackOneTest.classCompiled from "StackOneTest.java"public class testjava.StackOneTest {public testjava.StackOneTest();Code: 0: aload_0 1: invokespecial #8// Method java/lang/Object."":()V 4: returnpublic 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 代表 longs 代表 shortb 代表 bytec 代表 charf 代表 floatd 代表 doublea 代表 reference。也有一些指令的助记符没有明确用字母指明数据类型,例如 arraylength 指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,例如,无条件跳转指令 goto 则是与数据类型无关的。

因为 Java 虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码给指令集的设计带来了很大的压力。如果每一种与数据类型相关的指令都支持 Java 虚拟机的所有运行时数据类型,那恐怕就会超出一个字节所能表示的数量范围了。因此,Java 虚拟机的指令集对于特定的操作只提供了有限的类型相关指令,换句话说,指令集将会故意设计成非完全独立的(not orthogonal,即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可支持的类型。

表 2-2 列举了 Java 虚拟机所支持的字节码指令集。用数据类型列所代表的特殊字符替换 opcode 列的指令模板中的 T,就可以得到一个具体的字节码指令。如果在表中指令模板与数据类型两列共同确定的单元格为空,则说明虚拟机不支持对这种数据类型执行这项操作。例如,load 指令有操作 int 类型的 iload,但是没有操作 byte 类型的同类指令。请注意,从表 2-2 中可以看出,大部分的指令都没有支持整数类型 bytecharshort,甚至没有任何指令支持 boolean 类型。编译器会在编译期或运行期将 byteshort 类型的数据带符号扩展(sign-extend)为相应的 int 类型数据,将 booleanchar 类型数据零位扩展(zero-extend)为相应的 int 类型数据。与之类似,在处理 booleanbyteshortchar 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理。因此,操作数的实际类型为 booleanbytecharshort 的大多数操作,都可以用操作数的运算类型(computational type)为 int 的指令来完成。

from : Java虚拟机规范(JavaSE8)

附表:

实际类型与关系映射表:

加载与存储指令集

加载本地变量到操作数栈的指令:

指令描述
iload将 int 类型加载到操作数栈
iload 将指定索引的 int 类型加载到操作数栈
lload_将指定索引的 long 类型加载到操作数栈
fload将 float 类型加载到操作数栈
fload 将指定索引的 float 类型加载到操作数栈
dload将 double 类型加载到操作数栈
dload 将指定索引的 double 类型加载到操作数栈
aload将引用类型加载到操作数栈
aload 将指定索引的引用类型加载到操作数栈

存储操作数栈到局部变量表的指令:

指令描述
istore将 int 类型存储到局部变量表
istore 将 int 类型存储到指定索引的局部变量
lstore 将 long 类型存储到指定索引的局部变量
fstore将 float 类型存储到局部变量表
fstore 将 float 类型存储到指定索引的局部变量
dstore将 double 类型存储到局部变量表
dstore 将 double 类型存储到指定索引的局部变量
astore将引用类型存储到局部变量表
astore_将引用类型存储到指定索引的局部变量

加载常量到操作数栈的指令:

指令描述
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 将整数常量加载到操作数栈
lconst 将长整数常量1加载到操作数栈
fconst 将浮点数常量加载到操作数栈
dconst 将双精度浮点数常量加载到操作数栈

扩充局部变量表的访问索引或立即数的指令:

指令描述
wide扩展下一条指令使用的局部变量索引的宽度

算数指令集

算术指令:

操作类型指令
加法iaddladdfadddadd
减法isubIsubfsubdsub
乘法imulmulfmuldmul
除法idivldivfdivddiv
求余iremIremfremdrem

逻辑位运算指令:

操作类型指令
按位或iorlor
按位与iandland
按位异或ixorlxor

其他运算指令:

操作类型指令
求负值inegInegfnegdneg
移位ishlishriushrIshlIshrlushr
局部变量自增iinc
比较dcmpgdcmplfcmpgfcmplIcmp

栈指令集字节码分析

main方法中的算数字节码

D:\CodeProjects\Eclipse\StackTest\bin\testjava>javap -c StackOneTest.classCompiled from "StackOneTest.java"public class testjava.StackOneTest {public testjava.StackOneTest();Code: 0: aload_0 1: invokespecial #8// Method java/lang/Object."":()V 4: returnpublic 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中的指令集:

0: iconst_11: istore_12: iconst_23: istore_2

先将两个常量12加载到操作数栈,并且进行了存储,接下来:

4: iload_15: iload_26: iadd7: istore_3

加载了两个变量ab到操作数栈,然后使用算数指令进行相加,最后进行存储结果(这里的第三个变量未使用,变量加载可能被编译器优化掉了),我们稍加修改:

可以发现在使用了第三个变量后,JVM进行了正确的变量加载

含方法的字节码分析

我们增加一个方法来进一步分析:

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是借用泛型里的一个代号)

多次入栈字节码分析

修改代码:

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;}}

相同方法进行分析:

可以发现其字节码也是逐行进行操作的,在变量de位置的处理和第一处也是类似的,只不过a进行了重复入栈