03_虚拟机栈(方法栈)

虚拟机栈概述

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

栈是运行时的单位(线程绑定、方法相关),而堆是存储的单位。即,栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。

image-20200911114147108

基本内容

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

  • 生命周期和线程一致

  • 主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。

这里有个疑问,JVM的用户方法是否对应到native中是否是一对一的方法,如果不是,用户方法的栈帧就应该是JVM自己实现的一个数据结构?

栈的特点(优点)

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。JVM直接对Java栈的操作只有两个:

  • 每个方法执行,伴随着压栈
  • 执行结束后的出栈工作

对于栈来说不存在垃圾回收问题

开发中遇到的异常有哪些?

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

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将抛出一个StackOverflowError异常。
    • 我们可以使用参数-Xss选项来设置线程的栈容量,栈的容量直接决定了函数调用的最大可达深度。
  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutofMemoryError异常。

栈的存储单位-栈帧

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

栈执行原理

  • JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循"先进后出"/"后进先出"原则。

  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。

  • 如果在该方法中调用了其它方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。

  • 如果当前方法调用了其它方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

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

image-20200911145920934

1、局部变量表(Local Variables)

  • 局部变量表也被称之为局部变量数组或本地变量表。

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型在编译期即可确定,包括各类基本数据类型、对象引用(reference),以及returnAddress类型。

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

  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

  • 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束

  • 局部变量表,最基本的存储单元是slot(变量槽)

  • 在局部变量表里,32位以内的类型只占用一个slot(boolean、byte、char、short、int、float、reference和returnAddress),64位的类型(long和double)占用两个slot。

    • byte、short、char在存储前被转换成int,boolean也被转换成int,0表示false,非0表示true
  • long和double则占据两个slot

    • reference类型在不同虚拟机、不同机器上的长度可能不一样,有的32位有的64位

    • returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来代替了

  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

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

  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。

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

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

  • 如果当前帧时由构造方法或者实例方法创建的(非静态方法),那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。

    image-20200915152023282

    image-20200915153851804

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

    image-20200915152856343

    但是可能在垃圾回收方面会带来一些问题,如果失效作用域内的变量槽没有再次被赋值,那这个槽位就会一直持有对应的数据对象的引用,无法被GC;除非经过了JIT编译器的优化,JIT编译器会对这种情况进行优化,如果发现该变量没有发生逃逸且所属作用域已经失效,即判为引用失效,可以进行回收。

  • 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。

  • 成员变量:在使用前,都经历过默认初始化赋值

    • 类变量:linking的prepare阶段给类变量默认赋值;在initial阶段将程序员定义的值进行赋值
    • 局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用,编译错误
  • 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行的时候,虚拟机使用局部变量表完成方法的传递。

  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

2、操作数栈(Operand Stack)

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

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

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

    image-20200915155210392

    image-20200915155315502

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量(本地变量表中的变量)临时的存储空间。

  • 操作数栈就是JVM执行引擎的一个工作区(从本地变量表加载数据到操作数栈、对栈顶数据进行计算、把计算结果写回本地变量表并弹栈),当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。

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

  • 栈中的任何一个元素都是可以任意的Java数据类型(8种基本数据类型和引用类型)

    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问

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

  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

  • 另外,我们说Java虚拟机的解释引擎时基于栈的执行引擎,其中的栈指的就是操作数栈。

代码追踪

image-20200915160522240

image-20200915160613692

image-20200915160713714

image-20200915160758187

image-20200915160836515

栈顶缓存技术(ToS,Top-of-Stack Caching)

前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。

由于操作数时存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们**提出了(仅是提出?)**栈顶缓存(ToS,Top-of-Stack Caching)计数,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

3、动态链接(Dynamic Linking)

指向运行时常量池的方法引用。

  • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。
    • 这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。
    • 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
  • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池区中,在后续编译阶段会进行内存分配将符号引用替换为真实的直接引用,保存在运行时常量池,而在C/C++中,方法也是可以作为一个对象进行取址从而进行动态地传递和调用的,所以所有的方法符号引用也会被转换为直接内存引用保存在常量池中,此后在进行相关方法调用的时候,会根据编译期得到的信息判断它调用了哪些方法,从而将相关方法的直接引用也保存到其栈帧中。

