重载和重写的底层原理——虚拟机字节码执行引擎

虚拟机字节码执行引擎

摘要

重载和重写的底层原理。我们分析了虚拟机在执行代码时,如何找到正确的方法,如何执行方法内的字节码,以及执行代码时涉及的内存结构。

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层 面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执 行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)[1]的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,如果读者认真阅读过第6章JVM类文件结构,应该能从Class文件格式的方法表中找到以上大多数概念的静态对照物。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

java程序从main开始,从main结束

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。 在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算 出来,并且写入到方法表的Code属性之中[2]。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。——在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,

8.2运行时栈帧结构

一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在 调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方 法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与 这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只 针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图8-1所示。 图8-1所示的就是虚拟机栈和栈帧的总体结构,接下来,我们将会详细了解栈帧中的局部变量表、 操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MONViY77-1668328589803)(https://raw.githubusercontent.com/13884566853/eck-article-imgs/main/img/%E6%9F%90%E6%96%87%E7%AB%A0image-20221101131902403.png)]

8.2.1局部变量表(Local Variables Table)

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

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的.

红字前为扩展内容

方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为,请看代码清单8-1、代码清单8-2和代码清单8-3的3个演示。

public static void main(String[] args)() {
	byte[] placeholder = new byte[64 * 1024 * 1024];
	System.gc();
}
// 向内存填充了64MB的数据,然后通知虚拟机进行垃圾收集。我们在虚拟机运行参数中加上“-verbose:gc”来看看垃圾收集的过程
[GC 66846K->65824K(125632K), 0.0032678 secs]
[Full GC 65824K->65746K(125632K), 0.0064131 secs]
// 并没有被回收,下面改变作用域

public static void main(String[] args)() {
	{
	byte[] placeholder = new byte[64 * 1024 * 1024];
	}
	System.gc();
}
[GC 66846K->65888K(125632K), 0.0009397 secs]
[Full GC 65888K->65746K(125632K), 0.0051574 secs]
// 依旧没有被回收,再改一遍
public static void main(String[] args)() {
    {
    byte[] placeholder = new byte[64 * 1024 * 1024];
    }
	int a = 0;//多加这一句
	System.gc();
}
[GC 66401K->65778K(125632K), 0.0035471 secs]
[Full GC 65778K->218K(125632K), 0.0140596 secs]

// 内存真的被回收掉了
placeholder能否被回收的根本原因就是:局部变量表中的变量槽是否还存有
关于placeholder数组对象的引用第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之
后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量
**所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。**这种关联没有被及时打断,
绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面
又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句int
a=0,把变量对应的局部变量槽清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极
特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到即时编译器的编
译条件)下的“奇技”来使用。经过第一次修改的代码,System.gc()执行时就可以正确地回收内存,根本无须写成代码清单 8-3的样子。

在实际情况中,即时编译才是虚拟机执行代码的主要方式,赋null值的操作在经过即时编译优化后几乎是 一定会被当作无效操作消除掉的

8.2.2 操作数栈

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

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过 将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要 求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int 值出栈并相加,然后将相加的结果重新入栈。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器 必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为 例,这个指令只能用于整型数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型, 不能出现一个long和一个float使用iadd命令相加的情况。

