理解JVM运行时数据区(三)Java虚拟机栈

513 阅读19分钟

前言

前面我们对Java运行时数据区的一些基本概念做了简单介绍,具体可看理解JVM运行时数据区(一)概念。并讲解了程序计数器,可以查看理解JVM运行时数据区(二)程序计数器。接下来这篇文章我们将对运行时数据区的虚拟机栈做一个深入的了解。

什么是Java虚拟机栈

Java虚拟机栈,早期也称为Java栈。在Java中,虚拟机栈解决程序的运行问题,即程序如何运行,或者说如何处理数据。它是线程私有的,生命周期与线程相同。

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储对应方法的信息。每一个方法的执行到完成,对应着一个栈帧在虚拟机中从入栈到出栈的过程

可以先简单理解:

Java中每个线程运行的时候都需要给线程划分一块存放方法调用或执行信息的内存空间。一个线程运行就需要一个,多个线程运行就需要多个。我们把这个内存空间定义为虚拟机栈。而这里面存放的运行方法的信息就称为栈帧。而每当执行一个方法,就会创建对应方法的栈帧压入到虚拟机栈内,方法结束后,就把该方法对应的栈帧弹出。

什么是栈帧

1649599311(1).jpg

栈帧是用于支持虚拟机栈进行方法调用和方法执行的数据结构,它是虚拟机栈的栈元素。每当方法被执行的时候,Java虚拟机就会创建一个存储方法的信息的栈帧,然后压入到虚拟机栈里面去,等到方法执行完成或者抛出异常了,就会将当前方法对应的栈帧出栈。每个方法都各自对应中一个栈帧

每个栈帧里都存储了对应方法的局部变量表操作数栈动态连接、和方法返回地址等信息。

在java文件被编译成.class文件时。局部变量表所需的内存空间大小和操作数栈的深度就已确定,在方法运行期间不会改变。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响。

在活动线程中,只有位于栈顶的栈帧才是有效的,也成为当前栈帧。正在执行的方法称为当前方法执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。不同的线程包含的栈帧不允许存在相同引用。

如果方法中调用了其他方法,对应的新的栈帧就会被创建出来,并放在栈顶,称为新的当前栈帧

这边栈帧的出入栈可以这样理解:比如说

  • 我调用了方法A,这时候会创建方法A的栈帧A,然后压入虚拟机栈。也就是入栈
  • 方法A又调用了方法B,于是创建方法B的栈帧B,然后再压入虚拟机栈。
  • 方法B又调用了方法C,于是创建方法C的栈帧C,然后再压入虚拟机栈。
  • 方法C执行结束,就会把栈帧C内存释放掉,也就是出栈。此时方法B的栈帧B就变成栈顶元素了。
  • 方法B执行结束,就会把栈帧B出栈。此时方法A的栈帧A就变成栈顶元素了。
  • 方法A执行结束,就会把栈帧A出栈
    • 此时如果还有其他方法的栈帧,则其他方法的栈帧变为栈顶元素。
    • 如果栈里面没有栈帧了,则该线程就运行结束。

image.png

局部变量表

局部变量表也可以称为局部变量数组或本地变量表,它是一组变量值的存储空间,定义为一个数字数组, 主要用于存储方法入参定义在方法体内的局部变量。局部变量表所需的容量大小是在编译期就确定下来的。在方法运行期间是不会改变局部变量表的大小的。

特点:

  • 这些数据类型包括各类基本数据类型(boolean、byte、char、short、int、float)、对象引用(reference) ,以及returnAddress类型。reference类型表示对一个对象实例的引用。

  • 局部变量表是建立在虚拟机栈上的,是线程私有数据,因此不存在数据安全问题。

  • 方法嵌套的次数由栈的大小决定。一般来说,栈越大方法嵌套的次数就越多。对一个函数而言,它的参数和局部变量越多,就会使得局部变量表变大,那它的栈帧就越大。进而函数的调用就会占用更多的栈空间,导致其嵌套的的次数减少。

  • 局部变量表中的变量旨在当前调用方法中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

这边我们创建这样一个方法,看下方法对应的字节码

public void test(String name,int age){
    int aa = 1;
}

image.png

红色方框内的就是局部变量表的信息: (其中里面的slot,等下再来解释,这边理解为占用第几个槽。)

  • this,java在调用非静态方法的时候会默认传入this,放在第0个位置,也就是局部变量表[0]
  • nameage,为外部传入的参数。分别放在第一个位置和第二个位置。
  • aa 为在方法体里面定义的参数。放在第三个位置。 可以看到,在编译成class的时候,局部变量表的元素都已经确定了,并且其占用在那个槽也都确定下来了。

关于默认持有this的说明

  • 如果是一个非静态方法或者是构造方法,那么会将当前方法所属的类对象的引用this放在index为0的slot处,其余的参数按照参数表顺序继续排列。
  • 如果是内部类的话,内部类的构造方法上会默认有两个参数,分别是内部类自己的引用,和外部类的引用。
  • 如果是静态方法则不会传入this

