代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一 大步。

在这里插入图片描述

一、概述

虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执 行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

  • 在不同的虚拟机实现中,执行引擎在执行字节码的 时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。
  • 但从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果

二、运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。

  • 每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息
  • 每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程
  • 一个栈帧需要分配多少内存,并不会受到程序 运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式
  • 一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态
  • 而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”,与这个栈帧所关联的方法被称为当前方法

1、局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。

  • 在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
  • 局部变量表的容量以变量槽为最小单位

image.png

  • 一个变量槽可以存放一个32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有boolean、byte、char、short、int、float、reference和returnAddress这8种类型。

    • reference类型表示对一个对象实例的引用,《Java虚拟机规范》既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是一般来说,虚拟机实现至少都应当能通过这个引 用做到两件事情,一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则将无法实现《Java语言规范》中定义的语法约定
    • returnAddress类型目前已经很少见了,它是为字节 码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址
  • 对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。

由于局部变量表是建立在线 程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题

  • Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变 量槽数量。

如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽

如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。

对于两个相邻的共同存放一个64位数据 的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,如果遇到进行这种操作的字节码序列,虚拟机就应该在类加载的校验阶段中抛出异常。

  • 当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程, 即实参到形参的传递。
  • 如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。
  • 其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
  • 局部变量表中的变量槽是可以重用的

方法体中定义的变 量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用

局部变量表槽复用对垃圾收集的影响:

1、影响一

 public static void main(String[] args) {
        byte[]by = new byte[64*1024*1024];
        System.gc();
    }

在虚拟机运行参数中加上“-verbose:gc”来看看垃圾收集的过程,发现在System.gc()运行后并没有回收掉这64MB的内存

运行结果:

[0.025s][info][gc] Using G1
[0.226s][info][gc] GC(0) Pause Full (System.gc()) 68M->66M(230M) 3.789ms

在执行System.gc()时, 变量by还处于作用域之内,虚拟机自然不会回收掉by的内存

2、影响二

修改上面的代码,改变by的作用域

public static void main(String[] args) {
        {
            byte[]by = new byte[64*1024*1024];
        }
        System.gc();
    }

加入了花括号之后,by的作用域被限制在花括号以内,从代码逻辑上讲,在执行 System.gc()的时候,by已经不可能再被访问了,但执行这段程序,会发现运行结果如下,还是有64MB的内存没有被回收掉

[0.027s][info][gc] Using G1
[0.231s][info][gc] GC(0) Pause Full (System.gc()) 68M->66M(230M) 2.611ms

3、影响三

public static void main(String[] args) {
        {
            byte[]by = new byte[64*1024*1024];
        }
        int a = 0;
        System.gc();
    }

运行后,发现内存被回收了

[0.027s][info][gc] Using G1
[0.239s][info][gc] GC(0) Pause Full (System.gc()) 68M->1M(14M) 6.508ms

分析

by能否被回收的根本原因就是:局部变量表中的变量槽是否还存有 关于by数组对象的引用。

  • 第一次修改中,代码虽然已经离开了by的作用域,但在此之 后,再没有发生过任何对局部变量表的读写操作,by原本所占用的变量槽还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。
  • 但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面 又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其设置为null值(第三次中的int a = 0;把变量对应的局部变量槽清空)

这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到即时编译器的编译条件)下的“奇技”来使用。

局部变量不像前面介绍的类变量那 样存在“准备阶段

我们知道类的字段变量有两次赋初始值的过程,一次在准 备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。

  • 如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的

image.png

2、操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。

  • 操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占 的栈容量为1,64位数据类型所占的栈容量为2。
  • Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配
  • 在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。

但是在 大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数 栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了

image.png

3、动态连接

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

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

4、方法返回地址

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

  1. 执行引擎遇到任意一个方法 返回的字节码指令

  2. 在方法执行的过程中遇到了异常

一个方法使用异常完成出口的方式退出,是不会给它 的上层调用者提供任何返回值的。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的 局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