image-20200915163545120

4、方法返回地址(Return Address)

  • 一个方法的结束,有两种方式:

    • 正常执行完成
    • 出现未处理的异常,非正常退出
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。

    • 方法正常退出时,调用者的pc计数器的值都作为返回地址,即调用该方法的指令的下一条指令的地址。所以这里的意思是发生方法调用的时候,调用方的PC计数器的值由被调用方进行保存,保存的位置就是当前被调用方栈帧中的"方法返回地址"内存区域,在弹出栈帧的时候重新取出该值调整后设置到PC计数器。(同时如果方法有返回值就压入到调用方的操作数栈中)
    • 而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
  • 当一个方法开始执行后,只有两种方式可以退出这个方法:

    1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口。

      • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
      • 在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short、int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
    2. 在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表(根据try catch代码块构建)中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。

      image-20200915182531902

      上述描述了一个异常表(通过classlib或者javap都可以查看),表示在4到16、19到21直接的字节码指令遇到任何(any)类型的异常都跳转到(goto)19字节码指令。

本质上,方法退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给它的上层调用者产生任何的返回值。

5、一些附加信息

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

栈的相关面试题

虚方法与非虚方法

  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时时不可变的。这样的方法称为非虚方法。

    静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。

  • 其它方法称为虚方法。

虚拟机中提供了以下几条方法调用指令:

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

前面4 条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

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

方法的调用:链接与绑定维度

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

  • 静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为"静态链接"。
  • 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为"动态链接"

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

  • 早期绑定:早期绑定就是被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是属于哪个class,因此也就可以使用静态链接的方式将符号引用直接转换为符号引用。
    • 例如在某个方法中对某个class的构造函数的调用,这个就是可以在编译期可以确定它指的就是某个具体class的某个构造函数,此时在编译该方法所属class的时候可以直接在其内存区域保存该构造函数转换后的直接引用即可。
    • 另外this.method()super.method的调用都是可以明确确定具体要调用的方法的。
    • final方法表示不可被重写,也是可以在早期绑定的。
    • 其它参考下面虚方法与非虚方法介绍
  • 晚期绑定:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型(class)绑定相关的方法,这种绑定方式也就被称之为晚期绑定(例如方法中调用的其它非final普通方法的引用绑定)。
    • 对于其它方法的调用都是动态绑定,因为Java是面向对象的,具有多态性,即使在调用某个方法的时候,该方法的所属(declared)类是没有子类的,但是Java的字节码是可以动态注入的,所以体现了final关键字的作用。故不能在调用普通方法的class的编译期就将该方法的符号引用替换为具体的直接引用,而是在运行期(创建栈帧)的时候根据传入的实际类型(该普通方法的所属class对象地址)获取到对应的方法引用进行绑定。

随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。

Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C语言中的虚函数(C中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。

方法的调用:解析与分派维度

解析

所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。

所有非虚方法都会在类加载的时候直接解析。

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种主要的方法调用形式:分派(Dispatch)调用则要复杂许多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。

分派

静态分派与重载

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

package demo;

/**
 * 方法静态分派演示
 */
public class StaticDispatch {
    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,gentleman!");
    }
    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}
//运行结果:
//hello,guy!
//hello,guy!

代码中的“Human”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type),后面的“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类型”(Runtime Type)。静态类型和实际类型在程序中都可能会发生变化,区别是:

  • 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;

    // 强制转换使静态类型变化,但是依旧是编译期可知
    sr.sayHello((Man) human) 
    sr.sayHello((Woman) human)
    
  • 而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

    // 实际类型变化
    Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
    

重载方法匹配优先级

字面量就没有显式的静态类型,它的静态类型只能通过语言、语法的规则去理解和推断。例如下面代码中的’a’。

package demo;

import java.io.Serializable;

/**
 * @author: honphan.john
 * @date: 2020/9/22 09:10
 * @description:
 */
