03-虚拟机栈

2022-07-18

03-虚拟机栈

JVM之运行时数据区(二)-- 虚拟机栈

一. 简介

1.1 内存中的栈和堆

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

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放,放哪里。

1.2 虚拟机栈初识

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,栈是线程私有的。

Java虚拟机是以方法作为最基本的执行单元,一个方法对应一个"栈帧"。

虚拟机栈生命周期和线程一致,也就是线程结束了,该虚拟机栈也销毁了。

虚拟机栈的作用

  • 主管Java程序的运行,它保存方法的局部变量(8 种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
  • 局部变量,它是相比于成员变量来说的(或属性)
  • 基本数据类型变量 VS 引用类型变量(类、数组、接口)

1.3 虚拟机栈的特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
  • JVM直接对Java栈的操作只有两个:
    • 每个方法执行,伴随着进栈(入栈、压栈)
    • 执行结束后的出栈工作
  • 对于栈来说不存在垃圾回收问题
    • 栈不需要GC,但是可能存在OOM

1.4 虚拟机栈的异常

Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常。
  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutofMemoryError 异常。

1.5 设置栈内存大小

我们可以使用参数 -Xss 选项来设置每个线程堆栈的大小,栈的大小直接决定了函数调用的最大可达深度。

举例说明:

/**
 * @Author: M˚Haonan
 * @Date: 2021/6/24 22:46
 * @Description: 栈内存大小设置测试
 *
 * 当不设置栈内存大小,使用默认的大小时, window10为1024k,输出为:方法第6432次调用
 * 设置 -Xss2048k  输出为:方法第12414次调用
 *
 */
public class StackSettingTest {
    static int count = 0;

    public static void main(String[] args) {
        count ++;
        System.out.println("方法第" + count + "次调用");
        main(args);//java.lang.StackOverflowError
    }
}

二. 栈的基本组成

2.1 栈的存储单位

  1. 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
  2. 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
  3. 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

2.2 栈的运行原理

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

此外,还需要注意以下几点:

  1. 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
  2. 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
  3. Java方法有两种返回函数的方式。
    • 一种是正常的函数返回,使用return指令。
    • 另一种是方法执行中出现未捕获处理的异常,以抛出异常的方式结束。
    • 但不管使用哪种方式,都会导致栈帧被弹出。

2.3 栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或表达式栈)
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息

三. 局部变量表

3.1 初识

  1. 局部变量表也被称之为局部变量数组或本地变量表
  2. 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress返回值类型。
  3. 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  4. 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
  5. 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。
    • 对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。
    • 进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  6. 局部变量表中的变量只在当前方法调用中有效。
    • 在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
    • 当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

3.2 详解

如下的方法中:

public void test1(String str) {
    int a = 1;
    int b = 2;
}

局部变量表最大长度为4,分别为this,str,a,b

需要注意的是,对于非静态方法,默认第一个槽位slot为当前对象的引用,即this,接下来才是参数和局部变量。这也是非静态方法可以使用this的原因,而静态方法没有

image-20210625001503601

3.3 关于slot

  1. 参数值的存放总是从局部变量数组索引 0 的位置开始,到数组长度-1的索引结束。

  2. 局部变量表,最基本的存储单元是Slot(变量槽),局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

  3. 在局部变量表里,

    32位以内的类型只占用一个slot

    (包括returnAddress类型),

    64位的类型占用两个slot

    (1ong和double)。

    • byte、short、char在储存前被转换为int,boolean也被转换为int,0表示false,非0表示true
    • long和double则占据两个slot
  4. JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值

  5. 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上

  6. 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)

  7. 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。(this也相当于一个变量)

3.4 slot的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明新的局部变量变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

举例说明如下:

public void test2() {
    int a = 1;
    {
        int b = 2;
        b = a + 1;
    }
    int c = a + 1;
}

该方法中,b的作用域没有覆盖整个方法,到c声明的时候,b已经消亡了,因此c会重用b的slot

image-20210625002135318

3.5 静态变量与局部变量对比

  1. 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
  2. 我们知道类变量有两次初始化的机会**,**第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。
  3. 和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。

3.6 其他知识

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

四. 操作数栈

4.1 操作数栈的特点

  1. 每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last - In - First -Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)

  2. 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)

    • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈,
    • 比如:执行复制、交换、求和等操作