5、附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、 性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一

般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

三、方法调用

方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法),暂时还未涉及方法内部的具体运行过程

1、解析

  • 所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用
  • 这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。
  • 调用目标在程序代码写好、编译 器进行编译那一刻就已经确定下来

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

在Java虚拟机支持以下5条方法调用字 节码指令,分别是:

  • invokestatic:用于调用静态方法。
  • invokespecial:用于调用实例构造器()方法、私有方法和父类中的方法
  • invokevirtual:用于调用所有的虚方法。
  • invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

前面4条调用指令,分派逻辑都固化在Java虚拟机内部

  • invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法,被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为非虚方法,与之相反,其他方法就被称为虚方法

看段代码吧

public class MethodStaticResolution {
    public static void sayHello() {
        System.out.println("hello world");
    }

    public final static void sayFinal() {
        System.out.println("hello final");
    }

    public static void main(String[] args) {
        MethodStaticResolution.sayHello();
        MethodStaticResolution.sayFinal();
    }
}

image.png

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成

2、分派

分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的

1)静态分派

先看一段代码

package com.yky.dispatch;

/**
 * @Author: yky
 * @CreateTime: 2021-02-03
 * @Description: 方法静态分派演示
 */
public class StaticDispatch {

    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 guy) {
        System.out.println("hello,gentleman!");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }

}

类图

image.png

运行结果

image.png

为什么会出现这种情况

代码中故意定义了两个静态类型相同,而实际类型不同的变量,但虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

为什么虚拟机会选择执行参数类型为Human的重载版本呢

image.png

静态类型和实际类型在程序中都可能会发生变化

两者区别

  • 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;
  • 实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

静态分派

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的

注意

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

package com.yky.dispatch;

import java.io.Serializable;

/**
 * @Author: yky
 * @CreateTime: 2021-02-03
 * @Description: 重载案例
 */
public class Overload {

    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }

    public static void sayHello(int arg) {
        System.out.println("hello int");
    }

    public static void sayHello(long arg) {
        System.out.println("hello long");
    }

    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }

    public static void sayHello(char arg) {
        System.out.println("hello char");
    }

    public static void sayHello(char... arg) {
        System.out.println("hello char ...");
    }

    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }

    public static void main(String[] args) {
        sayHello('a');
    }

}

运行结果

image.png

这很好理解,'a’是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果注释掉 sayHello(char arg)方法,那输出会变为:

image.png

这时发生了一次自动类型转换,'a’除了可以代表一个字符串,还可以代表数字97(字符’a’的 Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的。我们继续注释掉sayHello(int arg)方法,那输出会变为:

image.png

这时发生了两次自动类型转换,'a’转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载。笔者在代码中没有写其他的类型如float、double等的重载,不过实际上自动转型还能继续发生多次,按照char>int>long>float>double的顺序转型进行匹配,但不会匹配到byte和short类型的重 载,因为char到byte或short的转型是不安全的。我们继续注释掉sayHello(long arg)方法,那输出会变为:

image.png

这时发生了一次自动装箱,'a’被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为 Character的重载,继续注释掉sayHello(Character arg)方法,那输出会变为:

image.png

这个输出可能会让人摸不着头脑,一个字符或数字与序列化有什么关系

出现hello Serializable, 是因为java.lang.Serializablejava.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类所实现的接口类型,所以紧接着又发生一次自动转型。char可以转型成int, 但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。Character还实现 了另外一个接口java.lang.Comparable<Character>,如果同时出现两个参数分别为Serializable和 Comparable的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为 哪种类型,会提示“类型模糊”(Type Ambiguous),并拒绝编译。程序必须在调用时显式地指定字面 量的静态类型,如:sayHello((Comparable<Character>)'a'),才能编译通过。

image.png

下面继续注释掉sayHello(Serializable arg)方法,输出会变为:

image.png

这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接 上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。我们把sayHello(Objectarg)也注释掉,输出将会变为:

