深入理解Java虚拟机(周志明第三版)- 第八章:虚拟机字节码执行引擎

系列文章目录

第一章: 走近Java
第二章: Java内存区域与内存溢出异常
第三章: Java垃圾收集器与内存分配策略


一、概述

执行引擎是Java虚拟机核心的组成部分之一

虚拟机是相对于物理机的概念。两种机器都有代码执行能力,区别在于物理机的执行引擎是建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎是由软件自行实现的,可以不受物理条件制约地定制指令集与执行引擎的的结构体系,能够执行那些不被硬件直接支持的指令集格式

        《Java虚拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,成为各大发行商的Java虚拟机执行引擎的统一外观。在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(解释器执行)和编译执行(通过即时编译器生成本地代码执行)两种选择,也可能两者兼备,还可能同时包含几个不同级别的即时编译器一起工作的执行引擎。但从外观看,所有的Java虚拟机的执行引擎输入、输出是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果


二、运行时栈帧结构

        Java虚拟机以方法作为最基本的执行单元栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,这些在Class文件格式的方法表中都能找到大多数的静态对照物,每一个方法从调用到执行结束的过程都对应着要给栈帧从从入栈到出栈的过程。
        编译Java程序源码时,栈帧中需要多大的局部变量表,多深的操作数栈就已经被计算出来了写入到方法表的Code属性中,即一个栈帧需要分配多少内存,并不会受到程序运行期数据的影响,仅仅取决于程序源码和具体的虚拟机实现的内存布局形式

以Java程序的角度,同一个时刻、同一个线程里,在调用堆栈的所有方法都同时处于执行状态;
以执行引擎的角度,在活动线程中,只有位于栈顶的方法是运行的,只有位于栈顶的栈帧是生效的,称为当前栈帧,与之关联的方法称为当前方法 
执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作

在这里插入图片描述

1、局部变量表

局部变量表是一组变量值的存储空间,以变量槽为最小单位,用于存放方法参数和方法内部定义的局部变量(编译为class文件时Code属性的max_locals数据项中确定了该方法所需分配的最大局部变量表的最大容量)。

《Java虚拟机规范》中并没有明确指出一个变量槽所占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference、returnAddress类型的数据,这8种数据类型都可以使用32位或更小的物理内存存储,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用64位的物理内存去实现一个变量槽,虚拟机仍要使用对齐和补白的方式让变量槽在外观上与32位虚拟机保持一致

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

reference类型表示对一个对象实例的引用,《Java虚拟机规范》既没有说明它的长度,也没有明确指出这种引用的结构,但一般来说,虚拟机实现至少都应当能通过这个引用做到两件事,一是根据引用直接或间接找到对象在Java堆中数据存放的起始地址或索引,二是根据引用直接或间接查找到对象所属数据类型在方法区中存储的类型信息
  
returnAddress类型目前很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向一条字节码指令的地址,某些古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表代替了

对于64位的数据类型,Java虚拟机采用高位对齐的方式为其分配两个连续的变量槽空间,Java中明确规定的64位的数据类型只有longdouble两种,,这里把long和double数据类型分割存储的做法与“long和double的非原子性协定中允许把一次long和double数据类型读写分割为两次32位读写的做法有些类似”,不过由于局部变量表是建立在线程堆栈中,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题

Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数据。如果访问的是32位的变量,索引N就代表使用第N个变量槽,如果访问的是64位,则说明会同时使用第N和第N+1两个变量槽。当一个方法被调用时,Java虚拟机采用局部变量表来完成参数值到参数变量列表的传递过程,即形参到实参的传递,如果执行实例方法(没有被static修饰)的,局部变量槽表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数,其余参数按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后再根据方法内部定义的变量顺序和作用域分配其余的变量槽。
  
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的(方法体中定义的变量,其作用域不一定会覆盖整个方法,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用,但这样的设计除了节省栈帧空间以外,在某些特殊情况下变量槽的重用会直接影响到系统的垃圾收集行为)

2、操作数栈

操作数栈称为操作栈,它是一个先入后出栈,操作数栈的最大深度在编译时就写入到Code属性的max_stacks数据项中,操作数栈的每一个元素都可以是包含long和double在内的任意Java数据类型,32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2,Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的最大深度都不会超过max_stacks中设定的最大值。

当一个方法开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,即入栈和出栈操作,譬如在做算术运算时将运算涉及的操作数压入栈顶后调用运算指令来进行,或在调用其他方法时通过操作数栈来进行方法参数的传递,举个栗子,整数加法字节码指令iadd运行时要求将操作数栈中最接近栈顶的两个元素存入int型的数值,当执行指令时,会把这两个int出栈并相加,然后将相加的结果重新入栈

