《深入了解Java虚拟机》第八章

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

8.1 概述

· 执行引擎是JVM核心的组成部分之一。“虚拟机”是一个相对于“物理机”而言的,“物理机”的执行引擎是直接建立在处理器、缓存、指令集、操作系统层面上的,“虚拟机”的执行引擎是由软件自行实现的。
·《Java虚拟机规范》中制定了概念模型,成为各大发行商的JVM执行引擎的统一外观。
· 在不同的虚拟机实现中,执行引擎在执行字节码时,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,也可能几个不同级别的即时编译器一起工作。
· 外观上看,执行引擎的输入是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果

8.2 运行时栈帧结构

· JVM以方法作为最基本的执行单元。
· 栈帧
①是用于支持虚拟机进行方法调用方法执行背后的数据结构
②是虚拟机运行时数据区中的虚拟机栈的栈元素
存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外附加信息。
注: 每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
· 在编译java程序源码时候,栈帧中需要多大的局部变量表,操作数栈需要多深就已经被分析出来,并写入方法表的Code属性中。
· 一个栈帧需要分配多少内存,并不会受到运行时数据影响。只取决于源码和具体虚拟机实现的栈内存布局形式
· 一个线程中的方法调用链可能会很长,以Java程序的角度看,同一时刻、同一条线程里,在调用堆栈的所有方法都同时处于执行状态而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”,与这个栈帧所关联的方法称为“当前方法”。
· 执行引擎所运行的所有的字节码指令都只针对当前栈帧操作
在这里插入图片描述

8.2.1 局部变量表

· 一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
· 在编译成class文件时,就在方法的code属性的max_locals项中确定了该方法所需分配的内存大小。
· 变量槽为最小单位,但一个变量槽应占多少内存并未明确指出。只是说都应能存放boolean、byte、char、short、int、float、reference、returnAddress 8种32位以内的数据类型。
注:
· java语言和JVM中基本数据类型的概念有本质区别。
· reference:对一个对象实例的引用。虚拟机至少都应通过引用做到:

  1. 直接或间接地找到对象在java堆中的数据存放的起始地址或索引。
  2. 直接或间接地找到对象所属数据类型在方法区中的存储的类型信息。
    · returnAddress:基本不用。是为jsr等字节码指令服务的,古老虚拟机使用这几条指令实现异常处理的跳转,现已经被异常表替代。

· 对于64位数据类型,JVM采用高位对其方式为其分配2个变量槽。Java语言中规定只有long和double。这里把它们分割存储的方法与“long和double的非原子协定”中允许把一次long和double读写分割为2次32位读写做法类似。
· 局部变量表建立在线程堆栈中,属于线程私有数据,所以无论两个连续变量槽是否为原子操作都不会引起数据竞争和线程安全问题。
· 局部变量表采取索引定位的方式访问。索引值从0开始。如果是64位数据类型,一次性访问第N和N+1个变量槽,如果单独访问某一个,在类加载的校验阶段抛出异常。

· 当一个方法调用时,JVM根据局部变量表完成实参到形参的传递。
· 如果是实例方法(没有被static修饰),第0位变量槽固定存放用于传递该方法所属对象实例的引用,在方法中可以通过this访问这个默认隐含参数,其余参数根据参数列表顺序从1开始排列。
· 参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余变量槽。
· 变量槽可以重用,不过某些情况下会直接影响到系统的垃圾收集行为。
· 比如一个方法,前面定义了一个占用内存极大但实际已经不会再使用的变量,后面的代码有一些耗时很长的动作,手动赋值null的操作来优化内存回收。
(这个操作极端情况有奇用:在对象占用内存大、此方法的栈帧长时间不能回收、方法调用次数达不到即时编译器的编译条件)
· 虽然确实有用,但不该产生特别依赖。

  • 其一,从编码角度讲,以恰当变量作用域控制回收时间才是最好解决办法。
  • 其二,从执行角度讲,这个操作是建立在执行引擎概念基础上的,但由于”公有设计私有实现”,虚拟机使用解释器执行的时候,通常与概念模型还比较很接近,但是经历即时编译器优化后,差异更大。
    而实际中,即时编译才是虚拟机执行代码的主要方式,赋null值在经过即时编译优化后几乎是一定会被当作无效操作清除的。

