diff --git a/blogs/JVM/虚拟机字节码执行引擎.md b/blogs/JVM/虚拟机字节码执行引擎.md index 75ede7b..b7b04a9 100644 --- a/blogs/JVM/虚拟机字节码执行引擎.md +++ b/blogs/JVM/虚拟机字节码执行引擎.md @@ -11,6 +11,9 @@ * 方法返回地址 * 附加信息 2. 方法调用 + - 解析 + - 分派 +3. 基于栈的字节码解释执行引擎 ### 运行时栈帧结构 @@ -86,3 +89,168 @@ Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法 ##### 静态分派 +```java +public class InvokeMethodTest { + static abstract class Human { + + } + + static class Man extends Human { + + } + + static class Woman extends Human { + + } + + public static void show(Human human) { + System.out.println("Human"); + } + + public static void show(Man man) { + System.out.println("Man"); + } + + public static void show(Woman woman) { + System.out.println("Woman"); + } + + public static void main(String[] args) { + Human man = new Man(); + Human woman = new Woman(); + show(man); + show(woman); + } +} +``` + +所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是 “唯一的”,往往只能确定一个 “更加合适的” 版本。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规划去理解和推断。 + +```java +public class OverLoad { + public static void main(String[] args) { + sayHello('c'); + } + + public static void sayHello(char c) { + System.out.println("hello char"); + } + + public static void sayHello(int i) { + System.out.println("hello int"); + } + + public static void sayHello(long l) { + System.out.println("hello long"); + } + + public static void sayHello(float f) { + System.out.println("hello float"); + } + + public static void sayHello(double d) { + System.out.println("hello double"); + } + + + public static void sayHello(Serializable s) { + System.out.println("hello serializable"); + } + + + public static void sayHello(Object o) { + System.out.println("hello object"); + } + + public static void sayHello(char... chars) { + System.out.println("hello chars"); + } +} +``` + +以上方法调用的优先级依次是: + +``` +char > int > long > float > double > Serializable > Object > 可变长参数 +``` + +char 不会匹配到 byte 和 short 类型的重载,因为 char 到 byte 或 short 的转型是不安全的,其次,可变长参数的重载优先级是最低的。 + +##### 动态分派 + +动态分派与重写密切相关。对应于 invokevirtual 指令的多态查找过程,invokevirtual 指令的运行时解析过程大致分为以下几个步骤: + +1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C +2. 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常 +3. 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程 +4. 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常 + +由于 invokevirtual 指令执行的第一步就是在运行期确定接受者的实际类型,所以两次调用中的 invokevirtual 指令会把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是 Java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。 + +##### 单分派与多分派 + +方法的接受者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多余一个宗量对目标方法进行选择。 + +Java 语言是一门静态多分派、动态单分派的语言。 + +##### 虚拟机动态分派的实现 + +由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中3性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最常用的 “稳定优化” 手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表 — Interface Method Table,简称 itable),使用虚方法表索引来替代元数据查找以提高性能。 + +![](https://i.loli.net/2019/07/21/5d33bbbdd29c819732.png) + +虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。 + +为了程序实现上方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。 + +方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。 + +### 基于栈的字节码解释执行引擎 + +#### 解释执行 + +不论是解释还是编译,大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过以下图中的各个步骤,中间的那条分支,就是解释执行的过程,而最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程。 + +如今,基于物理机、Java 虚拟机,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(AST)。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++ 语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是 Java 语言。 + +![](https://i.loli.net/2019/07/21/5d33c40c2146f79871.jpeg) + +Java 语言中,Java 编译器完成了对程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为一这部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机内部,所以 Java 程序的编译就是半独立的实现。 + +#### 基于栈的指令集与基于寄存器的指令集 + +Java 编译器输出的指令流,基本上是一种基于栈的指令集架构(ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 x86 的二进制指令集,说的通俗一些,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。那么,基于栈的指令集与基于寄存器的指令集两者之间有什么不同呢? + +举个最简单的例子,分别使用这两种指令集计算 “1+1” 的结果,基于栈的指令集会是这样子的: + +``` +iconst_1 +iconst_1 +iadd +istore_0 +``` + +两条 iconst_1 指令连续把两个常量 1 压入栈后,iadd 指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 Slot 中。 + +如果基于寄存器,那程序可能会是这个样子: + +``` +mov eax, 1 +add eax, 1 +``` + +mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把这个值加 1,结果就保存在 EAX 寄存器里面。 + +了解了基于栈的指令集与基于寄存器的指令集的区别之后,你可能会问,这两套指令集谁更好一些呢? + +既然两套指令集会同时并存和发展,那肯定是各有优势的。 + +基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提高,程序直接依赖这些硬件寄存器则不可避免的要受到硬件的约束。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑、编译器实现更加简单。 + +栈架构指令集的主要缺点是执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构也侧面印证了这一点。 + +虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因此出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之后,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采用栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。 + +#### 基于栈的解释器执行过程 + +整个执行过程的中间变量都以操作数栈的出栈、入栈为信息交换途径。 \ No newline at end of file diff --git a/images/JVM/方法表结构.png b/images/JVM/方法表结构.png new file mode 100644 index 0000000..f196b6c Binary files /dev/null and b/images/JVM/方法表结构.png differ diff --git a/images/JVM/编译过程.jpeg b/images/JVM/编译过程.jpeg new file mode 100644 index 0000000..27d2667 Binary files /dev/null and b/images/JVM/编译过程.jpeg differ