image.png

7个重载方法已经被注释得只剩1个了,可见变长参数的重载优先级是最低的,这时候字符’a’被当作了一个char[]数组的元素。

2)动态分派

动态分派与Java语言多态性的另外 一个重要体现——重写(Override)有着很密切的关联。

1、方法动态分派演示
package com.yky.dispatch;

/**
 * @Author: yky
 * @CreateTime: 2021-02-04
 * @Description: 方法动态分派演示
 */
public class DynamicDispatch {

    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }

}

运行结果

image.png

使用javap命令查看字节码文件

javap -c DynamicDispatch

image.png

0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实 例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作实际对应了Java源码中的这两行:

Human man = new Man(); 
Human woman = new Woman();

接下来的16~21行是关键部分,16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)都完全一样,但是这两句指令最终执行的目标方法并不相同。

invokevirtual指令的运行时解析过程大致分为以下几步

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

正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者 的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。

我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

2、字段没有多态性

在Java里面只有虚方法存在, 字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该 名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

package com.yky.dispatch;

/**
 * @Author: yky
 * @CreateTime: 2021-02-04
 * @Description: 字段没有多态性
 */
public class FieldHasNoPolymorphic {
    static class Father {
        public int money = 1;
        public Father() {
            money = 2;
            showMeTheMoney();
        }
        public void showMeTheMoney() {
            System.out.println("I am Father, i have $" + money);
        }
    }

    static class Son extends Father {
        public int money = 3;
        public Son() {
            money = 4;
            showMeTheMoney();
        }
        public void showMeTheMoney() {
            System.out.println("I am Son, i have $" + money);
        }
    }

    public static void main(String[] args) {
        Father gay = new Son();
        System.out.println("This gay has $" + gay.money);
    }
}

运行结果

image.png

输出两句都是“I am Son”,这是因为Son类在创建的时候,首先隐式调用了Father的构造函数,而 Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是 Son::showMeTheMoney()方法,所以输出的是“I am Son”,这点经过前面的分析相信读者是没有疑问的了。而这时候虽然父类的money字段已经被初始化成2了,但Son::showMeTheMoney()方法中访问的却是子类的money字段,这时候结果自然还是0,因为它要到子类的构造函数执行时才会被初始化。main()的最后一句通过静态类型访问到了父类中的money,输出了2。

3)单分派与多分派

方法的接收者与方法的参数统称为方法的宗量。

根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。

单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

package com.yky.dispatch;

/**
 * @Author: yky
 * @CreateTime: 2021-02-04
 * @Description: 单分派、多分派演示
 */
public class Dispatch {
    static class QQ {}
    static class _360 {}
    public static class Father {
        public void hardChoice(QQ arg) { System.out.println("father choose qq"); }
        public void hardChoice(_360 arg) {System.out.println("father choose 360"); }
    }
    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }
        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }
    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

运行结果

image.png

1)首先是编译阶段中编译器的选择过程,也就是静态分派的过程。这时候选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向 Father::hardChoice(360)Father::hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型

2)再看运行阶段中虚拟机的选择,也就是动态分派的过程。在执行son.hardChoice(new QQ())这行代码时,更准确地说,是在执行这行代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时候参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有该方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型

Java语言是一门静态多分派、动态单分派的语言。

4)虚拟机动态分派的实现

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不 会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以 提高性能

虚拟机动态分派的实现.png

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。

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

四、动态类型语言支持

Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,二十余年间只新增 过一条指令,它就是随着JDK 7的发布的字节码首位新成员——invokedynamic指令。这条新增加的指 令是JDK 7的项目目标:实现动态类型语言(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8里可以顺利实现Lambda表达式而做的技术储备。

1、动态类型语言

1)什么是动态语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编 译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、 JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。那相对地,在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。

2)什么叫“在编译期还是在运行期进行”?

Java中

public class DynamicLanguage {
    public static void main(String[] args) {
        int[][]arr = new int[0][-1];
    }
}