· 局部变量不像类变量那样存在“准备阶段”。如果一个局部变量定义了但没有赋值,它是完全不能使用的。

8.2.2 操作数栈

· 也叫操作栈,操作数后入先出。
· 与局部变量表类似,在编译时往code属性的max_stacks属性中写入了操作数栈的最大深度。
· 操作数栈每个元素可以为任意Java数据类型。32位所占的栈容量为1,64位的栈容量为2。
· Javac编译器的数据流分析工作保证方法执行的任何时候,操作数栈的深度都不会超过max_stacks里面设置的最大值。

· 当一个方法开始执行的时候,栈为空。方法执行过程中,会有各种入栈出栈的操作(指令往栈中写入或者提取内容)。譬如,调用其他方法的时候是通过操作数栈来进行方法参数的传递。
· 操作数栈元素数据类型必须与字节码指令的序列匹配。在编译代码时,编译器必须严格保证,在类校验阶段,数据流分析还会进行验证。比如,对于iadd指令,操作数栈顶两个元素必须都为int。
· 模型概念中两个不同的栈帧是完全独立的。实际中,虚拟机会进行优化处理,让两个栈帧有重叠部分。一方面节约内存,一方面在方法调用时直接共享部分数据,不需要再参数复制传递。
在这里插入图片描述
· java虚拟机的解释执行引擎被称为“基于栈的执行引擎”。

8.2.3 动态连接

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

8.2.4 方法返回地址

方法开始执行后,只有两种退出方法。

  1. 正常调用完成”:执行引擎遇到了任意一个方法返回的字节码指令,这时候可能会有返回值返回给上一层的方法调用者(或者称为主调方法)。是否有返回值、返回值类型都由何种指令决定。
  2. 异常调用完成”:方法体内有异常,或者是使用athrow指令产生异常,并且在本方法的font color = #F9000>异常表中没有搜索到匹配的<异常处理器,就会导致方退出。不会给上层调用者提供返回值。
    注:方法退出都必须返回到方法被调用的位置。方法正常退出,主调方法的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个值。方法异常退出,返回地址要通过异常处理器表来确定,栈帧中通常不会保存这个值。
    · 方法退出时可能的执行操作有:
  3. 恢复上层方法的局部变量表和操作数栈
  4. 如果有返回值,将值压入调用者的操作数栈中
  5. 调整PC计数器的值以指向下一条指令
    注:说可能是因为这是基于概念模型的讨论,具体实现要看虚拟机

8.2.5 附加信息

《Java虚拟机规范》允许虚拟机实现中增加一些规范中没有描述的信息到栈帧中,比如有关调试的信息。
· 通常讨论概念时,把附加信息、返回地址、动态连接归为一类,称“栈帧信息

8.3 方法调用

· 方法调用阶段唯一任务是确定调用哪一个方法。进行方法调用是程序运行时最频繁的操作之一。
· Class文件的编译过程不包含连接步骤,一切方法调用在Class文件里存储的都只是符号引用。
该特性给Java带来更强大的动态扩展能力,但过程也变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

8.3.1 解析

