深入理解 JVM 运行时数据区—虚拟机栈

 2023-01-13
原文作者:香香软软的战列舰 原文地址:https://juejin.cn/post/6896865263363293191

虚拟机栈是啥?

栈与数组和链表不同,是一种虚拟的数据结构,可以由数组或链表进行实现。从这一点也可以看出,栈比较容易实现。而关于栈的操作也比较简单,主要就两个,出栈和入栈(压栈)。因为简单,所以才会被JVM所采用。

  • JVM为什么选择栈

不同CPU的架构不同,基于寄存器则对CPU的耦合比较高。

虚拟机栈的指令集小,方便编译器解释,更容易实现跨平台的特性。但缺点也很明显,性能比寄存器要差很多。尽管如此,为了实现跨平台,JVM还是选择了栈

  • 栈的特点

虚拟机栈主管JAVA程序的运行,保存方法的局部变量(基本数据类型和对象的引用地址)、部分结果,并参与方法的调用和返回。虚拟机栈基于数组实现,访问速度仅次于PC寄存器。由于操作简单,栈不存在垃圾回收的问题,但存在OOM(栈也是有深度的嘛)。

  • 栈和堆有啥不同

栈和堆不同,栈是运行时的单位,而堆是存储的单位。

栈解决程序如何执行,如何处理数据,而堆解决数据怎么放,放哪的问题。

每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧,一个栈帧对应一次方法调用。而堆内存则由每个线程共享。

啥是栈帧?

正在执行的方法对应的栈帧称为当前栈帧。执行引擎的所有字节码指令只针对栈帧进行操作。

栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据。

如果当前栈帧调用了新的方法,那么新的方法对应的栈帧会被创建出来并压入栈中成为新的栈帧。

JAVA方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另一种是抛出异常;不管是哪种方式,都会导致栈帧被弹出。

栈帧的内部结构

栈帧中有五部分的数据,分别是:局部变量表、操作数栈、方法返回地址、动态链接和一些附加信息。

方法返回地址、动态链接和一些附加信息也统称为帧数据区。

  • 局部变量表

局部变量表底层其实是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量。这些数据类型包括基本数据类型,对象引用和方法返回地址。存在于每个线程的每个栈的每个栈帧中,只在当前方法有效,在调用结束后,随着栈帧的销毁而销毁,不存在线程安全的问题。

局部变量表所需的容量大小是在编译期就确定下来的,不会在运行期间改变。在类的字节码文件中,可以看到经编译确定下来的大小。

在局部变量表中,32位以内的类型占一个slot(包括方法返回地址),64位的类型占两个slot。byte、short、char在存储前被转为int,boolean也被转为int,0表示false,非0表示true。long和double占据两个slot。

slot是局部变量表最基本的存储单元,如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index=0的slot处。slot是可以重用的,当一个局部变量过了期作用域,那么该槽位可会会被回收,以达到节省资源的目的。

方法嵌套调用的次数由栈的大小决定。如果一个方法的参数和局部变量很多,会使局部变量表膨胀,导致栈帧占用空间增加,压缩嵌套调用的次数。

局部变量与成员变量的区别:

成员变量 类变量 局部变量
使用前经过初始化赋值。随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值 在链接的准备阶段,给类变量赋默认值,在初始化阶段(静态代码块),给类变量显性赋值 在使用前,必须进行显式赋值,否则编译不通过;

在栈帧中,与性能调优关系最为密切的部分就是局部变量表(其实也没多少可GC的),在方法执行时,虚拟机使用局部表量表完成方法的传递。局部变量表也是重要的垃圾回收根节点,只要在局部变量中直接或间接引用的对象,都不会被回收。

  • 操作数栈(表达式栈)

Java中的操作数栈,使用数组实现。Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈便是指操作数栈。

它主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。操作数栈是JVM执行引擎的一个工作区,当一个方法开始执行时,对应的新栈帧就会被创建出来,此时为空栈。

每一个操作数栈都拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就已经确定,保存在方法的Code属性中,为max_stack值。

栈中,32bit的数据占一个单位深度,64位占两个深度。如果被调用的方法有返回值的话,那么其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

为了优化操作数栈的执行效率,JVM还使用了栈顶缓存技术

将栈帧中,操作数栈栈顶的元素全部缓存在CPU的寄存器中,以此减少对内存的读写次数,提高执行效率。

题外话:i++和++i的区别:i++,先将i从栈顶取出,参与其他计算,然后进行加1再放回栈中;++i,先进行加1操作,再参与其他计算,再放回栈中;

  • 动态链接

字节码文件中定义了一个常量池,动态链接就是指向运行时常量池的方法引用。

源文件被编译到字节码文件中时,所有变量和方法引用都作为符号引用保存在class文件的常量池中,动态链接的作用就是为了讲这些符号引用转换为调用方法的直接引用

  • 方法返回地址

方法返回地址存放调用当前方法PC寄存器的值。

方法可能正常结束,也可能因异常结束,无论通过哪种方式退出,在方法退出后都返回到该方法的被调用位置。方法正常退出时,调用者的PC寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而因异常结束的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。即异常结束的方法不会给调用者产生任何的返回值

方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。在遇到异常时,没有在异常表中搜索到匹配的异常处理,就会导致方法结束。

常见的返回指令有:

名称 说明
ireturn 返回值是boolean、byte、char、short、int
lreturn long
freturn foat
dreturn double
areturn 引用类型
return void的方法、实例初始化方法、类和接口的初始化方法
  • 附加信息

栈帧允许携带与Java虚拟机实现相关的一些附加信息。例如对程序调试提供支持的信息。

