第8章 虚拟机字节码执行引擎

237 阅读13分钟

第一节:运行时栈帧结构

概述

  • 1、栈帧是用于支持jvm运行时,调用方法和执行方法的数据结构。它是jvm运行时,栈中的栈元素
    • 方法的局部变量表
    • 操作数栈
    • 动态链接
    • 方法返回地址
    • 附加信息
  • 2、每一个方法从调用开始至执行完成的过程,都对应着一个栈帧从入栈到出栈的过程。
  • 3、栈帧内存大小:在编译程序代码的过程中,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定,并写在方法表的Code属性中,因此一个栈帧需要分配多大的内存空间,不会受到运行变量数据影响,仅仅取决于虚拟机表现。
  • 4、当前栈帧(位于栈顶的栈帧)

一、局部变量表

1、变量值存储空间

  • 一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量
  • java代码编译成class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量

2、变量槽slot

  • 局部变量表以变量槽slot为最小存储单位,
  • jvm规范没有规定slot大小
  • slot应该能存放boolean、int、short、char、byte、float、reference或者returnAddress类型的数据。
  • reference类型代表一个对象实例的引用(HotSpot中就是obj在堆中的地址)
    • 此引用直接或间接的查找obj在堆中数据存放的起始地址索引
    • 此引用直接或间接的查找到obj对应方法区中存储的类型信息(即obj是哪个class的实例)
  • returnAddress类型,现在少见指向一条字节码指令地址用来处理异常,因为大部分异常都是用异常表处理

3、局部变量的使用

  • jvm通过索引定位的方式使用局部变量表,从0开始至局部变量表中最大的slot数量
  • double和long是两个64位数据
  • 如果是32位数据类型变量,索引n就是第n个slot。如果是64位数据类型变量那么索引n就是同时使用第n个和第n+1个两个slot

4、执行方法

  • jvm通过使用局部变量表完成方法参数到参数变量列表的传递过程
  • 例如:一个obj方法
    • 局部变量表中第0个slot默认用于传递方法所属obj的引用,方法中可以用this访问到这个隐含参数
    • 其余参数按照参数表顺序排序,占用从1开始的局部变量slot
    • 参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的slot

5、slot复用

  • 方法体中定义的变量,作用域并不一定覆盖整个方法,如果当前字节码PC计数器的值超过了某个变量的作用域,那么这个变量对应的slot就会交给其他变量使用
  • 复用可以节省栈帧空间,但是有副作用(栈帧没有弹栈的情况下)
    • slot的空间没有被新的内容填充的话,slot原来的内容不能被回收(placeHolder无法被回收)

public void changeSlot() {   
	{   
		byte[] placeHolder = new byte[64 * 1024 * 1024];   
	}
	System.gc();
} 

6、局部变量

  • 局部变量表没有类的准备阶段,所以jvm也就没有办法给局部变量进行初始化默认值

二、操作数栈

1、概述:

  • 栈结构, 最大深度在编译时写入方法表集合code属性的max_stacks数据项中。
  • 操作数栈的每一个元素可以是java的任意数据结构(包括long和double)
  • 32位数据类型所占的栈容量1,64位数据类型所占的栈容量2

2、方法执行

  • 方法开始执行时,栈是空的,会有各种字节码指令向操作数栈中写入和读取,也就是入栈和出栈
  • 算术运算是通过操作数栈进行,调用其他方法也是通过操作数栈进行参数传递

3、栈帧重叠

  • 概念模型中,连个栈帧独立的,但是为了优化,栈帧会有重叠
  • 下面栈帧的操作数栈会和上面栈帧的局部变量表有部分的重叠,共享一部分局部变量,无需额外数据复制传递

4、基于栈的执行引擎

  • jvm解释执行引擎叫做基于栈的执行引擎,其中的栈就是操作数栈(Android基于寄存器)

三、动态连接

1、方法引用

  • 每个栈帧中包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
  • 字节码中的方法调用就是以常量池中的符号引用作为参数
  • 常量池:字面量
    • 接近java的常量概念,文本字符串,生命final的常量值(JDK1.8以后把String移到了堆中)
  • 常量池:符号引用
    • 类和接口的全限定名(包名+类名)
    • 字段的名称和描述符
    • 方法的名称和描述符