下面我们根据上面说到这三种方法,来看下this参数情况:

可以通过javap -v XXXX.class查看编译后的字节码文件信息

1. 无参构造方法
public class Test {

    public Test() {
    
    }
}

通过反编译查看字节码文件: image.png

可以看到默认会传入this,这里的this就是当前类Test的引用。

2. 带参的构造方法:
public class Test {

    public Test(int a) {

    }
}

image.png 可以看到带参的依然会有传入this,并且放在第一个位置。而本身定义的方法入参a则放在第二个位置。

3. 内部类的构造方法
public class Test {

    public Test(int a) {

    }

    class Demo1{

        public Demo1(){

        }

    }

}

image.png

这边我们之查看内部类的字节码,可以看到在字节码文件里面,构造方法会传入一个com.zhz.leetcode.test.Test,这个就是外部类引用的类型,但是从上面的代码可以看到我们在创建内部类的时候,并没有传入。这是编译器自动帮我们完成的。

并且在下面的局部变量表里面可以看到有两个变量,一个是内部类的引用,还有一个是外部类的引用。这也就是大家常说的内部类会隐式持有外部类的引用

4. 匿名内部类构造方法

当然这边还有另一种情况就是匿名内部类:这边我们先创建一个接口来做匿名内部类。

public interface TestImpl {

    public void getName();

}

public class Test {

    public Test(){
        new TestImpl() {
            @Override
            public void getName() {
                System.out.println("aaa");
            }
        };
    }

}

然后反编译下,看下字节码

image.png 注意这边的的Test$1,匿名内部类在编译成.class的时候,会以类$数字来作为class的名称。

所以要使用 javap -v Test$1.class来查看。

通过上面的字节码可以看到匿名内部类的的构造方法的局部变量表里面存放着两个参数,一个是当前匿名内部的引用,还有一个就是外部类的引用。这也解释了为什么匿名内部类会隐式持有外部类的对象,为什么我们能在匿名内部类里面调用外部类的方法

5. 静态方法
public class Test {

    public static void main(String[] args) {
        
    }

}

image.png 这边可以看到,静态方法的局部变量表里面只有一个参数,就是String数组的引用,而没有this对象。

什么是槽slot

局部变量表的容量以变量槽(variable slot,下称slot)为最小单位,虚拟机规范中并没有明确指明一个slot应占用的内存空间大小,只是很有“导向性”地说明每个slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。

  • 参数值的存放总是在局部变量表数组的index 0 开始,到数组长度-1的索引结束。
  • byte、short、char在存储前被转换为int,boolean也被转为int,0表示false,非0表示true
  • long和double则占据两个slot
  • JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中置顶的局部变量值
  • 当一个实例方法被调用的时候,他的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot中。
  • 栈帧中的局部变量表的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量表的槽位,从而达到节省资源的目的。

这边我们可以通过字节码来查看以下代码:

public class Test {
    
    public Test(int a,double b,String s,boolean isTest){

    }
}

image.png 可以看到

  • 参数a占用一个slot,处在第一个位置
  • 参数b为double类型,处在第2个位置,但是下一个String的参数处于第4个位置,这边可以看出double占据了两个slot。 这边只简单列举少数的类型,如果有兴趣可以,大家可以自行去实践下反编译查看不同的类型占用情况。

操作数栈

也可称为表达式栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。时JVM执行引擎的一个工作区。当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法执行过程中,根据字节码指令往栈中写入数据或提取数据,也就是出栈 / 入栈操作。我们常说的java是基于栈来执行的,这边的栈指的就是操作数栈注意Android中的dalvik和ART虚拟机就不是基于栈的,而是基于寄存器的,这块我们后面会说到。

  • 栈中的任何一个元素都是可以任意的Java数据类型。

    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈,比如:执行复制、交换、求和等操作。

  • 如果被调用的方法带有返回值的话,其返回值将会被压入到当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的指令。

    • 比如方法A调用了方法B,那么方法B结束后的返回值会被压入到方法A的操作数栈中,然后程序计数器更新为A方法下一条执行指令,这样就完成了方法A对方法B的调用和取到方法B的返回值。
  • 栈的深度在编译期就已经确定。

  • 栈在方法刚开始执行时被创建,创建时为空。

image.png

这边的stack=1指的就是栈的深度。

另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在很多虚拟机的实现里都会进行一些优化,另两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠再一次,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接公用一部分数据,无须进行额外的参数复制传递。重叠过程如下图所示:

79496312151320f17d5c8a36ab295a8.jpg

动态连接

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

在Java源文件被编译为字节码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分称为动态连接

简单描述就是:如果被调用的目标方法在编译期无法确定下来,只能够在程序运行时将方法的符号引用转换为直接引用,这种转换过程具备动态性,所以被称之为动态链接

