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

前一章讲解了类加载机制。那么把字节码加载到虚拟机以后,JVM该如何去执行呢?这就是这章要讲的内容。所谓执行字节码,其实就是执行程序中的一个个和业务相关的方法。我们都知道,每个java方法的执行,其实都对应着一个栈帧从入栈到出栈的过程。因此,要知道方法如何执行,就必须要了解栈帧的存储结构,这样才能了解到这个栈帧是如何对应着一个方法的执行的。这就是本章第一个重要的部分:栈帧结构。接下来要从JVM角度分析,针对一个调用某方法的字节码指令, JVM是如何找到这个方法,把其栈帧放入栈中的几种查找方法版本的机制。这是本章第二个比较重要的地方。前面两个部分都是讲的JVM是如何调用方法的内容的,那本章最后一节讲的是如何执行方法中的字节码指令的。这样整个执行过程:从字节码加载到JVM,到输出结果,就全了。

1 概述

虚拟机和物理机都有代码执行能力,区别是:虚拟机的执行引擎是由自己实现。物理机的执行引擎是建立在处理器、硬件、指令集和操作系统上的。

jvm的执行引擎完成的工作是:输入字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

jvm的执行引擎工作时的两种工作方式:
★ 解释执行:通过解释器执行。
★ 编译执行:通过即时编译器产生本地代码再执行。

2 运行时栈帧结构

栈帧是用于方法调用和执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每个java方法的执行,都对应着一个栈帧从入栈到出栈的过程。

在编译的时候,栈帧需要多大的局部变量表,多深的操作数栈都已确定了,并写入到方法表的Code中,栈帧需要分配多少内存,不会受程序运行期变量数据的影响。

只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

2.1 局部变量表

定义:用于存放方法参数和方法中定义的局部变量。

大小确定时间:程序编译时,存在于Code属性表中max_locals数据项中。

分配单位:变量槽(slot),可存放32位以内数据类型。

注1:java虚拟机数据类型和java数据类型是有本质差别的。
注2:每个slot都能存放一个boolean、byte、char、short、int、short、float、double、reference、returnaddress类型的数据。
注3:reference表示对一个实例对象的引用。通过reference,JVM至少可以做到两点:
a、从此引用直接or间接的在java堆中查找到对象的起始地址索引。
b、利用此引用直接or间接的找到对象所属数据类型在方法区中存储的类型信息。
注4:returnaddress现在已经不再使用。
注5:对于64位bit数据long和double,需要使用2个slot来存储。读取时,需要分2此读取。因为局部常量表在线程的堆栈上,是线程私有的数据,无论读写2个连续的slot是否为原子操作,都不会引起线程安全问题。

工作方式:JVM通过索引定位的方式,使用局部变量表,索引从0开始,到局部变量表最大的slot数量。如果访问32位数据类型的变量,索引n就代表了使用第n个slot;如果是64位数据类型的变量,则说明同时使用n和n+1两个slot。对于相邻的共同存放一个64位数据的2个slot,不允许只访问一个,否则在类加载的校验阶段抛出异常。

如何分配局部变量表?先按照参数表顺序,分配参数;再顺序分配局部变量。如果是实例方法,那局部变量表的第0位索引的slot默认的是用于传递方法所属对象实例的引用。

功能:方法执行时,JVM使用局部变量表,完成参数值(存在于局部变量表)到参数变量列表的传递过程。

局部变量是否自动赋值?局部变量和类变量不同,没有“准备阶段”,所以没有想当然的自动赋值阶段。所以局部变量在使用时必须先手动赋值。

2.2 操作数栈(操作栈)

同局部变量表一样,操作数栈的最大深度在编译的时候写入Code属性的max_stacks数据项中。

操作数栈的元素可以是任意的java数据类型,32位的数据类型占的栈容量为1,64位的数据类型占的栈容量为2。

数据类型一致:当前执行的字节码指令中的数据类型和要和当前方法中操作数的数据类型对应。在编译代码、类校验阶段都会检查这一点。

在概念模型中,2个栈帧是虚拟机栈的元素,是完全相互独立的,但大多虚拟机的实现里会做一些优化处理,让2个栈帧出现部分重叠,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。

java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中栈就是操作数栈。

2.3 动态连接

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

class文件的常量池中有大量的符号引用,字节码中的方法调用指令:就以常量池中指向方法的符号引用为参数。这些符号引用一部分,在类加载阶段或者第一次使用的时候就转化为直接引用,这种转换称为“静态调用”。另一部分将在每一次执行期间转化为直接引用,这部分称为“动态连接”。

2.4 方法返回地址

方法执行后,有2种方法可以退出这个方法。

  1. 正常结束
    执行引擎遇到任意一个方法返回的字节码指令,这时可能有返回值传递给上层方法调用者,这种退出方法的方式是“正常完成出口”。
  2. 异常结束
    方法执行过程中出现了异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常完成出口”。这时不会给上层调用者返回任何值。