另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在 大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数 栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调 用时就可以直接共用一部分数据,无须进行额外的参数复制传递了,重叠的过程如图8-2所示。 Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。后文会对基于栈的代码执行过程进行更详细的讲解,介绍它与更常见的基于寄存器的执行引擎有哪些差别。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4ugcjhNJ-1668328589804)(https://raw.githubusercontent.com/13884566853/eck-article-imgs/main/img/%E6%9F%90%E6%96%87%E7%AB%A0image-20221102093635601.png)]

8.2.3 动态连接

每个栈帧都包含一个指向运行时常量池(插入第六章的文章链接)中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。通过第6章的讲解,我们知道Class文件的常量池中存 有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。关于这两个转化过程的 具体过程,将在8.3节中再详细讲解。

8.2.4 方法返回地址

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

第一种方式是执行引擎遇到任意一个方法 返回的字节码指令,这时候可能会有返回值传递给上层的调用者。称之为“正常调用完成”(Normal Method Invocation Completion)。

另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理,是不会给它 的上层调用者提供任何返回值的。

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

8.3 方法调用

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

主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

8.3.1 解析

承接前面关于方法调用的话题,所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。

换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法 的调用被称为解析(Resolution)。

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

调用不同类型的方法,字节码指令集里设计了不同的指令。

在Java虚拟机支持以下5条方法调用字节码指令,分别是:

invokestatic。用于调用静态方法。

invokespecial。用于调用实例构造器()方法、私有方法和父类中的方法。

invokevirtual。用于调用所有的虚方法。

invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。

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

前面4 条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本, Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final 修饰的方法(尽管它使用invokevirtual指令调用,因为无法被覆盖,没有其他版本可能),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。

代码清单8-5演示了一种常见的解析调用的例子,该样例中,静态方法sayHello()只可能属于类型 StaticResolution,没有任何途径可以覆盖或隐藏这个方法。 代码清单8-5 方法静态解析演示

/**
* 方法静态解析演示
*
* @author zzm
*/
public class StaticResolution {
    public static void sayHello() {
        System.out.println("hello world");
    }
    public static void main(String[] args) {
        StaticResolution.sayHello();
    }
}
/**
使用javap命令查看这段程序对应的字节码,会发现的确是通过invokestatic命令来调用sayHello()方
法,而且其调用的方法版本已经在编译时就明确以常量池项的形式固化在字节码指令的参数之中(代
码里的31号常量池项):
*/
javap -verbose StaticResolution
public static void main(java.lang.String[]);
Code:
Stack=0, Locals=1, Args_size=1
0: invokestatic #31; //Method sayHello:()V
3: return
LineNumberTable:
line 15: 0
line 16: 3


使用javap命令查看这段程序对应的字节码,会发现的确是通过invokestatic命令来调用sayHello()方 法,而且其调用的方法版本已经在编译时就明确以常量池项的形式固化在字节码指令的参数之中(代码里的31号常量池项):

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

这就是之前面试死记硬背的先执行静态代码块,在执行静态方法再执行父类方法再执行子类方法的底层原因。

8.3.2 分派

众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装 和多态。

本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在 Java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

1.静态分派(重载的本质)

代码8-7

package org.fenixsoft.polymorphic;
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');
    }
}
// 输出
hello char
// 如果注释掉 sayHello(char arg)方法,那输出会变为:
hello int
// 我们继续注释掉sayHello(int arg)方法,那输出会变为
hello long
// 继续注释掉sayHello(long arg)方法,那输出会变为
hello Character
// 继续注释掉sayHello(Character arg)方法
hello Serializable
// 续注释掉sayHello(Serializable arg)方法
hello Object
// 把sayHello(Object arg)也注释掉,输出将会变为:
hello char ... //7个重载方法已经被注释得只剩1个了,可见变长参数的重载优先级是最低的,这时候字符'a'被当作了一个char[]数组的元素。

代码清单8-7演示了编译期间选择静态分派目标的过程,这个过程也是Java语言实现方法重载的本质。

演示所用的这段程序无疑是属于很极端的例子,除了用作面试题为难求职者之外,在实际工作中 几乎不可能存在任何有价值的用途,笔者拿来做演示仅仅是用于讲解重载时目标方法选择的过程,对绝大多数下进行这样极端的重载都可算作真正的“关于茴香豆的茴有几种写法的研究”。无论对重载的认识有多么深刻,一个合格的程序员都不应该在实际应用中写这种晦涩的重载代码。

2.动态分派(重写的本质)

代码8-8

package org.fenixsoft.polymorphic;
/**
* 方法动态分派演示
* @author zzm
*/
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程序员认为这个结果理所当然,那么Java虚拟机是如何判断应该调用哪个方法的

我们使用javap命令输出这段代码的字节码

public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0: new #16; //class org/fenixsoft/polymorphic/DynamicDispatch$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/DynamicDispatch$Woman
11: dup
12: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$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/DynamicDispatch$Woman
27: dup
28: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
36: return

    015行的字节码是准备动作,作用是建立man和woman的内存空间、调用ManWoman类型的实
例构造器,将这两个实例的引用存放在第12个局部变量表的变量槽中,这些动作实际对应了Java源
码中的这两行:
    Human man = new Man();
	Human woman = new Woman();
