JVM之运行时栈帧结构与方法调用

本篇文章主要从概念模型的角度介绍虚拟机的方法调用和字节码执行,以下大部分内容是笔者在《深入理解Java虚拟机》摘抄总结来的,旨在记笔记加深印象。

在Java虚拟机规范中指定了虚拟机字节码执行引擎的概念模型,这个概念模型称为各种虚拟机执行引擎的统一外观。在不同的虚拟机实现里,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。但从外观看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。在介绍虚拟机的方法调用和字节码执行前先来了解一下运行时栈帧结构。

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时出于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图:

栈帧的概念结构

下面详细介绍栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。

局部变量表

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

局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性的说到每个Slot都应该存放一个boolean、byte、char、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型都可以使用32位或更小的物理内存来存放,Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。只要保证在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致。

上面提到的reference类型表示对一个对象实例的引用,虚拟机规范即没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当通过这个引用做到两点:

  1. 从此引用中直接或间接地查找到对象在Java堆中得数据存放的起始地址索引;
  2. 此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束。

对于long和double两种64位的数据类型,虚拟机会以高位对齐的方法为其分配两个连续的Slot空间。局部变量表建立在线程的堆栈上,是线程私有的数据,因此无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的遍历,索引n就代表使用第n个Slot,如果是64位数据类型的变量,则会同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

为尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过,这样的设计除了节省栈帧空间以外,还会伴随这一些额外的副作用。例如,在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为。

在虚拟机运行参数中加上“-verbose:gc”,可以看到垃圾收集的过程,然后执行下面的代码并查看输出结果。(例子是《深入理解Java虚拟机》中的,建议跑一下加深印象)

//测试程序
public static void main(String[] args) {
    byte[] byteArr = new byte[64*1024*1024];
    System.gc();
}
//输出结果
[GC (System.gc())  68713K->66352K(121856K), 0.0014446 secs]
[Full GC (System.gc())  66352K->66202K(121856K), 0.0062438 secs]

可以看到并没有回收byteArr占用的内存,因为在执行System.gc()时,变量byteArr还在作用于内,修改代码如下再次运行。

//测试程序
public static void main(String[] args) {
    {
        byte[] byteArr = new byte[64*1024*1024];
    }
    System.gc();
}
//输出结果
[GC (System.gc())  68713K->66384K(121856K), 0.0012549 secs]
[Full GC (System.gc())  66384K->66202K(121856K), 0.0056940 secs]

加入大括号之后,byteArr的作用域被限制在大括号内,但内存还是没有被回收。在解释原因之前,再次修改代码如下再次运行。

//测试程序
public static void main(String[] args) {
    {
        byte[] byteArr = new byte[64*1024*1024];
    }
    int a = 0;
    System.gc();
}
//输出结果
[GC (System.gc())  68713K->66400K(121856K), 0.0008509 secs]
[Full GC (System.gc())  66400K->666K(121856K), 0.0051992 secs]

可以看到这次内存被正确回收了。事实上byteArr能否被回收的根本原因是:局部变量表中的Slot是否还存有关于byteArr数组对象的引用。在第一次修改代码后,虽然执行System.gc()时已经脱离了byteArr的作用域,但在此之前并没有任何对局部变量表的读写操作,byteArr原本所占用的Slot还没有被其他变量复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。因此我们在开始学习Java时会看到“将不使用的对象手动赋值为null”的建议。

这篇文章中曾经提到过类变量在准备阶段会被赋予其数据类型对应的零值,在初始化时赋予程序员定义的初始值。因此即便初始化时程序员没有为类变量赋值,其仍然具有一个确定的初始值。但局部变量没有赋初始值是不能使用的。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,是一个后入先出(Last In First Out,LIFO)栈。与局部变量表一样,操作数栈的最大深度在编译的时候就写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来运行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

例如整数加法的字节码指令iadd再运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数赋值传递。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中得动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载器阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接饮用,这部分称为动态连接。后面会详细介绍这两个转化过程。

方法返回地址

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

  1. 执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  2. 在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在笨方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。以这种方式退出的方法是不会给它的上层调用者产生任何返回值的。

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

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

方法调用

方法调用不等同于方法执行,方法调用阶段位移的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是是最普遍、最频繁的操作,但Class文件的编译过程中不包含传统编译中得连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(即直接引用)。这个特性给Java带来了更强大的动态扩展能力,但是也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期才能确定目标方法的直接引用。

解析

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

在Java语言中符合“编译器可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

静态方法、私有方法、实例构造器、父类方法和被final修饰的方法这五类都可以在解析阶段中确定唯一的调用版本,它们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法可以称为非虚方法。与之相反,其他方法称为虚方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数(后面会详细介绍)可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派和动态多分派4种分派组合情况。

分派

Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装和多态。下面讲解的分派调用过程会揭示多态性特征的一些最基本的提现,如“重载”和“重写”在Java虚拟机中是如何确定正确的目标方法的。

静态分派

在讲解静态分派前,我们先看下面这段代码和输出结果。

//测试程序
public class TestClass {

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

    public static void sayHello(Human human){
        System.out.println("hi guy!");
    }

    public static void sayHello(Man man){
        System.out.println("hi man!");
    }

    public static void sayHello(Woman woman){
        System.out.println("hi woman!");
    }
}

abstract class Human{
}

class Man extends Human{
}

class Woman extends Human{
}

//输出结果
hi guy!
hi guy!

这是一段考验重载的代码,从最后的输出结果来看执行的是参数类型为Human的重载方法,在解释为什么会调用这个方法而不是其他两个方法之前先了解以下两个概念。