在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的,但在大多数虚拟机实现中会进行一些优化处理,令两个栈帧出现一部分重叠,即下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,不仅可以节约空间,更重要的是在方法调用时可以直接公用一部分数据,无需进行额外的参数复制传递。
在这里插入图片描述

3、动态连接

每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数,这些符号引用一部分在类加载阶段或第一次使用时被转换为直接引用,这种转换称为静态解析,另外一部分在每一次运行期间转换为直接引用,这部分称为动态连接)。

4、方法返回地址

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

  • 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,可能会有方法返回值传递给上层的方法调用者,方法是否有返回值及返回值的类型将根据遇到何种方法返回指令决定,这种退出方法的方式称为“正常调用完成”
  • 第二种方式是在方法执行过程遇到异常,且这个异常在方法内部没有妥善处理,无论是虚拟机内部产生的异常还是使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成”,一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的

无论采用哪种方式退出,在方法退出之后,都必须返回到最初方法被调用的位置程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态,一般来说,方法正常退出时,主调方法的PC计数器的值就作为返回地址,栈帧中很可能会存储这个计数器值,而当方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般就不会保存这部分信息
  
方法退出的过程等同于把当前栈帧出栈,因此退出可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值指向方法调用指令后面的下一条指令。(“可能”是由于这是基于概念模型的讨论,具体操作需要具体的某一款Java虚拟机)

5、附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范中没有描述的信息到栈帧中,例如与调试、性能收集相关的信息,这部分信息取决于具体的虚拟机实现。


三、方法调用

方法调用并不等同于方法执行,唯一的任务是确定被调用方法的版本(即调用哪个方法),暂时还未涉及到方法内部的具体运行过程

1、解析

所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载解析阶段,会将其中的一部分符号引用转换为直接引用,这种解析成立的前提是:方法在程序真正执行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,即调用目标在程序代码写好、编译器进行编译那一刻就已经确定了,这类方法的调用称为“解析”
  
Java语言中符合“编译期可知,运行期不可变”要求的方法,主要有静态方法和私有方法,前者与类型相关,后者在外部不可访问,这两种方法各自的特点决定了它们不可能通过继承或别的方式重写出其他的版本,因此它们都适合在类加载阶段进行解析
  
Java虚拟机支持以下5条方法调用的字节码指令:

  • invokestatic 用于调用静态方法
  • invokespecial 用于调用实例构造器方法、私有方法和父类中的方法
  • invokevirtual 用于调用所有虚方法
  • invokeinterface 用于调用接口方法,会在运行时再确定一个实现该接口的对象
  • invokedynamic 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法(前4条调用指令是固化在Java虚拟机内部,而invokedynamic指令的分配逻辑是由用户的引导方法决定的)

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言中符合这个条件的共有静态方法、私有方法、实例构造器、父类方法及被final修饰的实例方法,这5种方法调用时会在类加载时将符号引用解析为该方法的直接引用,这些方法统称为“非虚方法”,其他方法称为“虚方法”。

尽管final使用invokevirtual指令调用,但这是历史设计原因,它无法被覆盖,没有其他版本的可能,即无需对方法接收者进行多态选择,多态选择的结构是唯一的,《Java语言规范》中明确定义了final修饰的方法是一种非虚方法

示例:
package com.tencent;

/**
 * 方法静态解析演示
 *
 * @author zzm
 */

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

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

// class version 52.0 (52)
// access flags 0x21
public class com/tencent/Demo {

  // compiled from: Demo.java

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 9 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this Lcom/tencent/Demo; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x9
  public static sayHello()V
   L0
    LINENUMBER 13 L0
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    LDC "hello world"
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
   L1
    LINENUMBER 14 L1
    RETURN
    MAXSTACK = 2
    MAXLOCALS = 0

  // access flags 0x9
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 17 L0
    INVOKESTATIC com/tencent/Demo.sayHello ()V
   L1
    LINENUMBER 18 L1
    RETURN
   L2
    LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
    MAXSTACK = 0
    MAXLOCALS = 1
}

查看生成字节码,发现是通过invokestatic调用sayHello方法,且调用的方法版本已经在编译时以常量池项形式固化到字节码指令参数中(代码中的31号常量池项)

2、分派

解析调用一定是个静态的过程,在编译器就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转换为明确的直接引用,不必延迟到运行期再去完成,而另一种主要的方法调用形式:分派则要复杂许多,可能是静态也可能是动态的。

2.1 静态分派

静态分派与重载的关系

package com.tencent;

/**
 * 方法静态分派演示
 *
 * @author lbz
 */
