探究Java虚拟机栈—Java进阶

2
发表时间:2019-12-05 00:00

前言

Java 虚拟机的内存模型分为两部分:一部分是线程共享的,包括 Java 堆和方法区;另一部分是线程私有的,包括虚拟机栈和本地方法栈,以及程序计数器这一小部分内存。今天我就 Java 虚拟机栈做一些比较浅的探究。

熟悉 Java 的同学应该都知道了,JVM 是基于栈的。但是这个“栈” 具体指的是什么?难道就是虚拟机栈?想要回答这个问题我们先要从虚拟机栈的结构谈起。

虚拟机栈

何为虚拟机栈

虚拟机栈的栈元素是栈帧,当有一个方法被调用时,代表这个方法的栈帧入栈;当这个方法返回时,其栈帧出栈。因此,虚拟机栈中栈帧的入栈顺序就是方法调用顺序。什么是栈帧呢?栈帧可以理解为一个方法的运行空间。它主要由两部分构成,一部分是局部变量表,方法中定义的局部变量以及方法的参数就存放在这张表中;另一部分是操作数栈,用来存放操作数。我们知道,Java 程序编译之后就变成了一条条字节码指令,其形式类似汇编,但和汇编有不同之处:汇编指令的操作数存放在数据段和寄存器中,可通过存储器或寄存器寻址找到需要的操作数;而 Java 字节码指令的操作数存放在操作数栈中,当执行某条带 n 个操作数的指令时,就从栈顶取 n 个操作数,然后把指令的计算结果(如果有的话)入栈。因此,当我们说 JVM 执行引擎是基于栈的时候,其中的“栈”指的就是操作数栈。举个简单的例子对比下汇编指令和 Java 字节码指令的执行过程,比如计算 1 + 2,在汇编指令是这样的:

mov ax,1;把1放入寄存器 axadd ax,2;用 ax 的内容和2相加后存入 ax

而 JVM 的字节码指令是这样的:

iconst_1 //把整数 1 压入操作数栈iconst_2 //把整数 2 压入操作数栈iadd //栈顶的两个数相加后出栈,结果入栈

由于操作数栈是内存空间,所以字节码指令不必担心不同机器上寄存器以及机器指令的差别,从而做到了平台无关。

注意,局部变量表中的变量不可直接使用,如需使用必须通过相关指令将其加载至操作数栈中作为操作数使用。比如有一个方法 void foo(),其中的代码为:int a = 1 + 2; int b = a + 3;,编译为字节码指令就是这样的:

iconst_1 //把整数 1 压入操作数栈iconst_2 //把整数 2 压入操作数栈iadd //栈顶的两个数出栈后相加,结果入栈;实际上前三步会被编译器优化为:iconst_3istore_1 //把栈顶的内容放入局部变量表中索引为 1 的 slot 中,也就是 a 对应的空间中iload_1 // 把局部变量表索引为 1 的 slot 中存放的变量值(3)加载至操作数栈iconst_3 iadd //栈顶的两个数出栈后相加,结果入栈istore_2 // 把栈顶的内容放入局部变量表中索引为 2 的 slot 中,也就是 b 对应的空间中return// 方法返回指令,回到调用点

需要说明的是,局部变量表以及操作数栈的容量的较大值在编译时就已经确定了,运行时不会改变。并且局部变量表的空间是可以复用的,例如,当指令的位置超出了局部变量表中某个变量 a 的作用域时,如果有新的局部变量 b 要被定义,b 就会覆盖 a 在局部变量表的空间。

盗用别人的图以让大家对虚拟机栈有个直观的认识(其中小字体 Stack 指的的是虚拟机栈,Frame 是栈帧,Local variables 是局部变量表,Operand Stack 是操作数栈):

由虚拟机栈引出的问题

看完上面的代码大家可能会有几点疑惑:什么是 slot?那些指令是什么意思?为什么 a 对应的 slot 的索引值不是从零开始的,它明明是靠前个定义的变量啊?

