虚拟机栈(JVM Stacks)
名词解释
当前栈帧:一条线程在执行过程中,一个时间点上,只会有一个活动的栈帧(栈顶的栈帧,当前正在执行的方法对应的栈帧)
当前方法:当前栈帧对应的方法
定义这个方法的类:当前类
栈的存储结构
- 上面已经提到,栈中的基本单位是栈帧(Stack Frame)
- 在该栈对应的线程上,其每一个方法都对应一个栈帧
- 栈帧占用的内存中,维系着对应方法的各种数据信息
- 每个线程在创建的时候会随之创建一个虚拟机栈,每一个虚拟机栈中保存了一个一个栈帧(Stack frame),对应着一次次的Java方法调用
- 生命周期与线程一致
- 主要管理Java代码的执行,保存了方法的局部变量、部分结果,参与方法的调用和结果的返回
- 主要注意的是栈帧中保存的局部变量包含的范围:
- 8中基本数据类型
- 如果是引用数据类型(类对象、接口、数组)保存在栈帧内的则是其对象的引用地址,因为引用数据类型是保存在堆(heap)中的
- 主要注意的是栈帧中保存的局部变量包含的范围:
- PC寄存器当前的指令地址是其对应的栈中栈顶的栈帧对应的方法执行指令,同理执行引擎运行的所有指令也就只针对栈顶方法!!!
使用栈的优点
- 栈是一种快速有效的分配内存的方式,访问速度仅次于程序计数器(Program Counter Register,即PC寄存器)
- 只针对栈顶的元素进行操作,简单明确,速度快
- JVM对栈的操作只有进栈和出栈(当然就满足栈数据结构的特性:“选进后出,后进先出”)
- 每一个方法的执行开始被push到栈中(压栈),执行结束pop出栈
- 前一个方法出栈之后,当前栈帧即为栈顶元素,开始执行栈顶的方法
- 栈顶的栈帧对应的方法调用了其他方法后,就会创建出新的栈帧位于栈顶,成为新的当前栈帧
- 栈不存在垃圾回收问题(栈操作的只有进栈出栈,不存在垃圾回收问题)
- 栈存在OOM
栈分配可能会出现的异常
JVM规范中允许栈大小是动态的或者是固定不变的。
- 如果JVM栈设置为了固定大小,如果一个线程在创建的时候申请的栈容量超过了JVM设置栈最大容量,那么将会出现StackOverflowMemoryError。
- 如果JVM设置的是动态容量的栈,那么在内存不足的时候,线程申请的栈容量没有办法得到满足,JVM会抛出OOM(OutofMemoryError)。这种内存不足的情况在固定大小栈容量的时候也会出现。
设置栈的大小:JVM参数 -Xss[capacity] ,单位可以是 k m g
除了windows中栈默认大小是根据JVM所占内存大小动态决定的,linux MacOS中默认为1024k
运行时异常在栈中的表现
方法的结束分为两种:正常结束return(包括void),执行中出现未捕获异常
- 如果处理了异常(try/catch),那么栈帧还是按照正常的情况进行出栈入栈
- 如果没有处理异常,栈顶正在执行的栈帧将不会pop出栈,也就没有之前的栈帧继续执行,随后该栈随着线程的结束而死亡
栈帧
栈帧内部结构
基本如上图所示,准确的来讲有以下五部分
- 局部变量表(local variables)
- 操作数栈(operand stack,也叫表达式栈)
- 动态链接(dynamic linking,即指向运行时常量池的方法引用)
- 方法返回地址(return address,即方法正常退出或者异常退出的结果定义)
- 其他内容信息
局部变量表和操作数栈是栈帧中的主要部分
局部变量表
概述
局部变量表也成为局部变量数组(以数组的方式存储slot)!!!这个概念很重要
虚拟机是使用局部变量表来完成方法的传递
- 定义为一个数组,主要用于存储方法参数和定义在方法内的局部变量。
- 包括基础数据类型
- 引用类型(应用对象)
- return address的类型
- 特别要注意的是:在局部变量表中,32位以内的类型只占用一个slot(包括return address),64位的类型(long、double)占用两个slot
- slot是局部变量表(这个数组)最基本的单位
- byte、short、char在存储之前将会被转换为int,boolean也被转换为int,0-false,非0-true
- long、double占据两个slot
- 由于局部变量被存储在一条线程对应栈中的栈帧,不存在多线程访问情况,不存在线程安全问题,常识中局部变量不会发生线程安全问题的原因就在于此。
- 局部变量表的[容量大小]是在编译器确定下来的,保存在方法的Code属性的maximun local variables数据项中,在方法运行期间是不会改变局部变量表[容量大小]的。
class字节码文件如下:
也可用idea插件查看,对字节码文件数据进行了解析,美化操作(JClasslib)
slot
slot是局部变量表的最基本单位(槽位)
- JVM会为局部变量表中的slot分配索引,局部变量表的变量通过这个索引来访问(局部变量表是个数组嘛,肯定是有索引的)
- 方法内的局部变量将会按照声明的顺序被分配到各个slot中
- 当局部变量占用两个slot的时候,以这个slot的第一个索引为该slot的索引值
- 如果当前栈帧对应的是一个类的构造方法或者是实例方法(非static修饰的方法),那么将会把该构造方法或者实例方法所属的【类对象的引用 this 】放在局部变量表的索引为 0 的位置,其他局部变量按照声明顺序依次放入。
- 原因就是构造方法和实例方法可以使用this关键字来应用当前类对象
类构造方法:
实例方法:
- slot是有可能被重复利用的,当方法中的局部变量失效的时候(或者说是当前执行阶段已经出了该变量的作用域的时候),那么之后声明的局部变量就可能会被分配到该slot中(非常重要!!!)
由于在方法b中,y的作用域只限于代码块中,所以当代码块执行完成后,z的声明重复使用了y之前所占有的slot
- 局部变量为什么要显式的赋值?
说到这个问题,就要说一下成员变量,成员变量又包含类变量(静态变量)和实例变量:
类变量的是在类加载器加载class字节码文件的【链接阶段】的【准备步骤】中首先赋予初始值(0、null、false),之后在【链接阶段】之后的【初始化阶段】调用<clinit>()才进行真正的赋值操作。
实例变量是随着对象的创建,在堆中分配该实例变量的空间,并进行默认初始化(默认赋值)
局部变量在调用方法的时候才会声明,如果不进行赋值,那么该局部变量就没有值可用。因为在栈帧的局部变量表中slot需要有该变量的值,所以在编码过程中,未显式赋值的局部变量编译都不通过!!!
- 局部变量表中的变量也是重要的垃圾回收的根节点,只要是被局部变量表中引用或者间接引用的对象都不会被回收
操作数栈
对于广义上的栈来说,可以使用数组或者是链表来实现,只要满足只能操作队尾元素的连续集合,并且拥有元素的索引,就可以理解为广义上的栈结构。而操作数栈就是使用数组来实现的栈特性。
操作数栈(operand stack),有地方称之为表达式栈。在方法的执行过程中,根据字节码指令,向栈中写入或者是提取数据(push/pop):
某些字节码指令将值push入操作数栈,有些指令将操作数pop出栈。使用这些操作数之后再把结果push入栈,如执行复制、交换、求和等操作。这里的使用指的就是【解释器】通过【执行引擎】把指令变成机器指令使用CPU计算的过程。
所以常说的JVM就是解释器就是基于栈的执行引擎,其中的栈指的就是操作数栈。
基于上述堆操作数栈的基本认识可以有如下信息:
- 主要用于保存计算过程中的中间结果,同时作为计算过程中变量的临时存储空间
- 在一些计算的结果在赋值给局部变量表总的变量之前,都是存在于操作数栈中的
- 操作数栈实际上就是JVM执行引擎的一个工作区,当一个方法开始执行的时,该方法对应的栈帧被创建,这时这个方法的操作数栈是空的
- 因为操作数栈是使用数组实现的,所以在操作数栈被创建的时候,其大小就是已经确定的,在编译期就已经设置好了,保存在字节码文件方法的Code属性中(max_stack)
- 操作数栈中的任何一个元素都可以是Java任意一种数据类型
- 32bit的类型占用一个栈单位深度(通俗点说占用数组的一位)
- 64bit的类型占用两个栈单位深度
- 虽然操作数栈是使用数组来实现的,但是不能通过索引来操作其中的元素,只能通过push和pop来操作,满足栈数据结构的特点
- 如果当前栈帧所对应的方法是存在返回值的,其返回值将会在该方法执行结束之后被push入当前栈帧的操作数栈,并更新PC寄存器中下一条需要执行的字节码指令标识
下图就是字节码指令从操作数栈的第一个元素位置获取调用方法(上一个栈顶的方法)的的返回值
动态链接(指向运行时常量池的方法引用)
每一个栈帧内部都包含一个指向【运行时常量池】中该栈帧所属方法的引用,这个引用就是为了支持当前方法可以实现动态链接(说白话一点,就是指令执行需要进行常量池的访问,动态链接就提供给了指令对常量池访问时的指针)。
Java源文件被编译为字节码文件之后,所有【方法的引用】和【变量】都作为符号引用(如下图中的#{数字})被保存在class文件的常量池中,动态连接的作用就是将这些符号引用转换为直接引用,同时提供了对Java语言多态的支持:
在运行之后,这些class文件常量池中的信息就会被加载进入方法区(元数据区)的常量池中,动态链接指向的就是这里。
方法返回地址
简单的来讲,就是存放调用该方法的PC寄存器的值。关于PC寄存器的介绍可查阅JVM——PC寄存器(Program Counter Register,程序计数器)
还是以A方法调用B方法举例:
当A方法执行过程中,执行到了B方法的调用,则B方法的栈帧随之创建,当B方法执行结束后,需要返回到A方法继续执行。由于指令的执行是通过PC寄存器储存的指定地址来明确,解释器来控制PC寄存器中的指定地址的变更,执行引擎来根据PC寄存器的指令地址翻译为机器指令交给CPU执行的,所以在B方法执行结束之后,需要继续执行A方法,那么就需要通过B的方法返回地址来明确下一条指令执行的是什么,这就是方法返回地址的作用。
方法的执行结束分为两种情况:
- 正常执行结束退出
- 调用者对应的PC寄存器的值就作为方法返回地址(即调用该方法的指令的下一条指令地址)
- 本质上方法的正常执行退出,就是当前栈帧的出栈操作,此时需要恢复上一层栈帧的局部变量表、操作数栈等,将返回结果压入上一层栈帧(当前方法栈帧的调用者)的操作数栈、设置PC寄存器的值,让指令能够继续执行
- 一个方法的正常执行退出之后需要使用哪一个返回指令,需要根据方法返回值的实际数据类型决定,返回指令包括:
- ireturn(boolean,byte,char,int,short)
- lreturn(long)
- dreturn(double)
- freturn(float)
- areturn(应用类型)
- return(void方法、构造方法<init>()、类构造器<clinit>()、静态代码块)
- 出现异常退出
- 返回地址要通过异常表(对应try/catch的异常声明)来确定,栈帧中一般不会保存这部分信息
其他内容信息
这一块没办法具体解释,根据JVM的不同,可能会有一些于JVM实现相关的一些信息