类加载的解析阶段,会将一部分的符号引用转化为直接引用,这种解析成立前提:方法在程序真正运行之前,就有一个可确定的调用版本,在运行期间该版本也不可改变。这类方法的调用称为“解析”。
· 符合“编译期可知,运行期不变”特性的主要是静态方法和私有方法。前者与类型有关,后者在外部不能访问,这种特性决定了它们不可能通过继承等方式重写出其他版本,因此都适合在类加载的解析阶段解析。
· 调用不同类型的方法,字节码指令不同。

  1. invokestatic:静态方法
  2. invokespecial:实例构造器()方法、私有方法、父类中的方法
  3. invokevirtual:所有虚方法
  4. invokeinterface:接口方法,运行时再确定一个实现该接口的对象
  5. invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
    注:前四条指令的分派逻辑固定在JVM内部,而最后一条由用户设定的引导方法决定。
    · 只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一调用版本。符合这样条件的方法有:静态方法、实例构造器方法、私有方法、父类方法四种,再加上final修饰的方法(被invokevirtual调用),这五种方法调用在类加载时候就可以进行解析。这些方法称为“非虚方法”,其他的就称为“虚方法”。
    · 被final修饰的方法,历史设计原因,是使用invokevirtual调用的,但是因为它也无法被覆盖,没有其他版本的可能,所以调用版本肯定也是唯一的,因此final方法也属于非虚方法
    · 解析调用一定是个静态的过程。在编译期间完全确定,在类加载的解析阶段就把涉及到的符号引用转化为直接引用。而另一种主要的方法调用形式:分派,既有静态又有动态,根据宗量数可以分为单分派和多分派。两类分派形式两两组合,就用4种情况。
    注:方法的接收者与方法的参数统称为方法的宗量

