JVM虚拟机(六)虚拟机字节码执行引擎

所有Java虚拟机的执行引擎都是一致的:输入的字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。以下主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。

1.运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量,操作数栈、动态链接和方法返回地址等信息。

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下:


1.1局部变量表

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

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

既然前面提到了Java虚拟机的数据类型,在此再简单介绍一下它们。一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference[3]和returnAddress8种类型。

    前面6种不需要多加解释,读者可以按照Java语言中对应数据类型的概念去理解它们(仅是这样理解而已,Java语言与Java虚拟机中的基本数据类型是存在本质差别的)

    第7种reference类型表示对一个对象实例的引用,虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束。

    第8种即returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。

对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。Java语言中明确的(reference类型则可能是32位也可能是64位)64位的数据类型只有long和double两种。值得一提的是,这里把long和double数据类型分割存储的做法与“long和double的非原子性协定”中把一次long和double数据类型读写分割为两次32位读写的做法有些类似,读者阅读到Java内存模型时可以互相对比一下。不过,由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不都不会引起数据安全问题。

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

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

1.2 操作数栈

操作数栈也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2.

当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,在编译代码的时候,编译器要严格保证这一点,在类校验阶段的额数据流分析中还要再次验证这一点。

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


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

1.3动态链接

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

1.4方法返回地址

当一个方法开始执行之后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令。这时候可能会有返回值传递给上层的方法调用者,否则有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式成为正常完成出口。

另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生异常,还是diamante中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完全出口。

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

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

1.5 附加信息

一般会把动态连接,方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

2 方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法版本,暂时还不涉及方法内部的具体运行过程。一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时的内存布局中的入口地址。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

2.1 解析

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

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

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下:

    

    invokevirtual指令:调用所有虚方法。

    invokeinterface指令:调用借口方法,会在运行时在确定一个实现此接口的对象。

    invokespecial指令:调用实例构造器<init>方法、私有方法和父类方法。

    invokestatic指令:用于调用类方法(static方法)。

    invokedynamic指令:先在运行时动态解析出调用点限定符引用的方法,然后在执行该方法,在此之前的5条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestatic和invokespecial指令调用的方法都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以成为非虚方法。与之相反,其他方法称为虚方法(除去final方法)。Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态的结果肯定是唯一的。在Java语言规范中明确说明了final方法时一种非虚方法。

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

2.2分派

Java具备面向对象的3个特征:继承、封装和多态。分派调用过程将会揭示多态的一些最基本的体现,如“重载”和“重写”。

    静态分派

后面我们的话题将围绕这个类的方法来重载(Overload)代码,以分析虚拟机和编译器确定方法版本的过程。方法静态分派如下代买清单:

/** *方法 静态 分派 演示  */ 

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 man = new man();

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

    //实际类型变化

      Human man= new Man(); 

        Human woman= new Woman(); 

       //静态类型变化

        sr. sayHello((Man) man); 

        sr. sayHello( (Woman) woman); 

解释了这两个概念,再回到上面代码中main()里面调用了两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机在重载时是通过参数的静态类型而不是实际类型作为判断依据的。并且静态类型是编译期可知的,因此,在编译阶段,javac编译器会根据参数的额静态类型决定使用哪个重载版本,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令参数中。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出来方法的重载版本,但是在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。

    动态分派

它和多态的另一个体现——重写(Override)有着密切的关系。

/** *方法 动态 分派 演示 */

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

导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用javap命令输出这段代码的字节码,尝试从中寻找答案,如下。

main()方法的字节码

public static void main( java. lang. String[]); 

Code: 

Stack= 2, Locals= 3, Args_ size= 1

0: new# 16;// class org/ fenixsoft/ polymorphic/ Dynamic- Dispatch $ Man 

3: dup 

4: invokespecial# 18;//Method org/fenixsoft/polymorphic/Dynamic- Dispatch $ Man." < init >":() V 

7: astore_ 1 

8: new# 19;// class org/ fenixsoft/ polymorphic/ Dynamic- Dispatch $ Woman

11: dup 

12: invokespecial#21;//Method org/fenixsoft/polymorphic/DynamicDispa tch $Woman."<init>":() V 