接下来的1621行是关键部分,1620行的aload指令分别把刚刚创建的两个对象的引用压到栈
顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);
    1721行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量
池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)都完全一样,但是这两句指
令最终执行的目标方法并不相同。那看来解决问题的关键还必须从invokevirtual指令本身入手,要弄清
楚它是如何确定调用方法版本、如何实现多态查找来着手分析才行。根据《Java虚拟机规范》,
invokevirtual指令的运行时解析过程[4]大致分为以下几步:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果
通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.illegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。 既然这种多态性的根源在于虚方法调用指令invokevirtual的执行逻辑,那自然我们得出的结论就只会对方法有效,对字段是无的,因为字段不使用这条指令。(这也就是子类字段是私有的方法是公有的原因)事实上,在Java里面只有虚方法存在,字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

3.单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于著名的《Java与模式》 一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

代码清单8-11中举了一个Father和Son一起来做出“一个艰难的决定[5]”的例子

/**
* 单分派、多分派演示
* @author zzm
*/
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 12和预览版的Java 13)的 Java语言是一门静态多分派、动态单分派的语言。 强调“如今的Java语言”是因为这个结论未必会恒久不 变,按照目前Java语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如 JavaScript)执行引擎、加强与其他Java虚拟机上动态语言交互能力的方式来间接地满足动态性的需求。

4.虚拟机动态分派的实现

前面介绍的分派过程,作为对Java虚拟机概念模型的解释基本上已经足够了,它已经解决了虚拟 机在分派中“会做什么”这个问题。但如果问Java虚拟机“具体如何做到”的,答案则可能因各种虚拟机的实现不同会有些差别。在此不过多介绍

8.4 动态类型语言支持

Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,二十余年间只新增过一条指令,它就是随着JDK 7的发布的字节码首位新成员——invokedynamic指令。这条新增加的指令是JDK 7的项目目标:实现动态类型语言(Dynamically Typed Language)支持而进行的改进之一, 也是为JDK 8里可以顺利实现Lambda表达式而做的技术储备。在本节中,我们将详细了解动态语言支持这项特性出现的前因后果和它的意义与价值。

8.4.1 动态类型语言

在介绍Java虚拟机的动态类型语言支持之前,我们要先弄明白动态类型语言是什么?它与Java语 言、Java虚拟机有什么关系?了解Java虚拟机提供动态类型语言支持的技术背景,对理解这个语言特性 是非常有必要的。 何谓动态类型语言[1]?

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

如果读者觉得上面的定义过于概念化,那我们不妨通过两个例子以最浅显的方式来说明什么是“类 型检查”和什么叫“在编译期还是在运行期进行”。首先看下面这段简单的Java代码,思考一下它是否能 正常编译和运行?

public static void main(String[] args) {
	int[][][] array = new int[1][0][-1];
}

上面这段Java代码能够正常编译,但运行的时候会出现NegativeArraySizeException异常。在《Java 虚拟机规范》中明确规定了NegativeArraySizeException是一个运行时异常(Runtime Exception),通俗 一点说,运行时异常就是指只要代码不执行到这一行就不会出现问题。与运行时异常相对应的概念是 连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使导致连接时异常的代码放 在一条根本无法被执行到的路径分支上,类加载时(第7章解释过Java的连接过程不在编译阶段,而在类加载阶段)也照样会抛出异常。

不过,在C语言里,语义相同的代码就会在编译期就直接报错,而不是等到运行时才出现异常:

int main(void) {
    int i[1][0][-1]; // GCC拒绝编译,报“size of array is negative”
    return 0;
}

由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译期进行并没有什么 必然的因果逻辑关系,关键是在语言规范中人为设立的约定。 解答了什么是“连接时、运行时”,笔者再举一个例子来解释什么是“类型检查”,例如下面这一句再普通不过的代码:

obj.println("hello world");

虽然正在阅读本书的每一位读者都能看懂这行代码要做什么,但对于计算机来讲,这一行“没头没 尾”的代码是无法执行的,它需要一个具体的上下文中(譬如程序语言是什么、obj是什么类型)才有 讨论的意义。 现在先假设这行代码是在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实包含有println(String)方法相同签名方法的类型,但只要它与PrintStream接口没有继承关系,代码依然不可能运行——因为类型检查不合法。

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

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

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