2、静态解析

  • 常量池中的符号引用一部分会在类加载阶段或者第一次使用的时候就转换为直接引用

3、动态连接

  • 每次运行期间转化为直接引用

四、方法返回地址(方法出口)

  • 概述:一个方法开始执行后有两种方式可以结束。

1、正常退出:

  • jvm引擎遇到任意一个返回的字节码指令,如果有返回值传递给调用者
  • 是否有返回值和返回值类型,根据遇到的何种方法返回指令来决定

2、异常退出

  • 遇到异常,并且这个异常没有在方法体内进行处理,只要在本方法的异常表中没有找到匹配异常处理器,那么就会导致异常退出
  • 异常退出不会给调用者产生返回值

总结:

  • 无论方法以何种方式退出,都需要返回到调用者的位置。调用者需要回复局部变量表和方法数栈,如果有返回值的话,要把返回值压入调用者的方法数栈,调整pc计数器的值指向下一条指令
    • 正常退出,pc计数器的值可以作为返回地址,栈帧中很可能保存这个计数器值
    • 异常退出,返回地址是通过异常表处理的,栈帧不会保存这部分信息

五、附加信息

  • jvm规范允许在栈帧中添加一些额外内容,比如调试信息

第二节:方法调用

  • 概述:本节是方法调用,不涉及方法执行
    • 所有方法在class字节码文件中的常量池中都是符号引用,不是内存中的入口地址
    • 在类加载和运行期间,才能知道这个方法的直接引用(内存地址)
    • 方法的调用,分成解析调用(静态)和分派调用两大类(可能是静态也可能是动态)

一、解析调用(静态)

概述:

  • 类生命周期的解析阶段,就有一部分符号引用被转化成了直接引用。
  • 这种情况的前提,就是程序在执行前,方法就有确定的版本,并且方法的调用版本在运行期间不可变(方法不能被重写)
  • 符合这个要求的主要包括静态方法和私有方法,会在类加载阶段解析

1、方法调用字节码指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器<init>(),私有方法和父类方法
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后在执行该方法
    • 再此之前的四条调用指令,分派逻辑是固化在jvm内部的,而invokedynamic分派逻辑是由用户设定的引到法决定的

2、类解析阶段:

  • invokestaticinvokespecial指令调用的方法,类解析阶段确定唯一调用版本
    • 静态方法、私有方法、构造器、父类方法4类(能确定方法版本的唯一性),被称为非虚方法。剩余被称为虚方法(除final方法)
  • final方法
    • final方法被invokevirtual指令调用,但是final方法不能被重写,只有唯一版本。所以被final修饰的方法也是非虚方法

3、总结:

  • 主要是因为多态,不能确定方法版本,所有就造成了方法动态性。只要是非虚方法就会在类生命周期的解析阶段确定其唯一性,生成直接应用。

二、分派(重写和重载)

1、静态分派(方法的重载,overload)

  • 概述:所有依赖静态类型确定方法执行版本的分派动作被称为,静态分派。
    • 静态分派发生在编译阶段,因此不由jvm运行时控制
    • 会自动去查找方法参数中,和静态类型最接近的方法。
  • Human称为静态类型,Man称为实际类型。
    • 静态类型和实际类型在程序中都可以发生一些变化
      • 静态类型的变化,是在使用时发生,变量本身的静态类型不会发生变化,并且最终的静态类型是在编译期可知的
      • 实际类型的变化,是在运行期确定,编译器在编译时不能确定对象类型

-- Code

public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Human man = new Man();
        Human woman = new Human();
        sayHello(man);
        sayHello(woman);
    }
     class Human { }
     class Man extends Human { }
     class Woman extends Human { }
    public void sayHello(Human guy) {
        Log.i("Ethan", "hello Human");
    }
    public void sayHello(Man guy) {
        Log.i("Ethan", "hello Man");
    }
    public void sayHello(Woman guy) {
        Log.i("Ethan", "hello Woman");
    }
}