上面说到的直接引用,在被转化前是有前提的,这个前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来了。

在了解静态解析和动态连接之前,我们需要先了解下面5条字节码指令:

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

  • invokespecial: 调用init方法(构造方法)、私有及父类方法,解析阶段确定唯一方法版本

  • invokevirtual: 调用所有虛方法

  • invokeinterface: 调用接口方法

  • invokedynamic: 先在在运行时动态解析出调用点限定符所引用的方法,然后再执行

我们先看下下面代码的字节码

public class Test  {

    public Test(TestImpl impl){
        String str = impl.getName();
        Test.testStatic(str);
        startRunnable(() -> { });
        testVirtual();
    }

    static void testStatic(String str){

    }

    private void startRunnable(Runnable run){

    }
    
    public void testVirtual(){

    }

}

image.png

这边我们对这几个指令做了简单的演示,这边出现的这些指令,与我们上面的指令描述是一样的,对于不同类型的方法,会使用不同的指令。

其中只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“虚方法”,与之相反的,其他方法就被称为“非虚方法”。

大家可以再看下上面的图片里, invokexxxxx 后面会跟着#1、#2 等。这是因为编译为字节码文件后,此时方法只是一个符号引用,指向我们字节码文件的常量池里面的数据。对于非虚方法,在类加载的解析阶段会把涉及的符号引用转换为明确的直接引用。而对于调用非虚方法invokevirtual指令呢?(非final修饰的方法)

我们来看下下面这段代码

public class Test {

    public static void main(String[] args) {
        Father father = new Father();
        father.printName();
        Father son = new Son();
        son.printName();
    }

}

class Father {

    void printName() {
        System.out.println("我是Father!!");
    }
}

class Son extends Father{

    @Override
    void printName() {
        System.out.println("我是Son!!");
    }
}

先看下它的字节码:

image.png 可以看到这边的方法符号引用都是Father.printName:(),那么在实际运行的时候是去调用Father里面的方法么? 来看下打印结果:

image.png

因为涉及的知识比较多,这边我们只简单讲下

针对上面代码里的Father son = new Son(); 首先,我们把Father称为变量的静态类型或者外观类型,后面的Son则称为变量的实际类型或者运行时类型。对于变量的实际类型,只有在运行时才可以确定,编译器在编译期间并不知道对象的实际类型是什么。而在程序运行时是根据变量的实际类型来决定最终方法的调用。

而根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步:

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C;
  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
  • 否则,按照继承关系从下往上以此对C的各个父类进行上一步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

这样就能解释我们上面说到的为什么字节码里面显示都是Father.printName:()方法的符号引用,但是在实际运行时执行的是new出来的对象里面的方法。

其实上面的这个小例子也解释了动态连接的作用,就是在运行时根据变量的实际类型将符号引用转换为直接引用的。

这边由于本人的知识量有限,有一些理解可能不够到位,因此如果有描述错误的地方,欢迎大家指出。

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法:

  • 遇到返回的字节码指令
  • 执行过程遇到异常

如果是遇到返回的字节码指令的话,这时候可能会有返回值传递给上层方法调用者,方法在正常执行完成后需要使用何种方法返回指令需要根据方法返回值的实际数据类型来决定。在字节码指令中,返回指令包含ireturn(当返回值boolean、byte、char、short和int类型时使用)、lreturn、freturn以及areeturn,另外还有一个return指令声明为void的方法,实例初始化方法、类和接口的初始化方法使用。这种退出方法的方式称为正常调用完成 (Normal Method Invocation Completion)

如果是在执行过程中遇到异常,并且这个异常没有在方法体内得到妥善处理。也就是在代码中产生的异常没有在本方法的异常表中搜索到匹配的异常处理器,最会导致方法退出。如果是因为异常导致方法退出的话,是不会给上层调用的方法返回任何返回值。这种退出方法的方式称为异常调用完成(Abrupt Method Invocation Completion)

无论采用何种退出方法的方式,在方法退出之后都必须返回到最初方法被调用时的位置,程序才能继续执行。方法正常退出时,调用者的PC计数器的值就可以作为返回地址。而方法异常退出时,返回地址是要通过异常处理器来确定,栈帧中就一般不会保存这部分信息。

本质上,方法的退出就是当前方法的栈帧出栈的过程。此时可能执行的操作有:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值(如果有的话)压入调用者栈帧的操作数栈中
  • 调整PC计数器的值以指向方法调用指令的下一条指令等

这边使用可能是由于这个是基于模型的讨论,不同的虚拟机可能会有不同的落地实现。

附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。

虚拟机栈的异常错误有哪些?

  • StackOverflowError : 如果线程请求的栈深度大于虚拟机所允许的深度,则抛出异常

  • OutOfMenoryError : 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存则抛出异常

结束语

好了,关于java虚拟机栈的深入讲解就到这边,如果文中有描述错误的地方,请在评论区指出,大家可以一起讨论。

系列文章