JVM@虚拟机字节码执行引擎
- 首先代码编译的结果是字节码而不是本地机器码了;
- 物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机执行引擎是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
- Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,不同虚拟机可以有不同实现即执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种方式,也可能两者都有,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上来看,所有 Java 虚拟机的执行引擎是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
(一)运行时栈帧(stack frame)结构
-
是用于帮助虚拟机执行方法调用和方法执行的数据结构
-
栈帧归属于一个一个的线程,每个线程只会拥有自己独有的栈帧的结构,因此对于栈帧不存在并发和同步调用问题;
-
栈帧本身是一种数据结构,封装了方法的局部变量表,动态链接信息,方法的返回地址以及操作数栈等信息。
-
每一个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
-
在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
-
一个线程中的方法调用链可能会很长,很多方法都处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法成为当前方法。执行引擎运行的所有字节码指令对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图:
局部变量表
-
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序中编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。
-
局部变量表的容量单位是:Slot(Variable Slot);32 位数据类型占用一个 Slot,64 位数据类型以高位对齐的方式分配两个连续的 Slot 空间。
-
Java 虚拟机的数据类型和 Java 数据类型存在本质差距,JVM 中 32 位数据类型:boolean/byte/char/short/int/float/reference(对一个对象实例的引用)/returnAddress (指向一条字节码指令的地址), 64 位数据类型:long/double
-
局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续 Slot 是否为原子操作,都不会引起数据安全问题。
操作数栈
操作数栈(Operand Stack)是一个后进先出栈。同局部变量表一样,操作数栈的最大深度也在编译阶段写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过 max_stacks 数据项中设定的最大值。
一个方法刚开始执行的时候,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化成为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层方法的调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。
另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。这种称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上次方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。
二. 方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。
在程序运行时,进行方法调用是最为普遍、频繁的操作。前面说过 Class 文件的编译过程是不包含传统编译中的连接步骤的,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
所有方法调用中的目标方法在 Class 文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。话句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
Java 语言中符合「编译器可知,运行期不可变」这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或者别的方式重写其它版本,因此它们都适合在类加载阶段解析。
与之相应的是,在 Java 虚拟机里提供了 5 条方法调用字节码指令,分别是:
- invokestatic:调用静态方法;
- invokespecial:调用实例构造器 方法、私有方法和父类方法;
- invokevirtual:调用所有虚方法;
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在加载的时候就会把符号引用解析为直接引用。这些方法可以称为非虚方法,与之相反,其它方法称为虚方法(final 方法除外)。
Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无需对方法接受者进行多态选择,又或者说多态选择的结果肯定是唯一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法。
解析调用一定是个静态过程,在编译期间就能完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况,下面我们再看看虚拟机中的方法分派是如何进行的。
分派
面向对象有三个基本特征,封装、继承和多态。这里要说的分派将会揭示多态特征的一些最基本的体现,如「重载」和「重写」在 Java 虚拟机中是如何实现的?虚拟机是如何确定正确目标方法的?
静态分派
在开始介绍静态分派前我们先看一段代码。
/**
* 方法静态分派演示
*
* @author baronzhang
*/
public class StaticDispatch {
private static abstract class Human { }
private static class Man extends Human { }
private static class Woman extends Human { }
private void sayHello(Human guy) {
System.out.println("Hello, guy!");
}
private void sayHello(Man man) {
System.out.println("Hello, man!");
}
private void sayHello(Woman woman) {
System.out.println("Hello, woman!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch dispatch = new StaticDispatch();
dispatch.sayHello(man);
dispatch.sayHello(woman);
}
}
复制代码
运行后这段程序的输出结果如下:
Hello, guy!
Hello, guy!
复制代码
稍有经验的 Java 程序员都能得出上述结论,但为什么我们传递给 sayHello() 方法的实际参数类型是 Man 和 Woman,虚拟机在执行程序时选择的却是 Human 的重载呢?要理解这个问题,我们先弄清两个概念。
Human man = new Man();
复制代码
上面这段代码中的「Human」称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的「Man」称为变量为实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅发生在使用时,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
弄清了这两个概念,再来看 StaticDispatch 类中 main() 方法里的两次 sayHello() 调用,在方法接受者已经确定是对象「dispatch」的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中定义了两个静态类型相同但是实际类型不同的变量,但是虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此在编译阶段, Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到 man() 方法里的两条 invokevirtual 指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
另外,编译器虽然能确定方法的重载版本,但是很多情况下这个重载版本并不是「唯一」的,因此往往只能确定一个「更加合适」的版本。产生这种情况的主要原因是字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规则去理解和推断。下面的代码展示了什么叫「更加合适」的版本。
/**
* @author baronzhang
*/
public class Overlaod {
static void sayHello(Object arg) {
System.out.println("Hello, Object!");
}
static void sayHello(int arg) {
System.out.println("Hello, int!");
}
static void sayHello(long arg) {
System.out.println("Hello, long!");
}
static void sayHello(Character arg) {
System.out.println("Hello, Character!");
}
static void sayHello(char arg) {
System.out.println("Hello, char!");
}
static void sayHello(char... arg) {
System.out.println("Hello, char...!");
}
static void sayHello(Serializable arg) {
System.out.println("Hello, Serializable!");
}
public static void main(String[] args) {
sayHello('a');
}
}
复制代码
上面代码的运行结果为:
Hello, char!
复制代码
这很好理解,‘a’ 是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法,如果注释掉 sayHello(chat arg) 方法,那么输出结果将会变为:
Hello, int!
复制代码
这时发生了一次类型转换, ‘a’ 除了可以代表一个字符,还可以代表数字 97,因为字符 ‘a’ 的 Unicode 数值为十进制数字 97,因此参数类型为 int 的重载方法也是合适的。我们继续注释掉 sayHello(int arg) 方法,输出变为:
Hello, long!
复制代码
这时发生了两次类型转换,‘a’ 转型为整数 97 之后,进一步转型为长整型 97L,匹配了参数类型为 long 的重载方法。我们继续注释掉 sayHello(long arg) 方法,输出变为:
Hello, Character!
复制代码
这时发生了一次自动装箱, ‘a’ 被包装为它的封装类型 java.lang.Character,所以匹配到了类型为 Character 的重载方法,继续注释掉 sayHello(Character arg) 方法,输出变为:
Hello, Serializable!
复制代码
这里输出之所以为「Hello, Serializable!」,是因为 java.lang.Serializable 是 java.lang.Character 类实现的一个接口,当自动装箱后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生了一次自动转换。char 可以转型为 int,但是 Character 是绝对不会转型为 Integer 的,他只能安全的转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable,如果同时出现两个参数分别为 Serializable 和 Comparable 的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显示的指定字面量的静态类型,如:sayHello((Comparable) 'a'),才能编译通过。继续注释掉 sayHello(Serializable arg) 方法,输出变为:
Hello, Object!
复制代码
这时是 char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用的入参值为 null,这个规则依然适用。继续注释掉 sayHello(Serializable arg) 方法,输出变为:
Hello, char...!
复制代码
7 个重载方法以及被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时字符 ‘a’ 被当成了一个数组元素。
前面介绍的这一系列过程演示了编译期间选择静态分派目标的过程,这个过程也是 Java 语言实现方法重载的本质。
动态分派
动态分派和多态性的另一个重要体现「重写(Override)」有着密切的关联,我们依旧通过代码来理解什么是动态分派。
/**
* 方法动态分派演示
*
* @author baronzhang
*/
public class DynamicDispatch {
static abstract class Human {
abstract void sayHello();
}
static class Man extends Human {
@Override
void sayHello() {
System.out.println("Man say hello!");
}
}
static class Woman extends Human {
@Override
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!
复制代码
对于上面的代码,虚拟机是如何确定要调用哪个方法的呢?显然这里不再通过静态类型来决定了,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个结果的原因是因为它们的实际类型不同。对于虚拟机是如何通过实际类型来分派方法执行版本的,这里我们就不做介绍了,有兴趣的可以去看看原著。
我们把这种在运行期根据实际类型来确定方法执行版本的分派称为动态分派。
单分派和多分派
方法的接收者和方法的参数统称为方法的宗量,这个定义最早来源于《Java 与模式》一书。根据分派基于多少宗量,可将分派划分为单分派和多分派。
单分派是根据一个宗量来确定方法的执行版本;多分派则是根据多余一个宗量来确定方法的执行版本。
我们依旧通过代码来理解(代码以著名的 3Q 大战作为背景):
/**
* 单分派、多分派演示
*
* @author baronzhang
*/
public class Dispatch {
static class QQ { }
static class QiHu360 { }
static class Father {
public void hardChoice(QQ qq) {
System.out.println("Father choice QQ!");
}
public void hardChoice(QiHu360 qiHu360) {
System.out.println("Father choice 360!");
}
}
static class Son extends Father {
@Override
public void hardChoice(QQ qq) {
System.out.println("Son choice QQ!");
}
@Override
public void hardChoice(QiHu360 qiHu360) {
System.out.println("Son choice 360!");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new QQ());
son.hardChoice(new QiHu360());
}
}
复制代码
代码输出结果:
Father choice QQ!
Son choice 360!
复制代码
我们先来看看编译阶段编译器的选择过程,也就是静态分派过程。这个时候选择目标方法的依据有两点:一是静态类型是 Father 还是 Son;二是方法入参是 QQ 还是 QiHu360。因为是根据两个宗量进行选择的,所以 Java 语言的静态分派属于多分派。
再看看运行阶段虚拟机的选择过程,也就是动态分派的过程。在执行 son.hardChoice(new QiHu360()) 时,由于编译期已经确定目标方法的签名必须为 hardChoice(QiHu360),这时参数的静态类型、实际类型都不会对方法的选择造成任何影响,唯一可以影响虚拟机选择的因数只有此方法的接收者的实际类型是 Father 还是 Son。因为只有一个宗量作为选择依据,所以 Java 语言的动态分派属于单分派。
综上所述,Java 语言是一门静态多分派、动态单分派的语言。
三. 基于栈的字节码解释执行引擎
虚拟机如何调用方法已经介绍完了,下面我们来看看虚拟机是如何执行方法中的字节码指令的。
解释执行
Java 语言常被人们定义成「解释执行」的语言,但随着 JIT 以及可直接将 Java 代码编译成本地代码的编译器的出现,这种说法就不对了。只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。
无论是解释执行还是编译执行,无论是物理机还是虚拟机,对于应用程序,机器都不可能像人一样阅读、理解,然后获得执行能力。大部分的程序代码到物理机的目标代码或者虚拟机执行的指令之前,都需要经过下图中的各个步骤。下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;中间那条分支,则是解释执行的过程。
如今,基于物理机、Java 虚拟机或者非 Java 的其它高级语言虚拟机的语言,大多都会遵循这种基于现代编译原理的思路,在执行前先对程序源代码进行词法分析和语法分析处理,把源代码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++。也可以为一个半独立的编译器,这类代表是 Java。又或者把这些步骤和执行全部封装在一个封闭的黑匣子中,如大多数的 JavaScript 执行器。
Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树、再遍历语法树生成字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。
许多 Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。而对于最新的 Android 版本的执行模式则是 AOT + JIT + 解释执行,关于这方面我们后面有机会再聊。
基于栈的指令集与基于寄存器的指令集
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构。基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件约束。栈架构的指令集还有一些其他优点,比如相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译实现更加简单(不需要考虑空间分配的问题,所有空间都是在栈上操作)等。
栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能需要的指令集数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存中,频繁的栈访问也意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
正是基于上述原因,Android 虚拟机中采用了基于寄存器的指令集架构。不过有一点不同的是,前面说的是物理机上的寄存器,而 Android 上指的是虚拟机上的寄存器。
- 符号引用:符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。(在编译的时候一个每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段)。
- 直接引用:(1)直接指向目标的指针(指向对象,类变量和类方法的指针)(2)相对偏移量。(指向实例的变量,方法的指针)(3)一个间接定位到对象的句柄。
有些符号引用在加载阶段或者或是第一次使用时,转换为直接引用,这种转换叫做静态解析;另外一些符号引用则是在每次运行期转换为直接引用,这种转换叫做动态链接。这种动态链接体现了 Java 的多态性。
-
助记符:
- invokeinterface:调用接口的方法,在运行期决定调用该接口的哪个对象的特定方法。
- invokestatic:调用静态方法
- invokespecial:调用私有方法, 构造方法(使用
<init>
标识),父类的方法 - invokevirtual:调用虚方法(语言层面上不存在该概念,字节码中存在),运行期动态查找的过程
- invokedynamic:动态调用方法
-
测试4:测试 invokestatic
public class MyTest4{
public static void test(){
System.out.println("static test");
}
public static void main(Stirng[] args){
test(); //invokestatic
}
}
对应的字节码文件
能被 invokestatic、invokespecial 两个指令调用的方法都是能在解析阶段就能唯一确定该方法的调用过程;就是下面中的静态静态解析四种场景
静态解析的四种场景:静态方法、父类方法、构造方法、私有方法(公有方法是可能被重写的,就存在多态的可能的)。以上四种方法称为非虚方法,在类加载阶段将符号引用转换为直接引用。
- 测试5:方法重载
package com.gjxaiou.bytecode;
/**
* 方法的静态分派。
* Grandpa g1 = new Father();
* 以上代码, g1的静态类型(声明的类型)是Grandpa,而g1的实际类型(真正指向的类型)是Father.
* 我们可以得出这样一个结论:变量的静态类型是不会发生变化的,而变量的实际类型则是可以发生变化的(多态的一种体现)
* 实际变量是在运行期方可确定
*/
class Grandpa {
}
class Father extends Grandpa {
}
class Son extends Father {
}
public class MyTest5 {
//方法重载,是一种静态的行为,在调用方法时候,JVM唯一判断依据就是根据该方法本身接收的参数(声明的参数类型)来决定调用哪一个方法,编译期就可以完全确定
public void test(Grandpa grandpa) {
System.out.println("Grandpa");
}
public void test(Father father) {
System.out.println("father");
}
public void test(Son son) {
System.out.println("Son");
}
public static void main(String[] args) {
MyTest5 myTest5 = new MyTest5();
Grandpa g1 = new Father();
Grandpa g2 = new Son();
myTest5.test(g1);
myTest5.test(g2);
}
}
/**
* output:
* Grandpa
* Grandpa
*/
对于方法重载:其可以在编译期就可以完全确定,因为虽然方法本身(test)进行了重载,但是调用方法本身的永远都是方法所在类的实例(myTest5),唯一变化的就是向方法中传入什么类型的参数而已;
对于方法重写:是一种动态信息,到底调用哪一个对象特定的方法是在运行期才能确定的;
- 测试6:方法重写
package com.gjxaiou.bytecode;
/**
* 方法的动态分派(运行期才能确定调用哪个方法)
* 方法的动态分派涉及到一个重要概念:方法接收者(方法到底是由哪个对象来调用的)。
*
* invokevirtual 字节码指令的多态查找流程:
* 首先到操作数的栈顶去寻找栈顶元素所指向的对象的实际类型(不是静态类型);(这里就是 apple 类型)
* 如果寻找到了与常量池中描述符和名称都相符的方法(这里就是在 APPle 类中找到一个与<com/gjxaiou/bytecode/Fruit
* .test>方法的描述符和名称一样的方法),并且具备访问权限,就返回目标方法的直接引用(这里就是返回 Apple 中 test 方法的直接引用),流程结束;
* 如果找不到,就按照继承关系从子类到父类的一层一层的使用上面的查找流程,一直能找到为止,如果找不到报错;
*
* 比较方法重载(overload)与方法重写(overwrite) ,我们可以得到这样一个结论:
* 方法重载是静态的,是编译期行为;
* 方法重写是动态的,是运行期行为。
*
* 下面就是三个 test 方法的符号引用虽然相同(都是 <com/gjxaiou/bytecode/Fruit.test>),但是在运行期转换成了不同的直接引用
*/
class Fruit {
public void test() {
System.out.println("Fruit");
}
}
class Apple extends Fruit {
@Override
public void test() {
System.out.println("Apple");
}
}
class Orange extends Fruit {
@Override
public void test() {
System.out.println("Orange");
}
}
public class MyTest6 {
public static void main(String[] args) {
// new 的作用:首先为该对象在堆上开辟一个内存空间,然后执行其构造方法,最后将构造方法执行完后返回的针对在堆上所生成的对象的引用返回;
/** new 关键字对应于字节码中的下面四个操作:
* 0 new #2 <com/gjxaiou/bytecode/Apple> // 开辟内存空间并创建对象
* 3 dup // 将引用的对象的值压入到栈顶
* 4 invokespecial #3 <com/gjxaiou/bytecode/Apple.<init>> // 调用对象的构造方法
* 7 astore_1 // 将对象在堆上的引用返回赋给一个局部变量
*/
Fruit apple = new Apple();
Fruit orange = new Orange();
// 16 aload_1:从局部变量中加载索引为 1 的引用,就是 apple
apple.test();
orange.test();
apple = new Orange();
apple.test();
// 上面三个 test() 方法最终对应的字节码都是:invokevirtual #6 <com/gjxaiou/bytecode/Fruit.test>
}
}
/** output:
* Apple
* Orange
* Orange
*/
对应的 Class 文件
测试7:重载和重写
package com.gjxaiou.bytecode;
import java.util.Date;
public class MyTest7 {
public static void main(String[] args) {
Animal animal = new Animal();
Animal dog = new Dog();
animal.test("hello");
dog.test(new Date());
}
}
class Animal {
public void test(String str) {
System.out.println("animal str");
}
public void test(Date date) {
System.out.println("animal date");
}
}
class Dog extends Animal {
@Override
public void test(String str) {
System.out.println("dog str");
}
@Override
public void test(Date date) {
System.out.println("dog date");
}
}
对应的 Class 结构
因为 JVM 要从堆栈数的最顶部进行搜索,所以如何优化:使用虚方法表的索引来代替查找过程
针对方法调用调用动态分配过程,虚拟机会在类的方法区建立一个虚方法表的数据结构(virtual method table,简称:vtable)
针对于接口的 invokeinterface 指令来说,虚拟机会建立一个叫做接口方法表的数据结构(interface method table,简称:itable)
应为上述都是运行期的概念,所以虚方法表中存储的是每一个方法真正的实际入口的调用地址,虚方法表中每一项就标识着这个特定方法的实际入口的调用地址,如果子类没有重写父类的某个方法,那么子类和父类中的该方法就是一样的,则子类针对从父类继承并且没有重写的方法在子类中该方法的入口地址就直接指向了父类中的这个特定的方法,而不是在子类中又复制了一遍(可以增加查找效率和减少内存占用空间);
虚方法表一般在类加载的连接阶段完成的;
测试7:
package com.poplar.bytecode;
/**
* Created BY poplar ON 2019/12/4
* 基于栈的解释器的执行过程概念模型
*/
public class BasicStackExecutionProcess {
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
/*
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100 执行地址偏移量为0 将100推送至栈顶
2: istore_1 执行地址偏移量为2 将栈顶的100出栈并存放到第一个局部变量Slot中
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1 执行地址偏移量为11 将局部变量中第一个Slot中的整型值复制到栈顶
12: iload_2
13: iadd 将栈顶的两个元素出栈并作整形加法,然后把结果重新入栈
14: iload_3
15: imul 将栈顶的两个元素出栈并作整形乘法,然后把结果重新入栈
16: ireturn 结束方法并将栈顶的值返回给方法调用者
LineNumberTable:
line 10: 0
line 11: 3
line 12: 7
line 13: 11
LocalVariableTable:
Start Length Slot Name Signature
0 17 0 this Lcom/poplar/bytecode/BasicStackExecutionProcess;
3 14 1 a I
7 10 2 b I
11 6 3 c I
*/
}
public static void main(String[] args) {
BasicStackExecutionProcess process = new BasicStackExecutionProcess();
int res = process.calc();
System.out.println(res);
}
}
动态分派:
package com.poplar.bytecode;
/**
* 动态分派的演示与证明:
* 在动态分派中虚拟机是如何知道要调用那个方法的?
*/
public class DynamicDispatch {
static abstract class Human {
public abstract void hello();
}
static class Man extends Human {
@Override
public void hello() {
System.out.println("Hello Man");
}
}
static class Woman extends Human {
@Override
public void hello() {
System.out.println("Hello Woman");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woMan = new Woman();
man.hello();
woMan.hello();
man = new Woman();
man.hello();
/*public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class main/java/com/poplar/bytecode/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method main/java/com/poplar/bytecode/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class main/java/com/poplar/bytecode/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method main/java/com/poplar/bytecode/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1 从局部变量加载一个引用 aload1是加载索引为1的引用(man),局部变量有三个(0:args; 1 :man ; 2 :woMan)
17: invokevirtual #6 // Method main/java/com/poplar/bytecode/DynamicDispatch$Human.hello:()V
20: aload_2 加载引用woMan
21: invokevirtual #6 // Method main/java/com/poplar/bytecode/DynamicDispatch$Human.hello:()V
24: new #4 // class main/java/com/poplar/bytecode/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method main/java/com/poplar/bytecode/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method main/java/com/poplar/bytecode/DynamicDispatch$Human.hello:()V
36: return
LineNumberTable:
line 28: 0
line 29: 8
line 30: 16
line 31: 20
line 33: 24
line 34: 32
line 36: 36
LocalVariableTable:
Start Length Slot Name Signature
0 37 0 args [Ljava/lang/String;
8 29 1 man Lmain/java/com/poplar/bytecode/DynamicDispatch$Human;
16 21 2 woMan Lmain/java/com/poplar/bytecode/DynamicDispatch$Human;
}
invokevirtual 运行期执行的时候首先:
找到操作数栈顶的第一个元素它所指向对象的实际类型,在这个类型里边,然后查找和常量里边Human的方法描述符和方法名称都一致的
方法,如果在这个类型下,常量池里边找到了就会返回实际对象方法的直接引用。
如果找不到,就会按照继承体系由下往上(Man–>Human–>Object)查找,查找匹配的方式就是
上面描述的方式,一直找到位为止。如果一直找不到就会抛出异常。
比较方法重载(overload)和方法重写(overwrite),我们可以得出这样的结论:
方法重载是静态的,是编译器行为;方法重写是动态的,是运行期行为。
*/
}
}
静态分派:
package com.poplar.bytecode;
/**
* Created BY poplar ON 2019/12/4
* 静态分派的演示与证明:
*/
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void hello(Human param) {
System.out.println("Hello Human");
}
public void hello(Man param) {
System.out.println("Hello Man");
}
public void hello(Woman param) {
System.out.println("Hello Woman");
}
public static void main(String[] args) {
StaticDispatch dispatch = new StaticDispatch();
/*Human man = new Man();
Human woMan = new Woman();
dispatch.hello(man);
dispatch.hello(woMan);*/
Human human = new Woman();
human = new Man();
dispatch.hello((Woman) human);
dispatch.hello((Man) human);
//java.lang.ClassCastException: main.java.com.poplar.bytecode.WoMan cannot be cast to main.java.com.poplar.bytecode.Man
}
}
-
现代 JVM 在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行.
- 解释执行:就是通过解释器来读取字节码,遇到相应的指令就去执行该指令;
- 编译执行:就是通过即时编译器 (Just in Time, JIT) 将字节码转换为本地机器码来执行;现代 JVM 会根据代码热点(执行频率)来生成目应的本地机器码。 在布尔德E马文项目
-
基于栈的指令集和基于寄存器的指令集之间的关系
- JVM 执行指令时所采取的方式是基于栈的指令集。
-
基于栈的指令集主要有入栈和出栈两种;
-
基于栈的指令集的优势在于它可以在不同平台之间的移植,而基于寄存器的指令集是与硬件架构紧密关联,无法做到可移植;
-
基于栈的指令集的缺点在于完成相同的操作,指令集通常要比基于寄存器的指令集要多,基于栈的指令集是在内存中完成操作的,而基于寄存器的指令集是直接由CPU来执行的,它是在高速缓冲区中进行的,速度要快很多。虽然虚拟机可以采用一些优化手段(该一些高频的指令集映射到寄存器中),但总体来说,基于栈的指令集的执行速度要慢一些;
-
基手栈的指令集的优势在于它可以在不同平台之间移植,而基于寄存器的指令集是与硬件架构累密关联的,无法做到可移植。
示例:如果完后 2 -1 的操作,对应的基于栈的指令集操作
-
iconst_1 // 首先将减数 1 压入到栈顶
iconst_2 // 然后将被减数 2 压入到栈顶
isub // 将栈顶和栈顶下面两个值弹出,然后执行相减的操作,并将运算结果压入到栈顶
istore_0 // 将结果放入局部变量表的第 0 个位置
如果对应寄存器的操作
首先使用 mov 将 2 放入寄存器
然后使用 sub 调用减法操作,后面加上参数 1,最后将结果放入寄存器中
- 示例8:基于栈的指令集
package com.gjxaiou.bytecode;
public class MyTest8 {
public int myCalculate(){
int a = 1;
int b = 2;
int c = 3;
int d = 4;
int result = (a + b - c) * d;
return result;
}
}
对上面的代码进行反编译之后,其中 myCalculate() 方法反编译结果为:
public int myCalculate();
descriptor: ()I
flags: ACC_PUBLIC
Code:
// 栈的最大深度为 2,即栈中最多容纳 2 个元素;最大的局部变量为 6,参数数量为 1,即为 this
stack=2, locals=6, args_size=1
// 将常量数(-1 ~ 5)这里为 1 推送到操作数栈
0: iconst_1
// 将操作数栈顶元素弹出,同时将局部变量表中索引 istore 后面参数位置的值设置为弹出值
// 局部变量表 0 号位置为 this
1: istore_1
2: iconst_2
3: istore_2
4: iconst_3
5: istore_3
6: iconst_4
// 含义同上,但是因为只有三个值,后面 4 即为局部变量表索引
7: istore 4
// 将局部变量表中索引位置为 1 的变量值推送到栈顶
9: iload_1
10: iload_2
// 将操作数栈顶和栈顶下面两个数弹出相加并将结果压入栈中
11: iadd
12: iload_3
13: isub
14: iload 4
16: imul
17: istore 5
19: iload 5
21: ireturn
动态代理
视频 57- 58,代码见下,分析么有看完
package com.gjxaiou.bytecode;
/**
* 被代理的接口
*/
public interface Subject {
void request();
}
package com.gjxaiou.bytecode;
public class RealSubject implements Subject{
@Override
public void request() {
System.out.println("From real subject");
}
}
package com.gjxaiou.bytecode;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class DynamicSubject implements InvocationHandler {
// 创建一个真实对象
private Object sub;
// 将真实对象作为参数传入构造方法
public DynamicSubject(Object obj){
this.sub = obj;
}
// 对真实对象的调用都会通过动态代理中 invoke 方法来执行
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("before calling: " + method);
// 对于真正目标方法的调用
method.invoke(this.sub, args);
System.out.println("after calling: " + method);
return null;
}
}
package com.gjxaiou.bytecode;
import java.lang.reflect.Proxy;
public class Client {
public static void main(String[] args) {
RealSubject rs = new RealSubject();
DynamicSubject ds = new DynamicSubject(rs);
// 获取 class 对象,因为后面创建动态代理的类需要类加载器,然后通过 class 对象和类加载器创建对象
Class<?> cls = rs.getClass();
Subject subject = (Subject) Proxy.newProxyInstance(cls.getClassLoader(),
cls.getInterfaces(), ds);
subject.request();
System.out.println(subject.getClass());
System.out.println(subject.getClass().getSuperclass());
}
}
/** output:
* before calling: public abstract void com.gjxaiou.bytecode.Subject.request()
* From real subject
* after calling: public abstract void com.gjxaiou.bytecode.Subject.request()
* class com.sun.proxy.$Proxy0
* class java.lang.reflect.Proxy
*/