4.2 操作数栈的作用

  1. 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  2. 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这时方法的操作数栈是空的。
  3. 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值。
  4. 栈中的任何一个元素都是可以任意的Java数据类型
    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  5. 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。只不过操作数栈是用数组这个结构来实现的而已
  6. 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
  7. 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
  8. 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

4.3 举例说明

public void testAddOperation() {
    int i = 8;
    int j = 15;
    int k = i + j;
}
 0 bipush 8
 2 istore_1
 3 bipush 15
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return

上述方法和对应的字节码指令,其中操作数栈的最大深度为2

下面来一次解读每条指令和操作数栈的执行情况

  1. bipush 15
    pc寄存器指向指令0,这一步就是把15放到了操作数栈中,局部变量表为空

    image-20210630004506722

  2. istore_1

    pc寄存器指向2,这一步是把15从操作数栈中取出,对应的变量i放入局部变量表索引为1的位置(0为this)

    image-20210630004757552

  3. bipush 23 和istore_2 同上,不再赘述

    image-20210630005059479

  4. iload_1

    pc寄存器指向6。这一步是把之前存储的变量i从局部变量中获取,压入操作数栈中

image-20210630005134355

  1. iload_2同理

    image-20210630005215614

  2. iadd

    这一步需要执行引擎计算求和,并把结果23压入操作数栈中

    image-20210630005344039

  3. istore_3

    把结果23保存到局部变量表索引为3的位置

    image-20210630005550283

  4. return

    方法执行结束,返回

4.4 其他知识

关于类型转换:

  • 8 在 byte 范围内,所以压入操作数栈的类型为 byte ,而不是 int ,所以执行的字节码指令为 bipush 8
  • 但是存储在局部变量的时候,会转成 int 类型的变量:istore_2
  • 如果是800,就到了short的范围,对应的指令为sipush 800

被调用的方法带有返回值:

返回值入操作数栈,如下所示:

public void testReturn() {
    int a = createEle();
    int c = 12;
    int d = a + c;
}

private int createEle() {
    int b = 1;
    return b;
}
 //testReturn()
 0 aload_0
 1 invokespecial #2 <jvm/runtime_area/ExpressionStackTest.createEle>
 4 istore_1
 5 bipush 12
 7 istore_2
 8 iload_1
 9 iload_2
10 iadd
11 istore_3
12 return

//createEle()
0 iconst_1
1 istore_1
2 iload_1
3 ireturn

如上所示的代码和字节码指令中,aload_0就是从操作数栈中加载createEle方法的返回值。

五. 栈顶缓存技术

栈顶缓存技术:Top Of Stack Cashing

  1. 前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数(也就是你会发现指令很多)和导致内存读/写次数多,效率不高。
  2. 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
  3. 寄存器的主要优点:指令更少,执行速度快,但是指令集(也就是指令种类)很多

六. 动态链接

动态链接(或指向运行时常量池的方法引用)

  1. 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic指令

  2. 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

  3. 在class文件的常量池的符号引用中,有一些能够在编译阶段就唯一确定可调用版本,那么这些符号引用会在类加载阶段或者第一次使用的时候被转换为直接引用。另一部分则不能确定,只有在每一次运行期间才能确定转化为直接引用,这部分就成为动态链接。

    静态解析的部分即为前面类加载过程中第二部linking中的解析

七. 方法的调用

7.1 静态链接与动态链接

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

  • 静态链接

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

  • 动态链接

如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。

7.2 早期绑定与晚期绑定

静态链接与动态链接针对的是方法。早期绑定和晚期绑定范围更广。早期绑定涵盖了静态链接,晚期绑定涵盖了动态链接。

静态链接和动态链接对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  • 早期绑定

早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用

  • 晚期绑定

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

7.3 虚拟机中调用方法的指令

  • 普通指令:
  1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
  2. invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
  3. invokevirtual:调用所有虚方法
  4. invokeinterface:调用接口方法,会在运行时在确定一个实现该接口的对象
  • 动态调用指令

invokedynamic:动态解析出需要调用的方法,然后执行

java8的lamda表达式即用了该指令

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预。而invokedynamic指令则支持由用户确定方法版本。

其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法

静态方法,私有方法,实例构造器,父类方法,final修饰的方法。这5种是非虚方法,编译器解析唯一确定。其余的都是虚方法,为动态链接

class Father {
    public Father() {
        System.out.println("father的构造器");
    }

    public static void showStatic(String str) {
        System.out.println("father " + str);
    }

    public final void showFinal() {
        System.out.println("father show final");
    }

