深入理解JVM虚拟机——8. 虚拟机字节码执行引擎

8.1 概述

执行引擎是Java虚拟机用来执行字节码的,也是这章所讲内容,物理机的执行引擎是直接建立在物理资源层面上的,而虚拟机的执行引擎是自己实现的。

8.2 运行时栈帧结构

栈帧(Stack Frame) 是用于方法调用和执行的数据结构,它是运行时数据区的虚拟机栈的栈元素。栈帧存储了方法的各种信息。每一个方法从调用到执行完成都对应一个栈帧在虚拟机入栈出栈的过程。

对于执行引擎来说,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),相对应的方法叫做当前方法(Current Method)。这也意味着执行引擎所有的字节码指令只针对当前栈帧操作。

image

接下来讲栈帧存储的方法的各个信息的作用和数据结构。

8.2.1 局部变量表

局部变量表(Local Variable Table)是用于存放方法参数和方法内部的局部变量的一组变量值存储空间。

局部变量的容量以变量槽(Variable Slot)为最小单位,一个Slot可以存放一个32位以内的数据类型,虚拟机通过索引定位的方式使用局部变量表,索引值范围从0开始至局部变量表最大的Slot数。如果访问的是32位数据类型的变量,索引n就代表了第n个Slot。

为了节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那这个变量对应的Slot可以交给其他变量使用。但是这样会带来一些副作用。

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

这段代码向内存填充了64m的数据,然后让虚拟机回收。根据书中的结果并没有回收这64m的内存。
这是因为在执行System.gc的时候变量还处在作用域之内,所以虚拟机不敢回收。

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

加上花括号以后,变量的作用域限制在了花括号以内,但是结果是依然没有回收。

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

加上int a = 0这行奇怪的代码之后,内存可以正常回收了。

是否回收的根本原因是:局部变量表中的Slot是否还存有关于对象的引用。第一次修改时,代码虽然离开了作用域,但是没有对局部变量表进行任何读写操作,placeholder所占有的Slot还没有被其他变量所复用,所以关联依然存在。第二次更改添加的代码就是打断这个关联。

局部变量表不像类变量有准备阶段和初始化阶段进行赋值。所以局部变量定义了但没有赋初值是不能使用的,不过编译器能在编译期间检查并提示这一点。

8.2.2 操作数栈

操作数栈的最大深度也在编译的时候写入到code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的数据类型,32位栈容量为1,64位为2。

方法的执行过程需要不断地出栈/入栈,例如iadd指令,会将最接近栈顶的两个int型的数值出栈并相加,然后将结果入栈,也就是说执行时,最接近栈顶的两个元素必须是int型,这就需要保证数据类型必须与字节码指令的序列严格匹配。

另外虽然在概念模型里两个栈帧是相互独立的,但在虚拟机中会让两个栈帧出现一部分重叠。

image

8.2.3 动态连接

每个栈帧都包含一个指向运行时常量池中栈帧所属方法的引用,持有这个引用是为了支持**动态链接(Dynamic Linking)。

  • 静态解析: 一部分符号引用会在类加载阶段或者第一次使用时就转化为引用。
  • 动态连接: 另一部分在每一次运行期间转化为直接引用。
8.2.4 方法返回地址

当一个方法开始执行时,只有两种方式可以退出这个方法。

  • 正常完成出口(Normal Method Invocation Completion): 执行引擎遇到任意一个方法返回的字节码指令,是否有返回值或返回类型将根据方法返回的指令种类决定。
  • 异常完成出口(Abrupt Method Invocation Completion): 方法执行过程当中遇到了异常,并且这个异常没有得到处理就会导致方法退出。

方法退出后需要返回到方法调用的位置,方法正常退出时,调用者的PC计数值可以作为返回地址,而异常退出时需要通过异常处理器来确定。


8.3 方法调用

方法调用阶段的唯一任务是确定被调用方法的版本,暂时与方法内部具体运行无关。

8.3.1 解析

类加载的解析阶段会将其中一部分的符号转换为直接引用,前提是调用目标在程序代码写好、编译器进行编译时就必须额确定下来,这类方法调用称为解析。

静态方法与类型直接关联,私有方法在外部不能访问,这两种特性让它们不能重写为其他版本,因此它们都适合在类加载阶段解析。

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

静态方法、私有方法、实例构造器、父类方法,只有这四类可以在编译阶段确定版本称为非虚方法,其他方法是虚方法

8.3.2 分派

