深入理解JVM 字节码执行引擎

简介

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

在java虚拟机规范中指定了java虚拟机字节码执行引擎的概念模型,这个概念模型称为各大开发商的java虚拟机执行引擎的统一外观(Facade)。在不同虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过编译器产生本地代码执行)俩种选择,也可能俩者兼备,但从外观上来看所有的java虚拟机的执行引擎输入和输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输入的是执行结果。本章从概念模型的角度来讲虚拟机的方法调用和字节码执行

运行时栈帧结构

java虚拟机以方法作为最基本的执行单元,栈帧(Stack frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素,栈帧存储了方法的局部变量泵,操作数栈,动态连接,和方法返回地址等信息。每一个方法从调用开始至结束的过程,都对应着一个战帧在虚拟机栈里面从入栈到出栈的过程

在编译java程序源码时,战帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。换言之,一个栈帧需要分配多少内存,并不会收到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

一个线程汇总的方法调用链可能会很长,以java程序的角度来看,同一时刻,同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的战帧才是生效的,被称为当前栈帧(Current Stack Frame),这个栈帧锁关联的方法被称为当前方法。执行引擎的所有字节码指令都只针对当前战帧进行操作,在概念模型上典型的战帧如图所示

局部变量表

局部变量表(local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量

局部变量表的容量以变量槽(Variable Slot)为最小单位,java虚拟机规范中并没有明确指出一个变量槽应占用的内存空间大小,只是很有想到性地说道每个变量槽都应该能存放一个boolean,byte,char,short,int,float,reference或者returnAddress类型的数据,这8中种数据类型,都可以使用32位或更小的物理内存来存储,但这种描述与明确指出每个变量槽占用32位长度空间是有本质区别的,它允许变量槽的长度可以随着处理器,操作系统或虚拟机实现的不同而发生变化,保证了即时在64位虚拟机中使用64位的物理内存空间去实现一个变量槽,虚拟机仍需要使用对其和补白的手段让变量槽在外观上看起来与32位的虚拟机中的一致

既然前面提到了java虚拟机的数据类型,再次之前对它们再简单介绍一下。一个变量槽可以存放一个32位以内的数据类型,java中占用不超过32位的数据类型由boolean,byte,char,short,int,float,reference和return8种类型.

java虚拟机规范中 没有说明引用的长度,也没用明确指出这种引用应该有怎样的结构。但是一般来说,虚拟机至少都应当能通过这个引用做到俩件事情,一是根据引用直接或间接的查找到对象所属数据类型在方法区中的存储的存储的类型信息,否则将无法实现java语言规范中定义的语法约定。第八种returnAddres类型目前已经很少见了,它是为字节码指令jsr,jsr_w和ret来服务的,指向了一条字节码指令的地址,某些很古老的java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常来代替了。

32位操作系统下指针为32位,64位默认开启指针压缩为32位,否则为64位

对于64位的数据类型,java虚拟机会以高位对其的方式为其分配俩个连续的变量槽空间。java语言中明确的64位数据类型只有long和double俩种。这里把long和double数据类型读写分割为俩次32位读写的做法有点类似。不过局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写俩个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全的问题。

java虚拟机通过索引定位的方式使用局部变量表,索引值就是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N槽,如果访问的是64位的变量,则同时使用第N和N+1俩个变量槽,虚拟机不允许采用任何方式单独访问其中的任何一个。(如果操作了会在校验阶段排除异常)

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

为了尽可能节省栈帧消耗用的内存空间,局部变量表的变量槽是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过这样的设计除了节省栈帧空间外,还会伴随着少量额外的副作用

比如如下这段程序

public static void main(String[] args) {
    {
        byte[]placeholder=new byte[64*1024*1024];
    }
    int a=0;//如果不加这一行内存不会被正确回收
    System.gc()
}

根本原因就是局部变量表中的变量槽是否还存在有关于placeholder数组的对象引用。如果不加哪一行,代码虽然已经离开了placeholder的作用域,但是在此之后,在没发生过对局部变量表读写操作,局部变量表任然保持这对它的关联。这种关联没有被及时打断,绝大部分影响都很轻微,但如果遇到一个方法,其后面的代码有一些耗时很长的操作,前面又定义了占用了大量内存但实际上已经不回再使用的变量,收到将其设置为null值(原来代替a=0,把标量对应的局部变量槽清空),不是无异议的操作。

操作数栈

操作数栈(Operand Stack)也被称为操作栈,它是一个后入先出的(LIFO)栈。同局变量表一样,操作数栈的最大深度也在编译的时候被写到Code属性的max_statcks数据项之中。操作数栈的每一个元素都可以包括long和double在内的任意java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的容量为2.javaC年一起的数据流分析工具保证了在方法执行的任何时候,操作数栈的深度都不会超过max_stacks数据项中设定的最大值。

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

操作数栈中元素的数据类型必须与字节码指令的顺序严格匹配,在编译的时候编译器必须要严格保证这点,在类校验阶段的数据流分析中还要再次验证这一点。在以上面iadd指令为例,这个指令只能用于整数的假发,它在执行时,最接近栈顶的俩个元素的数据类型必须为int类型,不能同时出现一个long和一个float使用iadd命令相加的情况。

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

java虚拟机解释执行引擎被称为基于栈的执行引擎,里面的栈就是操作数栈(还有一个基于寄存器的执行引擎)

动态连接

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

在java源文件被编译到字节码文件中时,所有变量和方法引用都作为符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法直接调用.

方法返回地址

一个方法开始执行后,只有俩种方式退出这个方法。一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型讲根据遇到何种方法返回指令来决定,这种退出方法称为正常调用完成。

另一种退出方式实在方法执行的过程中遇到了异常,并且这个异常没有在方法内得到妥善处理。无论是java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在笨方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出的方法称为异常调用完成。一个方法使用异常完成出口的方式退出,是不会给它上层调用者提供任何返回值的

无论采用何种退出方式,在方法退出之后,都必须返回最初方法被调用时的位置,程序才能继续运行,方法返回时,可能需要在栈帧中保存一些信息,来帮助恢复它上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的pc计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时才可能执行的操作有:恢复上层的局部变量表和操作数栈,把返回值(如果有的话)压入调用者的操作数栈中,调整pc计数器的值以指向方法调用后面的一条指令等。(可能是基于模型的讨论,具体要按不同虚拟机讨论)

方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还未涉及方法内部的具体过程。在程序运行时,进行方法调用是最普遍,最频繁的操作之一。但Class文件编译过程中不包含传统程序语言编译的链接步骤,一切方法调用在class文件里面存储的都是符号引用,而不是方法实际在运行时内存布局的入口地址(也就是之前说的直接引用)。这个特性给java带来了更强大的动态扩展能力,但也使得java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

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

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

调用不同类型的方法,字节码指令级里设计了不同的指令。在java虚拟机支持一下5条方法调用字节码指令,分别是

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

前面4条调用这令,分派逻辑都固话在java虚拟机内部,而invokednamic指令的分配逻辑是由用户设定的引到方法来决定的

只要能够被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,java语言里符合这个条件的方法共有静态方法,私有方法,实例构造器,父类方法四种,在加上被final修饰的方法(尽管被invokevirtual指令调用)这五中方法在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为非虚方法(Non-Virtual Methods)相反,其他方法就被称为虚方法

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

分派

静态分派

分派这一次本来就具有动态性,一般不应用在静态语境中,静态分派的英文是Methods overload resolution(方法重载决议)

看下以下代码

8-6

/**
 * 方法静态分派演示
 */
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);
    }
}

