虚拟机字节码执行引擎,JVM的马达图,是爱情呀

908

JVM整体架构

首先我们要知道,虚拟机是相对于物理机而言,这点毋庸置疑。冒然的讲执行引擎可能会觉得这个东西很突兀,让我们来简单回顾一下JVM的架构图,看看执行引擎所处的位置:

JVM的架构图

类加载和运行时数据区,前面几篇我都讲过了,如果有不太清楚的大家可以看上几篇内容。

JVM架构图简易版:

JVM架构图简易版

我们要讲的也就是这个执行器,引擎那就是汽车的马达,得让整个汽车或者虚拟机动起来啊,前面类加载和运行时数据区做了那么多的前面准备工作,都是为了执行做铺垫。读者可能会对mysql比较熟悉,这里我们看看mysql的执行引擎来做一个联想,加深一下引擎在我们心中的概念:

mysql架构图 从mysql架构里面,我们可以看到总的架构分为Server层和存储引擎两个部分,Server层进行连接接入,sql语句命中缓存直接返回数据,没有就进入分析器进行各种词法语法分析,在优化器里面生成执行计划选择索引,最终操作引擎进行读写数据,也是前面做了各种的准备和优化,最后才来到执行器进行执行。

我们的 JVM虚拟机 也是这种思路,前面的类加载初始化等过程,都是在为执行做准备,jvm执行引擎才是JVM运转的灵魂,所有的Java虚拟机的执行引擎都是:

  1. 输入的是字节码文件;
  2. 处理过程是字节码解析的过程;
  3. 输出的是执行结果;

字节码执行引擎

字节码执行引擎模型

那么问题来了,Java虚拟机是编译执行还是解释执行? 混合执行

先不管这些花里胡哨的概念,机器是只能识别机器码的,编译执行是直接将源代码编译成机器码,这样机器可以直接执行,热点代码是运行的时候才进行编译的;

解释执行在这里是通过JVM将源代码字节码编译成JVM解释器可以识别的虚拟机指令,然后虚拟机将这些指令和底层的操作系统以及硬件对应起来,从而屏蔽掉了OS和硬件的差异,比如你写一个正常的打开文件系统的Java代码,例如file.open,虚拟机的解释器在windows平台会解释成windows平台对应的打开文件指令,在Linux平台会解释成Linux平台的打开文件指令,解释器就相当于一个翻译官,将代码转化为各种硬件所知道的操作。

Java最开始的口号“一次编译,到处运行”就是因为解释执行,但是随着发展优化需求,已经加入了对热点代码进行编译执行(即JIT编译器,just in time,即时编译器),所以现在是混合执行。

方法调用

栈帧概念图

每个线程都有自己的操作数栈,栈帧是用于支持虚拟机方法调用和方法执行的数据结构,对应了虚拟机运行时数据区中的虚拟机栈,方法从开始到调用结束就是栈帧在虚拟机里面入栈出栈的过程。

我们分析一下栈帧需要保存什么,作为方法调用的数据结构,需要存储什么呢?

从方法的内容来看,方法里面有方法参数,变量,叫做局部变量,操作数如1,2,3,方法返回,动态连接等内容,栈帧里面需要保存所有的信息,那么栈帧里面的设计就需要包括局部变量表,操作数栈,动态连接,方法返回地址以及一些额外的附加信息,编译的时候栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,写入到了方法表的Code属性中。

局部变量表

局部变量表是以变量曹slot为最小单位,虚拟机规范里面没有明确一个slot应占多大内存,只是说了每个slot应该能放下一个boolean,byte,char,short,int,float,reference或者returnAddress类型的数据,这八种数据类型都可以使用32位或者更小的物理内存来存放,但是不等于每个slot占用32位长度内存空间。

reference表示表示对一个对象的实例引用,没有明确的大小以及结构,但是这个引用需要做到以下两点:

  1. 此引用直接或者间接查找到对象在Java堆中的数据存放的起始地址索引;
  2. 此引用中直接或者间接查找到对象所属数据类型在方法区中的存储的类型信息

其实就是能占到对象起始地址,和对象所属类的信息就可~

long和double数据类型是64位,采用的是分割存储,虚拟机以高位对齐的方式为其分配两个连续的slot空间,但是由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的slot是否为原子操作都不会引起数据安全问题。

方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的是实例(非static)方法,那局部变量表中第0位索引的slot默认是用于传递方法所属对象实例的引用,在方法中通过this来访问到这个隐含的参数。其余的参数按照按照参数列表顺序排列,占用从1开始的局部变量slot,参数表分配完之后再根据方法体内部定义的变量顺序和作用域分配其余的slot。

操作数栈

一个方法刚刚开始执行的时候,方法的操作数栈是空的,方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈/出栈操作。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且在运行期间不可变。其实就是将不会受到运行期间影响的方法在准备阶段就做好解析准备,毕竟调用时少做一点事情就可以加快执行效率,也不容易出错。

符合编译期可知,运行期不可变要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两个特点决定了他们不可通过继承或者别的方式重写出其他版本,因此适合在类加载阶段进行解析。

虚方法和非虚方法

方法又分为虚方法和非虚方法:

非虚方法 在类加载的时候就会把符号引用解析为该方法的直接引用,在解析阶段中确定唯一的调用版本,有4类是非虚方法:静态方法、私有方法、实例构造器、 父类方法

虚方法 除去final方法和虚方法,其他方法称为虚方法

方法调用指令

invokevirtual指令 用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。

invokeinterface指令 用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。

invokespecial指令 用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。

