《深入理解Java虚拟机》读书笔记(七)–虚拟机字节码执行引擎(上)

《深入理解Java虚拟机》读书笔记(七)--虚拟机字节码执行引擎(上)

大家好,我是架构君,一个会写代码吟诗的架构师。今天说一说《深入理解Java虚拟机》读书笔记(七)--虚拟机字节码执行引擎(上),希望能够帮助大家进步!!!

目录

 

前言

一、运行时栈帧结构

1.1 局部变量表

1.2 操作数栈

1.3 动态连接

1.4 方法返回地址

1.5 附加信息

二、确定执行方法

2.1 解析

2.2 分派

2.2.1 静态分派

2.2.2 动态分派

2.2.3 单分派和多分派

2.2.4 虚拟机动态分派的实现


前言

本章主要讲述虚拟机如何确定调用方法的版本和如何执行方法。

一、运行时栈帧结构

1.1 局部变量表

用于存放方法参数和方法内定义的局部变量。在编译阶段,就在方法表的Code属性的max_locals数据项确定了方法所需的局部变量表最大空间。其容量以变量槽(slot)为最小单位,虚拟机规范没有明确规定一个slot应占用的空间大小,只是有导向性地说每个slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型都可以使用32位或更小的内存来存放,但是也允许slot的长度可以随着处理器、操作系统或虚拟机的不同而变化,只要保证即使使用64位的内存空间去实现一个slot,虚拟机仍然要使用对齐和补白的手段让slot在外观上看起来与32位虚拟机中的一致。

注:关于reference类型,虚拟机规范没有明确规定它的长度,可能占用32位也可能是64位,也没有明确指出应该是怎样的结构,一般来说虚拟机至少都应当能通过这个引用做到两点:

  • 从此引用可以直接或间接地查找到对象在Java堆中的数据存放的起始地址索引

  • 从此引用可以直接或间接地查找到对象所属数据类型在方法区中存储的类型信息

Java语言明确的64位数据类型只有long和double两种,对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的slot空间,由于局部变量表建立在线程私有的栈空间,所以无论读写两个连续的slot是否为原子操作,都不会引起数据安全问题,这点和long和double的非原子性协议可能会导致安全问题不同。

虚拟机通过索引定位的方式使用局部变量表,索引范围为0开始至局部变量表最大的slot数量。如果访问的是32位数据,索引n就代表第n个slot,如果访问的是64位数据,则说明会同时使用n和n+1两个slot。对于两个相邻的共同存放一个64位数据的两个slot,不允许采用任何方式单独访问其中一个。

注:对于非static方法,局部变量表中的第0位索引的slot默认用于传递方法所属对象实例的引用,从而使得方法内可以通过“this”关键字来访问这个隐含的参数。

另外,为了节省栈帧空间,slot是可以重用的,如果当程序计数器的值已经超出了某个变量的作用域,那这个变量对应的slot就可以交给其它变量使用。

但是从概念模型上来说,slot复用可能会导致GC问题:一个局部变量引用了一个大对象,现在该变量超过了其作用域,按理说此时该大对象已经无用,GC可以将它回收,但是由于slot复用的情况,在该slot还没有被复用的时候,它作为GC Roots仍然保持着大对象的引用,导致GC无法回收。如果后面代码有一些耗时操作,那么前面占用的大对象就是一个大的负担,所以逐渐有了手动将该变量设置为null值的“建议”。但是作者的意思是,这个操作只是建立在对字节码执行引擎概念模型的理解之上的,在虚拟机使用解释器执行时,通常还和概念模型比较接近,但是经过JIT编译后,才是虚拟机执行代码的主要方式,赋null值操作在JIT编译优化后就会被消除掉,而在JIT编译后,遇到超过作用域还引用对象的情况,GC通常也能正常回收,所以无须依赖这个“骚操作”。

局部变量和类变量不同,局部变量如果定义了没有赋初始值是不能使用的,如果使用了未赋值的局部变量,编译器在编译期间就会报错,如果通过手动生成字节码跳过编译器检查,也会在类加载的字节码校验阶段被发现。

1.2 操作数栈

同普通的栈数据结构一样,FILO,其最大深度在编译的时候就被写入到方法的Code属性的max_statcks项中,后面不会改变。在方法的执行过程中,各种字节码指令会在操作数栈中不断进行入栈/出栈操作。栈中的数据元素必须与字节码指令的序列严格匹配,在编译器编译的时候要保证这一点,在类校验阶段还要再次验证这一点(通过StackMapTable)。比如在使用iadd执行做整型数据加法的时候,栈顶的两个元素必须是int类型。

另外,在概念模型中,两个栈帧是相互独立的,但是在大多数虚拟机的实现里都会做一些优化,令两个栈帧出现一部分的重叠:让下面栈帧的操作数栈和上面栈帧的局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数赋值传递。

1.3 动态连接

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

1.4 方法返回地址