结果为

hello,guy!
hello,guy!

Human man = new Man();

我们把上面代码中的"Human"称为变量的“静态类型”(Static Type)或者叫外观类型
后面“Man”则被称为变量的“实际类型”(Actual Type)或者称为“运行时类型”(Runtime Type)。

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

// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();

// 静态类型变化
sr.sayHello((Man) human)
sr.sayHello((Woman) human)

对象human 的实际类型是可变的,编译期间它完全是个“薛定谔的人”,到底是Man还是Woman,必须等到程序运行到这行才可以确定。

而human的静态类型是Human,也可以在使用时临时改变这个类型,但这个改变仍是在编译器可知的,两次sayHello()方法的调用,在编译期完全可以明确转型的是Man还是Woman。

其实清楚了静态类型与实际类型的概念,我们就把话题再转回到代码清单8-6的样例代码中。main俩次sayHello方法调用,在接受者已经确定是对象sr的前提下,使用哪个版本的重载,就完全取决于参数的数量和数据类型。代码中固定义了俩个静态类型相同,而实际类型不同的变量,但虚拟机(准确的说是编译期)在重载时是通过参数的静态类型而不是实际类型作为判定一句的。静态类型在编译器可知,所以在编译阶段,javac编译器就根据参数的类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用传递到了main()方法里的俩条invokevirtual指令参数中

所有依赖静态类型来决定方法执行版本的分派动作都称为静态分派。静态分派最典型的应用表现就是方法的重载。静态分派发生在编译阶段,因此确定静态分派动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入解析而不是分派的原因

需要主义javac编译器虽然能确定方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个,相对更合适的版本。这种模糊的结论在由0和1构成的计算机世界中是个比较罕见的事情,产生这种模糊结论的主要原因是因为字面量天生的模糊性,它不需要定义,所以字面量就没有显式的静态类型,它的静态类型只能通过语法规则去理解和推断