15: astore_ 2 

16: aload_ 1 

17: invokevirtual# 22;// Method org/ fenixsoft/ polymorphic/ Dynamic- Dispatch $ Human. sayHello:() V

20: aload_ 2 

21: invokevirtual# 22;// Method org/ fenixsoft/ polymorphic/ Dynamic- Dispatch $ Human. sayHello:() V

24: new# 19;// class org/ fenixsoft/ polymorphic/ Dynamic-Dispatch $ Woman 

27: dup

28: invokespecial# 21;// Method org/ fenixsoft/ polymorphic/ Dynam icDispatch $ Woman." < init >":() V 

31: astore_ 1 

32: aload_ 1 

33: invokevirtual# 22;// Method org/ fenixsoft/ polymorphic/ DynamicDispatch $ Human. sayHello:() V 

36: return

0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第一、二个局部变量表Slot之中,这个动作对应了代码中的这两句代码:

Human man = new Man();

Human woman = new Woman();

接下来的16~21行是关键部分,16/20两句分别把刚刚创建的两个对象的引用压入到栈顶,这两个对象是将要执行的sayHello()方法的调用者,称为接收者(receiver);17和21行是方法的调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池地22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

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

    3)否则,按照继承关系从下往上一次对C的各个父类记性第2步的搜索和检验过程。

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

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

    单分派与多分派

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

/** *单分派、 多分派演示  */

public class Dispatch{ 

    static class QQ{} 

    static class _360{} 

    public static class Father{ 

        public void hardChoice( QQ arg){ System. out. println(" father choose qq"); } 

        public void hardChoice(_360 arg){ System. out. println(" father choose 360"); } 

    } 

    public static class Son extends Father{ 

        public void hardChoice( QQ arg){ System. out. println(" son choose qq"); }

        public void hardChoice(_ 360 arg){ System. out. println(" son choose 360"); } 

    } 

    public static void main( String[] args){ 

        Father father= new Father(); 

        Father son= new Son();

        father. hardChoice( new_ 360()); 

        son. hardChoice( new QQ()); 

    } 

}

运行结果:

father choose 360

son choose qq

main函数中调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已经现实的很清楚了。

我们来看看编译阶段编译器的选择过程,也就是静态分派过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360.这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为根据宗量进行选择,所以Java语言的静态分派属于多分派类型。

再看看运行阶段虚拟机的选择,也就是动态分派的过程。在之赐你个“son.hardChoice(new QQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。


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

    虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(virtual Method Table),也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——interface method Table,简称itable),使用虚方法索引来代替元数据查找以提高性能。我们看看代码清单所对应的虚方法表结构示例。


虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型转换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所虚需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

方法表示分派调用“稳定优化”的手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于‘类型继承关系分析(Class Hierarchy Analysis,CHA)’技术的守护内联(Guarded Inline)两种非稳定的“激进优化”手段来获得更高的性能。

总结:

1.局部变量表的最大容量通过Code属性中的max_locals确定。局部变量表最小存储单位Slot,可以直接存储byte、boolean、char、short、int、float、reference和returnAddress8种类型。double和long需要占用两个连续的Slot空间。

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

3.动态链接:每个栈帧都包含一个指向运行时常量池的该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中动态链接。

4.方法返回地址:方法退出有两种方式,一种是遇到字节码返回指令,一种是遇到异常。

5.解析:方法在程序真正运行之前就有一个可确定的调用版本,这个调用版本在运行期是不可变的。解析的方法有:静态方法、私有方法、实例构造方法、父类方法。

6.宗量:方法的接收者和方法的参数。

7.静态分派——方法重载,动态分派——方法重写,单分派——根据一个宗量对目标方法进行选择;多分派——根据多个宗量对目标方法进行选择;现在的Java程序的静态多分派,动态单分派的语言。

8.动态调用使用虚方法表代替元数据查找提高性能。原理是父类子类签名相同的方法(方法重写)在虚方法表中的索引是相同的,如果子类没有重写,则指向父类的方法,若子类重写了父类的方法,则 指向父类索引号相同的子类方法。


参考书籍:《深入理解虚拟机:JVM高级特性与最佳实践》(第二版)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值