8.3.2 分派

  1. 静态分派
    “Method Overload Resolution”。分派一词本身具有动态性,只是许多资料称为“静态分派”.

    Human human = new Man();
    · Human称为变量的“静态类型”或者“外观类型”,Man称为变量的“实际类型”或“运行时类型”。
    · 静态类型和实际类型在程序中都可能发生变化。
    · 静态类型在编译器是可知的,并且静态类型的变化仅在使用时发生(如强制转型),但这个改变在编译期也是可知的
    · 实际类型是不确定的,只有到运行到这行代码才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

    静态分派和重载

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    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("hi,human");
    }
    public void sayHello(Man guy) {
    system.out.println("hi,man");
    }
    public void sayHello(Woman guy) {
    system.out.println("hi,woman");
    }
    public static void main(String[] args) {
    Human man = new Man();
    Human woman = new Woman();
    StaticDispatch sr = new StaticDispatch();
    sr.sayHello(man);
    sr.sayHello(woman);
    }
    }

    运行结果:
    hi,human
    hi,human

    · main方法两次调用重载方法,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。
    · 代码故意定义了两个静态类型相同,实际类型不同的变量作为调用方法的参数,但是编译器在重载时是通过参数的静态类型作为判断依据的。因此选择了sayHello(Human)作为调用目标。
    · 由于静态类型在编译期可知,所以编译阶段,Javac编译器就根据参数的静态类型决定使用的重载版本,并把这个方法的符号引用写到main方法里的两条invokevirtual指令的参数中。

    · 所有依赖静态类型来决定方法执行版本的分派动作,就是“静态分派”。
    · 静态分派最典型表现就是方法重载。
    · 静态分派发生在编译阶段,因此确定静态分派的动作实际上并不是由虚拟机执行的,所以有的资料把它归为“解析”而非“分派”。
    注:javac编译器往往只能确定一个“相对更合适的”版本。原因是字面量的模糊性

    · 选择静态分派目标的过程Java语言实现【方法重载】的本质):
    1)如果传入参数是‘a’,最佳会选择参数类型为char的重载方法
    2)如果没有,也可能会选择参数为int类型的重载方法。因为‘a’除了能代表一个字符串,也可以代表数字97(发生一次自动类型转换)
    3)如果没有,可能选择long类型的重载方法97->97L(二次自动类型转换)

    按照char>int>long>float>double的顺序转型进行匹配
    4)如果都没有,可能会匹配到参数为Character类型的重载方法(发生一次自动装箱
    5)如果没有,可能会匹配到参数为Serializable(序列化)的重载方法。
    原因:自动装箱后还是找不到装箱类,但找到了装箱类所实现的接口类型,所以发生又一次自动转型。但Character绝不能转型为Integer,它只能安全转型为它的实现的接口或者父类。Serializable和Comparable是它实现的两个接口。
    参数为Serializable和Comparable的重载方法优先级相同。如果这两个同时存在会提示类型模糊,拒绝编译。
    必须调用时显示指定字面量的静态类型,如(Comparable<Character>)'a'才能编译通过
    6)如果没有,可能会匹配到参数为Object的重载方法。
    这时是char装箱后转型为父类了,如果有多个父类,将在继承关系中从下往上搜索
    7)如果没有,可能会匹配到参数为char…的重载方法。
    可见,变长参数的重载优先级是最低的。此时‘a’被当成是char[]的元素。
    注:有些在单个参数中能成立的自动转型(char->int),在变长参数中是不成立的。
    解析和分派不是二选一关系,而是在不同层次上去筛选、确定目标方法的过程。例如静态方法是在编译期确定、类加载期间解析,而选择重载版本的过程是通过静态分派完成的。

  2. 动态分派

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    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();
    }
    }

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

    javap命令输出代码的字节码,分析:

    Human man = new Man();
    Human woman = new Woman();
  • 0~15行对应这两步,建立man、woman的内存空间,调用man、woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中。

  • 之后,16、20行aload指令分别把刚刚创建的两个对象的引用压到栈顶,这个对象是将要执行sayHello()的接收者。

  • 17、21行都是利用方法调用指令invokevirtual,参数为Human.sayHello()的符号引用,但是这两条指令最终执行的目标方法不同。因此,关键在于指令本身。

    指令invokevirtual的运行时解析过程大概分为以下几步:
    1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C(man的实际类型为Man
    2)如果在类型C中找到与常量描述符和简单名称都相符的方法,则进行访问权限校验,如果通过返回这个方法的直接引用,查找过程结束;不通过返回java.lang.IllegalAccessError异常(在Man类中寻找与sayHello()匹配的方法,找到
    3)否则,按照继承关系从下往上对C的各个父类进行第二步
    4)如果始终没有找到,抛出java.lang.AbstractMethodError异常

    所以man.sayHello();woman.sayHello();分别输出man say hellowoman say hello

    · 我们把这种在运行期根据方法接收者的实际类型确定方法执行版本的分派过程称为动态分派。这个过程是Java语言【方法重写】的本质。
    注:方法的接收者这里理解为Human man = new Man();中的man
    · 静态类型相同的对象调用重写方法,结果不同。这种多态性的根源在于虚方法调用指令invokvirtual的执行逻辑,因此我们得出的结论只对方法有效,对字段无效。因为字段不使用该指令。
    · 事实上,在java中只有虚方法存在,没有虚字段。即字段永远不参与多态。当子类声明了与父类相同名称的字段时,子类字段会掩蔽父类同名字段。
    · 哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段
    在这里插入图片描述
    在这里插入图片描述

    · Son类创建时,先隐式调用Father的构造函数。Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,执行的是子类版本,而此时子类的money为0.要到子类构造函数执行时才会初始化。
    · Son的构造函数往后执行,对money赋值为4,调用自己的函数。
    · 最后一句通过静态类型访问到了父类的money,为2。

  1. 单分派与多分派

    根据分派基于多少种宗量(方法的接收者和方法的参数)
    划分。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

    · 如今的java语言是一门静态多分派、动态单分派的语言。
    在这里插入图片描述

    main方法中调用两次hardChoice()方法。

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

  • 再看运行阶段中虚拟机的选择,也就是动态分派的过程。
    · 在执行son.hardChoice(new QQ())时,准确说执行这行代码所对应的invokvirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),这时候参数的静态类型、实际类型都对方法的选择不会产生影响。
    · 唯一可以影响虚拟机选择的因素只有该方法接收者的实际类型是Father还是Son。
    · 只有一个宗量作为选择依据,所以Java动态分派属于单分派类型。

    · JDK10时Java出现了var关键字,与dynamic有本质区别:var是在编译时根据声明语句中等号右侧的表达式类型来静态推断类型,本质是种语法糖;而dynamic在编译时完全不关心类型是什么,等运行的时候再进行类型判断。
    · Java语言通过内置动态语言(如JavaScript)执行引擎、加强与其他JVM上动态语言交互能力的方式,来间接满足动态性的需求。
    · Java虚拟机层面,早在JDK7就开始提供对动态语言的方法调用的支持了,如JDK7引入了invokedynamic指令。

  1. 虚拟机动态分派的实现
    · 虚拟机不同,实现也会有区别。
    · 动态分派的方法版本选择需要运行时,在接收者类型的方法元数据中搜索合适的目标方法。JVM基于执行性能考虑,真正运行时不会频繁反复搜索类型元数据。
    · 一种基础而常见的优化手段:为类型在方法区中建立一个虚方法表(vtable,与之对应的,在invokeinterface执行时也会用到接口方法表——itable),使用虚方法表索引来代替元数据查找以提高性能(与直接搜索元数据相比。实际算最慢的一种分派,只在解释执行状态时使用)
    · 虚方法表中存放着各个方法的实际入口地址。
    · 如果某个方法在子类中没有被重写,那子类虚方法表中的入口地址和父类中该方法的入口地址相同,都指向父类的实现入口。
    · 如果子类重写了这个方法,那子类虚方法表中的入口地址会替换成子类实现版本的入口地址。
    · 具有相同签名的方法,在父子类的虚方法表中具有一样的索引号。
    · 虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚拟机表一同初始化完毕。
    在这里插入图片描述
  • 如图,Son重写了父类所有方法,所以没有指向父类的箭头。但它们都没有重写Object的方法,所以都指向Object的数据类型。

    · 由于java对象里面的方法默认(即不使用final修饰)为虚方法, 除了查虚方法表外,还有类型继承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(InlineCache)等多种非稳定的激进优化来争取更大性能空间。