另外还有一点可能比较容易混淆:解析和分派之间的关系不是二选一的排他关系,它们是在不同层次上去筛选,确定目标方法的过程。列入前面说过静态方法会在编译器确定,在类加载器就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过分派来完成的

动态分派

它与java语言多态性的另外一个重要体现,重写有着密切的关系

package com.jvm;
/**
 * 动态分派
 * @author renhj
 *
 */
public class DynamicDispatch {
        
    static abstract class Human {
        protected abstract void sayHello();
    }
    
    static class Man extends Human {
        
        @Override
        protected void sayHello() {
            System.out.println("hello man!");
        }         
    }
     
    static class Women extends Human {
     
        @Override
        protected void sayHello() {
            System.out.println("hello women!");
        }         
    }
     
    
    public static void main(String[] args){
        
        Human man = new Man();
        Human women = new Women();
         
        man.sayHello();
        women.sayHello();
        
        man = new Women();
        man.sayHello();
 
    }
 
}

显然这里选择调用的方法版本是不可能再根据静态类型来决定的,因为静态类型同样都是Human的俩个变量man和woman在调用sayHello方法时产生了不同的行为,甚至变量man在来此调用中还执行了来个不同的方法,导致这个现象的原因很明显,因为俩个变量的实际类型不同。

  • Java虚拟机是如何根据实际类型分派方法的执行版本的:invokevirtual指令的多态查找过程开始 ,invokevirtual指令运行时解析过程大致分为以下几个步骤:

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

    • invokevirtual指令执行的第一步就是在运行时期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上
  • 这种在运行时期根据实际类型确定方法执行版本的分派过程就叫做动态分派

单分派与多分派

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

运行输出

father choice 360

son choice QQ

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

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

总结一下

静态分派是指编译就可以确定调用哪个方法,动态分派是指运行时才能确定调哪个方法,java的动态单分派体现在调用重写方法的时候,也就是运行是调用变量引用的对象方法

虚拟机动态分派实现

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

这里的提高性能是响度直接搜索元数据来说的,实际上在hotspot虚拟机的实现中,直接去查itable和vtable已经算是最慢的一种分派,只在解释执行的状态时使用,在编译执行时,会有更多的性能优化措施

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

图中Son重写了来自father的全部方法,因此没有指向father的箭头,但是Son和Father都没有重写来自Object的方法,所以他们的方法表中所有Objectt类集成来的方法都指向了Object的数据类型

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

java对象里面不使用final修饰的就是虚方法,虚拟机除了使用虚方法表之外为了进一步提高性能还会使用类型继承关系分析,守护内连,内联缓存等非稳定的激进优化策略来争取更大的空间

动态类型语言的支持

java字节码发布二十年至今只新增过一条字节码指令,invokedynamic指令,这条新增加的指令是jdk7项目的目标,实现动态类型语言(Dynamically Typed Language)也是为JDK8可以顺利实现lambda便道上做的技术储备

动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译器进行的,满足这个特点的语言非常多,包括,js,php,lua,python,ruby,等等,那么相对的,在编译器就进行类型检查过程的语言,譬如C++,java等就是最常用的静态类型语言

java与动态类型

JDK7时新加入的java.lang.invoke包是JSR292的一个重要组成部分,这个包的主要目的是在之前淡出年艺考符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为方法句柄。不好懂的话,可以把方法句柄与C/C++中的函数指针,或者C#中的委派互相类比来理解。

举个例子,如果我们要实现一个带谓词(谓词就是由外部传入的排序时来比较大小的动作)的排序函数,在C/C++中的常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样

void sort(int list[],const int size,int(*compare)(int,int));

但在java中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的接口,以实现这个接口的对象作为参数,例如java类库中的Collections::sort()方法就是这样定义的

void sort(List list,Comparator c)

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

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
/**
 * JSR 292 MethodHandle基础用法演示
 * @author zzm
 */
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没有多少困难,不过看完它的用法之后,大概会产生疑问,相同的事情用反射不是早就可以实现了吗

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上也应当可以采用类似思路去

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对象,最终调用到要执行的目标方法上。

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class InvokeDynamicTest {
    public static void main(String[] args) throws Throwable {
        INDY_BootstrapMethod().invokeExact("icyfenix");
    }
    public static void testMethod(String s) {
        System.out.println("hello String:" + s);
    }
    public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
        return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
    }
    private static MethodType MT_BootstrapMethod() {
        return MethodType
                .fromMethodDescriptorString(
                        "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String; Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", null);
    }
    private static MethodHandle MH_BootstrapMethod() throws Throwable {
        return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
    }
    private static MethodHandle INDY_BootstrapMethod() throws Throwable {
        CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(lookup(), "testMethod",
                MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
        return cs.dynamicInvoker();
    }
}

