虚拟机栈(Stack)概述

  1. 出现的背景:Java跨平台的特性,指令集使用的是基于栈的架构设计
  2. 是什么:
    • 虚拟机栈描述的是Java方法执行的线程内存模型每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
    • 每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。也就是说,一个栈帧就对应一个方法
    • 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(地址)(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
  3. 生命周期:Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同每个线程在创建时都会创建一个虚拟机栈
  4. 栈的优点

    虚拟机栈 - 图1

  5. 栈的异常情况

    1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
    2. 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
  6. 设置栈内存大小

    我们可以使用-Xss选项来设置线程的最大栈空间

    栈的大小直接决定了函数调用的最大可达深度

    官方文档

    虚拟机栈 - 图2

    默认栈递归调用

    虚拟机栈 - 图3

    设置栈大小

    虚拟机栈 - 图4

    再次执行,发现递归次数少了很多,也就是函数调用的最大可达深度

    虚拟机栈 - 图5

栈的存储单位

  • 每个线程都有自己的虚拟机栈,栈中的数据的基本单位是栈帧,栈帧是一块内存区域,是一个数据集,维系着方法执行过程中的各种数据信息
  • 栈运行原理

    1. JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循”先进后出”或者”后进先出”原则。
    2. 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
    3. 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
    4. 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧

      方法1调用方法2,方法2调用方法3…

      虚拟机栈 - 图6

    5. 不同线程中的栈帧不允许相互存在引用,也就是不可能存在一个栈帧之中引用另一个线程中的栈帧(虚拟机栈是线程私有的)

    6. 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
    7. Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常(不处理异常)。不管使用哪种方式,都会导致栈帧被弹出

栈帧的内部结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素

栈帧的内部结构
虚拟机栈 - 图7

编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。

换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

局部变量表

  1. 什么是局部变量表

    • 局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量
    • Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

      编译之后 所需分配的局部变量表的最大容量(变量槽数量)就已经确定了

      虚拟机栈 - 图8

      虚拟机栈 - 图9

    • 对于同一个方法,参数和局部变量越多,那么对应的局部变量表就越大,方法对应的栈帧就越大,如果栈的大小是固定的,那么方法的嵌套调用次数就会减少。

    • 局部变量表只在当前方法调用中有效。当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递
    • 如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。

      虚拟机栈 - 图10

      为什么static方法中不能使用this?

      因为在static方法中,方法的局部变量表中并没有this变量

  2. 局部变量表的存储单位:变量槽

    • 局部变量表的容量以变量槽(Variable Slot)为最小单位
    • 每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存储
    • 对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。Java语言中明确的64位的数据类型只有long和double两种
    • Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。
    • 如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个
    • 变量槽重复利用:方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用

      虚拟机栈 - 图11

  3. 静态变量和局部变量

    变量按照在类中声明的位置来分:

    成员变量(类中):在使用之前,都会完成初始化

    • 类变量(static修饰):连接的准备阶段赋初值,初始化阶段赋值
    • 实例变量:对象创建的时候赋初值

局部变量(方法中):使用前必须显示赋值

因为局部变量表不存在初始化这个过程,所以局部变量必须人为初始化

虚拟机栈 - 图12

  1. 补充说明
    • 局部变量表中存储的有基本数据类型和引用数据类型的地址,地址指向堆中的引用类型的数据。所以这会影响到后面的垃圾回收,只要被局部变量表直接或者间接引用的对象都不会被回收

操作数栈(Operand Stack)

  1. 什么是操作数栈
    • 操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈
    • 主要用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间
    • 如果被调用的方法有返回值,那么返回值将会被压入当前栈帧的操作数栈中
  2. 操作数栈的特点
    • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作
    • 操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中
    • 操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
    • Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
    • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。比如iadd指令,需要操作两个int型的数,而不能出现一个long和一个float使用iadd命令相加的情况。(byte short char boolean都以int类型存储)
    • Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈
  3. 代码演示

    虚拟机栈 - 图13

    刚开始执行方法的时候,操作数栈是空的

    执行第一个字节码指令

    bipush 15:将15入操作数栈

    虚拟机栈 - 图14

    istore_1:将操作数栈栈顶元素放入到局部变量表的变量槽index为1的位置(index0是this)

    虚拟机栈 - 图15

    bipush 8:将8入操作数栈

    虚拟机栈 - 图16

    istore_2:将操作数栈栈顶元素放入到局部变量表的变量槽index为2的位置

    虚拟机栈 - 图17

    iload_1:取出局部变量表变量槽index为1的变量,放入操作数栈
    iload_2:取出局部变量表变量槽index为2的变量,放入操作数栈

    虚拟机栈 - 图18

    iadd:取出操作数栈栈顶的两个数字,交给执行引擎进行计算,计算结果存入操作数栈

    虚拟机栈 - 图19

    istore_3:将操作数栈栈顶元素出栈,并存储到局部变量表变量槽Index为3的位置

    虚拟机栈 - 图20 尽管定义的是int类型,如果在byte或者short范围内,放入操作数栈的时候会使用对应的类型

    但是存储到局部变量表的时候还是int

    虚拟机栈 - 图21 如果被调用的方法有返回值,那么返回值将会被压入当前栈帧的操作数栈中

    虚拟机栈 - 图22



动态连接(Dynamic Linking)

  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)

    虚拟机栈 - 图23

    方法method2()中包含一个指向运行时常量池(Class文件加载到内存中之后会有一个常量池)中该方法的引用#16

    虚拟机栈 - 图24

  • 字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接

    也即:动态连接的作用就是在方法运行期间将符号引用转化为直接引用


    调用方法的字节码指令invokevirtual将指向方法的引用#5作为参数

    虚拟机栈 - 图25

  • 图示

    虚拟机栈 - 图26