本节介绍虚拟机如何实现Java的多态的,例如重写重载

  1. 静态分派
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!"。
Hunman man = new Man();
上面代码的Human称为变量的静态类型(Static Type),后面的Man称为变量的实际类型(Actual Type),静态类型的变化仅仅在使用时发生,最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期才可确定。

虚拟机在重载时是通过参数的静态类型作为判定依据的。并且静态类型是编译期可知的。因此重载版本是根据静态类型决定的,这种分派动作就叫做静态分派

  1. 动态分派
    还是用上述的例子
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

这里显然不是根据静态类型来决定的了,而导致输出不同的原因是实际类型不同。

根据字节码角度来看方法调用指令是完全一样的,导致不同的是因为invokevirtual指令的多态查找过程,而这个过程的第一步就是确定元素所指向的对象的实际类型,所以两个一样的指令实际上是把类方法符号解析到了不同的直接引用上。
这种根据实际类型确定的执行方法版本的分派叫做动态分派

  1. 单分派和多分派
    方法的接收者与方法的参数称为方法的宗量,根据分派基于多少种宗量可以将分派划分为单分派多分派两种。
    选择目标时根据两个宗量以上的属于多分派类型,反之只有一个宗量作为依据的就是单分派类型。
    最后可以总结一句话,目前的Java是一门静态多分派、动态单分派的语言。
  2. 虚拟机动态分派的实现

动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的方法目标,虚拟机会为这种频繁的搜索进行优化

在方法区中建立一个虚方法表,使用虚方法表索引来代替查找以提高性能。

image

8.3.3 动态类型语言支持
  1. 动态类型语言
    动态类型语言是指类型检查的主体过程在运行期而不是编译期,相对的编译期就进行类型检查的语言(C++/Java)就是静态类型语言。举个例子
public static void main(String[] args){
    int[][] array = new int[1][0][-1];
}

这段代码可以正常编译但会报出一个运行时异常。

int main(void){ 
    int i[1][0][-1];//GCC拒绝编译,报“size of array is negative”
    return 0;
}

c语言中,含义相同的代码会在编译器就报错。
2. JDK1.7与动态类型

Java虚拟机做了扩展以便虚拟机同时达到静态类型语言的严谨性与动态语言的灵活性

1.7以前的4条调用方法的指令的第一个参数都是被调用方法的符号引用,而方法的符号引用在编译期产生,动态类型语言需要在运行期才能确定接收者类型。
所以实现动态语言类型就需要其他方式,由于复杂度和内存开销的问题使得实现在虚拟机层面上较为合适,所以JDK1.7出现了invokedynamic指令以及java.lang.invoke包。
3. java.lang.invoke包

这个包的主要目的是提供一种新的动态确定目标方法的机制,称为MethodHandle

  1. invokedynamic指令

这个指令与invoke包的作用类似,是为了解决原有4条指令分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权转嫁到用户代码中。

  1. 掌握方法分派规则

这里举出一个例子说明掌握分派规则可以具体干什么。

我们使用super关键字可以很方便的调用到父类的方法,但是调用祖类的方法显得没有那么轻松。

import staticjava.lang.invoke.MethodHandles.lokup; 
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
class Test{ 
    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(){ 
            try{ 
                MethodType mt=MethodType.methodType(void.class);
                MethodHandle mh=lookup().findSpecial(GrandFather.class"thinking",mt,getClass());
                mh.invoke(this);
            }
            catch(Throwable e){ 
            } 
        } 
    } 
    public static void main(String[]args){ 
        (new Test).new Son()).thinking(); 
    } 
}

运行结果

i am grandfather


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

这章讲解虚拟机如何执行字节码指令,以及在解释执行时,虚拟机执行引擎是如何工作的。

8.4.1 解释执行

Java虚拟机的执行引擎在执行代码时都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)。

Java虚拟机大多会遵循基于现代经典编译原理的思路,在执行前对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。Java语言选择把其中一部分步骤实现为一个半独立的编译器。

image

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

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

之前有用过iadd指令作为例子讲过基于栈的指令集是基于操作数栈的,所有的指令都是入栈/出栈的过程。而相对的是基于寄存器的指令集(汇编语言)。

基于栈的指令集的主要优势在于可移植,基于寄存器的话会不可避免的收到硬件的约束。而栈结构可以由虚拟机自行决定把一些访问最频繁的数据放到寄存器中以获得更好的性能。

而缺点就是执行速度相对较慢,毕竟直接访问寄存器是最快的。

8.4.3 基于栈的解释器执行过程

书中给出了一个详细的例子

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 
}

image

image

image

image

image

image

image

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值