8.4 动态类型语言支持

· JDK7增加了invokedynamic指令。
· 这条指令是实现动态类型语言支持而进行的改进之一,也是为JDK8顺利进行Lambda表达式做的技术储备。

8.4.1 动态类型语言

· 与动态语言不同。
· 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的。例如JavaScript、Python、PHP、Ruby等语言。
· 相对的,在编译期就进行类型检查过程的语言,譬如c++和java等就是最常用的静态类型语言
· 动态类型语言的一个核心特征:变量无类型而变量值才有类型
· 编译与运行示例
在这里插入图片描述

  • 上面这段java代码能正常编译,但在运行过程中会抛出NegativeArraySizeException异常(属于运行时异常)。
  • 运行时异常指只要代码不执行到这一行就不会出现问题
  • · 连接时异常,例如NoClassDefFoundError,即使无法执行到这一句,类加载时(java的连接过程不在编译阶段,在类加载阶段)也会抛出异常。
    · 然而同样语义的代码在c语言的编译期就直接报错。
    · 因此,哪门语言哪种检查行为要在运行期进行或者在编译期进行没有必然逻辑关系。在于语言规范中的人为设立的约定。
    · 类型检查
    obj.println("hello");
  • Java语言中如果obj的静态类型是一个接口(PrintStream类),那么obj的实际类型必须是实现这个接口的类,否则即使obj属于一个确实包含了这个方法相同签名方法的类型,代码依旧无法运行。类型检查不合法。
  • JavaScript语言中,无论obj具体是何种类型,无论继承关系如何,只要这个变量所属的类确实包含有println(String)方法,能找到相同签名的方法,调用就可以成功。
  • 原因
    · Java语言在编译期已经将println(String)方法完整的符号引用(本例中为一项CONSTANT_InterfaceMethodref_info常量)生成出来,并作为方法调用指令的参数存储到Class文件中。
    · 动态类型语言,变量obj本身没有类型,变量obj的值才有类型。所以编译器在编译时只能确定方法名、参数、返回值信息,而不会确定方法所在的具体类型(方法接收者不固定
    注:符号引用包含该方法定义在哪个类型中,方法名,参数顺序,参数类型,方法返回值等信息。

8.4.2 Java与动态类型

java语言运行在Java虚拟机上,而许多动态类型语言现在也运行于Java虚拟机上,不过还有所欠缺。主要表现值方法调用方面:

  • JDK7前的4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量)。
  • 方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。
  • 所以在JVM上实现动态类型语言只能“曲线救国”(如编译时留一个占位符,运行时动态生成字节码,实现具体类型到占位符类型的适配),但这样会带来额外内存开销。其中最严重的性能瓶颈是:动态类型方法调用时,由于无法确定调用对象的静态类型,而导致方法内联无法有效进行
    注:方法内联是其他优化措施的基础,是十分重要的一项优化
    在这里插入图片描述
  • 动态类型语言下这样的代码没有问题,但即使每个元素的类型中都有sayHello(),也无法在编译优化的时候就确定具体的sayHello()代码在哪里。编译器只能不停编译每一个方法,缓存起来供运行期选择、调用、内联。如果元素过多,内联缓存压力很大,导致缓存内容不断失效和更新。
  • 这类问题应当在JVM层次上去解决。
  • 因此,JVM层面上提供动态类型的直接支持亟待解决。这就是JDK7时JSR-292提案中invokedynamic指令以及java.lang.invoke包出现的技术背景。