方法退出的过程等同于把当前栈帧出栈,退出时可能执行的操作有:
a、恢复上层方法的局部变量表和操作数栈(恢复上层方法的执行状态)。
b、把返回值(有的话)压入调用者栈帧的操作数栈。
c、调整pc计数器的值指向方法调用指令后面的一条指令。

3 方法调用

方法调用不等同于方法执行,方法调用的任务是确定被调用方法的版本(调用哪个方法),不涉及方法运行过程。class文件的编译过程不包括连接步骤,一切方法调用在class文件里存储的只是符号引用,不是方法在实际运行时的直接引用。这个特性给java带来了动态扩展能力,需要类在加载期间,或运行期间才能确定目标方法的直接引用。

从字节码到方法调用到底是一个怎样的过程?
首先,需要明确的一点是:.class文件没有连接过程,也就没有需要被执行代码在内存中的入口地址,它仅仅存储了被执行方法的符号引用。JVM根据字节码中方法的符号引用,去方法区中的常量池中查找对应的符号引用,将带有该符号引用的栈帧入栈,有了真正的内存入口地址后,就完成了常量池中的符号引用到直接引用的转化,继而开始执行java方法。

方法调用的2种方式
注1:此处,要知道的两个知识点:每种调用方法对应的对象是什么;重载和重写在JVM中是如何确定目标方法的。

3.1 解析

方法调用目标方法在class文件中是常量池中的符号引用。类加载的解析阶段,会将部分符号引用转化成直接引用。这种解析成立的前提是:方法在程序运行之前有一个可确定的调用版本,且该版本在运行期是不可变的,即:调用目标在程序中写好,编译器进行编译时就必须确定下来。这类方法的调用称为“解析”

适合在类加载的解析阶段的方法:

  • 静态方法:与类型直接关联
  • 私有方法:外部不可被访问

非虚方法在字节码指令中对应的指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器< init>方法、私有方法和父类方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,运行时确定实现此接口的对象
  • invokedynamic:运行时动态解析出调用点限定符引用的方法,然后执行该方法

前4条固化在java虚拟机内部,invokedynamic由用户设定的引导方法决定。

只要能被invokestatic、invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本。非虚方法包括:静态方法、私有方法、实例构造器方法、父类方法、被final修饰的方法。这些方法被称为非虚方法。final方法是被invokevirtual字节码指令调用的。

为什么非虚方法可以在类加载解析阶段确定方法调用版本?
因为这些方法,在程序运行前就是一个确定的方法调用版本。并且在程序运行期间方法调用版本不会改变。

解析调用:一定是静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用。
分配调用:可能是静态也可能是动态的。

3.2 分派

本节讲解的分派调用过程将会揭示多态性特征的基本表现:重载和重写在java虚拟机中是如何实现的。

★ 分类:

  • 根据分派依据的宗量数目,分为:单分派、多分派;
  • 根据是依据静态类型or实际类型来分派方法执行版本,分为:静态分派、动态分派;
  • 根据以上因素综合,分为:静态单分派、静态多分派、动态单分派、动态多分派。

注1:宗量:值java方法中的调用者和方法参数。

Eg:Man  man=new Man();
        man.sayhello(“hello”);  //这里的man和hello都是宗量。前者为调用者,后者为方法的参数。

★ 静态分派:参数的静态类型在编译时期就确定了,但参数的实际类型需要在运行时才能确定。分派动作执行者是javac编译器,而非虚拟机。

  • 实现原理:JVM根据参数或者变量的静态类型确定执行方法的哪个版本。
  • 应用:java中的重载

★ 代码例子:

/**
 * Created by cxh  on 17/07/21.
 */
public class Main {
    static abstract class Human{};
    static class Man extends Human{};
    static class Woman extends Human{};
    public void sayHello(Human guy){
        System.out.println("hello,guy!");
    }
    public void sayHello(Man man){
        System.out.println("hello,gentleman!");
    }
    public void sayHello(Woman woman){
        System.out.println("hello,lady!");
    }
 
    public static void main(String[] args) {
        Human man=new Man(); //man静态类型为Human
        Human woman=new Woman();//woman静态类型为Human
        Main test=new Main();
        test.sayHello(man);
        test.sayHello(woman);
    }
}

运行结果:

 hello,guy!
 hello,guy!
     
 Process finished with exit code 0 

★ 动态分派:
invokevirtual指令的运行时解析过程大致分为以下几个步骤:

  1. 找到操作数栈顶的第一个元素所指向的对象的实例类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行权限的校验,如果通过则返回这个方法的直接引用,查找结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

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

★ 单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可将分派划分为单分派和多分派。

  • 单分派:基于一个宗量对目标方法进行选择
  • 多分派:根据多于一个宗量对目标方法进行选择

静态分派属于多分派类型、动态分派属于单分派类型

p256页之后就没整理哦~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值