Java 虚拟机初探(二)—— 虚拟机栈

tips:本篇文章基于Hotspot JVMJDK 1.8所撰写。

内存区域

我们首先来根据一张图初步了解一下内存区域的划分:
JVM内存区域
因为我发现每一版块都有好多东西要说,故把各区域单拿出来一一说明。
下面介绍的是主管JVM程序运行的区域——栈。

Java虚拟机栈(stack)

每一条Java虚拟机线程都有自己私有的Java虚拟机栈,这个栈与线程同时创建,也就是说,虚拟机栈的生命周期跟线程是一样的。

虚拟机栈描述的是Java方法执行的内存模型:
每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
线程在运行的过程中,只有一个栈帧处于活跃状态,这个栈帧称为
当前栈帧
,这个栈帧对应的方法称为当前方法,定义这个方法的类称作当前类

那栈中,或者说栈帧中的各个结构究竟保存着什么?他们在JVM中又起到什么作用?
JVM规范上是这么说的:栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。
存储数据和部分过程结果?什么数据?什么结果?动态链接是啥?异常分派又是个啥?
这样解释未免有些抽象,下面我们根据栈帧的整体结构,一步步将栈帧分解开来。


局部变量表

局部变量表,顾名思义,就是用来存储方法中的所有局部变量值(包括传递的参数)
这个局部变量值的含义是:变量的作用范围、变量的名称和数据类型信息

JVM在编译期时,就已经决定好了局部变量表的大小,并将这些数据放进了方法区(方法的code属性的max_locals数据项)。

一个slot(槽位)可以保存一个类型为:booleanbytecharshortintfloatreferencereturnAddress的数据,而longdouble类型占两个连续的slot,采用其较小的索引值定位。
reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置
returnAddress类型:指向了一条字节码指令的地址


局部变量表的理论简介到此为止,那它里面究竟装着些什么东西呢?它的结构又是怎么样的?
我们首先来看LocalVariableTable的属性结构

LocalVariableTable属性的格式如下:
LocalVariableTable
这里简单介绍一下前几样属性:

  • attribute_name_index:对常量池表的一个有效索引。常量池在该索引处的成员用以表示字符串LocalVariableTable
  • attribute_length:当前属性的长度,不包括初始的6个字节(也就是不包含attribute_name_indexattribute_length本身的长度)。
  • local_variable_table_length:关于local_variable_table[]数组的成员数量。

主要看locao_variable_table
《Java虚拟机规范》中对其的描述是这样的:local_variable_table数组中的每一项,都以偏移量的形式给出了code数组中的某个范围,当局部变量处在这个范围内的时候,它是有值的。此项还会给出局部变量在当前帧的局部变量表中的索引。

这个是什么意思呢?我们先来看它的成员结构:
local_variable_info

我们通过联系程序本身,来看这个表的成员:
首先来看一段java代码:

public void testMethod(int a) {
        int b = 2;
        int c = 3;
    }

javap -verbose Test.class指令对其反编译:

public void testMethod(int);
Code:
      stack=1, locals=4, args_size=2
         0: iconst_2
         1: istore_2
         2: iconst_3
         3: istore_3
         4: return

      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LTest;
            0       5     1     a   I
            2       3     2     b   I
            4       1     3     c   I

结合其中的LocalVariableTable,逐一看locao_variable_table中的成员:

  • start_pc:代表了这个局部变量的生命周期开始时期的字节码偏移量。
    也就是上面LocalVariableTable中的Start项,与Code中字节码指令对应。
    比如变量b,其字节码偏移量(也就是Start)为2。
    而它初始化是什么时候呢?是字节码偏移量为1的时候:
    1: istore_2这一步将操作数栈栈顶元素弹出,而其上一步(iconst_2)已经将常量2压入栈中,所以这一步的操作恰恰对应了原代码中的 b=2,也就是变量b被初始化时(生命周期开始)。
    所以它的作用域开始于字节码偏移量为2的时期,也就是起始于2: iconst_3

  • length:代表这个局部变量的作用范围,与上面的start_pc结合起来就是局部变量在字节码中的范围。
    继续拿变量b举例吧,它的Length为3,代表其作用域宽度为3,也就是2~4
    正好是该方法结束,局部变量被回收的位置。
    start_pclength结合也可以看成指定局部变量占用Slot的时间。

  • name_indexdescriptor_index均是对常量池表的一个有效索引。
    前者表示局部变量的名称,后者表示局部变量的描述符。
    其实就是上面的NameSignature,看常量池中的内容:

  #15 = Utf8               a
  #16 = Utf8               I
  #17 = Utf8               b
  #18 = Utf8               c

a、b、c 就是局部变量的名称(name_index),I 即为局部变量的描述(int类型)。

  • index:表示这个局部变量在当前栈帧的局部变量表中的索引(也就是Slot的位置)。
    (注意,longdouble类型因占用两个Slot,所以会占用indexindex+1两个位置)

