文前说明
作为码农中的一员, 需要不断的学习, 我工作之余将一些分析总结和学习笔记写成博客与大家一起交流, 也希望采用这种方式记录自己的学习之旅.
本文仅供学习交流使用, 侵权必删.
1. 概述
执行引擎是 Java 虚拟机最核心的组成部分之一,"虚拟机" 是一个相对于 "物理机" 的概念, 这两种机器都具有执行代码的能力.
物理机 的执行引擎是直接建立在处理器, 硬件, 指令集和操作系统层面上的.
虚拟机 的执行引擎则是由自己实现的, 因此可以自行制定指令集和执行引擎的结构体系, 并且能够执行那些不被硬件直接支持的指令集格式.
在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型, 这个概念模型成为各种虚拟机执行引擎的统一外观(Facade).
从外观来看, 所有的 Java 虚拟机执行引擎都是一致的: 输入的是字节码文件, 处理过程是字节码解析的过程, 输出的是执行结果.
2. 栈帧
栈帧 (Stack Frame) 是用于支持虚拟机进行方法调用和方法执行的数据结构, 它是虚拟机运行时数据区中的虚拟机栈的栈元素.
栈帧相关信息的详细说明可查看[Java 虚拟机笔记] 内存模型相关整理 .
3. 方法调用
方法调用不等同于方法执行, 方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法), 暂时还不涉及方法内部的具体运行过程.
Class 文件的编译过程中不包含传统编译中的连接步骤, 一切方法调用在 Class 文件中存储的都是符号引用, 而不是方法在实际运行时内存布局中的人口地址(即直接引用).
这个特性给 Java 带来更强大的动态扩展能力, 但也使得 Java 方法调用变得复杂, 需要在类加载期间, 甚至到运行期间才能确定目标方法的直接引用.
方法调用可以分为 解析调用 和 分派调用 两种.
3.1 解析调用
所有方法调用中的目标方法在 Class 文件中都是一个常量池中的符号引用, 在类加载的解析阶段, 会将其中一部分符号引用转化为直接应用, 这种解析能成立的前提是.
方法在程序真正运行前就有一个可确定的调用版本, 并且这个方法的调用版本在运行期是不可变的. 调用目标在程序代码写好, 编译器进行编译时就必须确定下来. 这类方法的调用称为解析调用.
在 Java 语言中符合 "编译期可知, 运行期不可变" 这个要求的方法主要包括 静态方法 和 私有方法 两大类, 前者与类型直接关联, 后者在外部不可被访问.
这两种方法各自的特点决定了它们不可能通过继承或别的方式重写其他版本, 因此它们都适合在类加载阶段进行解析.
Java 虚拟机提供了 5 条方法调用字节码指令.
指令 | 说明 |
---|---|
invokestatic | 调用静态方法。 |
invokespecial | 调用实例构造器 <init> 方法、私有方法和父类方法。 |
invokevirtual | 调用所有的虚方法。 |
invokeinterface | 调用接口方法,会在运行时再确定一个实现此接口的对象。 |
invokedynamic | 动态解析要调用的方法。 |
只要能被 invokestatic 和 invokespecial 指令调用的方法, 都可以在解析阶段中确定唯一调用版本, 符合这个条件的有 静态方法, 私有方法, 构造器方法, 父类方法 4 类, 它们在类加载的时候就会把 符号引用解析为该方法的直接引用.
3.2 分派调用
Java 作为一门面向对象的程序语言有一个基本特征: 多态.
方法分派调用的过程正是多态特征的一些基本体现.
分派调用将会解释 Java 中的 "重载" 与 "重写" 在虚拟机中是如何实现的.
静态分派
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派.
静态分派的典型应用是 方法重载.
静态分派发生在编译阶段, 需要注意的是, 编译器虽然能确定方法的重载版本, 但在很多情况下这个重载版本并不是 "唯一的", 往往只能确定一个 "更加合适的" 版本, 产生这种模糊的原因是字面量不需要定义, 所以字面量没有显示的静态类型, 它的静态类型只能通过语言上的规划去理解和推断.
动态分派
和多态性的另外一个重要体现 "重写" 有着密切的联系.
invokevirtual 指令的运行时解析过程大致分为以下几个步骤.
找到操作数栈顶的第一个元素所指向的对象的实际类型, 记做 C.
如果在类型 C 中找到了与常量中的描述符和简单名称都相符的方法, 则进行访问权限校验, 如果通过则返回这个方法的直接引用, 查找结束; 如果不通过, 则返回 java.lang.IllegalAccessError 异常.
否则, 按照继承关系从下往上依次对 C 的各个父类进行步骤 2 的搜索和验证过程.
如果始终没有找到合适的方法, 则抛出 java.lang.AbstractMethodError 异常.
invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型, 所以两次调用中的 invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上, 这个过程就是 Java 语言中方法重写的本质.
把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派.
单分派和多分派
方法的接收者和方法的参数统称为 方法的宗量. 根据分派基于多少种宗量, 可以将分派划分为单分派和多分派两种.
单分派是根据一个宗量进行选择.
多分派是根据多于一个宗量对目标方法进行选择.
- public class Test {
- 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
- */
上述代码, 编译阶段编译器的选择过程, 也就是静态分派的过程, 选择目标方法的依据有两点.
一是方法接收者的静态类型是 Father 还是 Son.
二是方法参数是 QQ 还是 360.
选择结果的最终产物是产生两条 invokevirtual 指令, 两条指令的参数分别为常量池中指向
Father.hardChoice(_360)
和
Father.hardChoice(QQ)
方法的符号引用.
因为是根据两个宗量进行选择, 所以 Java 语言的 静态分派属于多分派类型.
运行阶段虚拟机的选择, 也就是动态分派的过程.
在执行
son.hardChoiece(new QQ())
这句代码时, 由于编译期已经确定了目标方法的签名必须为 hardChoice(QQ), 此时虚拟机不会关心传递给方法的实际参数, 它不会影响虚拟机的选择, 唯一可以影响虚拟机选择的因素是此方法的接收者的实际类型是 Father 还是 Son.
因为只有一个宗量作为选择依据, 所以说 Java 语言的 动态分派属于单分派类型.
3.3 动态分派的实现
由于动态分派是非常频繁的动作, 而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法, 出于 性能 的考虑, 大部分虚拟机的真正实现都不会进行如此频繁的搜索.
作为优化, 虚拟机会为类在方法区建立一个 虚方法表(Virtual Method Table), 使用虚方法表索引来代替元数据查找来提高性能.
虚方法表实现
虚方法表中存放着各个方法的实际入口地址.
某个方法在子类中没有被重写, 那么 子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的, 都指向父类的实现入口.
子类重写了这个方法, 子类方法表中地址将会指向子类实现版本的入口地址.
为了程序实现上的方便, 具有相同签名的方法, 在父类, 子类的虚方法表中都应当具有一样的索引序号, 当类型变换时, 仅需要变更要查找的方法表, 就可以从不同的虚方法表中按索引转换出所需的入口地址.
方法表一般在类加载的连接阶段进行初始化, 准备了类的变量初始化后, 虚拟机会把该类的方法表也初始化完毕.
3.4 动态类型语言支持
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期, 最本质的区别是动态类型语言在运行期才能确定接受者类型, 而静态类型语言在编译期就确定了接受者类型.
在编译期就进行类型检查过程的语言 (C++ 和 Java) 是最常用的静态类型语言.
在 JDK 1.7 中, 新加入的 java.lang.invoke 包, 这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外, 提供一种新的动态确定目标方法的机制, 称为 MethodHandle.
在拥有 Method Handle 之后, Java 语言也可以拥有类似于函数指针或者委托的方法别名工具了.
与反射 (Reflection) 的区别在于.
Reflection 是在模拟 Java 代码层次的方法调用. MethodHandle 是在模拟字节码层次的方法调用.
Reflection 是重量级的(包含方法签名, 描述符, 方法属性表, 执行权限等运行期信息),MethodHandle 是轻量级的(只包含与执行该方法相关的信息).
MethodHandle 是字节码方法调用模拟, 理论上可以做虚拟机方面的优化. Reflection 不行.
public class MethodHandleTest { static class ClassA{ public void println(String s){ System.out.println(s); } } public static void main(String[] args) throws Throwable{ Object obj=System.currentTimeMillis()%2==0?System.out : new ClassA(); getPrintlnMH(obj).invokeExact("icyfenix"); } private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable { //MethodType 代表 "方法类型", 包含了方法的返回值 (第一个参数) 和具体参数(第二个参数) MethodType mt=MethodType.methodType(void.class.String.class); //lookup()方法来自于 MethodHandles.lookup, 这句话的作用是在指定类中查找符合给定的方法名称, 方法类型, 并且符合调用权限的方法句柄 // 因为这里调用的是一个虚方法, 按照 java 语言的规则, 方法第一个参数是隐式的, 代表该方法的接受者, 也即是 this 指向的对象, 这个参数以前是放在参数列表中进行传递的, 现在提供了 bindTo()方法 return lloup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver); } }
通过 MethodHandle 由程序员掌控方法分派规则.
JDK 1.7 以前在 Java 程序中, 想要访问父类的方法可以使用 super(), 但是访问祖类的方法很难.
JDK 1.7 中可以使用 MethodHandle 解决相关问题.
IMPL_LOOKUP 是用来判断私有方法是否被信任的标识, 用来控制访问权限, 默认是 false.
默认情况下 findSpecial() 方法中的最后一个参数 Class<?> specialCaller 会进行校验
checkSpecialCaller()
, 如果当前的 lookup 的类和 specialCaller 不一致就会检测不通过.
IMPL_LOOKUP.setAccessible(true)
设置为 true 之后,
(MethodHandles.Lookup) IMPL_LOOKUP.get(null)
获取一个 Lookup, 这样返回的 allowedModes 为 -1, 可以绕过检查, 从而执行执行传入 specialCaller 类中的方法(这样做舍弃了强验证, 有一定的风险).
public class Test { 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() { try { MethodType mt = MethodType.methodType(void.class); Field IMPL_LOOKUP = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP"); IMPL_LOOKUP.setAccessible(true); MethodHandles.Lookup lookup = (MethodHandles.Lookup) IMPL_LOOKUP.get(null); MethodHandle mh = lookup.findSpecial(GrandFather.class, "thinking", mt, Father.class); mh.invoke(this); } catch (Throwable e) { } } } public static void main(String[] args) { Test t = new Test(); (t.new Son()).thinking(); } } /*print i am grandfather. */ //lookup.findSpecial public MethodHandle findSpecial(Class<?> refc, String name, MethodType type, Class<?> specialCaller) throws NoSuchMethodException, IllegalAccessException { this.checkSpecialCaller(specialCaller); MethodHandles.Lookup specialLookup = this.in(specialCaller); MemberName method = specialLookup.resolveOrFail(7, refc, name, (MethodType)type); this.checkSecurityManager(refc, method); return specialLookup.getDirectMethod(7, refc, method, this.findBoundCallerClass(method)); } //allowedModes = -1 可以绕开检查 private void checkSpecialCaller(Class<?> specialCaller) throws IllegalAccessException { int allowedModes = this.allowedModes; if(allowedModes != -1) { if((allowedModes & 2) == 0 || specialCaller != this.lookupClass()) { throw (new MemberName(specialCaller)).makeAccessException("no private access for invokespecial", this); } } }
在 JDK 1.7 中, 增加了 invokedynamic 指令, 这条指令是 JDK 1.7 实现 "动态类型语言" 支持而进行改进之一, 也是为了 JDK 1.8 可以实现 Lambda 表达式所做的技术准备.
JDK 1.7 之前 invokevirtual 指令的分派逻辑是按照方法接收者进行分派的.
invokedynamic 指令在于它的分派逻辑不是由虚拟机决定的, 而是由程序员决定, 与 MethodHandle 一样可以解决原有 4 条 "invoke*" 指令方法分派规则固化在虚拟机之中的问题.
4. 基于栈的字节码解释执行引擎
Java 虚拟机的执行引擎在执行 Java 代码时都有 解释执行 (通过解释器执行) 和 编译执行 (通过即时编译器产生本地代码执行) 两种选择.
4.1 解释执行
在 JDK 1.0 时代, Java 虚拟机完全是解释执行的, 随着技术的发展, 现在主流的虚拟机中大都包含了即时编译器(JIT).
现阶段 Class 文件中的代码到底是会被解释执行还是编译执行, 就成了只有虚拟机自己才能准确判断的事情, 但是无论什么虚拟机, 其原理基本符合现代经典的编译原理.
编译原理
如今, 基于物理机和 Java 虚拟机的语言大多都遵循这种基于现代经典编译原理的思路, 在执行前先对程序源码进行词法分析, 语法分析处理, 把源码转化为抽象语法树, 对于一门具体的语言实现来说.
词法分析, 语法分析以至于后面的优化器和目标代码生成器都可以独立于执行引擎, 形成一个完成意义的编译器去实现, 这类代表是 C/C++ 语言.
也可以选择将一部分步骤 (如生成抽象语法树之前的步骤) 实现为一个半独立的编译器, 这类代表是 Java 语言.
或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中, 如大多数的 JavaScript 执行器.
Java 语言中, Javac 编译器 完成了程序代码经过词法分析, 语法分析到抽象语法树, 再遍历语法树生成线性的字节码指令流的过程.
因为这一部分动作是在 Java 虚拟机之外进行的, 而解释器在虚拟机的内部, 所以 Java 程序的编译就是半独立的实现.
4.2 基于栈的指令集和基于寄存器的指令集
Java 编译器输出的指令流, 基本上是一种基于栈的指令集架构, 指令流中的指令大部分都是零地址指令, 它们依赖 操作数栈 进行工作.
基于栈的指令集的主要优点是可移植, 因为不直接依赖于寄存器, 所以不受硬件的约束.
主要缺点是执行速度相对会稍慢一些.
速度慢原因有两点.
一是基于栈的指令集需要更多的指令数量, 因为出栈和入栈本身就产生了相当多的指令.
二是因为执行指令时会有频繁的入栈和出栈操作, 频繁的栈访问也就意味着频繁的内存访问, 相对于处理器而言, 内存始终是执行速度的瓶颈.
另外一种常用的指令集架构是基于 寄存器 的指令集, 最典型的就是 x86 的二地址指令集.
4.3 基于栈的解释器执行过程
使用样例.
public class Test { public static int cal() { int a = 100; int b = 200; int c = 300; return a + b + c; } public static void main(String[] args) { System.out.println(cal()); } }
字节码指令.
public static int cal(); descriptor: ()I flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=0 0: bipush 100 2: istore_0 3: sipush 200 6: istore_1 7: sipush 300 10: istore_2 11: iload_0 12: iload_1 13: iadd 14: iload_2 15: iadd 16: ireturn LineNumberTable: line 6: 0 line 7: 3 line 8: 7 line 9: 11 LocalVariableTable: Start Length Slot Name Signature 3 14 0 a I 7 10 1 b I 11 6 2 c I
方法 call() 使用了 public 和 static 关键字.
需要深度 (stack) 为 2 的操作数栈和 3 个 Slot 的局部变量空间(locals).
因为是静态方法又没有传递参数, 所以参数数量 (args_size) 为 0.
以下为方法在执行过程中代码, 操作数栈和局部变量表的变化情况.
执行偏移量为 0 的指令 bipush, 将单字节的整形常量值推入操作数栈顶, 推送的参数 (常量值) 为 100.
操作 1
执行偏移量为 2 的指令 istore_0, 将操作数栈顶的整形值出栈放入局部变量表第 0 个 Slot 中.
操作 2
后续的 3~10 指令与上述操作类似, 将 b,c 变量赋值.
执行偏移量为 11 的指令 iload_0, 将局部变量表中第 0 个 Slot 中的整形值入栈.
操作 3
执行偏移量为 12 的指令 iload_1, 将局部变量表中第 1 个 Slot 中的整形值入栈.
操作 4
执行偏移量为 13 的指令 iadd, 将两元素出栈, 做整形加法, 将结果重新入栈.
操作 5
执行偏移量为 14 的指令 iload_2, 将局部变量表中第 2 个 Slot 中的整形值入栈.
操作 6
执行偏移量为 16 的指令 return, 将整形加法后重新入栈的整形值返回给调用者.
操作 7
上述执行过程仅仅是一种概念模型, 虚拟机最终会对执行过程做一些优化来提高性能, 实际的运作过程可能和概念模型相差较大.
参考资料
https://blog.csdn.net/it_gjw/article/details/80627661 https://www.cnblogs.com/royi123/p/3569511.html https://blog.csdn.net/suifeng629/article/details/82349784 https://www.cnblogs.com/wxw7blog/p/7264475.html
来源: http://www.jianshu.com/p/b3303ee834e2