把上面的代码编译,再使用INDY转换后重新生成的字节码

Constant pool:
        #121 = NameAndType #33:#30 // testMethod:(Ljava/lang/String;)V
        #123 = InvokeDynamic #0:#121 // #0:testMethod:(Ljava/lang/String;)V
public static void main(java.lang.String[]) throws java.lang.Throwable;
        Code:
        stack=2, locals=1, args_size=1
        0: ldc #23 // String abc
        2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod: (Ljava/lang/String;)V
        7: nop
        8: return
public static java.lang.invoke.CallSite BootstrapMethod(java.lang.invoke.Method Handles$Lookup, java.lang.String, java.lang.invoke.MethodType) throws java.lang.Throwable;
        Code:
        stack=6, locals=3, args_size=3
        0: new #63 // class java/lang/invoke/ConstantCallSite
        3: dup
        4: aload_0
        5: ldc #1 // class org/fenixsoft/InvokeDynamicTest
        7: aload_1
        8: aload_2
        9: invokevirtual #65 // Method java/lang/invoke/MethodHandles$ Lookup.findStatic:(Ljava/lang/Class;Ljava/ lang/String;Ljava/lang/invoke/Method Type;)Ljava/lang/invoke/MethodHandle;
        12: invokespecial #71 // Method java/lang/invoke/ConstantCallSite. "<init>":(Ljava/lang/invoke/MethodHandle;)V
        15: areturn

从main()方法的字节码中可见,原本的方法调用指令已经被替换为invokedynamic了,它的参数为 第123项常量(第二个值为0的参数在虚拟机中不会直接用到,这与invokeinterface指令那个的值为0的 参数一样是占位用的,目的都是为了给常量池缓存留出足够的空间): 2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod:(Ljava/lang/String;)V

从常量池中可见,第123项常量显示“#123=InvokeDynamic#0:#121”说明它是一项 CONSTANT_InvokeDynamic_info类型常量,常量值中前面“#0”代表引导方法取Bootstrap Methods属性 表的第0项(javap没有列出属性表的具体内容,不过示例中仅有一个引导方法,即 BootstrapMethod()),而后面的“#121”代表引用第121项类型为CONSTANT_NameAndType_info的常 量,从这个常量中可以获取到方法名称和描述符,即后面输出的“testMethod: (Ljava/lang/String;)V”。

再看BootstrapMethod(),这个方法在Java源码中并不存在,是由INDY产生的,但是它的字节码很 容易读懂,所有逻辑都是调用MethodHandles$Lookup的findStatic()方法,产生testMethod()方法的 MethodHandle,然后用它创建一个ConstantCallSite对象。最后,这个对象返回给invokedynamic指令实 现对testMethod()方法的调用,invokedynamic指令的调用过程到此就宣告完成了。

实战

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
 
class GrandFather {
    void thinking() {
        System.out.println("i am grandfather");
    }
}
 
class Father extends GrandFather {
    @Override
    void thinking() {
        System.out.println("i am father");
    }
}
 
class Son extends Father {
    public static void main(String[] args) {
        new Son().thinking();
    }
 
    @Override
    void thinking() {
// 请读者在这里填入适当的代码(不能修改其他地方的代码)
// 实现调用祖父类的thinking()方法,打印"i am grandfather"
        try {
            MethodType mt = MethodType.methodType(void.class);
            // 必须保证
            // findSpecial()查找方法版本时受到的访问约束(譬如对访问控制的限制、对参数类型的限制)应与使用
            // invokespecial指令一样,两者必须保持精确对等,包括在上面的场景中它只能访问到其直接父类中的方法版本。
            Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
            lookupImpl.setAccessible(true);
            MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
            mh.invoke(this);
        } catch (Throwable e) {
        }
 
    }
}

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

基于栈和基于寄存器的指令级

javac编译器输出的字节码指令流,基本上是一种基于栈的指令级架构,字节码指令流里面的指令大部分是零地址指令,他们依赖操作数栈进行工作。预制另外一套常用的指令级架构是基于寄存器的指令级,最经典就是X86指令级。

俩种指令级会同时并存和发展,各有优势

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地受到硬件的约束。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由具体的虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能。

栈架构的指令集还有其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数),编译器实现更简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

栈架构指令集的主要缺点就是执行速度相对来说会稍慢一些,源于指令数量和内存访问。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、褥栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问。相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。

Last modification:January 13, 2023
如果觉得我的文章对你有用,请随意赞赏