回过头重新来看locao_variable_table的描述:
local_variable_table数组中的每一项,都以偏移量的形式给出了code数组中的某个范围。
(其实也就是Startlength代表的作用域范围)
当局部变量处在这个范围内的时候,它是有值的。
(因为它基本代表了局部变量的生命周期)
此项还会给出局部变量在当前帧的局部变量表中的索引(index)。


接下来说一下Slot被复用的情况。
我们用代码块来限制变量的作用域:

public void testMethod(int a) {

		{
        	int b = 2;
        }
        
        int c = 3;
    }

再来看反编译后的字节码指令:

public void testMethod(int);
    Code:
      stack=1, locals=3, args_size=2
         0: iconst_2
         1: istore_2
         2: iconst_3
         3: istore_2
         4: return
         
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LTest;
            0       5     1     a   I
            4       1     2     c   I

可以看到,变量b消失了,那么b没有占用Slot吗?
实际上是占用了的,只不过到了其作用域后,它的Slot就被变量c复用了
而如果变量定义的位置是作用域最后一行的话,那么它是不会出现在LocalVariableTable表中的。

变量b在局部变量结构中的表示应该是这样的:

Start  Length  Slot  Name   Signature
    2       0     2     b   I 

它的作用域可以说为0,也就是当它被保存在局部变量表,占用Slot=2的槽位后(1: istore_2),马上被清除,其Slot也被变量c复用。

简单来说,如果当前字节码程序计数器的值超出了某个变量的作用域,那么这个变量对应的Slot就可以被其他变量使用。
(变量的作用域为 [start, start+length] ,要注意的一点是作用域两边都是包含的)


操作数栈

每个栈帧内部都包含一个称为操作数栈的后进先出栈
同样,操作数栈的最大深度由编译期决定,并通过code属性(max_stack项)保存及提供给栈帧使用。

操作数栈的作用是什么?
它的作用基本上可以总结为两个:

  1. 你可以把它当做是变量进行操作的存储区域,比如对变量的加减乘除运算,都是利用操作数栈来进行。
  2. 在调用其他方法时利用操作数栈来完成参数的传递。

下面我们根据一个简单的Java程序对操作数栈进行说明:

public class Test {

    public void testA() {

        int a = testB(5);
        
    }

    public int testB(int x) {
        int a = 1;
        int b = 2;
        int c = (a + b) * x;
        
        return c;
    }

    public static void main(String[] args) {
        
        Test test = new Test();
        test.testA();
    }
}

javap对程序进行反编译,看它的字节码指令,先来看testB方法的:

 public int testB(int);
    Code:
      stack=2, locals=5, args_size=2
         0: iconst_1
         1: istore_2
         2: iconst_2
         3: istore_3
         4: iload_2
         5: iload_3
         6: iadd
         7: iload_1
         8: imul
         9: istore        4
        11: iload         4
        13: ireturn
  • 0~3iconst_n指令表示将常量n压入操作数栈,而istore_n表示将操作数栈栈顶元素弹出并保存在局部变量表slot=n的位置上。
    这里是为变量a和变量b的赋值操作。
    执行完istore_3的指令后,操作数栈仍然为空状态。

  • iload_niload_n命令表示从局部变量表中取出slot=n位置上的变量压入操作数栈中,与istore_n对应。
    此时操作数栈中的情况:
    操作数栈

  • iaddiadd命令将操作数栈栈顶的两个int型数值相加,将结果入栈(这里就是将变量a和b相加)。
    此时操作数栈的情况:
    操作数栈

  • imulimul命令将操作数栈栈顶的两个int型数值相乘,将结果入栈。
    上一步iload_1将变量x入栈,而调用方法时传过来的x的值为5,所以此时操作数栈:
    操作数栈

最后将结果保存在局部变量表索引为4的地方(也就是变量c的位置),返回结果。

这就是操作数栈的第一个作用:对变量进行操作的存储区域。

上面的论述之中有一个小问题:
局部变量表结构中是没有字面值的,那么变量的值从操作数栈出栈后,保存在了哪里呢?
对于这个问题,我的理解是:
局部变量表中的Slot储存了变量的类型和名字等信息,而这个Slot对应的变量名是一个类似于地址索引之类的东西,指向变量的地址空间,而操作数栈是根据这个索引将变量的值保存在这个地址上的。
所以说从操作数栈出栈保存到局部变量表指定Slot位置上,实际上是保存在其指向的地址空间上。

tips:这个引用和对象引用不同,对象实例保存在堆中,当两个对象引用指向同一个对象实例时,实例内容的改变是可以被另一个引用观测到的,而基本类型的引用是观测不到的。
举个例子:

public class Test {

    int a = 1;