public class Overload {
    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) {
        sayHello('a');
    }
}

上面的’a’会按照以下顺序通过本身的类型或者安全地转型之后得到一个静态类型结果进行匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。(可以通过一行行地注释代码得到验证)

  • char:首先对自身的类型作为静态类型进行匹配,如果没有匹配到就往下走

  • int>long>float>double:按照从左到右地优先级进行安全的宽化转型之后进行匹配,如果没有匹配到就往下走

  • Character:寻找装箱类型,如果还是没有就往下走

  • Serializable:寻找装箱类型实现的接口(Character还实现了一个Comparable接口,如果同时出现这两个重载方法,javac编译期将无法确定是哪一个,将无法编译通过,此时必须进行手动强转入参’a’为其中的一个接口类型)

  • Object:所有接口到找不到就寻找父类,按照继承关系从下到上一层层找,直到Object

  • char …:最后匹配多参类型

注:赋值为null的时候,只有装箱、接口、父类重载可以匹配。

动态分派与重写

package demo;
/**
 * 方法动态分派演示
 */
public class DynamicDispatch {
    static abstract class Human {
        protected abstract void sayHello();
    }
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }
    static class Woman extends 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到15步分别new了Man和Woman对象并存储到了本地变量表的1和2位置,16到21分别加载对应的对象引用到操作数栈顶,然后调用invokevirtual指令取操作数栈顶引用进行虚方法调用:

image-20200922092619242

invokevirtual指令的运行时解析过程大致分为以下几步:

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

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

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

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

    优化:由于在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能印象到执行效率。因此,为了提高性能,JVM采用在class对象的内存区域中建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现,使用索引表来代替查找,借助数组利用到CPU的缓存已经减少寻址次数。

    该虚方法表中存放着当前class的所有方法引用,如果有重写了父类的方法,则保存的是重写的方法引用;如果有未重写父类的方法,则保存的是父方法的引用。

    虚方法表会在类加载的链接(第三阶段-解析)阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。

正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

方法动态分派以及继承体系下同名字段的问题

package demo;

/**
 * 字段不参与多态
 */
public class FieldHasNoPolymorphic {
    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 gay = new Son();
        System.out.println("This gay has $" + gay.money);
    }
}
//输出
//I am Son,  i have $0
//I am Son,  i have $4
//This gay has $2

上面两句输出"I am Son",说明showMeTheMoney()方法都是调用到了Son的方法,此时方法栈中本地变量表第一个槽位压入的就是Son对象的引用,而第一句接着输出"i have $0"说明在Father()构造函数中money的赋值动作是赋值到了Father对象,因为此时Son#showMeTheMoney()money(即this.money)得到的值是0,因为Son#money还没有初始化。

这就奇怪了,对于showMeTheMoney()方法能调用到子类的方法这个还好理解,只能说在子类构造函数中对父类构造函数调用时,父类构造函数中栈帧的本地变量表压入的this是子类对象引用。但是为什么对于money的访问(即对字段的访问)又是访问到父类的呢?下面是父类<init>方法的字节码,可以看到在putfieldinvokevirtual之前都有一个aload_0指令加载this到操作数栈的,难道putfield之类对于字段访问的指令的相关aload_0都是无意义的?关于这个有待后面研究了。

image-20200922093825364

单分派和多分派

方法的接收者(invokevirtual或者invokeinterface之前通过aload_0加载到的对象引用)方法的参数统称为方法的宗量,这个定义最早应该来源于著名的《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

Java的静态分派是在编译阶段,根据方法所属的类的静态类型以及方法参数列表中的静态类型进行方法分派,涉及两个宗量,所以属于多分派。

在运行阶段中的动态分派过程,执行到invokevirtual指令的时候,此时虚拟机只关心方法所属的类的实际类型,所以只有一个宗量作为选择依据,所以属于单分派。

综上,称目前Java语言是一门静态多分派、动态单分派语言。

总结

可以认为解析调用和分派调用是对立的,一个方法只能是其中的一种调用方式。