有两种方式可以退出方法:

  • 第一种是执行引擎遇到任意一个返回的字节码指令,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方式为正常完成出口
  • 另一种是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(没有在本方法的异常表中匹配到异常处理器),这种方式为异常完成出口,并且不会产生返回值。

无论如何退出,在方法退出之后都需要返回到方法被调用的位置,程序才能继续执行。一般来说,方法正常退出时,方法调用者的程序计数器的值可以作为返回地址,保存在方法对应的栈帧中;而异常退出时,需要通过异常处理表来确定。

1.5 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,由虚拟机自行实现。

二、确定执行方法

2.1 解析

class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在class文件里存储的都只是符号引用,而不是方法在实际运行时内存中的入口地址(直接引用)。

其中的一部分符号引用在类加载的解析阶段会被转化为直接引用,这种解析的前提是:方法在程序运行之前就有一个可确定的版本,且在运行期不可改变。符合这个前提的方法主要包括静态方法私有方法。因为这两种方法都不能通过继承或别的方式重写其它版本,所以适合在类加载阶段进行解析。

Java虚拟机提供了5条方法调用字节码指令:

  • invokestatic:调用静态方法

  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法(super.method(...))

  • invokevirtual:调用所有的虚方法

  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象

  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法

其中,被invokestatic和invokespecial调用的方法可以在类加载的解析阶段把符号引用解析为该方法的直接引用,这些方法包括静态方法、私有方法、父类方法、<init>方法,称之为非虚方法。其它方法称为虚方法。

注:

1. final修饰的方法虽然使用invokevirtual指令调用,但是由于其不能被覆盖,所以不会有其它版本,也就属于非虚方法。

2. 关于父类方法属于非虚方法,使用invokespecial调用说的是通过super调用父类方法的情况,如果子类重写了父类方法,那么子类中的这个方法就属于自己,和父类方法没关系了

2.2 分派

除了上述的解析过程确定执行方法的版本,还有另外一种确定虚方法的手段:分派。分派分为静态分派动态分派,根据分派依据的宗量数可分为单分派多分派。两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派。

2.2.1 静态分派

静态分派的典型应用是处理方法重载,英文技术文档的称呼是“Method Overload Resolution”(书中的解释是国内资料普遍都将这种行为翻译成“静态分派”)。如果对象A继承对象B,那么对于语句:B b = new A(); 其中B称为b变量的静态类型(Static Type),A称为b变量的实际类型(Actual Type)。方法参数的静态类型可能发生转变,比如通过强转操作,b的静态类型为B,但是(A)b的静态类型转换为了A。不过变量本身的静态类型(B)不会改变,且最终的静态类型是在编译期可知的;而实际类型要在运行期才可确定,编译器在编译的时候并不知道一个对象的实际类型是什么。

虚拟机在处理重载时是通过参数的静态类型而不是实际类型作为判断依据的,并且上面提到了,静态类型是编译期可知的。因此,在编译阶段,编译器会根据参数的静态类型决定使用哪个重载版本,选择了方法的重载版本之后,编译器会把这个方法的符号引用写到方法调用字节码指令的参数中。比如下面示例代码,say()方法有3个重载版本(注意,这里的main对象是唯一确定的):

public class Main {

    static class A {
    }

    static class B extends A {
    }

    static class C extends B {
    }

    public void say(A a) {
        System.out.println("A");
    }

    public void say(B b) {
        System.out.println("B");
    }

    public void say(C c) {
        System.out.println("C");
    }

    public static void main(String[] args) throws Exception {
        Main main = new Main();
        B os = new C();
        main.say(os);//静态类型为B,实际类型为C,确定的say方法重载版本为say(B b)
        main.say((A) os);//最终静态类型转换为了A,实际类型为C,确定的say方法重载版本为say(A a)
        main.say((C) os);//最终静态类型转换为了C,实际类型为C,确定的say方法重载版本为say(C c)
        //输出 B A C
    }
}

只听到从山间传来架构君的声音:

万壑有声含晚籁,数峰无语立斜阳。

有谁来对上联或下联?

另外,编译器虽然能确定出方法的重载版本,但在很多情况下,这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

比如对于方法:say(...),有7个重载版本:say(char arg)、say(int arg)、say(long arg)、say(Character arg)、say(Serializable arg)、say(Object arg)、say(char... arg)。如果程序现在尝试调用方法:say('a');'a'不需要定义,可以直接使用,所以没有显示的静态类型。那编译器该选择哪个重载版本呢?

  • 'a'首先是一个char类型:对应say(char arg)

  • 其次还可以代表数字97(参照ASCII码):对应say(int arg)

  • 而转化为97之后,还可以转型为long类型的97L:对应say(long arg)

  • 另外还能被自动装箱包装为Character:对应say(Character arg)

  • 装箱类Character还实现了Serializable接口(若直接或间接实现了多个接口,优先级都是一样的,如果出现能适配多个接口的多个重载方法,会提示类型模糊,拒绝编译):对应say(Serializable)

  • 而且Character还继承自Object(如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低):对应say(Object arg)

  • 最终还能匹配到变长类型:对应say(char... arg)