    public static void main(String[] args) {

        Test test1 = new Test();
        Test test2 = test1;

        System.out.println("test1.a =  " + test1.a);
        System.out.println("test2.a =  " + test2.a);

        test1.a = 5;

        System.out.println("test1.a =  " + test1.a);
        System.out.println("test2.a =  " + test2.a);

    }
}/* Output
test1.a =  1
test2.a =  1
test1.a =  5
test2.a =  5
*/

test1改变了实例的内容时,test2因为与test1指向的同一个实例,所以内容一样。

我们来看基本类型的变量:

int a = 1;
int b = a;
int c = 2;

System.out.println("a = " + a);
System.out.println("b = " + b);
System.out.println("c = " + c);

a = 2;

System.out.println("a = " + a);
System.out.println("b = " + b);
System.out.println("c = " + c);
/* Output
a = 1
b = 1
c = 2
a = 2
b = 1
c = 2
*/

这个结果是众所周知的,但是它在内存中的表现呢?
a初始化时,他们会先在栈中扫描,看有没有字面量为1的地址,有的话,就指向它,没有的话就开辟出一块字面量为1的地址,再指向它,c也一样。
而当执行b = a的时候,b指向的与a指向的是同一块地址,也就是字面量为1的地址。
也就是说,当初始化完成时,是这样的:
字面量地址
而当执行a = 2时,它并不会改变原先地址的值,而是重新搜索栈中有没有字面量为2的地址,如果有,就让a指向它,没有就开辟一块出来,所以不会影响到b指向的内容:
字面量地址
而对象的引用就像上面说的,改变内容是不会重新开辟空间的
这里简单的提一句,关于String类型的变量,如果创建时是:

String s = new String("ABC");

那么它跟对象的引用是一样的。
但是如果创建时是:

String s = "ABC";

那么它跟基本类型引用是相同的。

至于第二个作用:利用操作数栈来完成参数的传递,根据程序流程图就可以理解。
还是刚才那个程序,我们分析完了testB方法的字节码,现在来看testA方法的字节码指令:

public void testA();
    Code:
      stack=2, locals=2, args_size=1
         0: aload_0
         1: iconst_5
         2: invokevirtual #2                  // Method testB:(I)I
         5: istore_1
         6: return
  • aload_0:将本身的引用this推入局部变量表。
  • invokevirtual:调用常量池符号引用为2的方法,后面的注释已经打印出来了调用方法的名字:testB

也就是说,在没调用方法前,testA的操作数栈是这样的:
操作数栈
而调用后,操作数栈的变化:
操作数栈
testA调用testB传过去的参数实际保存在testB的局部变量表中,以便随时拿出来调用。

返回后,操作数栈的变化:
操作数栈
至此,依靠操作数栈完成了传参与返回参数的整个流程。


动态链接

每个栈帧内部都包含一个指向当前方法所在类型的运行时常量池的引用,以便对当前方法的代码实现动态链接
tips:运行时常量池包括了若干种不同的常量,从编译器可知的数值字面量到必须在运行期解析后才能获得的方法或字段引用。
(关于运行时常量池我以后可能会单独开一章,这里不深入说了)

一个方法若要调用其他方法,或者访问成员变量,需要通过符号引用来表示。
动态链接的作用就是将这些以符号引用所表示的方法转换为对实际方法的直接引用。

符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。
而另一部分将在每一次运行期间转化为直接引用,这部分称为动态链接。

关于动态链接的详细执行过程可以参考我的另一篇文章:
从JVM层面对Java多态机制深入探寻


方法出口

如果方法调用正常完成(没有抛出任何异常包括throw显式抛出的异常),当前栈帧承担着恢复调用者状态的责任,包括恢复调用者的局部变量表和操作数栈,以及正确递增程序计数器,以跳过刚才执行的方法调用指令等。被调用方法的返回值会被压入调用者栈帧的操作数栈,然后继续执行。

如果方法调用异常完成(某些指令导致了Java虚拟机抛出异常,且在该方法中没办法处理该异常,或者在执行过程中遇到athrow字节码指令并显式的抛出异常,同时方法内部没有捕获异常),那么一定不会有方法返回值返回给它的调用者。


本地方法栈

简单说一下本地方法栈吧,本地方法栈同样是一个线程私有的栈,用来支持native方法(用Java调用其他语言编写的方法)的执行,与虚拟机栈非常相似。

如果JVM支持本地方法栈,那么这个栈一般会在线程创建的时候按线程分配。

Java虚拟机规范允许本地方法栈实现成固定大小或者根据计算来动态扩展和收缩。
如果采用固定大小的本地方法栈,那么每一个线程的本地方法栈容量可以在创建栈的时候独立选定。


参考文献

  • 《深入理解Java虚拟机:JVM高级特性与最佳实践》
  • 《Java虚拟机规范 Java SE 8版》
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值