这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方 法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。而JavaScript等动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型 (即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征。

了解了动态类型和静态类型语言的区别后,也许读者的下一个问题就是动态、静态类型语言两者谁更好,或者谁更加先进呢?这种比较不会有确切答案,它们都有自己的优点,选择哪种语言是需要权衡的事情。静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到 更大的规模。而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。 [1] 注意,动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待。

8.4.2 Java与动态类型(了解)

JDK 7时新加入的java.lang.invoke包,提供一种新的动态确定目标方法的机制,称为“方法句柄”(Method Handle)。

Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次 的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的3个方法 findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及 invokeinterface)和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API时是不需要关心的。

8.4.5 实战:掌控方法分派规则

invokedynamic指令与此前4条传统的“invoke*”指令的最大区别就是它的分派逻辑不是由虚拟机决 定的,而是由程序员决定。在介绍Java虚拟机动态语言支持的最后一节中,笔者希望通过一个简单例子(如代码清单8-15所示),帮助读者理解程序员可以掌控方法分派规则之后,我们能做什么以前无 法做到的事情。

代码清单8-15方法调用的问题

class GrandFather {
    void thinking() {
    		System.out.println("i am grandfather");
   		}
    }
    class Father extends GrandFather {
        void thinking() {
        	System.out.println("i am father");
        }
    }
    class Son extends Father {
        void thinking() {
        // 请读者在这里填入适当的代码(不能修改其他地方的代码)
        // 实现调用祖父类的thinking()方法,打印"i am grandfather"
    }
}

在Java程序中,可以通过“super”关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?

在拥有invokedynamic和java.lang.invoke包之前(在JDK 7之前有),使用纯粹的Java语言很难处理这个问题(使用ASM 等字节码工具直接生成字节码当然还是可以处理的,但这已经是在字节码而不是Java语言层面来解决 问题了),原因是在Son类的thinking()方法中根本无法获取到一个实际类型是GrandFather的对象引用, 而invokevirtual指令的分派逻辑是固定的,只能按照方法接收者的实际类型进行分派,这个逻辑完全固化在虚拟机中,程序员无法改变。

void thinking() {
    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"mh.invoke(this);
    } catch (Throwable e) {
    }
}
// 运行以上代码,在目前所有JDK版本中均可获得如下结果:
i am grandfather                                                                                    

8.5 基于栈的字节码解释执行引擎

关于Java虚拟机是如何调用方法、进行版本选择的内容已经全部讲解完毕,从本节开始,我们来探讨虚拟机是如何执行方法里面的字节码指令的。概述中曾提到过,许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在本节中,我们将会分析在概念模型下的Java虚拟机解释执行字节码时,其执行引擎 是如何工作的。笔者在本章多次强调了“概念模型”,是因为实际的虚拟机实现,譬如HotSpot的模板解释器工作的时候,并不是按照下文中的动作一板一眼地进行机械式计算,而是动态产生每条字节码对应的汇编代码来运行,这与概念模型中执行过程的差异很大,但是结果却能保证是一致的。

8.5.1 解释执行

但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事。现在只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较合理确切。

中间的那条分支,就是解释执行的过程。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ThJQzfXZ-1668328589805)(https://raw.githubusercontent.com/13884566853/eck-article-imgs/main/img/%E6%9F%90%E6%96%87%E7%AB%A0image-20221113153809014.png)]

如今,基于物理机、Java虚拟机,或者是非Java的其他高级语言虚拟机(HLLVM)的代码执行过 程,大体上都会遵循这种符合现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。对于一门具体语言的实现来说, 词法、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行引擎。 在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现

8.5.2 基于栈的指令集与基于寄存器的指令集

Javac编译器输出的字节码指令流,基本上[1]是一种基于栈的指令集架构(Instruction Set Architecture,ISA),字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工 作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令 集,如果说得更通俗一些就是现在我们主流PC机中物理硬件直接支持的指令集架构,这些指令依赖寄 存器进行工作。

基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供[2],程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

8.5.3 基于栈的解释器执行过程(重点!!!)

关于栈架构执行引擎的必要前置知识已经全部讲解完毕了,本节笔者准备了一段Java代码以便向读者实际展示在虚拟机里字节码是如何执行的。

代码清单8-17 一段简单的算术代码

public int calc() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
}