invokestatic指令 用于调用类方法(static方法)

invokedynamic指令(JDK1.7中新增) 用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,上面4条指令的分派逻辑都固化在java虚拟机内部,而该指令的分派逻辑是由用户所设定的引导方法决定的。

这是JDK1.7中新增的指令,为实现动态类型语言支持而进行的改进之一,也是为JDK8可以顺利实现Lambda表达式做技术支持。

方法调用

方法调用并不等同于方法执行,该阶段唯一的任务就是确定调用哪一个方法。方法在实际运行时内存布局中的入口地址需要在类加载期间,甚至到运行期间才能确定。java 是面向对象语言,具备面向对象三个基本特征:继承,封装,多态。多态性典型的两种体现方式重载和重写,虚拟机是如何实现的呢?

方法调用

静态分派:

package com.jvm;
/**
 * 静态分派
 */
public class StaticDispatch {
        
    static class Human {
         
    }
     
    static class Man extends Human {
         
    }
     
    static class Women extends Human {
         
    }
    
    public void sayHello(Human guy) {
        System.out.println("hello, guy!");
    }
     
    public void sayHello(Man guy) {
        System.out.println("hello, man!");
    }
     
    public void sayHello(Women guy) {
        System.out.println("hello, women!");
    }
     
    
    public static void main(String[] args){
        
        Human man = new Man(); 
        Human women = new Women();
         
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);   
        sr.sayHello(women);
 
    }
 
}

运行结果是:

hello,guy!
hello,guy!

Human man = new Man();

我们把上面代码中的“Human”称为变量的静态类型,后面的“Men”称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会改变,并且最终的静态类型是在编译期间可知的;而实际类型变化的结果在运行期间才可确定,编译器在编译程序时并不知道一个对象的实际类型是什么。

例如:

// 实际类型变化
Human man = new Man();
man = new Woman();
// 静态类型变化
sr.sayHello((Man) man);
sr.sqyHello((Woman) man);

回到上面例子代码中,mian()中两次调用sayHello()方法,在方法接受者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。编译器在重载时通过参数的静态类型而不是实际类型作为判断依据的,因此在编译阶段Java编译器根据参数的静态类型决定使用哪个重载版本。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派典型应用就是重载,发生在编译阶段。另外,编译器虽然能确定出方法的重载版本,但是在很多情况下这个重载版本并不是‘唯一的’,往往只能确定一个‘更加合适的’版本。究其根本原因是字面量不需要定义,没有显示的静态类型,只能通过语言上的规则去理解和推断。

动态分派:

它是重写的重要体现,同样看一个重写的简单例子:

package com.jvm;
/**
 * 动态分派
 */
public class DynamicDispatch {
        
    static abstract class Human {
        protected abstract void sayHello();
    }
    
    static class Man extends Human {
        
        @Override
        protected void sayHello() {
            System.out.println("hello man!");
        }         
    }
     
    static class Women extends Human {
     
        @Override
        protected void sayHello() {
            System.out.println("hello women!");
        }         
    }
     
    
    public static void main(String[] args){
        
        Human man = new Man();
        Human women = new Women();
         
        man.sayHello();
        women.sayHello();
        
        man = new Women();
        man.sayHello();
 
    }
 
}

运行结果:

hello man!
hello women!
hello women!

虚拟机是如何调用哪个方法的呢?

javap查看字节码 015主要是建立man和woman的存储空间、调用Man和Woman类型的实例构造器,并将两个实例存放在第一个和第二个局部变量表Slot之中。接下来的1621句是关键部分,16、20两句分别是把刚刚穿件的两个对象的引用压到栈顶,17、21两句是方法调用指令,这两条调用指令从字节角度来看,无论指令(invokevirtual)还是参数完全一样,但是这两条指令最终执行的目标方法并不相同,原因需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致如下几个步骤:

1). 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C.

2). 如果在类型C中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限的校验,如果校验不通过,则返回java.lang.IllegaAccessError异常,校验通过则直接返回方法的直接引用,查找过程结束。

3). 否则,按照继承关系从下往上一次对C的各个父类进行第二步骤的搜索和验证过程。

4). 如果始终还是没有找到合适的方法直接引用,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步是在运行时确定接收者的实际类型,所以两次中的invokevirtual指令把常量池中的类方法符号引用解析到不同的直接引用上,这个就是java语言中方法重写的本质,我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

动态分配的实现:

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会直接真正进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table),使用虚方法表索引来代替元数据查找以提高性能。

方法表结构

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化后,虚拟机会把该类的方法表也初始化完毕。方法表示分派调用的“稳定优化”手段,虚拟机除了使用方法表外,在条件允许的情况下,还会使用内联缓存(Inine Cache)和基于“类型继承关系分析”技术的守护内联(Guarded Inlining)

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 第一种,执行引擎遇到任意一个方法返回的字节码指令
  2. 第二种,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理

方法退出

方法退出的过程实际上就等同于把当前栈帧出栈,退出时可能的操作有:

  1. 恢复上层方法的局部变量表和操作数栈
  2. 把返回值(如果有的话)压入调用者栈帧的操作数栈中
  3. 调整PC计数器的值以指向方法调用指令后面的一条指令等

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里面没有描述的信息到栈帧之中,例如与调试相关的信息,这部分完全取决于具体的虚拟机实现。

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

JVM是混合方式,即解释执行和编译执行的两种方式的,我们主要看解释执行。

编译过程

下面的分支是传统编译原理中程序代码到目标机器代码的生成过程,而中间那条分支自然就是解释执行的过程。

java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作,与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是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寄存器中。