虚拟机栈区

见面我们介绍过,JVM的内存布局分为程序计数器、本地方法栈区、虚拟机栈区、堆区、元数据区和JIT代码缓存。本地方法栈区主要用于JVM和本地代码相互调用,这里就不展开介绍了,这篇笔记我们重点介绍虚拟机栈区。

虚拟机栈

Java虚拟机中,栈是运行时的单位,堆是存储的单位。其实这和真实的操作系统有些类似,如果熟悉操作系统,JVM的堆栈结构也很容易理解。

JVM中的每个线程在创建时都会创建一个虚拟机栈,虚拟机栈是线程私有的,其内部保存栈帧,对应线程内每一次方法调用。虚拟机栈的生命周期和线程一致,虚拟机栈主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

栈是一种快速有效的存储分配方式,虚拟机栈的操作只有两个:方法执行时入栈,执行结束后出栈。栈中只保存基本数据类型和引用地址,这些数据会随着进栈出栈添加或销毁,因此不需要类似堆中的垃圾回收机制。不过虚拟机栈还是有大小限制的,如果超过栈大小将抛出StackOverFlowError错误,这种情况一般出现在错误的无限递归中。

我们可以使用-Xss参数调整虚拟机栈的大小,Linux下默认的虚拟机栈大小为1MB,不过我们实际上很少调整该参数,因为1MB已经足够使用,除非代码逻辑写错否则很难出现栈溢出的情况。

Stack Frame 栈帧

JVM中每个线程都有自己的虚拟机栈,栈中的数据以Stack Frame(栈帧)为单位存储,线程上正在执行的每个方法都对应一个栈帧。栈帧是栈区的一个内存区域,保存方法执行过程中的各种数据信息。

虚拟机栈遵循先进后出的原则,在一个活动线程中,同时只会有一个活动的栈帧,即只有当前正在执行的方法栈帧(位于栈顶)是有效的,这个栈帧被称为当前栈帧(Current Frame),与其对应的就是当前方法(Current Method),定义这个方法的就是当前类(Current Class)。执行引擎运行的所有字节码指令只针对当前栈帧进行操作。如果在该方法中调用了其它方法,对应的新栈帧就会被创建出来,放在栈的顶端,成为新的当前栈帧。

上面图中,我们的代码从main()方法开始,依次嵌套调用了funcA()funcB()funcC()方法,我们可以在调试器中查看到栈帧的结构。

对于栈帧我们还应该明确,不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。如果当前方法调用了其他方法,方法返回时当前栈帧会回传此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。Java方法有两种返回函数的方式:一种是正常的方法返回,使用return指令;另一种是抛出异常,根据异常表返回。无论哪种返回方式,都会导致栈弹出。

对于栈帧的结构,我们应该知道栈帧中包含以下信息:

  • 局部变量表
  • 操作数栈
  • 动态链接信息
  • 方法返回地址
  • 附加信息

Local Variables 局部变量表

局部变量表是一个数组,其大小在编译阶段由编译器确定并保存在字节码文件中,其中存储方法参数和定义在方法体内的局部变量,这些数据包括基本数据类型和到堆空间的引用(reference),以及方法返回地址的returnAddress类型。对于一个函数而言,其参数和局部变量越多,局部变量表和其栈帧就越大,因此就会占用更多的栈空间。

前面提到虚拟机栈是线程私有的,因此局部变量表中的数据不存在线程安全问题(但不包括引用堆中的同一个数据这种情况)。此外,局部变量表只在当前方法调用中有效,在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程,当方法调用结束后,随着栈帧的销毁,局部变量表也一起销毁。

对于如下例子代码,我们通过javap -v Demo.class反汇编能够看到对应的局部变量表。

package com.gacfox.demo;

public class Demo {
    public int add(int i, int j) {
        double k = 3.0;
        int m = 1;
        return i + j + (int) k + m;
    }
}
LocalVariableTable:
  Start  Length  Slot  Name   Signature
      0      17     0  this   Lcom/gacfox/demo/Demo;
      0      17     1     i   I
      0      17     2     j   I
      4      13     3     k   D
      7      10     5     m   I

前面说过局部变量表采用数组实现,在抽象上使用槽(Slot)的概念表达。这里注意我们编写的是一个实例方法,因此有一个this局部变量,我们可以看到上面thisijm都是占用了一个槽,但为什么k占用了两个槽呢?这是因为kdouble类型,它占用64位的空间。JVM的局部变量表中,只有两种大小:32位和64位,前者占据1槽,后者占据2槽。实际上,只有longdouble类型占据2槽空间,其余byteshortchar等均使用1槽,这是出于内存对齐考虑的。

此外,编译器可能对局部变量表中的槽进行重复利用,如果一个局部变量过了其作用域,那么在其作用域之后申明的局部变量就很可能会复用局部变量表槽位,达到节省内存空间的目的。例子如下:

package com.gacfox.demo;