静态分派和动态分派是合作关系,它们是分派调用的两个阶段,来共同确定最终要调用的方法是哪一个。

动态类型语言支持

动态语言

何谓动态类型语言[1]?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。那相对地,在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。

对于Java 来说,下面的代码合法性很明显。对于代码片段2正是说明了Java语言会在编译期做类型检查,导致该段代码编译不通过

//片段1:不合法
PrintStream obj = new Object();
obj.println("hello world");
//片段2:合法
PrintStream obj = System.out;
obj.println("hello world");

但是在ECMAScript(JavaScript)中情况则不一样,无论obj具体是何种类型,无论其继承关系如何,只要这种类型的方法定义中确实包含有println(String)方法,能够找到相同签名的方法,调用便可成功。

//片段2:合法
var obj = System.out;
obj.println("hello world");

产生这种差别产生的根本原因是Java语言在编译期间却已将println(String)方法完整的符号引用(本例中为一项CONSTANT_InterfaceMethodref_info常量)生成出来,并作为方法调用指令的参数存储到Class文件中,例如下面这个样子:

invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V

#4表示字节码常量池中的第4项常量,这个常量必定是一个"CONSTANT_InterfaceMethodref_info"方法符号引用,这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。

而ECMAScript等动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征。

它们都有自己的优点,选择哪种语言是需要权衡的事情。静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。

Java与动态类型

Java虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方面:JDK 7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量), 方法的符号引用在编译时产生, 而动态类型语言是在运行期才确定的

java.lang.invoke包

JDK 7时新加入的java.lang.invoke包是JSR 292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”(Method Handle)。与C/C++中的函数指针(Function Pointer),或者C#里面的委派(Delegate),如:

void sort(int list[], const int size, int (*compare)(int, int))

但在Java语言中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数,例如Java类库中的Collections::sort()方法就是这样定义的:

void sort(List list, Comparator c)

不过,在拥有方法句柄之后,Java语言也可以拥有类似于函数指针或者委托的方法别名这样的工具了:

package demo;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

/**
 * JSR 292 MethodHandle基础用法演示 * @author zzm
 */
public class MethodHandleTest {
    static class ClassA {
        public void println(String s) {
            System.out.println(s);
        }
    }

    public static void main(String[] args) throws Throwable {
        Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
        // 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。
        getPrintlnMH(obj).invokeExact("icyfenix");
    }

    private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
        // MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和 具体参数(methodType()第二个及以后的参数)。
        MethodType mt = MethodType.methodType(void.class, String.class);
        // lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
        // 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情。
        return MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
    }
}

以上代码中无论obj是何种类型(临时定义的ClassA抑或是实现PrintStream接口的实现类System.out),都可以正确调用到println()方法。

仅站在Java语言的角度看,MethodHandle在使用方法和效果上与Reflection有众多相似之处。不过,它们也有以下这些区别:

  • Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。

    • 在MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及invokeinterface)和invokespecial这几条字节码指令的执行权限校验行为(即将原本编译期就能确定的符号引用延迟到运行期由用户代码来确定)

    • 而这些动作对于Reflection API来说是一个JVM的底层动作,先对于它来说是抽象的,它是不需要关心的。

  • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅包含执行该方法的相关信息。用开发人员通俗的话来讲,Reflection是重量级,而MethodHandle 是轻量级。

  • 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施。

MethodHandle与Reflection除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提“仅站在Java语言的角度看”之后:Reflection API的设计目标是只为Java语言服务的,而MethodHandle 则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已,而且Java在这里并不是主角。

invokedynamic指令

JDK 7为了更好地支持动态类型语言引入了第五条方法调用的字节码指令invokedynamic, 将MethodHandle的示例代码反编译后并没有找到invokedynamic的身影,invokedynamic到底有什么应用呢? 某种意义上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4 条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题:把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实现, 另一个用字节码和Class中其他属性、常量来完成

每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamically-Computed Call Site)”, 这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7 时新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:

  1. 引导方法(Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)

    引导方法是有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用

  2. 方法类型(MethodType)

  3. 方法名称。