public class Demo {

    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();
        Demo sr = new Demo();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}

运行结果
hello,guy!
hello,guy!

Human man = new Man();
Human称为变量的静态类型或外观类型,Man称为变量的实际类型或运行时类型。
静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译器是可知的,而实际类型变化的结果在运行期才确定,编译器在编译程序时并不知道一个对象的实际类型是什么。
  
实际类型变化:
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
  
静态类型变化:
sr.sayHello((Man) human) sr.sayHello((Woman) human)

为什么虚拟机会选择执行参数类型为Human的重载版本?
答:main()方法里面的两次sayHelloe()方法调用,确定方法接收者是对象sr的前提下,使用哪个重载版本取决于传入参数的数量和类型,虚拟机(准确地说是编译器)在重载时是通过参数的静态类型作为判定依据的,由于静态类型在编译期可知,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择sayHello(Human)作为调用方法,并把这个方法的符号引用写入到main方法的两条invokevirtual指令的参数中。

所有依赖静态类型来决定方法执行版本的分派动作都称为静态分派。静态分派最典型的应用表现就是方法重载。

注意:Javac编译器虽然能确定出方法的重载版本,但很多情况下并不是唯一的,往往只能确定一个“相对更合适”的版本

/**
 * 重载方法匹配优先级
 *
 * @author lbz
 */
public class Demo {
    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');
    }   
}

输出结果
hello char

注释掉sayHello(char arg)方法
hello int

注释掉sayHello(int arg)方法
hello long

注释掉sayHello(long  arg)方法
hello Character

注释掉sayHello(Character arg)方法
hello Serializable

注释掉sayHello(Serializable arg)方法
hello Object

注释掉sayHello(Object arg)方法
hello char ...

解析与分派两者之前的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程,例如静态方法会在编译期间确定、在类加载期间进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的

2.2 动态分派

/**
 * 方法动态分派演示
 *
 * @author zzm
 */
public class Demo {

    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();
    }
}

字节码:
public static void main(java.lang.String[]); Code:
Stack=2, Locals=3, Args_size=1
0:	new	#16; //class org/fenixsoft/polymorphic/DynamicDispatch$Man
3:	dup
4:	invokespecial	#18; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Man."<init>":()V
7:	astore_1
8:	new	#19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
11: dup
12:  invokespecial	#21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17:  invokevirtual	#22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
20: aload_2
21:  invokevirtual	#22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
24:  new	#19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
27: dup
28:  invokespecial	#21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33:  invokevirtual	#22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
36: return


运行结果
man say hello
woman say hello
woman say hello

Java虚拟机是如何判断应该调用哪个方法的?
这里选择调用的方法版本是不可能根据静态类型来决定的,因为静态类型同样是Human的两个变量man和woman在调用方法时产生了不同行为,查看字节码指令,两次方法调用都是invokevirtual指令且参数都是常量池中第22项的常量,所以问题的关键是invokevirtual指令本身。

根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步

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

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

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

/**
 * 字段不参与多态
 *
 * @author zzm
 */
public class Demo {

    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();
        }

        @Override
        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);
    }
}

输出结果
I am Son, i have $0
I am Son, i have $4
This gay has $2

2.3 单分派与多分派

方法的接收者和参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。

/**
 * 单分派、多分派演示
 *
 * @author zzm
 */
public class Demo {

    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 {
        @Override
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }

        @Override
        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());
    }
}

运行结果:
father choose 360
son choose qq

编译阶段编译器的选择过程(静态分派):一是静态类型是Father还是Son,二是方法参数是QQ还是360,所以最终分别产生两条invokevirtual指令,两条指令的参数分别为常量池中指向Father:hardChoice(360)及Father:hardChoice(QQ)方法的符号引用,因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型

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

2.4 虚拟机动态分派的实现

前面介绍的分派过程,作为对Java虚拟机概念模型的解释基本上已经足够了,它已经解决了虚拟机在分派中“会做什么”这个问题。但如果问Java虚拟机“具体如何做到”的,答案则可能因各种虚拟机的实现不同会有些差别。
  
动态分派是执行非常频繁的动作,而且动态分派的方法选择版本过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,Java虚拟机实现基于执行性能的考虑,一种基础而常见的优化手段是为类型在方法区中建立一个虚方法表(vtable,对应的在执行invokeinterface时会用到接口方法表-itable),使用虚方法表索引代替元数据查找以提升性能。
在这里插入图片描述

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

为了实现程序方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样设计当类型变换时,仅需要查找变更的虚方法表,就可以从不同的虚方法表中按索引转换出所需入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化值后,虚拟机会把该类的虚方法表也一同初始化完毕。
  
虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用类型继承关系分析、守护内联、内联缓存等方式
  
提高性能是相对于直接搜索元数据来说,实际在Hotspot虚拟机实现中,直接去查vtable和itable已经算是最慢的一种分派,只在解释执行状态时使用,在即时编译执行时,会有更多的性能优化措施。


四、动态类型语言支持

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,常见的动态语言包括:Python、Erlang、JavaScript、PHP、Ruby等,相对地,在编译期就进行类型检查过程的静态类型语言有C++、Java等。

什么是类型检查

obj.println("hello world");

    假设这行代码是在Java语言中,并且obj的静态变量类型为java.io.PrintStream,那变量obj的实际类型就必须是PrintStream的子类才是合法的,否则哪怕obj属于一个确实含有println(String)方法相同签名方法的类型,但只要与PrintStream没有继承关系,代码依然不可能执行-类型检查不合法。但是相同的代码在JavaScript中,无论obj是何种类型,无论其继承关系如何,只要这种类型的方法中确实定义有println(String)方法,能够找到相同签名的方法,调用便可成功。

为什么会产生这种差别
产生这种差别的根据原因是Java语言在编译期就将pringln(String)方法完整的符号引用生成出来,并作为方法调用指令的参数存储到Class文件中。例 invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
  
这个符号引用包含了这个方法定义在哪个具体类型中、方法的名字、参数顺序、参数类型、方法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用,而JavaScript等动态类型语言与Java核心的差异就是变量obj本身没有类型,变量obj的值才有类型,所以编译器在编译时最多只能确定方法名称、方法参数、返回值这些信息,而不会去确定方法所在具体类型(方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征

动态类型语言和静态类型语言两者谁更好
它们都有各自的优点,选择哪种语言是需要权衡的事情。静态语言在编译期确定变量类型,最显著的好处就是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题也会在编码时被及时发现,利于稳定性。而动态类型语言在运行期确定类型可以为开发人员提供极大的灵活性,某些静态语言要花大量臃肿的代码实现的功能,由动态类型语言实现会清晰简洁,即开发效率的提升。

Java与动态类型的关系
Java虚拟机层面对动态类型语言的支持有所欠缺主要表现在方法调用方面:Jdk7之前的4条字节码方法调用指令中,第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。因此在Java虚拟机上实现的动态类型语言就不得不使用曲线救国的方式(如编译时预留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)实现,但这种做法会导致动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销。所以这种动态类型方法调用的底层问题应当在虚拟机层次上解决,这便是JDK 7 时JSR-292提案中invokedynamic指令以及java.lang.invoke包出现的技术背景。

java.lang.invoke包是什么
Jdk7加入的invoke包主要目的是在之前单纯依靠符号引用来确定调用的目标方法之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”。

package com.tencent;


import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

import static java.lang.invoke.MethodHandles.lookup;

/**
 * 单分派、多分派演示
 *
 * @author zzm
 */
public class Demo {

    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对象),可以视为对最终调用方法的一个“引用”。

MehtodHandle与反射Reflection区别

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

除了以上列举的区别外,最关键的一点还在于去掉前面讨论施加的前 提“仅站在Java语言的角度看”之后:Reflection API的设计目标是只为Java语言服务的,而MethodHandle 则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已,而且Java在这里并不是主角。

invokeDynamic指令有什么用
某种意义上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4 条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实现, 另一个用字节码和Class中其他属性、常量来完成。
  
每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamically-Computed Call Site)”, 这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7 时新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用到要执行的目标方法上。

掌控方法分派规则可以干什么

class GrandFather { 
	void thinking() {
		System.out.println("i am grandfather");
	}
}

class Father extends GrandFather { 
	void thinking() {
		System.out.println("i am father");
	}
}

class Son extends Father { 
	void thinking() {
		// 请读者在这里填入适当的代码(不能修改其他地方的代码)
		// 实现调用祖父类的thinking()方法,打印"i am grandfather"
	}
}

在拥有invokedynamic和invoke包之前,使用纯粹的java语言很难处理这个问题(使用ASM字节码工具直接生成字节码还是可以处理的,但这已经不是java 语言层面解决问题了),原因是在Son类的thinking()方法中根本无法获取到一个实际类型是GrandFather的对象引用,而invokevirtual指令的分派逻辑是固定的,只能按照方法接收者的实际类型进行分派,这个逻辑完全固化在虚拟机中,程序员无法改变。