8.4.3 java.lang.invoke包

· 目的:在之前单纯依靠符号引用来确定调用目标方法以外,提供一种新的动态确定目标方法的机制,称为“方法句柄”(类比c/c++中的函数指针)
· 在c/c++中,可以把函数当作参数,用函数指针进行传递。java语言中无法做到,普遍做法是设计一个带有这个函数方法的接口,把接口的实现对象作为参数传递。
· 但拥有“方法句柄”后,Java语言也可以拥有类似函数指针这样的工具了。
· 方法句柄和反射的区别

  1. Reflection和MethodHandle机制本质上都是在模拟方法调用,但前者在模拟Java代码层次的方法调用,而后者在模拟字节码层次的方法调用。
    MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及invokeinterface)和invokespecial这几条指令的执行权限校验行为,而这些底层细节使用Reflection API时是不需要关心的。
    方法的作用:在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
  2. Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。
    · 前者是方法在java端的全面映像,包含了方法的签名、描述符、方法属性表中各种属性的Java端表示方式,还有执行权限等运行期信息。(重量级)
    · 后者仅包含执行该方法的相关信息。(轻量级)
  3. MethodHandle是对方法指令调用的模拟,所以理论上虚拟机在这方面的各种优化在其上都可以采取相同思路去支持。而反射几乎不可能。
  4. 仅站在Java语言的角度,两者的效果相似。而Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计为可服务于所有Java虚拟机之上的语言。

8.4.4 invokedynamic指令

· 某种意义上说,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke”指令方法分派规则完全固化在虚拟机之中的问题。把如何查找目标方法的决定权转交到程序员手上。只是后者是用上层代码和API实现,前者是用字节码和class中其他属性、常量来完成 。
· 每一处含有invokedynamic指令的位置都被称为“*
动态调用点**”。这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是JDK7新加入的CONSTANT_InvokeDynamic_info常量
· 从这个新常量中能够获得:

  1. 引导方法
    · 存放在新增的BootstrapMethods属性中
    · 是固有参数
    · 返回值是java.lang.invoke.CallSite对象,代表了真正要执行的目标方法调用
  2. 方法类型
  3. 方法名称
    · 根据上述信息,虚拟机能找到并执行引导方法,从而获得一个CallSite对象,最终调用到要执行的目标方法上(把对象返回给invokedynamic指令,实现对目标方法的调用)
    · 由于invokedynamic指令面向的主要服务对象是JVM上动态类型语言,因此只靠Javac,在JDK7时甚至没办法生成带有这个指令的字节码。直到JDK8引入Lambda表达式和接口默认方法后,java语言才开始享受到这个指令的好处。
    在这里插入图片描述

