深入理解JVM(四)——虚拟机执行子系统
Java 虚拟机的指令由一个操作码(opcode)和紧随其后的可选操作数(operand)组成,占一个字节长度,故称其为“字节码(bytecode)“。
Java虚拟机栈和栈帧
虚拟机常见的实现方式有两种:基于栈(Stack based)和基于寄存器(Register based)。Hotspot JVM是一种典型的基于栈的虚拟机,而Android开发者熟知的DalvikVM则是基于寄存器的虚拟机。
这两种实现方式各有优缺点:
- 基于栈的指令集架构的优点是移植性更好、指令更短、实现简单,但是不能随机访问堆栈中的元素,完成相同功能所需的指令数一般比寄存器架构多,需要频繁的入栈出栈,不利于代码优化。
- 基于寄存器的指令集架构的优点是速度快,可以充分利用寄存器,有利于程序做运行速度优化,但操作数需要显式指定,指令较长。
运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。
每一个栈帧中都包含了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
以下列代码为例:
1 |
|
使用javac -p TestClass.java进行编译,然后执行javap -c -v -l TestClass查看字节码,如下所示。
1 |
|
可以看到Code属性中有stack、locals、args_size分别对应操作数栈的容量、局部变量表容量以及参数长度。LineNumberTable属性表存放方法的行号信息,LocalVariableTable属性表中存放方法的局部变量信息。
foo方法中只有两个参数,但实际args_size的大小为3,这是因为foo作为实例方法(非静态方法)被调用时,第0个局部变量实际上固定为调用这个实例方法的对象的引用,也就是我们所说的this。
局部变量表的容量以变量槽(Variable Slot)为最小单位,示例中局部变量表容量为4个slot。这里需要注意的是,局部变量表的容量并不等于实际局部变量的个数。这是因为有些局部变量占用的槽位在其作用域结束时可以被复用,例如if-else代码块中声明的变量在if-else执行完毕时占用槽位就可以被复用。另外并不是每一个变量都只会占用一个slot,例如double类型就会占用两个slot。
操作数栈
操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。例如iadd指令,它要求操作数栈中已经存在两个int型整数,在iadd执行时,两个int数值从操作数栈中出栈,相加求和后将结果入栈,如下图所示。
整个JVM指令执行的过程实际上就是局部变量表与操作数栈之间不断加载、存储的过程。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方 调用过程中的动态连接(Dynamic Linking)。通过上面文章对于Class文件的解析,我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
方法返回地址
当一个方法开始后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion)。
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion)”。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:
- 恢复上层方法的局部变量表和操作数栈
- 把返回值(如果有的话)压入调用者栈帧的操作数栈中
- 调整PC计数器的值以指向方法调用指令后面的一条指令
- … …等等
附加信息
《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用
JVM中的方法调用字节码指令都以 “invoke” 开头,一共有5种。
- invokestatic:用于调用静态方法。
- invokevirtual:用于调用非私有实例方法。
- invokespecial:用于调用私有实例方法、构造器方法以及使用 super 关键字调用父类的实例方法等。
- invokeinterface:用于调用接口方法。
- invokedynamic:用于支撑动态类型语言,先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
Java 中将使用 invokestatic 调用的静态方法、使用 invokespecial 调用的私有实例方法、构造器方法和父类实例方法以及使用 invokevirtual 使用的被 final 修饰的不可覆盖方法统称为”非虚方法(Non-Virtual Method)”,与之相反,其他方法就被称为“虚方法”(Virtual Method)。
invokestatic指令
invokestatic用来调用static关键字修饰的方法,即静态方法。静态方法在编译期就已经确定,且运行时不会修改,属于静态绑定。调用 invokestatic 不需要将对象加载到操作数栈,只需要将所需要的参数入栈就可以执行 invokestatic 指令了。
invokevirtual指令
invokevirtual 指令用于调用普通实例方法,它调用的目标方法在运行时才能根据对象实际的类型来确定,编译期无法知道,类似于 C++ 中的虚方法。
在调用 invokevirtual 指令前,需要将对象的引用和方法参数入栈,调用结束将对象引用和方法参数出栈,如果方法有返回值,返回值会入栈到栈顶。
invokespecial指令
invokespecial 用于调用”特殊“的实例方法,包括如下三种:
- 实例构造器方法<init>。
- private 修饰的私有实例方法。
- 使用 super 关键字调用的父类方法。
这三种方法的特殊之处在于,其方法调用在编译期就可以确定,所以 JVM 单独使用了 invokespecial 指令来调用者三种实例方法来提升效率。
invokeinterface指令
invokeinterface 用于调用接口方法,同 invokevirtual 一样,也是需要在运行时根据对象的类型确定目标方法,以下面的代码为例:
1 |
|
foo 方法对应的字节码如下所示。
1 |
|
可以看到这里使用了 invokeinterface 指令来调用 close 方法。
invokedynamic指令
invokedynamic 是在JDK 7上添加的一个重量级的指令,它为指令多语言在 JVM 上的实现提供了技术支撑。
invokedynamic与其他四条方法执行指令不同的是,它会先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面 4 条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。具体的细节将在下下节“动态类型语言支持”中讨论。
方法分派
Java中多态的重要表现包括方法的重载和重写,那么虚拟机是如何确定正确的目标方法的呢?这涉及到一个概念:方法分派(Method Dispatch)。
静态分派
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。它在编译期间由编译器来确定应该编写哪个方法的字节码,对于虚拟机来说只需要执行指定的字节码即可,并不存在“分派”行为,因此被称为“静态分派”。来看一段示例代码:
1 |
|
运行结果:
1 |
|
实际上如果使用IDEA来编写上面的代码的话,编译器就已经提示了哪个方法被调用了:
这充分说明了对于重载方法的分派在编译期就已经确定。
对于一个对象引用的类型,我们称其为变量的“静态类型”(Static Type),例如上面代码中的 man 和 woman 的静态类型为 Human。而引用指向的具体类型称为“实际类型”(Actual Type),例如上面代码中 man 的实际类型为 Man,woman 的实际类型为 Woman。
静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如下列代码:
1 |
|
对象 human 的实际类型是可变的,到底是Man还是Woman,必须等到程序运行到这行代码的时候才能确定。而 human 的静态类型是 Human,也可以在使用时确定,例如通过类型强转(Cast)可以将其强转为 Man 类型或者 Woman类型,但这个改变仍是在编译期可知的,两次 sayHello 的调动都可以在编译期明确类型是 Man 还是 Woman。
动态分派
动态分派与Java语言多态性的另外一个重要体现——重写(Override)有着很密切的关联。依旧先来看一段代码:
1 |
|
运行结果:
1 |
|
这个运行结果相信不出乎意料,依照经验就可以无需运行知道其结果。
显然这里选择调用的方法版本是不可能再根据静态类型来决定的,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 say Hello() 方法时产生了不同的行为,甚至变量 man 在两次调用中还执行了两个不同的方法。
要搞懂虚拟机如何判断应该调用哪个方法,我们可以通过 javap 输出字节码来寻找答案。main 方法的字节码如下所示:
1 |
|
015 行是我们熟悉的对象创建相关的字节码,接下来的1621行是关键部分,16和20行的 aload 指令分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)都完全一样,但是这两句指令最终执行的目标方法并不相同。那看来解决问题的关键还必须从 invokevirtual 指令本身入手,要弄清楚它是如何确定调用方法版本、如何实现多态查找来着手分析才行。
invoke 指令的运行解析大致分为以下几步:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。
正是因为 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
动态类型语言的支持
动态类型语言指的是类型检查的主体过程是发生在运行期而不是编译期进行的,例如:Lua,JS,PHP,Python等,相对的C++和Java就是常见的静态类型语言。Kotlin也是静态类型语言的一种,不过Kotlin仍然可以支持无类型或者弱类型的调用,这个将在稍后进行演示。
如何理解这个“动态类型”?以下面代码为例:
1 |
|
假设这是一行Java代码,并且变量obj的静态类型为java.io.PrintStream,那么变量obj的实际类型就必须是PrintStream的实现类或子类,否则编译期间就会报错。哪怕obj中确实包含有println(String)方法相同签名方法的类型,但它与PrintStream没有实现或者继承关系,代码依然不会正确运行。
但如果是相同的代码运行在JS平台,例如Kotlin:
1 |
|
注意:dynamic不支持JVM平台。
这段代码可以顺利通过编译,并且只要obj对象中确实存在 println(String)和 whatever(int) 方法,调用便可成功。
产生这种差别产生的根本原因是 Java 语言在编译期间却已将 println(String) 方法完整的符号引用(本例中为一项CONSTANT_InterfaceMethodref_info常量)生成出来,并作为方法调用指令的参数存储到Class文件中,例如下面这个样子:
1 |
|
这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。而 JS 等动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型 (即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征。
java.lang.invoke包
为了在 Java 虚拟机层面解决动态类型语言支持的问题,JDK 7时JSR-292提案首次出现了额外的方法调用指令——invokedynamic指令,并且提供了java.lang.invoke包来动态确定目标方法的机制,称为“方法句柄(Method Handle)”。
举个例子,如果我们要实现一个带谓词(谓词就是由外部传入的排序时比较大小的动作)的排序函数,在C/C++中的常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样:
1 |
|
但在 Java 语言中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数,例如Java类库中的Collections::sort()方法就是这样定义的:
1 |
|
不过,使用invoke包中的方法句柄同样可以实现类似的效果了,如下代码:
1 |
|
方法 getPrintlnMH() 中实际上是模拟了 invokevirtual 指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个由用户设计的 Java 方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。以此为基础,有了 MethodHandle 就可以写出类似于C/C++那样的函数声明了:
1 |
|
invokedynamic指令
前面说到 JDK 7中引入了 Java诞生以来的唯一一条新加入的字节码指令:invokedynamic 指令,不过如果我们把查看上面的 MethodHandle 例子字节码会发现并没有找到 invokedynamic 的影子。那么 invokedynamic 到底有什么应用呢?
某种意义上可以说 invokedynamic 指令与 MethodHandle 机制的作用是一样的,都是为了解决原有4 条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实现, 另一个用字节码和Class中其他属性、常量来完成。
每一处含有 invokedynamic 指令的位置都被称作“动态调用点(Dynamically-Computed CallSite)”, 这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7 时新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法 (Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和方法名称。
invokedynamic指令的调用流程如下。
- JVM 首次执行 invokedynamic 指令时会调用引导方法(Bootstrap Method)。
- 引导方法返回一个 CallSite 对象,CallSite 内部根据方法签名进行目标方法查找。它的 getTarget 方法返回方法句柄(MethodHandle)对象。
- 在 CallSite 没有变化的情况下,MethodHandle 可以一直被调用,如果 CallSite 有变化,重新查找即可。
它们之间的关系如下图所示。
下面通过一个 Groovy 的例子来模拟这个过程:
1 |
|
使用 groovyc –indy 命令编译后查看字节码如下:
1 |
|
add(“hello”, “world”)这条调用被编译为 invokedynamic 指令,第一个参数是常量池中的#52,又指向了 BootsrapMethods 中的 #1 的元素,调用静态方法 IndyInterface.bootstrap,返回一个 CallSite 对象。最后,这个对象返回给invokedynamic 指令实现对add()方法的调用,invokedynamic 指令的调用过程到此就宣告完成了。可以把上面的过程翻译为 Java 代码,更清楚整个调用过程:
1 |
|
实例:调用祖父方法
invokedynamic指令与此前4条传统的“invoke*”指令的最大区别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。下面通过一个例子来实例我们如何做到改变虚拟机的分派规则。
1 |
|
上面代码中Son类的thinking()方法可以通过super关键字很容易的调用到Father中的thinking()方法,但要调用 GrandFather#thinking 的话传统方法就做不到了,因是在Son类的thinking()方法中根本无法获取到一个实际类型是GrandFather的对象引用。不过在拥有方法句柄之后,我们就可以通过 MethodType.Lookup 来找到祖父类中的方法句柄了:
1 |
|
运行成功输出:
1 |
|
参考
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!