    public void showCommon() {
        System.out.println("father 普通方法");
    }
}

public class Son extends Father {
    public Son() {
        //invokespecial
        super();
    }

    public Son(int age) {
        //invokespecial
        this();
    }

    //不是重写的父类的静态方法,因为静态方法不能被重写!
    public static void showStatic(String str) {
        System.out.println("son " + str);
    }

    private void showPrivate(String str) {
        System.out.println("son private" + str);
    }

    public void show() {
        //invokestatic
        showStatic("atguigu.com");
        //invokestatic
        super.showStatic("good!");
        //invokespecial
        showPrivate("hello!");
        //invokespecial
        super.showCommon();

        //invokevirtual
        showFinal();//因为此方法声明有final,不能被子类重写,所以也认为此方法是非虚方法。
        //虚方法如下:
        
        /*
        invokevirtual  你没有显示的加super.,编译器认为你可能调用子类的showCommon(即使son子类没有重写,也          会认为),所以编译期间确定不下来,就是虚方法。
        */
        showCommon();
        info();

        MethodInterface in = null;
        //invokeinterface
        in.methodA();
    }

    public void info() {

    }

    public void display(Father f) {
        f.showCommon();
    }

    public static void main(String[] args) {
        Son so = new Son();
        so.show();
    }
}

interface MethodInterface {
    void methodA();
}

7.4 动态语言和静态语言

  1. 动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。
  2. 说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。

7.5 分派

7.5.1 静态分派

静态分派是重载的本质。所有依赖静态类型来决定方法执行版本的分派动作,称为静态分派。

静态类型和实际类型

Human man = new Man();

Human称为变量的静态类型;Man称为变量的实际类型。

最终的静态类型是在编译器确定的,而实际类型变化的结果只有在运行期才知道。

重载版本的确定是根据静态类型确定的,即是在编译期就能确定调用哪个方法

public class OverloadTest {

    static abstract class Human {

    }

    static class Man extends Human {

    }

    static class Woman extends Human {

    }

    public void sayHello(Human guy) {
        System.out.println("hello guy");
    }

    public void sayHello(Man guy) {
        System.out.println("hello man");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello woman");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        OverloadTest o = new OverloadTest();
        //输出均为hello guy,编译期即根据静态类型Human确定调用的方法版本
        o.sayHello(man);
        o.sayHello(woman);
    }
}

对应的字节码如下所示:(仅列出调用方法的几句)

26 invokevirtual #13 <jvm/runtime_area/OverloadTest.sayHello>
29 aload_3
30 aload_2
31 invokevirtual #13 <jvm/runtime_area/OverloadTest.sayHello>
34 return

#13所代表的是image-20210706214216006

需要注意的是:

很多情况下重载的版本并不是唯一的,往往只能确定一个相对合适的版本。产生这种模糊的主要原因是字面量天生的模糊性,字面量没有显示的静态类型,它的静态类型只能通过语言,语法的规则去理解和推断,如下所示:

public class OverloadTest2 {
    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }

    public static void sayHello(int arg) {
        System.out.println("hello int");
    }

    public static void sayHello(long arg) {
        System.out.println("hello long");
    }

    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }

//    public static void sayHello(char arg) {
//        System.out.println("hello char");
//    }

    public static void sayHello(char... arg) {
        System.out.println("hello char...");
    }

    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }

    public static void main(String[] args) {
        //1. 输出hello char
        sayHello('a');
        //2. 注释掉sayHello(char arg), 输出hello int
        //3. 注释掉sayHello(int arg),输出hello long
        //4. 注释掉sayHello(long arg),输出hello Character
        //5. 注释掉sayHello(Character arg),输出Serializable
            //因为Serializable是Character的一个接口
        //6.注释掉sayHello(Serializable arg),输出hello Object
        //7.注释掉sayHello(Object arg),输出hello char...
    }
}

7.5.2 动态分派

重写的本质是动态分派。

public class OverrideTest {

    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends OverrideTest.Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends OverrideTest.Human {

        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }


    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
        //man say hello
        //woman say hello
        //woman say hello
    }
}

对应的字节码指令如下所示:

 0 new #2 <jvm/runtime_area/OverrideTest$Man>
 3 dup
 4 invokespecial #3 <jvm/runtime_area/OverrideTest$Man.<init>>
 7 astore_1
 8 new #4 <jvm/runtime_area/OverrideTest$Woman>