根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用到要执行的目标方法上(其实目前引导方法里面的实现还是最终会调到invoke包里面的api进行方法查找,然后将得到MethodHandle对象包装称一个CallSite)。下面例子说明invokedynamic指令的工作过程:

例1:自定义字节码查找方法逻辑

利用JDK源码中的一个字节码转换工具将特定格式的源文件的字节码生成一个包含invokedynamic指令的新的字节码。

以下是特定的源文件,先对它进行javac编译得到一个class文件

package demo.dynamic;

import java.lang.invoke.*;

public class InvokeDynamicTest {
    public static void main(String[] args) throws Throwable {
        INDY_BootstrapMethod().invokeExact("icyfenix");
    }
    //真正要执行的方法
    public static void testMethod(String s) {
        System.out.println("hello String:" + s);
    }
    //Boostrap方法,由Boostrap属性表中的属性指定
    public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
        return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
    }
    //获取方法描述符
    private static MethodType MT_BootstrapMethod() {
        return MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", null);
    }
    //获取引导方法的句柄
    private static MethodHandle MH_BootstrapMethod() throws Throwable {
        return MethodHandles.lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
    }
    //先获取引导方法的句柄,然后通过引导方法获取真正要执行方法的句柄,这个就是自定义寻找方法的实现,只不过这里放在了字节码层面来做,也就是说寻找逻辑可能在字节码层面就确定了,将无法在编译成字节码之前的源文件中定义逻辑,该逻辑一般由一门JVM语言来实现
    private static MethodHandle INDY_BootstrapMethod() throws Throwable {
        CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(MethodHandles.lookup(), "testMethod", MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
        return cs.dynamicInvoker();
    }
}

然后在OpenJDK源码的${SOURCE_CODE_HOME}/jdk/test/java/lang/invoke/indify/Indify.java下面找到Indify工具类,然后复制出来,编写以下代码主动调用该工具类的main方法或者java执行也可以,在参数中分别指定要转换的class文件的路径(即上面编译得到的文件)以及转换的class文件的存放路径

package demo.dynamic;

import java.io.IOException;

/**
 * @author: honphan.john
 * @date: 2020/9/22 12:28
 * @description:
 */
public class Test {

    public static void main(String[] args) throws IOException {
        System.out.println("main");
        Indify.main("--verify-specifier-count=1",
                /*INDY工具将Example.class转换成等效的class文件存放路径*/
                "--dest=/Users/zhonghongpeng/IdeaProjects/tech-learning/jvm/target/classes/indy",
                "--verbose",
                "--expand-properties", "--classpath", "${java.class.path}",
                /*输入class文件Example.class的全路径,然后使用javap打开该文件*/
                "/Users/zhonghongpeng/IdeaProjects/tech-learning/jvm/target/classes/demo/dynamic/InvokeDynamicTest.class");
        //Example.main();
    }
}

执行转换后,进入新的class文件所在目录,java执行新的class文件,可得到指定输出,testMethod()得到执行

zhonghongpeng@bogon dynamic % java -cp /Users/zhonghongpeng/IdeaProjects/tech-learning/jvm/target/classes/indy demo.dynamic.InvokeDynamicTest
hello String:icyfenix

下面分析新转换后的字节码是如何通过invokedynamic指令工作的:

首先可以看到的是在main方法中存在了一条invokedynamic指令,该指令指向了索引为124的常量池项。

image-20200922130539186

通过查看124项常量为CONSTANT_InvokeDynamic_info

image-20200922130731119

其结构为:

结构成员长度 结构成员类型 结构成员描述
u1 tag 值为18
u2 bootstrap_method_attr_index 值必须是对当前class文件中引导方法表的bootstrap_method[]数组的有效索引
u2 name_and_type_index 值必须是对当前常量池的有效索引,常量池在该索引处的项必须是CONSTANT_NameAndType_info结构,表示方法名称和方法描述符

查看该常量内容:

image-20200922131138608

