2

JVM系列之java虚拟机栈

tip:上面讲了JVM运行时数据区域的程序计数器(PC)
,这篇文章带大家走进JVM的运行时数据区域JAVA虚拟机栈

image

啥是java虚拟机栈

java虚拟机栈和程序计数器一样也是线程私有的,生命周期和线程相同;它是Java方法执行的线程内存模型。当一个方法被执行的时候,java会同步创建一个栈帧,这里的栈帧就是栈的元素;每一个栈帧包含局部变量表,操作数栈,动态连接,方法返回地址等信息,一个方法从开始到执行结束对应着虚拟机栈中一个栈帧的入栈和出栈。

下面放一张图让大家直观的理解一下

image

其中位于栈顶的栈帧是当前栈帧,所对应的方法是当前方法,java的执行引擎所执行的字节码指令值只针对当前栈帧操作。其实这也很好理解,我们通常都是在一个方法内又调用另一个方法,形成一个调用链,这样位于最底层的栈帧就是这个调用链的源头。

下面为大家一一解释栈帧中的内容

局部变量表

局部变量表,顾名思义就是存放局部变量的一个表。它存放的是java编译器生成的各种java的基本数据类型(boolean,byte,char,short,float,long,double),对象的引用,retuenAddess(指向了一条字节码指令的地址)。具体内容就是方法传入的参数(包括实例方法中的this),try-catch中定义的异常,以及方法体中定义的变量。

局部变量表是以槽(shot)为单位的,其中64位长度(long,double)类型数据占用俩个变量槽,而32位的占一个变量槽。

看一下上篇文章中我们反编译java代码的字节码文件

源代码

public class Main {
 public static void main(String[] args){
 int a=1;
 int b=2;
 System.out.println(a+b);
 }
}
​

反编译字节码