能正常编译,运行时会出现NegativeArraySizeException异常

image.png

C语言中

image.png

由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译期进行并没有什么必然的因果逻辑关系,关键是在语言规范中人为设立的约定。

3)什么是“类型检查”?

image.png

现在先假设这行代码是在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实包含有println(String)方法相同签名方法的类型,但只要它与PrintStream接口没有继承关系,代码依然不可能运行——因为类型检查不合法。

image.png

但是相同的代码在ECMAScript(JavaScript)中情况则不一样,无论obj具体是何种类型,无论其继承关系如何,只要这种类型的方法定义中确实包含有println(String)方法,能够找到相同签名的方法,调用便可成功。

image.png

产生这种差别产生的根本原因是Java语言在编译期间却已将println(String)方法完整的符号引用(本例中为一项CONSTANT_InterfaceMethodref_info常量)生成出来,并作为方法调用指令的参数存储到Class文件中,例如下面这个样子:

invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
  • 这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方 法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。
  • 而ECMAScript等动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型 (即方法接收者不固定)。
  • 变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征。

2、Java与动态类型

到了JDK7版本,为了支持动态类型,在JVM层面新增了一个方法调用指令:invokedynamic指令,新增了一个工具包:java.lang.invoke包。

1)java.lang.invoke包

包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄

方法句柄与C/C++中的函数指针类比

  1. C/C++中的常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法
void sort(int list[],const int size,int (*compare)(int ,int));
  1. 在Java语言中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计 一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数

例如Java类库中的 Collections::sort()方法的定义:

public static <T> void sort(List<T> list, Comparator<? super T> c)

在拥有方法句柄之后,Java语言也可以拥有类似于函数指针或者委托的方法别名这样的工具了

来!看代码

package com.yky.dynamicLanguage;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
import static java.lang.invoke.MethodHandles.lookup;

/**
 * @Author: yky
 * @CreateTime: 2021-02-05
 * @Description: TODO
 */
public class MethodHandleTest {
    static class ClassA {
        public void println(String s) {
            System.out.println(s);
        }
    }
    public static void main(String[] args) throws Throwable {
        Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA(); // 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。 
        getPrintlnMH(obj).invokeExact("icyfenix");
    }

    private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
        // MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和 具体参数(methodType()第二个及以后的参数)。
        MethodType mt = MethodType.methodType(void.class, String.class);
        // lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
        // 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接 收者,也即this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo() 方法来完成这件事情。
        return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
    }
}

方法getPrintlnMH()中实际上是模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个由用户设计的Java方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。以此为基础,有了MethodHandle就可以写出类似于C/C++那样的函数声明了:

void sort(List list, MethodHandle compare)

MethodHandle与Reflection区别

  • Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的3个方法 findStatic()findVirtual()findSpecial()正是为了对应于invokestatic、invokevirtual(以及 invokeinterface)和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API时是不需要关心的。
  • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而 后者仅包含执行该方法的相关信息。通俗来讲,Reflection是重量级,而MethodHandle 是轻量级
  • 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施。

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

虚拟机是如何执行方法里面的字节码指令的

1、解释执行

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图中的各个步骤:

image.png

对于一门具体语言的实现来说, 词法、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行引擎。

在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法 树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

2、基于栈的指令集与基于寄存器的指令集

Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

"1+1"的指令集(基于栈的指令集):

iconst_1 
iconst_1 
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果 放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。

  • 基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
  • 栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,出栈、入栈操作本身就产生了相当大量的指令,更重要的是栈实现在内存中, 频繁的栈访问也就意味着频繁的内存访问

3、基于栈的解释器执行过程

老规矩,上代码

 public int calc() {
        int a = 100;
        int b = 200;
        int c = 300;
        return (a + b) * c;
    }

使用javap命令查看字节码指令

image.png

本博客主要参考周志明老师的《深入理解Java虚拟机》第三版
欢迎指出文章的不足之处;更多内容请点进小游子YKY查看

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