public class Demo {
    public void foo() {
        {
            int a = 0;
            System.out.println(a);
        }
        int b = 0;
    }
}
LocalVariableTable:
  Start  Length  Slot  Name   Signature
      2       7     1     a   I
      0      12     0  this   Lcom/gacfox/demo/Demo;
     11       1     1     b   I

观察其反汇编,ab两个变量的Slot都是1

Operand Stack 操作数栈

栈帧中除了局部变量表还有一个LIFO的操作数栈(也叫表达式栈),它是用于真正计算的区域。在方法执行过程中,某些字节码指令能将值压入操作数栈,其余则将操作数取出计算后再将结果入栈。我们可以观察以下例子:

package com.gacfox.demo;

public class Demo {
    public int add(int i, int j) {
        int k = i + j;
        return k;
    }
}
public int add(int, int);
  descriptor: (II)I
  flags: (0x0001) ACC_PUBLIC
  Code:
    stack=2, locals=4, args_size=3
       0: iload_1
       1: iload_2
       2: iadd
       3: istore_3
       4: iload_3
       5: ireturn
    LineNumberTable:
      line 5: 0
      line 6: 4
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0       6     0  this   Lcom/gacfox/demo/Demo;
          0       6     1     i   I
          0       6     2     j   I
          4       2     3     k   I

上面两个整数相加的操作生成了5条指令,基于栈实现了k=i+j并返回的逻辑。

操作数栈基于数组实现,其大小在编译期就已经确定,上面Code中的stack属性就是操作数栈的大小。和局部变量表类似,操作数栈也只有两种深度槽位,32位和64位。

如果学过x86汇编,我们可以发现其实这种基于栈的做法很麻烦,而且性能很差,明明使用通用寄存器就可以完成的操作为何非要用栈结构读写内存呢?其实这是出于跨平台的考虑。JVM指令都是基于栈来设计的,不同平台CPU架构不同,因此JVM指令难以设计为基于寄存器来实现。基于栈的指令优点是跨平台,指令更短,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

ToS 操作数栈顶缓存技术

虽然基于操作数栈进行计算理论上性能很差,但HotSpot引入了ToS栈顶缓存技术(Top-of-Stack Caching),缓解了这一问题。

ToS将栈顶元素尽量缓存在CPU的物理寄存器中,以此降低对内存的读写次数,此外现代的CPU架构也都具备多级缓存,连续空间的缓存命中率是很高的,因此实际上真实的内存读写次数没有那么多,这也保证了现代JVM的执行效率。

Dynamic Linking 动态链接

我们知道,编译Java程序之后会得到程序中每一个类或者接口的独立的字节码.class文件。虽然这些文件看上去毫无关联,但实际上他们之间通过接口符号互相联系,或者与Java API的字节码文件相联系。前面介绍类加载子系统时有一个类链接中的解析步骤,这一步将字节码文件中的一部分符号引用直接解析为直接引用,这一部分被调用的目标方法在编译期可知,且运行期保持不变,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接;反之,如果被调用的方法在编译器无法被确定下来,只能在运行期转换为直接引用,那么这种引用转换过程具备动态性,也就成为动态链接。两种情况分别对应方法的绑定机制早期绑定和晚期绑定。

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。在Java源代码文件被编译为字节码文件中时,所有的变量和方法引用都作为符号引用保存在.class文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中的指向方法的符号引用来表示的,那么动态链接的作用就是为了在运行时将这些符号引用转换为调用方法的直接引用。

在Oracle官方的实现中,调用静态方法、私有方法、实例构造器、父类方法都是静态链接的;其余都是动态链接的。

关于虚方法和非虚方法以及方法调用指令

如果方法在编译器就确定具体的调用版本,且这个版本在运行时不可变,这种方法就是非虚方法,包括:静态方法,私有方法,final方法,实例构造器,父类方法都是非虚方法;其余都是虚方法(类似C++的virtual)。

实际上JVM中方法调用有5条指令:

  • invokestatic:调用静态方法
  • invokespecial:调用私有方法、实例构造器、父类方法
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法
  • invokedynamic:动态解析需要调用的方法然后执行

不过实际测试中发现final方法虽然明显可以编译器确定调用版本,但仍然会被编译为invokevirtual,这可能是处于某些其他方面考虑,这里就不深究了,我们知道JVM内部有这样一个机制即可。上面5条指令中,前2条对应调用非虚方法,后3条对应调用虚方法(final方法除外)。

另外,invokedynamic是Java7出于给Java添加动态类型语言特性的目的而添加的,在Java8中则引入了对应的语法,即Lambda表达式。

虚方法表

在面向对象的编程中会很频繁的使用方法的动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此JVM采用在类的方法区建立虚方法表来实现,使用索引表代替查找。每个类都有对应的虚方法表,表中存放各个方法的实际入口。

虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。

Return Address 方法返回地址

方法返回地址是栈帧的一小块区域,存放调用该方法时的PC程序计数器值。方法退出时,根据该地址返回方法被调用的位置继续执行,不过如果方法是异常退出的,返回地址需要通过异常表来确定,这部分信息一般不会保存在栈帧中。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。