* 输出结果是:
 `Ethan: hello Human   
  Ethan: hello Human`
  • 确定重载方法版本
    • 编译器只能从众多方法版本中,根据方法参数类型查找到和给定类型的最接近的那个方法
    • 原因:字面量不需要定义(调用方法时传递的参数),字面量只能根据语言规则去理解和推断
      • 也就是方法分派是有优先级的。根据静态类型最接近的去确定,类型转化,自动装箱
      • js不需要编译,也没有这种类型转化,也就不存在方法的重载

2、动态分派(方法重写@Override)

  • invokevirtual指令解析多态查找方法
    • 这就是java重写的本质:
    • 找到操作数栈顶,第一个元素所指向的对象的实际类型,记做C。
    • 如果在堆内obj的C中找到与常量池中描述符和简单名称都相符的方法。进行权限校验:
      • 通过返回方法直接引用(方法地址)
      • 不通过,抛出java.lang.IllegalAccessError异常
    • 否则,按照继承关系自下而上进行查找。查找C的父类以及祖辈。
    • 如果始终没有找到,抛出java.lang.AbstractMethodError异常

3、单分派与多分派

  • 概述:
    • 方法的接收者和方法的参数统称为方法的宗量
    • 单分派:根据一个宗量对目标方法进行选择
      • 动态分派:jvm运行时,只会根据方法的实际接收者,确定方法的唯一版本
    • 多分派:根据多于一个宗量对目标方法进行选择
      • 静态分派:根据静态类型和方法参数进行选择。(重载多个方法)

4、jvm动态分派实现

  • 概述:jvm动态分派是非常频繁的动作,动态分派方法版本需要在运行时类的方法元数据区中搜索合适的目标方法。为了避免频繁搜索,所以在方法区里面建了一个虚方法表
    • 使用虚方法表索引,代替搜索元数据
  • 虚方法表
    • 存放着方法的实际入口地址。
    • 子类没有重写方法,那么子类和父类的方法就会有相同的方法地址入口
    • 子类重写了方法,子类方发表中的地址将会替换子类实现方法的地址入口
  • 方法表一般是在类加载的初始化阶段完成
    • jvm初始化完类的变量初始值,jvm也会把方法表初始化完毕

二、动态类型语言支持

  • 概述:invokedynamic字节码指令是lambda表达式的技术准备

1、动态类型语言

  • 概述:
    • 动态类型语言:类型检查的主题是在运行期,而不是编译器(js)
      • 变量无类型,而变量值有类型。
    • 静态类型语言:编译器就进行类型检查

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

一、解释执行

  • 概述:现在主流jvm都包含解释执行(解释执行)和编译执行(通过即时编译器MIT产生本地代码执行)
    • 抽象语法树之前实现为一个半独立的编译器,java

二、基于栈的指令集和基于寄存器的指令集

概述:

  • java输出的指令流,是基于操作数栈的指令集架构。指令流中的指令大部分都是零地址指令
    • 纯粹的基于栈架构的指令集,应当都是零地址指令
    • java出于对代码的校验性大部分是因为有些字节码会带有参数。
  • x86的二地址指令集,pc机直接支持的指令集构架,基于寄存器架构,

1、栈架构指令集

  • 优点:
    • 可移植性好
    • 代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)
    • 编译器实现更加简单(不要考虑内存分配问题,所需空间都是在栈上操作)
    • 用户不会直接使用寄存器, jvm会自行把访问频繁的数据缓存在寄存器中(程序计数器,栈顶缓存信息)
  • 缺点:
    • 指令多,虽然代码紧凑,但是完成相同功能比寄存器架构需要的指令集更多(因为出栈入栈本身就消耗更多指令)
    • 访问速度,频繁的栈访问也就是频繁的内存访问,内存没有寄存器速度快

2、基于栈的解释器执行过程(概念模型)

public int show() {
	int a=1;
	int b=2;
	return a+b; 
}
  • a=1,解释器,根据当前指令集的偏移地址执行指令,程序计数器记录指令偏移地址。根据字节码指令压入操作数栈,然后出栈赋值个局部变量表的第1个slot(第0个是this)。
  • 在根据相应的情况,将1和2全部入栈
  • 在根据a+b,将栈中1和2出栈进行加法运算。然后结果入栈
  • 最后返回a+b的结果,方法执行结束