public static void main(java.lang.String[]) throws java.io.IOException;
 descriptor: ([Ljava/lang/String;)V
 flags: ACC_PUBLIC, ACC_STATIC
 Code:
 stack=3, locals=3, args_size=1   //local就是局部变量表的大小
 0: iconst_1
 1: istore_1    //栈顶元素弹出存入变量表的槽1
 2: iconst_2
 3: istore_2    //栈顶元素弹出存入变量表的槽2
 4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
 7: iload_1
 8: iload_2
 9: iadd
 10: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
 13: return
 LineNumberTable:
 line 18: 0
 line 19: 2
 line 20: 4
 line 21: 13
 LocalVariableTable:
 Start  Length  Slot  Name   Signature
 0      14     0  args   [Ljava/lang/String;
 2      12     1     a   I
 4      10     2     b   I
 Exceptions:
 throws java.io.IOException

从上面的字节码文件中我们可以看出,在java源代码被编译成class文件后每一个方法的变量表的大小就已经确定(locals的值)。而且JVM是通过索引来操作变量表的,当使用的是32位数据类型时就索引N代表使用第N个变量槽。64位则代表第N和第N+1个变量槽

那么JVM是如何来确定局部变量表的大小呢?

大家先猜一下下面这个方法,JVM会为它分配多大的局部变量表呢?

@Test
public void showLocals(){
 {   //代码块1
 int a=100;
 System.out.println(a);
 } 
 {   //2
 int b=200;
 System.out.println(b);
 }
 {  //3
 int c=300;
 System.out.println(c);
 }
}

答案:

 stack=2, locals=2, args_size=1

上面方法使用了a,b,c三个局部变量,上述代码JVM分配了2个变量槽,可见并不是定义了多少个局部变量就分配相应多大的空间。因为局部变量表和下面要说的操作数栈他们的大小直接决定栈帧的大小,不必要的操作数栈的深度和局部变量表的会浪费内存。所以为了节约内存,java采用的使用复用的思想,当代码执行超出一个局部变量的作用域的时候,这个变量占用的槽就可以被其他的变量重用,javac编译器会根据同时生存的最大的局部变量数量和类型计算出locals的大小。

在初学java的时候,老师都会告诉我们在实例方法中可以通过this代表了调用该方法的对象的引用,而这个this就是在javac编译的时候自动给传入方法的,它被放置在变量槽0的位置,所以在上述代码块中,a,b,c共用一个变量槽,而this使用一个变量槽。

对象引用

后面我会提到堆是java大多数对象分配内存的内存区域,而对象引用不一定就是对象在堆的内存地址还有可能是一个指向对象的句柄。这取决于JVM对对象访问方式的实现。


操作数栈

Operand Stack,可以理解为存放操作数的栈。它的大小也是在编译期就已经确定号了的,就是上面反编译代码中出现的stack,栈元素可以是包括long和double在内的任意的java数据类型。

当一个方法刚开始执行的时候,操作数栈是空的,在方法执行的过程中字节码指令会往操作数栈内写入和取出元素。

看一下代码

public static void main(java.lang.String[]) throws java.io.IOException;
 descriptor: ([Ljava/lang/String;)V
 flags: ACC_PUBLIC, ACC_STATIC
 Code:
 stack=3, locals=3, args_size=1  //栈深度最大为3,3个变量槽
 0: iconst_1             //常量1压入栈 
 1: istore_1             //栈顶元素出栈存入变量槽1
 2: iconst_2             //常量2压入栈
 3: istore_2             //栈顶元素出栈存入变量槽2
 4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream; 
 //调用静态方法main
 7: iload_1           //将变量槽1中值压入栈
 8: iload_2           //将变量槽2中值压入栈
 9: iadd              //从栈顶弹出俩个元素相加并且压入栈
 10: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
 //调用虚方法
 13: return   //返回

可以看出,在方法的执行过程中会有各种的字节码指令往操作数栈中写入和读出元素。而且操作数栈中的元素的数据类型必须和字节码指令操作的数据的数据类型相匹配,例如istore_2对int类型操作,而如果此时的栈顶元素是long占用俩个变量槽,那么后面的指令操作肯定都会出错。在类加载的时候,检验阶段会进行验证。

不知道大家听没听说过java的指令集架构是基于栈的,其实从这个就可以佐证这句话,而C语言则是基于寄存器的指令集架构,它底层参数的传递,操作变量以及对内存的访问都是通过读取寄存器中的值实现的。

JVM对操作数栈的优化

在概念模型中,俩个方法的栈帧相互之间是完全独立的。但是很多JVM都对栈帧进行了优化处理,使得俩个栈帧出现一部分重叠。让下面栈帧的部分操作数数栈与上面栈帧的部分局部变量重叠在一起,这样就节约了一些空间,而且进行方法调用的时候不用进行额外的参数传递和可以共用一部分数据

例如下面的代码

public class Main {
 public int getA(){
 int a=1;
 a++;
 return a;
 }
​
 public static void main(String[] args) {
 Main m=new Main();
 int a=m.getA();
 System.out.println(a);
 }
}

按照概念模型的设计,getA方法最后会执行ireturn指令,从栈顶弹出int类型元素然后返回,在main方法调用getA处会将返回值压入栈后再存入变量槽。

但是优化后,main方法对于的栈帧在getA方法的下面,因为main方法的操作数栈和getA方法对应栈帧的局部变量表部分重合,所以就不用返回a,而是直接放入变量槽中,在main方法中弹出即可。

image


动态连接

关于动态连接的内容,可以在阅读完后面运行时常量池,方法调用相关内容后再做理解

每一个栈帧中都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个是为了方法调用过程中的动态连接。在每一个class文件中都会包含一个常量池,这个常量池中有大量的符号引用(通过符号无歧义的指向一个目标),这些符号引用一部分会在类加载阶段转换为直接引用(直接指向目标的指针,相对偏移量或者是可以定位到目标的句柄)即静态解析,另一部分在运行期转换为直接引用即动态连接。


方法返回地址

在方法调用结束后,必须返回到该方法最初被调用时的位置,程序才能继续运行,所以在栈帧中要保存一些信息,用来帮助恢复它的上层主调方法的执行状态。方法返回地址就可以是主调方法在调用该方法的指令的下一条指令的地址。


重磅资源!!!

关注小白不想当码农微信公众号。

后台回复java核心技术卷关键字领取《java核心技术卷》pdf

回复jvm领取《深入理解Java虚拟机》pdf和《自己动手写jvm

回复设计模式领取《headfirst设计模式》pdf

回复计算机网络领取《计算机网络自顶向下》pdf

最后

我是不想当码农的小白,平时会写写一些技术博客,推荐优秀的程序员博主给大家还有自己遇到的优秀的java学习资源,希望和大家一起进步,共同成长。

以上内容如有错误,还望指出,感谢

公众号点击交流,添加我的微信,一起交流编程呗!

公众号: 小白不想当码农
image


小白不想当码农
13 声望3 粉丝