深入理解JVM-字节码执行引擎

718 阅读10分钟

本章主要是从概念模型的角度解释JVM的方法调用和字节码执行。

先来看几个提前知识:

解释执行:一边把字节码编译成机器码,一边执行,编译一条执行一条。 即时编译(JIT):一次性把某部分代码全部编译成机器码,执行。

后面会提及这两个编译方式,因为本文介绍JVM的执行引擎,就不可避免的会涉及这两个概念,在这里单领出来说一下。

运行时栈帧的结构

Java里,方法是最小的执行单元(所以Thread运行的是run()方法)。而方法运行需要跑在内存栈上,每个方法都有自己的那一块栈区域,称为“栈帧”。栈帧里存放着各种数据结构用以支持方法的运行。

前文理解JVM自动内存管理时稍微提到了一些,现在来进一步,更加全面的分析Java虚拟机栈(其实是Java方法栈,叫Java虚拟机栈是因为Java方法跑在虚拟机中,叫Java方法是为了和本次调用的方法(一般是C方法)分开)。

栈帧基本包含这些:

  • 1⃣️局部变量表。用来保存方法里定义的局部变量,同时为了节省空间,可能会进行逃逸分析然后复用变量表。一个局部变量表的大小在编译期就可以确定,运行时动态变化但绝不会超出限制。
  • 2⃣️操作数栈。Java字节码不是基于寄存器的,而是基于内存中的操作数栈的。此外栈最大深度在编译时也会确定。
  • 3⃣️动态链接。
  • 4⃣️方法返回地址
  • 5⃣️其他信息。

了解计算机组成原理的应该知道,方法链调用可能会很长,每次调用一个方法,就会在当前栈顶新开辟一个空间,存放调用方法的栈帧,所有的栈帧在内存栈里,每次调用都在栈顶申请。于是把栈顶的栈帧称为“当前栈帧”,对应的方法称为“当前方法”。

执行引擎执行的字节码全部对应当前栈帧。

局部变量表

局部变量表存放方法的参数和局部变量,还可以存放this指针(仅限此方法是实例方法)。

局部变量表的单位不是字节也不是啥,而是一种称为(slot)的东西。一个槽的大小视虚拟机实现而定,但是一个槽只能存放32位数据是一定的!JVM明确规定,只有long和double这两个类型是64位的,存放他们需要占用两个槽,其余类型,包括对象引用类型reference都是一个槽。

存放64位数据时,槽必须连续,每次读取写入必须两个两个来,因为局部变量表位方法私有,所以不存在线程安全性问题。

如果是实例方法,会使用第0个槽存放this指针,然后参数列表依次存放,再然后就是局部变量,依照定义的顺序和作用域。

对于变量槽的复用,可以把一些作用域超出本方法的变量进行保存或者与其他方法共享,这样虽然节省了空间,但是也对GC造成了一定延迟,因为可达性分析算法可能认为这个变量可用而不进行回收。

操作数栈

操作数栈的最大深度在编译时会计算出来,运行时不会超过这个深度。

操作数栈可以用来传递方法参数,和局部变量表不同,一个是传递,一个是保存,一个属于调用者,一个属于被调用者。此外,操作数栈有着严格的指令和数据类型的一一对应关系。

为了方便快速传递和节省空间,可以把调用者的操作数栈和被调用者的局部变量表进行重叠。

image.png

动态链接

每一个栈帧都有一个指向运行时常量池的栈帧所属方法的引用,这个引用用来支持方法调用过程中的动态链接。字节码的方法调用指令的参数就是常量池中的符号引用,这些符号引用一部分会在编译时直接转换成直接引用,称为静态解析,还有一部分会在每次运行期间都转换成直接引用,称为动态链接。

方法返回地址

Java方法退出有两种形式,一是正常退出,包括正常返回和发生异常但被捕获;二是异常退出,一般是发生了异常同时本方法的异常表没有此异常。

不管何种方式返回,栈帧都要出栈,执行位置需要切回到主调方法(调用这个方法的方法)的位置。一般切回到主调方法的PC记录的位置即可(还记得吗?程序计数器PC是每个线程私有的)。

正常退出一般会有返回值,此时会把返回值压入主调方法的操作数栈;异常退出则没有返回值。

附加信息

就是一些附加信息,包含debug,性能分析用的信息。

方法调用

⚠️方法调用不等于方法被执行,方法调用仅仅确定调用哪个方法

字节码编译后的方法调用指令的参数都是常量池的符号引用,直到类加载,甚至是运行期间才能确定究竟调用哪个方法。

解析

前面说过,方法调用指令的参数是一个符号引用,我们需要把符号引用变成直接引用才能使用。

有一些方法符合“编译期可知,运行期不可变”,比如静态方法和私有方法,它们都不可以被重写,就可以在编译期直接解析为直接引用。这一类方法的调用称为“解析”。