对于这些问题我们一个个来解决。

什么是 slot

首先什么是 slot?slot 是局部变量表中的空间单位,虚拟机规范中有规定,对于 32 位之内的数据,用一个 slot 来存放,如 int,short,float 等;对于 64 位的数据用连续的两个 slot 来存放,如 long,double 等。引用类型的变量 JVM 并没有规定其长度,它可能是 32 位,也有可能是 64 位的,所以既有可能占一个 slot,也有可能占两个 slot。

JVM 字节码指令

第二个问题,那些指令是什么意思?

指令格式

首先我们要理解 Java 指令的格式,Java 的指令以字节为单位,也就是一个字节代表一条指令。比如 iconst_1 就是一条指令,它占一个字节,那么自然 Java 指令不会超过 256 条。实际上 Java 指令目前定义了 200 多条。指令虽然是一个字节,但是它也可以带自己的操作数。JVM 中有这样一条指令 putstatic,其作用是给特定的的静态字段赋值。但是给哪个字段赋值呢?仅仅通过这条指令并不能说明,那么只有通过操作数来指定了。紧跟在 putstatic 后面的两个字节就是它的操作数,这个操作数是一个索引值,指向运行时常量池中该静态字段对应的符号引用。由于符号引用包含了该字段的基本信息,如所属类、简单名称以及描述符,因此 putstatic 指令就知道是给哪个类的哪个字段赋值了。

指令的操作数分两种:一种是嵌入在指令中的,通常是指令字节后面的若干个字节;另一种是存放在操作数栈中的。为了区别,我们把前者叫做嵌入式操作数,把后者叫做栈内操作数。这两者的区别是:嵌入式操作数是在编译时就已经确定的,运行时不会改变,它和指令一样存放于类文件方法表的 Code 属性中;而操作数是运行时确定的,即程序在执行过程中动态生成的。拿 putstatic 指令来说,它有一个嵌入式操作数,该操作数是一个索引值(前面已经提到),它由两个字节组成,紧跟在 putstatic 对应的字节之后;同时它还有一个栈内操作数,位于操作数栈的栈顶,这个操作数就是要赋给静态字段的值,其对应的字节数根据静态字段的类型决定。如果静态字段的类型是 short、int、boolean、char 或者 byte,那么这个操作数就必须是 int 类型,即由栈顶的 4 个字节组成;如果是 float、double 或者 long 类型,那么操作数就是相应的类型,即由栈顶的 4 个、8 个 或者 8 个 字节组成;如果静态字段是引用类型,那么这个操作数的类型也必须是引用类型,即由栈顶的 8 个字节组成。

再举一个例子。iconst_<i> 代表了一个指令族,它的意思是把整数 i 放入操作数栈中,i 的范围是(m1, 0, 1, 2, 3, 4, 5),其中 m1 代表的是 -1。注意,这里的 i 并不是指令的操作数(即非嵌入式操作数,也非栈内操作数),如 iconst_1、iconst_2 和 iconst3 都是由一个字节组成的字节码指令。我们可以把 i 可以看作是指令的 “隐含操作数”,即指令本身就蕴含了操作数。如果整数 i 超过 [-1, 5] 这个范围,就不能用 iconst<i> 表示了,因为仅一个字节的字节码指令不可能蕴含所有的整数。此时就需要 bipush 这条指令了,这条指令有一个嵌入式操作数,由一个字节组成,用来表示要放入栈顶的那个整数,该整数放入栈顶时通过扩展符号位变为 32 位的整型。但是一个字节也表示不了所有的整数,如果整数值超过一个字节所能表示的范围,就只能通过 ldc 这条指令了,这条指令带有一个字节的嵌入式操作数,它代表的是一个指向运行时常量池中 Constant_Integer_info 类型常量的索引,通过索引的方式引用运行时常量池中的整数,再大的整数也不怕了。





分享到:
搞开发 做推广 找众人
> 联系我们  Contact us
收起>