Java语言中,如果要访问祖父类:输出 I am grandfather

  1. 在JDK7 Update 9之前,可以使用方法句柄来解决问题。
    在这里插入图片描述
    在JDK7 Update 10开始,修正了这个逻辑,必须保证findSpecial()查找方法版本时受到访问约束(对访问控制的限制、参数类型的限制)应与使用invokespecial指令一样。因为invokespecial指令的分派逻辑是固定的,只能按照接收者的实际类型进行分派,所以修正后,输出也变为了I am father
  2. 在新版本的JDK中,可以通过MethodHandles.Lookup类中allowedModes参数来控制访问保护。虽然这个参数只是在Java类库本身使用,没开放给外部设置,不过我们可以通过反射打破限制。
    在这里插入图片描述

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

许多JVM的执行引擎在执行Java代码时有解释执行(解释器执行)和编译执行(即时编译器产生本地代码执行)两种选择。本节中分析了概念模型下JVM解释执行字节码时,执行引擎是如何工作的。

8.5.1 解释执行

· 大部分程序代码转换成物理机的目标代码或者是虚拟机的执行指令集之前,都需要经过以下各步骤。
在这里插入图片描述

  • 下面的那条分支,就是传统编译原理中,程序代码 -> 目标机器代码的生成过程;中间那条分支,就是解释执行的过程。
  • 在执行前先进行词法分析、语法分析,把源码转化成抽象语法树(AbstractSyntax Tree,AST)。
  • 对任何一门语言实现来说,词法、语法分析以至后面的优化器,目标代码生成器,都可以选择独立于执行引擎,形成一个完整意义的编译器去实现。代表:c/c++
  • 也可以选择把其中一部分步骤(如生成AST前的步骤)实现为一个半独立的编译器。代表:Java
  • 还可以把这些步骤和执行引擎全部集中封装起来,如大多数的JavaScript执行引擎。

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

8.5.2 基于栈的指令集与基于寄存器的指令集

· javac编译器输出的字节码指令流,基本上是基于栈的指令集架构(Instruction Set Architecture,ISA)。
注:“基本上”是因为有部分指令带有参数,而纯粹基于栈的指令集架构应该不存在显示参数(零地址指令)
· 基于栈的指令集架构依赖操作数栈进行工作。
· 基于寄存器的指令集,典型的有x86的二地址指令集,通俗讲就是目前主流PC机中物理硬件直接支持的指令集架构,依赖寄存器工作。
· 两者区别
e.g 计算“1+1”
基于栈的指令集:

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

基于寄存器的指令集:

1
2
MOV EAX, 1
ADD EAX, 1
  • 先把EAX寄存器值设为1,然后再用ADD指令把这个值加1。
  • 这种二地址指令是x86指令集中的主流,每个指令包含两个输入参数,依赖于寄存器来访问、存储数据。

· 两者比较
基于栈的指令集的优点:

  1. 基于栈的指令集主要优点是可移植。因为物理机上的寄存器是由硬件直接提供。即使是虚拟机上,也会希望把寄存器尽可能映射到物理寄存器上,获得更高性能。
  2. 依赖寄存器不可避免会受到硬件的约束。如果使用栈架构的指令集,用户程序不会直接使用到这些寄存器,就可以由虚拟机实现把访问最频繁的数据(程序计数器、栈顶内存)放到寄存器中,以获得更好性能。
  3. 栈架构的指令集代码更紧凑。字节码中每一个字节就代表一条指令,多地址指令集还需要存放参数。
  4. 编译器实现更简单。不需要考虑空间分派问题,所需空间都在栈上操作。
    基于栈的指令集的缺点:
    理论上解释执行状态下的执行速度慢一些。原因:
    1) 栈实现在内存中,频繁的栈访问,意味着频繁的内存访问。而对于处理器来说,内存始终是执行速度的瓶颈。
     尽管虚拟机可以采用栈顶缓存的优化方法,把最常用的操作映射到寄存器中避免直接内存访问,但解决不了本质问题。
    2) 虽然代码紧凑,但是完成相同功能的指令数量会更多。
    如果采用即时编译器,输出成物理机上的汇编指令流,就与虚拟机采用哪种架构无关了。