调用不同类型的方法,JVM提供了不同的指令:

  • 1⃣️invokestatic:调用静态方法。
  • 2⃣️invokespecial:调用构造器,私有方法,和父类方法。
  • 3⃣️invokevirtual:调用虚方法(什么是虚方法看下面解释)。
  • 4⃣️invokeinterface:调用接口方法,因为接口方法需要在运行时找到实现类,所以单拎出来。
  • 5⃣️invokedynamic:调用动态方法,前面四个指令具体调用哪个方法是JVM计算出来的,调用哪个动态方法是程序员指定的。

只要能被invokestatic和invokespecial调用的方法都是可以在编译期确定的唯一方法。除了静态方法,构造器,私有方法,父类方法外,还有一个由invokevirtual调用的但是被final修饰的方法(不可被重写)。这五个方法都是可以唯一确定的

在类加载时就能把上面五个方法的符号引用替换成直接引用,就是指令后面参数直接就是方法地址了,不是符号引用字符串了。这五个方法统称为“非虚方法”,除此之外的都是虚方法

分派

所谓分派就是找到该调用哪个方法,比如接口被多个类实现,父类型有多个子类之类的;调用他们的方法就需要明白到底调用谁的。

静态分派

直接一句话,所有依赖静态类型来决定方法执行版本的分派,全部是静态分派,这是编译期可知的。

什么是静态类型,什么又是实际类型?

public void test() {
    AF a1 = new AC();
    AF a2 = new AF();
    AF a3 = new AS();
    // AF(Father), AC(Child), AS(Son)
}

这段代码中,AC,AC继承自AF,a1,a2,a3的静态类型都是AF,但是它们的实际类型分别是AC,AF,AS。

一个典型的静态分派就是重载,方法的重载就是一个静态分派的过程,编译时就确定了选择哪个方法版本。

动态分派

明确一个概念:一个方法是哪个类型的,就称这个类型为方法的接收者

再来明确一件事,动态分派一般都是invokevirtual的事情。invokevirtual一般步骤是找到它的参数所属的实际类型,然后描述符+简单名称匹配,再之访问权限匹配,找不到去父类找......

所以invokevirtual要做的第一件事就是在运行期确定接收者的实际类型,而这就是重写的本质。

动态分派就是在运行期根据接收者实际类型确定执行方法的版本

单分派和多分派

只要记住一个结论就行了,Java是静态多分派,动态单分派的语言。

动态分派实现

一般来时,如果每次动态分派时,都要查找实际类型,然后调用,未免有点浪费性能,那既然我们已经知道了这个方法,或者某处方法调用会调用哪个实际类型的哪个方法,那为什么不记录下来,然后直接使用呢?

是的!JVM也确实是这么做了。一种常见且基础的手段就是打表,建立一个虚方法表,让每个类型的方法区都保存着这么一张表,表里记录着以什么参数类型,数量去调用哪个名称的方法应该去找哪个方法。除了invokevirtual的虚方法表,还有invokeinterface的接口方法表。

image.png

为了使程序切换方法方便一些,通常对于父类/子类,接口/实现类会维护一样的表序号,这样在切换实现类/子类时仅仅切换表就行了,使用第几个表项都是一样的。

动态类型语言支持

为了实现更好的动态支持,JDK7引入了一条新的指令:invokedynamic。

java.lang.invoke

为了使用invokedynamic,JDK引入了java.lang.invoke包。而其中有一个尤为重要的类,就是MethodHandle(方法句柄),它可以起到类似C/C++的函数指针的作用,把函数作为一个值进行传递。

使用lookup()的查找虚方法功能,可以返回一个方法句柄,拿到了此句柄就像拿到了方法引用一样,可以进行对该方法的调用(访问权限什么的必须满足才行)。

这不同于反射,反射是模拟的是Java代码层次的调用,方法句柄模拟的是字节码层次的调用。Reflection更加重量级,MethodHandle偏轻量级。此外,由于模拟的是字节码,于是可以手动优化方法调用措施,反射没有这么大的灵活性。

invokedynamic指令

invokedynamic指令很大的好处之一就是提供了更高的灵活性,其他四条invoke***指令都是固化在虚拟机中实现,优化,分析的;invokedynamic把这种决定权放开给了用户。

每一个invokedynamic出现的地方都称为动态调用点,指令的第一个参数不再是的CONSTANT_Methodref_info类型的符号引用,而是CONSTANT_InvokeDynamic_info类型的符号引用。

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

  • 解释执行:解释(编译)一条字节码,执行一条。
  • 即时执行:全部编译某一块字节码,全部执行,比如循环,经常调用的某个方法等。

基于栈和基于寄存器

解释执行时,运行方式是基于栈的,操作数通过操作数栈来进行指令操作,涉及到入栈和出栈操作。正因如此,解释执行时,会有很多多余的入栈和出栈操作,并且内存一般是CPU瓶颈,所以性能往往不如即时执行。

再次强调一下,即时执行是直接把字节码翻译成二进制指令,交给CPU去跑,速度蛮快的,解释执行跑在JVM里,会慢一些;但是解释执行可以做到更好地跨平台,启动速度快(不需要全部编译)等优点。