Human man = new Man();

我们把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可能发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如以下代码:

//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
sayHello((Man)man);
sayHello((Woman)man);

回到之前的问题,使用哪个重载版本完全取决于传入参数的数量和数据类型,编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译器可知的,因此,在编译阶段,编译器会根据参数的静态类型决定使用哪个重载版本,所以在上面的代码中选择了sayHello(Human)作为调用目标。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,虽然编译器能确定出方法的重载版本,但有时候这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。原因在于字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规则去理解和推断。例如下面的代码演示了什么叫做“更合适的”版本:

//测试程序
public class Overload {

	public static void main(String[] args) {

		sayHello('a');
	}

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

	public static void sayHello(Object arg){
		System.out.println("hi object!");
	}

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

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

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

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

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

//输出结果
hi char!

'a'是一个char类型的数据,所以调用了参数类型为char的重载方法。如果注释调sayHello(char arg)方法,那输出就变为:

hi int!

这时发生了一次自动类型变换,‘a’除了可以代表一个char,还可以代表数字97,因此在没有参数类型为char的重载方法的情况下,调用参数类型为int的重载方法也是合适的。如果再注释掉sayHello(int arg)方法,输出变为:

hi long!

这时发生了两个自动类型转换,'a'转型为数字97之后,进一步转型为长整数97L,匹配了参数类型为Long的重载。自动类型转换可以发生多次,按照char->int->long->float->double的顺序进行匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的类型转换是不安全的。再注释掉sayHello(long arg)方法,输出变为:

hi Character!

这时发生了一次自动装箱,'a'被包装为它的封装类型java,lang.Character,所以匹配到了参数类型为Character的重载。继续注释掉sayHello(Charactere arg)方法,输出变为:

hi Serializable!

出现上面的结果是因为java.io.Serializable是java.lang.Character类实现的一个接口。当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以又发生了一次自动转型。这里需要注意的是,char可以转型为int,但Character是不会转型为Integer的。而Character除了实现Serializable外还实现了Comparable<Character>,如果此处还有一个参数类型为Comparable<Character>的重载方法,那它们在此时的优先级是一样的,编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译,以下为编译器的错误提示:

Error:(17, 17) java: 对sayHello的引用不明确, com.jimmysun.Overload中的方法 sayHello(java.lang.Comparable<java.lang.Character>)和com.jimmysun.Overload中的方法 sayHello(java.io.Serializable)都匹配

因此发生以上情况时,程序必须在调用时显示的指定字面量的静态类型,如:sayHello((Comparable<Character>) 'a'),才能编译通过。再把sayHello(Serializable arg)方法注释后,输出变为:

hi object!

这时是因为char装箱后转型为父类了,如果有多个父类,将在继承关系中从下往上开始搜索,越接近上层的优先级月底。即使方法调用传入的参数值为null时,这个规则依然使用。再把sayHello(Object arg)注释调,输出结果变为:

hi char...!

可见可变长参数的重载优先级是最低的,这时候字符'a'被当作了一个数组元素。

以上可以得到参数类型为字面量的方法的重载的优先级由高到低为:字面量类型->自动类型转换类型(例如char->int->long->float->double)->封装类型(例如char的封装类型为Character)->封装类型实现的接口(如果有多个接口,优先级相同)->封装类型的父类(继承关系由下往上,越接近上层,优先级越低)->可变长参数。

动态分派

动态分派和重写(Override)有着很密切的关系,还是用前面的Man和Woman举例。

//测试程序
public class TestClass {

    public static void main(String[] args) {

        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}

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

class Man extends Human{

	@Override
	protected void sayHello() {
		System.out.println("hi man!");
	}
}

class Woman extends Human{
	@Override
	protected void sayHello() {
		System.out.println("hi woman!");
	}
}

//输出结果
hi man!
hi woman!
hi woman!

上面的结果并没有出乎我们的意料,但虚拟机怎么知道要调用哪个方法呢?与静态分派不同,上面代码中相同的静态类型(Human)的两个变量man和woman调用sayHello()方法时执行了两个不同的行为,并且变量man在两次调用中执行了不同的方法,由此我们可以推断出导致这个现象的原因与静态类型无关而与变量的实际类型有关。

对以上代码进行反编译就会发现在执行sayHello()方法时执行的是invokevirtual指令,而invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

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

单分派和多分派

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多余一个宗量对目标方法进行选择。从下面的例子中来理解单分派和多分派。

//测试程序
public class Test4 {

    public static void main(String[] args) {

        Father father = new Father();
        Father son = new Son();
        father.chooseCar(new Benz());
        son.chooseCar(new Audi());
    }
}

class Benz{}
class Audi{}

class Father{

    public void chooseCar(Benz car){
        System.out.println("father choose Benz");
    }

    public void chooseCar(Audi car){
        System.out.println("father choose Audi");
    }
}

class Son extends Father{

    public void chooseCar(Benz car){
        System.out.println("son choose Benz");
    }

    public void chooseCar(Audi car){
        System.out.println("son choose Audi");
    }
}

//输出结果
father choose Benz
son choose Audi

在静态分配过程中,选择目标方法的一句有两点:一是静态类型是Father还是Son,二是方法参数是Benz还是Audi。从代码中可以看到静态类型都是Father,参数分别为Benz和Audi,这次选择最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中国指向Father.chooseCar(Benz)及Father.chooseCar(Audi)的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

在动态分派过程中,在执行“son.chooseCar(new Audi())”这句代码时,由于编译器已经决定目标方法的签名必须为chooseCar(Audi),这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值