如果是JDK 7 Update 9可以使用MethodHandler解决
public class Son extends Father {
    @Override
    public void thinking() {
        try {
            MethodType mt = MethodType.methodType(void.class);
            Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
            lookupImpl.setAccessible(true);
            ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class, "thinking", mt, Father.class)
                    .bindTo(this)
                    .invoke();
        } catch (Throwable e) {
        }
    }
}
输出:
JDK 7 Update 9

但这个逻辑在JDK 7 Update 9之后被视作一个潜在的安全性缺陷修正了,原因是必须保证findSpecial()查找方法版本时受到的访问约束(譬如对访问控制的限制、对参数类型的限制)应与使用invokespecial指令一样,两者必须保持精确对等,包括在上面的场景中它只能访问到其直接父类中的方法版本,所以以上代码在JDK8中运行会得到结果i am father

新版本的JDK中,MethodHandles API实现时是预留了后门的,访问保护是通过一个allowModes的参数来控制,而且这个参数可以被设置成“TRUSTED”来绕开所有的保护措施。尽管这个类只是在Java类库本身使用,没有开放给外部设置,但我们可以通过反射轻易打破这种限制,

MethodType mt = MethodType.methodType(void.class);
Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
lookupImpl.setAccessible(true);
MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class,"thinking",mt, Father.class);
mh.invoke(this);

输出结果:
i am grandfather

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

概念模型中,虚拟机是如何执行方法里面的字节码指令其执行引擎是如何工作的

实际的虚拟机实现并不是按照下文中的动作一板一眼地进行机械式计算,而是动态产生每条字节码对应的汇编代码来运行,这与概念模型中执行过程的差异很大,但是结果却能保证是一致的
在这里插入图片描述
下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;而中间的那条分支,自然就是解释执行的过程

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

基于栈的指令集和基于寄存器的指令集有什么区别
Java编译器输出的字节码指令流基本上是一种基于栈的指令集架构,字节码指令流里面的指令大部分都是零地址指令,依赖操作数栈进行工作,与之相对地是基于寄存器架构的指令集,最典型的是x86的二地址指令集,即现在主流PC机中物理硬件直接支持的指令集架构,这些指令依赖寄存器工作

示例分别使用两种指令集去计算 “1+1”

基于栈的指令集
iconst_1 iconst_1 iadd istore_0
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。

基于寄存器的指令集
mov  eax, 1
add  eax, 1
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。

两套指令集各有优势,同时并存和发展
基于栈的指令集主要优点是可移植,因为寄存器由硬件提供,程序直接依赖这些硬件寄存器则不可避免会受到硬件的约束。
栈结构指令集的主要缺点是理论上执行速度相对会稍慢,这里的执行速度是局限要局限再解释执行的状态下,如果经过即时编译器输出成物理机的汇编指令,则就与虚拟机采用哪种指令集架构没有关系了。在解释执行时,栈架构指令虽然紧凑,但完成同样的功能所需指令数一般比寄存器架构的多,因为出栈、入栈操作本身就产生大量的指令,更重要的是栈实现在内存中,频繁的栈访问意味着频繁的内存访问,因此由于指令数量和内存访问的原因,导致了栈指令集架构的执行速度会相对慢一点。

基于栈的解释器执行过程

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

字节码
public int calc(); Code:
Stack=2, Locals=4, Args_size=1 
0:	bipush 100
2:	istore_1
3:	sipush 200
6:	istore_2
7:	sipush 300
10: istore_3
11:  iload_1
12:  iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
}

从字节码可以看出需要深度为2的操作数栈和4个变量槽的局部变量空间

第一步:执行偏移地址为0的指令,将常量值100推送入操作数栈栈顶

第二步:将操作数栈栈顶的整数值出栈并存放到第一个局部变量槽中,后续4条指令都是做一样的事,对应代码中把变量a、b、c赋值为100、200、300
在这里插入图片描述
第三步:将局部变量表第一个变量槽的整型数值复制到操作数栈栈顶
在这里插入图片描述
第四步:把第二个变量槽的数值入栈
在这里插入图片描述

第五步:将操作数栈中前两个栈顶元素出栈,做整型加法,然后把结果重新入栈
在这里插入图片描述
第六步:把存放在第三个变量槽中的300入栈到操作数栈中,这时操作数栈为两个整数300,下一条指令imul是将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果入栈,与iadd类似
在这里插入图片描述
第七步:执行ireturn指令 结束方法并将操作数栈栈顶的整数值返回给该方法的调用者。至此方法调用结束
在这里插入图片描述

上面的执行过程仅是一种概念模型,虚拟机最终会对执行过程做出一系列优化措施,实际的运作过程并不会完全符合概念模型的描述。


六、附录

JVM是怎么实现invokedynamic的?

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值