上面描述的其实就是编译期间选择静态分派目标的过程,这个过程也是Java语言实现方法重载的本质。

注:解析和分派不是二选一的关系,它们是在不同层次上去筛选、确定目标方法的过程。比如静态方法会在类加载的解析阶段就进行直接引用的转化,而静态方法也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

2.2.2 动态分派

动态分派的典型应用是方法重写。对于方法重写的情况,Java虚拟机在调用方法的时候是通过实际类型来分派方法执行版本的。对于以下代码:

此代码由Java架构师必看网-架构君整理
public class Main { static class A { public void say() { System.out.println("A"); } } static class B extends A { public void say() { System.out.println("B"); } } static class C extends A { public void say() { System.out.println("C"); } } public static void main(String[] args) throws Exception { A b = new B(); A c = new C(); b.say(); c.say(); //输出 B C } }

b.say()和c.say()调用在经过编译器编译后,方法调用字节码指令(这里是invokevirtual)和指令的参数(A.say()的符号引用)都是一样的,但是最终的执行目标并不相同(一个B,一个C)。这里就涉及到invokevirtual指令的多态查找过程:

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

b.say();语句的执行过程是先把b实例对象压到栈顶,然后通过invokevirtual指令调用,这个b对象称为say()方法的接收者(Receiver)。从上面步骤可以看到,第一步就是在运行期确定执行say方法的接收者实际类型为B。c.say();语句同理。所以两次调用中的A.say()符号引用被解析到了不同的直接引用上,这个过程就是Java方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程就称为动态分派。

2.2.3 单分派和多分派

方法的接收者和方法的参数统称为方法的宗量。 根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。回到2.2.1讲述静态分派的例子,该例子中,main对象是唯一确定的,现在代码做一个调整:

public class Main {

    static class A {
    }

    static class B extends A {
    }

    static class C extends B {
    }

    public void say(A a) {
        System.out.println("A");
    }

    public void say(B b) {
        System.out.println("B");
    }

    public void say(C c) {
        System.out.println("C");
    }

    public static void main(String[] args) throws Exception {
        Main main = new Main();
        Main superMain = new Super();
        B os = new C();
        main.say(os);
        superMain.say((A) os);
        //输出 B S-A
    }
}

class Super extends Main {
    public void say(A a) {
        System.out.println("S-A");
    }

    public void say(B b) {
        System.out.println("S-B");
    }

    public void say(C c) {
        System.out.println("S-C");
    }
}

对于main.say(os)和superMain.sauy(os)来说。

  • 先看编译阶段编译器的选择,也就是静态分派的过程:

这时选择目标方法的依据有两点:一是方法接收者的静态类型是Main还是Super,二是方法参数的静态类型是B还是C。由于main和superMain(方法接收者)的静态类型都是Main,而方法参数的静态类型一个是B,一个是A。所以此次选择产生的两条invokevitrual指令的参数分别为常量池中指向Main.say(B)和Main.say(A)的方法的符号引用。因为这是根据两个宗量进行选择,所以Java语言的静态分派称为多分派。

  • 再看运行阶段虚拟机的选择,也就是动态分派的过程:

从2.2.2节中动态分派的介绍和上述静态分派结果中我们知道,在执行main.say(os)和superMain.say((A)os)各自的invokevirtual指令的时候,方法签名已经在静态分派过程确定了,必须分别是say(B)和say(A)。虚拟机此时不用关心参数的静态类型、实际类型,只有方法接收者的实际类型会影响到方法版本的选择,也就是只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

所以说,目前Java语言是一门静态多分派、动态单分派的语言。

2.2.4 虚拟机动态分派的实现

动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此处于性能考虑,虚拟机做出了优化:为类在方法区中建立一个虚方法表(Virtual Method Table,与此对应的,在invokeinterface执行时也会用到接口方法表---Interface Method Table),虚方法表中存放着各个方法的实际入口地址。

如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口;如果子类重写了这个方法,那么子类方法表中虚方法表中的地址将会替换为指向子类实现版本的入口地址。这样通过冗余存放的方式,在运行时搜索目标方法的时候,就不用依次对对象的各个父类进行搜索了。

同时,具有相同签名的方法,在父类、子类的虚方法表中都应具有一样的索引号,这样当类型转换时,只需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。方法表一般在类加载的连接阶段(准备阶段)进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

除了使用方法表外,在条件允许的情况下,虚拟机还会使用内联缓存(Inline Cache)和基于”类型继承关系分析(Class Hierarchy Analysis,CHA)“技术的守护内联(Guarded Inlining)两种手段来获得更高的性能。

本文来源黄智霖-blog,由架构君转载发布,观点不代表Java架构师必看的立场,转载请标明来源出处:https://javajgs.com/archives/31660
3

发表评论