栈帧对应方法的调用过程

首先,方法需要被绑定

  • 方法的绑定

方法的绑定有两种,分别为早期绑定和晚期绑定。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,该过程仅发生一次。

早期绑定:是指被调用的方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属类型进行绑定。在子类中调用父类的方法,可以知道具体的指定,所以为早期绑定;经final修饰的方法不能被重写,也是早期绑定。

晚期绑定:如果被调用的方法再编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法。接口和抽象类的调用,无法知道他们具体的子类的调用,所以为晚期绑定。

早期绑定对应静态链接,晚期绑定对应动态链接

  • 方法的链接方式

静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时,这种情况将调用方法的符号引用转换为直接引用的过程称之为静态链接。

动态链接:如果被调用的方法无法在编译器被确定下来,只能在程序运行期间讲调用方法的符号引用转换为直接引用。

在链接完成后,才能JVM执行引擎再通过调用指令调用具体的方法。

方法的调用指令:

invokestatic:调用静态方法,解析阶段确定唯一方法版本

invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本

invokevirtual:调用所有虚方法

invokeinterface:调用接口方法

invokedynamic:动态解析出需要调用的方法,然后执行。该指令直到Java7才出现,为了支持动态类型语言的特性做出的改进。该指令常结合Lambda表达式使用。

类型的检查在编译期执行则为静态语言,反之则为动态语言。静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息。

前四条指令固化再虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。invokestaticinvokespecial为非虚方法指令,其他为虚方法指令。虚方法指令不一定调用的就是虚方法,非虚方法制定调用的一定是非虚方法。

  • 啥是(非)虚方法?

非虚方法:如果方法在编译期就确定了具体调用的版本,这个版本再运行时是不可变的,这样的方法就称之为非虚方法。静态方法、私有方法、final方法、实例构造器和super方法都是非虚方法。其他的则为虚方法。

非虚方法之外的方法则为虚方法喽。

  • 虚方法表

虚方法表就是虚方法的缓存。每次调用虚方法时,都可能重复上面的4个步骤,为了提高性能,JVM在类的方法区建立了一个虚方法表,作为缓存。虚方法表再类加载的链接阶段被创建并初始化,类的变量初始化准备完成后,虚方法表也初始化完成。

  • 方法重写的本质

1、找到操作数栈栈顶的第一个元素,所执行的对象实际类型,记作C。

2、如果在类型C中找到常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,如果不通过,则返回IllegalAccessError异常。

3、否则,按照继承关系从上往下依次对C的各个父类进行第二步的搜索和验证

4、如果始终没有找到合适的方法,则抛出AbstractMethodError异常

IllegalAccessError:一般编译时会进行处理,但如果发生在运行时,就说明一个类发生了不兼容的改变。

本地方法是否也有栈?

本地方法也是有对应的栈的,叫做本地方法栈

本地方法栈,也是线程私有。栈大小可以固定也可以动态扩展,内存溢出方面与虚拟机栈相同。

虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。

当本地方法被调用时,它就进入了一个全新的并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限。它可以直接从内存的堆中分配任意数量的内存,甚至可以直接使用处理器的寄存器,也可以通过本地方法借口访问虚拟机内部的运行时数据区。

在JVM规范中没有明确要求本地方法栈的使用语言、实现方式和数据结构等,所以,并不是所有的JVM都支持本地方法。而在Hotspot虚拟机中,直接将本地方法栈和虚拟机栈合二为一。

  • 本地方法栈对应的本地方法接口

由native修饰的方法就是本地方法。该方法的实现不是由Java编写,用以融合C/C++的程序。

在开发过程中,有时需要与外部系统进行交互,JDK提供的接口不足以满足需求,这是本地方法存在的主要原因;另外,操作系统由C语言编写,通过本地方法可以实现与操作系统的交互;JDK的部分功能需要由操作系统底层的函数提供;

常见异常和设置栈大小

JVM规范允许JAVA栈的大小是动态的或者是固定不变的。

固定大小: 如果采用固定大小的JAVA虚拟机栈,那每一个线程的栈容量可以在创建线程的时候独立选定。如果线程请求分配的栈容量超过允许的最大容量,将会抛出StackOverflowError异常。

动态扩展: 如果JAVA虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新线程的时没有足够的内存去创建对应的栈,将会抛出OutOfMemoryError异常。

设置为固定大小,单位,k=KB,m=MB,g=GB,默认为bytes

    -Xss1024
    -Xss256k
    -Xss1m

设置为动态扩展,在创建线程的时候指定

    public Thread(ThreadGroup group, Runnable target, String name,  
                  long stackSize) {  
        init(group, target, name, stackSize);  
    }
  • 使用PC寄存器存储字节码指令地址有什么用呢?

CPU需要不断切换各个线程,在切换回来的时候,需要知道从哪接着开始。

JVM字节码解释器需要通过PC寄存器的值来明确下一条该执行什么样的字节码指令。

常见面试题

  • 栈异常的场景?://todo 待补充
  • 调整栈大小,就保证不出现溢出?:对于问题程序,只能影响溢出的时间。对于一些对栈空间要求不高的方法,可能不会溢出。
  • 分配栈内存,越大越好?:不一定,对单个方法是利好,但从系统资源利用的角度来看,可能造成严重浪费。
  • 垃圾回收是否涉及到虚拟机栈?:不会,栈只有进栈和出栈的操作,不用由GC进行资源回收。
  • 方法中定义的局部变量是否线程安全?:方法对应栈帧,栈帧为每个线程内部的私有空间,所以线程安全。