可以看到name_and_type指向的CONSTANT_NameAndType_info结构描述的正是我们要动态寻找的方法testMethod,然后进一步查看Class文件的bootstrap_method属性表中的第0项内容,发现该项属性值为#120,指向了常量池中的一项CONSTANT_Methodhandle_info常量:

image-20200922131414368

查看该常量,它引用了#119CONSTATN_Methodref_info常量:

image-20200922131742684

CONSTATN_Methodref_info常量为:

image-20200922131954731

可以看到,它引用的就是测试代码中寻找方法testMethod()的句柄的方法INDY_BootstrapMethod

综上可以直到invokedynmaic方法可以通过CONSTANT_Invokedynamic_info常量中包含的对"被查找方法的符号引用常量"以及"查找方法逻辑的引导方法符号引用常量"一起实现动态地查找方法。

例2:lambda查找方法逻辑

来看下以下代码的字节码是怎么使用invokedynamic

package demo.dynamic;

/**
 * @author: honphan.john
 * @date: 2020/9/22 11:36
 * @description:
 */
public class LambdaTest {

    interface PrintTool {
        void println(String s);
    }

    public static void main(String[] args) {
        PrintTool printTool = System.out::println;
        printTool.println("icyfenix");
    }
}

直接查看main方法字节码,看到了invokedynamic指令

image-20200922132952160

查看它引用的#4CONSTANT_Invokedynamic_info常量项,可以看到此时内容和我们上面看到的不太一样了:

image-20200922133015064

进一步查看#35CONSTANT_NameAndType_info常量项,发现,此时该常量项指向了一个名为"println"、参数为java.io.PrintStream、返回值为demo.dynamic.LambdaTest$PrintTool的方法

image-20200922133029845

然后查看bootstrapmethod属性第0项,查找的是java.io.PrintStream#println方法

image-20200922133134602

另外我们看到最关键的Bootstrap Method为:java.lang.invoke.LambdaMetafactory#metafactory,代码如下:

    public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String invokedName,
                                       MethodType invokedType,
                                       MethodType samMethodType,
                                       MethodHandle implMethod,
                                       MethodType instantiatedMethodType)
            throws LambdaConversionException {
        AbstractValidatingLambdaMetafactory mf;
        mf = new InnerClassLambdaMetafactory(caller, invokedType,
                                             invokedName, samMethodType,
                                             implMethod, instantiatedMethodType,
                                             false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
        mf.validateMetafactoryArgs();
        return mf.buildCallSite();
    }

也就是说,Java8的lambda是通过该方法来动态寻找方法句柄的。

实战运行运用

package demo;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;

/**
 * @author: honphan.john
 * @date: 2020/9/22 14:24
 * @description:
 */
public class DynamicPractise {
    static class GrandFather {
        void thinking() {
            System.out.println("i am grandfather");
        }
    }
    static class Father extends GrandFather {
        @Override
        void thinking() {
            System.out.println("i am father");
        }
    }
    static class Son extends Father {
        /**
         * 请读者在这里填入适当的代码(不能修改其他地方的代码)实现调用祖父类的thinking()方法,打印"i am grandfather"
         */
        @Override
        void thinking() {
            //JDK 7 Update 9之前
            try {
                MethodType mt = MethodType.methodType(void.class);
                MethodHandle mh = MethodHandles.lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());
                mh.invoke(this);
            } catch (Throwable e) {
            }
            /*
            在JDK 7 Update 10之后
            这个逻辑在JDK 7 Update 9之后被视作一个潜在的安全性缺陷修正了,原因是必须保证findSpecial()查找方法版本时受到的访问约束(譬如对访问控制的限制、对参数类型的限制)
            应与使用invokespecial指令一样,两者必须保持精确对等,包括在上面的场景中它只能访问到其直接父类中的方法版本。
            */
            try {
                MethodType mt = MethodType.methodType(void.class);
                Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
                lookupImpl.setAccessible(true);
                MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
                mh.invoke(this);
            } catch (Throwable e) {
            }
        }
    }

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


  1. 注意,动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待。 ↩︎


   转载规则


《03_虚拟机栈(方法栈)》 阿钟 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录