这段代码从Java语言的角度没有任何谈论的必要,直接使用javap命令看看它的字节码指令,如代 码清单8-18所示。 代码清单8-18 一段简单的算术代码的字节码表示

public int calc();
    Code:
        Stack=2, Locals=4, Args_size=1
        0: bipush 100
        2: istore_1
        3: sipush 200
        6: istore_2
        7: sipush 300
        10: istore_3
        11: iload_1
        12: iload_2
        13: iadd
        14: iload_3
        15: imul
        16: ireturn
}	

javap提示这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间,笔者就根据这些信息画 了图8-5至图8-11共7张图片,来描述代码清单8-13执行过程中的代码、操作数栈和局部变量表的变化情况。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZDxQlh0j-1668328589805)(https://raw.githubusercontent.com/13884566853/eck-article-imgs/main/img/%E6%9F%90%E6%96%87%E7%AB%A0image-20221113154843361.png)]

首先,执行偏移地址为0的指令,Bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,跟随有一个参数,指明推送的常量值,这里是100。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0MbZs9TB-1668328589805)(https://raw.githubusercontent.com/13884566853/eck-article-imgs/main/img/%E6%9F%90%E6%96%87%E7%AB%A0image-20221113154946970.png)]

执行偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变 量槽中。后续4条指令(直到偏移为11的指令为止)都是做一样的事情,也就是在对应代码中把变量 a、b、c赋值为100、200、300。这4条指令的图示略过。
在这里插入图片描述

执行偏移地址为11的指令,iload_1指令的作用是将局部变量表第1个变量槽中的整型值复制到操作数栈顶。

执行偏移地址为12的指令,iload_2指令的执行过程与iload_1类似,把第2个变量槽的整型值入栈。 画出这个指令的图示主要是为了显示下一条iadd指令执行前的堆栈状况。

在这里插入图片描述

执行偏移地址为13的指令,iadd指令的作用是将操作数栈中头两个栈顶元素出栈,做整型加法, 然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200被出栈,它们的和300被重新入栈。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5GXoWTPU-1668328589806)(https://raw.githubusercontent.com/13884566853/eck-article-imgs/main/img/%E6%9F%90%E6%96%87%E7%AB%A0image-20221113155248641.png)]

执行偏移地址为14的指令,iload_3指令把存放在第3个局部变量槽中的300入栈到操作数栈中。这时操作数栈为两个整数300。下一条指令imul是将操作数栈中头两个栈顶元素出栈,做整型乘法,然后 把结果重新入栈,与iadd完全类似,所以笔者省略图示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d8V2Cl8W-1668328589806)(https://raw.githubusercontent.com/13884566853/eck-article-imgs/main/img/%E6%9F%90%E6%96%87%E7%AB%A0image-20221113155410258.png)]

执行偏移地址为16的指令,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给该方法的调用者。到此为止,这段方法执行结束。 再次强调上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做出一系列优化来提高性能,实际的运作过程并不会完全符合概念模型的描述。更确切地说,实际情况会和上面描述的概念模型差距非常大,差距产生的根本原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,即使解释器中也不是按照字节码指令去逐条执行的。例如在HotSpot虚拟机中,就有很多 以“fast_”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,即时编译器的优化手段则更是花样繁多[1]

不过我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变 量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。

8.6 本章小结

本章中,我们分析了虚拟机在执行代码时,如何找到正确的方法,如何执行方法内的字节码,以及执行代码时涉及的内存结构。在第6~8章里面,我们针对Java程序是如何存储的、如何载入(创建)的,以及如何执行的问题,把相关知识系统地介绍了一遍,第9章我们将一起看看这些理论知识在具体开发之中的典型应用

补充 再看一遍JVM的结构,消化一下内容,这节过后就是实战了,坚持住

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DII9Q8KZ-1668328589807)(https://raw.githubusercontent.com/13884566853/eck-article-imgs/main/img/%E6%9F%90%E6%96%87%E7%AB%A0image-20221113154825220.png)]

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值