方法返回地址(Return Address)

  • 方法结束的两种方式
    1. 正常调用完成
    2. 异常调用完成

无论是哪种方式结束方法,都必须返回到方法被调用的位置,以便程序可以继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,调用者的程序计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。
方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息


方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:
恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等

附加信息

  • 《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述
  • 在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

方法的调用:解析和分派

  • 方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程
  • 一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。那么什么时候可以将符号引用转化为直接引用呢?
  • 涉及到的字节码指令
    • invokestatic。用于调用静态方法
    • invokespecial。用于调用实例构造器**<init>()**方法私有方法父类中的方法
    • invokevirtual。用于调用所有的虚方法。(final修饰的方法使用的是此指令调用)
    • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
    • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

解析(Resolution)

  • 调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来(编译期可知,运行期不可变)。这类方法的调用被称为解析
  • 这类方法主要有:静态方法,私有方法,实例构造器,父类方法,还有一种就是final修饰的实例方法
  • 解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成
  • 这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用,这些方法统称为“非虚方法”,除此之外的方法都是虚方法

    虚拟机栈 - 图27

分派

  • 被调用的方法在编译期间无法被确定下来,在运行期间才能将方法的符号引用转换为直接引用
  • 分派是重载和重写的基本体现

静态分派

  • 所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载
  • 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。

    静态类型:编译期可知

    实际类型:运行期才可以确定

    在下面的程序中,在编译器只知道变量human的静态类型是Human,而实际类型究竟是Man还是Woman必须等到运行时候才能确定

    虚拟机栈 - 图28

    虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

    虚拟机栈 - 图29

  • 重载的优先级

    虚拟机栈 - 图30

    首先 执行main,调用的方法是①

    注释掉①之后再执行main,调用的方法是②

    …..

    原因:'a'是char类型的,

    注释掉参数是char类型的方法后,发生了一次自动类型转换,‘a’除了可以代表一个字符串,还可以代表数字97(字符’a’的Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的

    注释掉参数是int类型的方法后,’a’转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载

    ——不过实际上自动转型还能继续发生多次,按照char>int>long>float>double的顺序转型进行匹配,但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的——

    注释掉参数是long类型的方法后,这时发生了一次自动装箱,’a’被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为Character的重载

    注释掉参数是Character类型的方法后,因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类所实现的接口类型

    注释掉参数是Serializable类型的方法后,char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越上层的优先级越低(Character优先级大于Object)

    注释掉参数是Object类型的方法后,只剩一个可变长参数的方法,可见变长参数的重载优先级是最低的,这时候字符’a’被当作了一个char[]数组的元素 说明的问题:静态方法会在编译期确定、在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的


动态分派

  • 运行期根据实际类型确定方法执行版本的分派过程称为动态分派
  • 它与Java语言多态性的另外一个重要体现——重写(Override)有着很密切的关联

    虚拟机栈 - 图31

    结果显然,不是根据变量的静态类型决定调用哪一个方法的

    那么Java虚拟机是如何根据实际类型来分派方法执行版本的呢

    从字节码来看

    invokevirtual指令完全一样,那么incokevirtual究竟是如何运行的?

    虚拟机栈 - 图32 invokevirtual指令的运行时解析过程大致分为以下几步:

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

    2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。

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

    4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。 正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了还会根据方法接收者(将要执行方法的对象)的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质 虚拟机栈 - 图33

    如果Son没有重写,虚方法首先确定的调用方法的对象是Son,由于没有重写,那么执行的便是父类的方法

    虚拟机栈 - 图34




  • 虚拟机动态分派的实现

    虚方法的执行需要经历上面的四步invokevirtual执行步骤

    Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。

    面对这种情况,一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能虚拟机栈 - 图35

    虚方法表中存放着各个方法的实际入口地址

    虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。(非虚方法表不需要,因为直接可以找到)

    如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。

    如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。在图8-3中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。

    但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型