11 dup
12 invokespecial #5 <jvm/runtime_area/OverrideTest$Woman.<init>>
15 astore_2
16 aload_1
17 invokevirtual #6 <jvm/runtime_area/OverrideTest$Human.sayHello>
20 aload_2
21 invokevirtual #6 <jvm/runtime_area/OverrideTest$Human.sayHello>
24 new #4 <jvm/runtime_area/OverrideTest$Woman>
27 dup
28 invokespecial #5 <jvm/runtime_area/OverrideTest$Woman.<init>>
31 astore_1
32 aload_1
33 invokevirtual #6 <jvm/runtime_area/OverrideTest$Human.sayHello>
36 return

从16行到21行,即为sayHello掉用的本质。从字节码上看到二者使用的指令是一摸一样的,因此无法从编译期知道具体指向哪个方法。只有在运行期,才能确定,这也是invokevirtual的意义所在。

解析过程主要分为以下几步:

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

需要注意的是:

重写多态的根源在于invokevirtual指令。因此对于字段而言,不存在重写。即字段不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。

当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。如下所示:

public class FiledHasNoPolymorphic {

    static class Father {
        public int money = 1;
        public Father() {
            money = 2;
            showMeTheMoney();
        }

        public void showMeTheMoney() {
            System.out.println("I am Father, i have $" + money);
        }
    }

    static class Son extends Father {
        public int money = 3;

        public Son() {
            money = 4;
            showMeTheMoney();
        }


        @Override
        public void showMeTheMoney() {
            System.out.println("I am Son, i have $" + money);
        }
    }

    public static void main(String[] args) {
        Father guy = new Son();
        System.out.println("This guy has $" + guy.money);
        //输出
        //I am Son, i have $0
        //I am Son, i have $4
        //This guy has $2
    }
}

分析如下:

在Son类创建的时候,首先隐式的调用了Father的构造方法。而Father构造函数中对showMeTheMoney调用是一次虚方法调用,实际调用的版本是Son::showMeTheMoney,所以输出的是I am son,并且这时候虽然父类的money已经被初始化为2了,但Son::showMeTheMoney方法中访问的却是子类的Money,结果自然是0.

main的最后一句通过静态类型访问到了父类的money,因此是2

7.5.3 总结

java语言是一门静态多分派,动态单分派的语言。

7.6 虚方法表

  1. 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)来实现,非虚方法不会出现在表中。使用索引表来代替查找。【上面动态分派的过程,我们可以看到如果子类找不到,还要从下往上找其父类,非常耗时】

  2. 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

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

  4. 例子1

    如图所示:如果类中重写了方法,那么调用的时候,就会直接在该类的虚方法表中查找

img

  1. 比如说son在调用toString的时候,Son没有重写过,Son的父类Father也没有重写过,那就直接调用Object类的toString。那么就直接在虚方法表里指明toString直接指向Object类。
  2. 下次Son对象再调用toString就直接去找Object,不用先找Son-->再找Father-->最后才到Object的这样的一个过程。

在上图中,Son重写了Father的全部方法,因此Son的方法表中没有指向Father类型数据的箭头。但是Son和Father都没有重写Object的方法,因此所有从Object继承来的方法都指向了Object的数据类型。

虚方法表本质上就是方法指向类型的一张表,如果重写了就指向本身类型,没有重写的方法指向该方法的原始类型。

八. 方法返回地址

在一些帖子里,方法返回地址、动态链接、一些附加信息 也叫做帧数据区

  1. 存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:
    • 正常执行完成
    • 出现未处理的异常,非正常退出
  2. 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
  3. 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
  4. 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

方法退出的两种方式

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

正常退出:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
  2. 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
  3. 在字节码指令中,返回指令包含:
    • ireturn:当返回值是boolean,byte,char,short和int类型时使用
    • lreturn:Long类型
    • freturn:Float类型
    • dreturn:Double类型
    • areturn:引用类型
    • return:返回值类型为void的方法、实例初始化方法、类和接口的初始化方法

异常退出:

  1. 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口
  2. 方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码

异常处理表:

  • 反编译字节码文件,可得到 Exception table
  • from :字节码指令起始地址
  • to :字节码指令结束地址
  • target :出现异常跳转至地址为 11 的指令执行
  • type :捕获异常的类型

九. 一些附加信息

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


标题:03-虚拟机栈
作者:mahaonan
地址:https://mahaonan.fun/articles/2022/07/18/1658147068770.html