自娱自乐,浅析执行引擎如何运作

8.1 概述

从外观看,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

8.2 运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。

每一个栈帧都包括了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

在这里插入图片描述

8.2.1 局部变量表

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

局部变量表的容量以变量槽(Variable Slot)为最小单位,每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据(即32位以内,对于64位的数据类型(long、double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间)。

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

虚拟机通过索引定位的方法使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot;如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot,对于这种情况,不允许采用任何方法单独访问其中的某一个,如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。

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

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

如下代码,当执行System.gc()我们想当然以为虚拟机会回收这个局部变量的内存,然而作为GC Roots一部分的局部变量表仍然保持对它的关联,所以无法回收,只有将不再使用的对象置为null,或者其他变量占用了它的Slot才能回收。

public class TestLocalVariableTable {
	public static void main(String[] args) {
		byte[] placeholder = new byte[64*1024*1024];
		System.gc();//在执行System.gc()时,变量placeholder还在作用域中,虚拟机自然不会回收placeholder内存。
	}
}

修改一下:

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

仍然没有用,虽然代码离开了placeholder的作用域,但是placeholder原本占用的局部变量表的Slot还没有被其他变量复用,所以作为GC Roots一部分的局部变量表仍然保持对它的关联,所以虚拟机以为placeholder还有用,并没有进行回收。除非,人为手动的将placeholder设置为null。在《pratical java》中把“不使用的对象手动赋值为null”作为一条推荐的编码规则,但是没有解释其中具体的原因,一般读者是理解不了的。但是不建议对所有的对象都这个处理,没有必要的地方不需要有这么多的类似代码。

public class TestLocalVariableTable {
	public static void main(String[] args) {
		{
		byte[] placeholder = new byte[64*1024*1024];
		placeholder = null;//手动置为null
		}
		System.gc();
	}
}
public class TestLocalVariableTable {
	public static void main(String[] args) {
		{
		   byte[] placeholder = new byte[64*1024*1024];		
		}
		int a = 0;//占用placeholder在原有局部变量表中的Slot
		System.gc();
	}
}

赋null值的操作可以用,但是没必要:从编码角度来讲,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法。更关键的是,从执行角度讲,使用赋null值的操作来优化内存回收是建立在堆字节码执行引擎概念模型的理解之上的。虚拟机使用解释器执行时,通常与概念模型还是比较接近的,但经过JIT编译器后,才是虚拟机执行代码的主要方式,赋null值的操作在经过JIT编译优化后就会被消除掉,这个时候将变量设置为null就有没有意义。字节码被编译为本地代码后,对GC Roots的枚举也与解释执行时期有巨大差别,事实上下面代码经过编译后,System.gc()执行时就可以正确地回收掉内存,无须再修改成后面的 样子。

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

关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量不一样,如果一个局部变量定义了但是没有赋初始值是不能使用的,不要认为Java中任何情况下都存在默认零值。

8.2.2 操作数栈

操作数栈一个后进先出的栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值。

当一个方法刚刚开始执行时,它的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈写入和提取内容,也就是出栈/入栈操作。例如在做算术运算时是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数字,当执行这个指令时,会将这两个int值出栈并相加,然后将结果入栈。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证。

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全互相独立的。但是大多数虚拟机都会做一些优化,令两个栈帧出现一部分重叠,这样进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
在这里插入图片描述
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中栈就是操作数栈。

8.2.3 动态链接

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

8.2.4 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法,正常情况下,遇到任何一个方法返回指令(如return指令)退出,有返回值会传递给方法的调用者,这种退出方式称为正常完成出口

还有就是异常完成出口,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理。

无论哪种,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上次方法的执行状态。

正常情况:一般情况下,调用者的PC计数器的值可以作为返回地址,栈帧可能会保存这个计数器值。

异常情况:方法不会返回任何值,返回地址有异常处理器表来确定,栈帧一般不存储信息。

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

8.3 方法调用

方法调用并不等同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。

8.3.1 解析

所有方法调用中的目标方法在Class文件中都是一个常量池的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。这类方法的调用称为解析。

符合“编译期可知,运行期不可变”的方法主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,因此它们都适合在类加载阶段进行解析。

在Java虚拟机里提供了5条方法调用字节码指令:

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

只要能被invokestatic和invokeespecial指令调用的方法,就可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法称为非虚方法,除此和final方法之外,其他的方法称为虚方法。

虽然fianl方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也是非虚方法。

解析调用一定是一个静态过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用。

8.3.2 分派

“重载”和“重写”在虚拟机中是如何实现的呢?

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 dispatch = new StaticDispatch();
        dispatch.sayHello(man);
        dispatch.sayHello(woman);
        /*
            如果强转了以后,类型也跟着变化了。
            静态分配的典型应用是方法重载。但是方法重载有时候不是唯一的,所以只能选合适的。
            dispatch.sayHello((Man)man);
            dispatch.sayHello((Woman)woman);
        */
    }
}

运行结果:

hello,guy!
hello,guy!

我们把“Human”称为变量的静态类型(Static Type)或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都会变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可以确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

例如下面的代码:

//实际类型变化
Human man = new Man();
Human woman = new Women();
//静态类型变化
dispatch.sayHello((Man)man);
dispatch.sayHello((Woman)woman);

回到之前的代码,main()里面的两次sayHello()方法调用,在方法接受者已经确定对象dispatch的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。代码中的变量man和women静态类型相同,但是实际类型不同,虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此在编译阶段,Java编译器会根据参数的静态类型决定使用哪个重载版本,所以选定了sayHello(Human)作为调用目标,并把方法的符号引用写到main()方法里的两条invokevirtual指令参数中。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是虚拟机来执行的。另外,编译器只能确定一个“更加适合”的方法版本,而不是唯一的版本,因为字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

如下代码演示了何为“更加适合”:

import java.io.Serializable;

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

运行结果:

hello char

很好理解,'a’是一个char类型的数据,自然会寻找参数类型为char的重载方法。

但是如果注释掉sayHello(char arg)方法呢?

hello int

这里发生了一次自动类型转换,'a’除了可以代表一个字符,还可以代表数字97。

那么继续注释掉sayHello(int arg)呢?

hello long

这时发生了两次自动类型转换,‘a’–>97–>97L。自动转型是按照char–>int–>long–>float–>double的顺序进行的,但是不会转为byte和short,因为char到byte或short的转型是不安全的。

假如继续注释掉sayHello(long arg)

hello Character

这里发生自动装箱

继续注释掉sayHello(Character arg)

hello Serializable

因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱后发现还是找不到相应的重载方法,但是找到了装箱类实现的接口类型,索引又发生一次自动转型,char可以转为int,但是Character绝不能转为Integer,只能安全地转为它实现的接口或父类。

继续注释

hello Object

这时char装箱后转型为父类,如果有多个父类,将在继承关系中从下往上开始搜索,越接近上层优先级越低。即时方法调用传入的是null,这个规则仍然适用。

继续注释

hello char ...

可见变长参数的优先级是最低的,这时候’a’被当成一个数组。

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

2.动态分派

动态分派和重写有密切的关联。

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

分析一下字节码

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

这两句会建立man和woman的内存空间,调用Man和Woman的实例构造器,将这两个实例的引用存放在第1、2个局部变量表Slot中。

接下来会把这两个对象的引用压入到操作数栈栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者,虽然这两个对象的方法调用指令是一样的(指令都是invokevirtual,参数都是Human.sayHello()的符号引用),但是结果却完全不一样。

这跟invokevirtual指令有关,该指令执行有以下步骤:
①找到栈顶第一个元素指向对象的实际类型,记作类型C;
②如果在类型C中找到与常量描述符和简单名称都相符的方法,并进行权限校验,如果通过则返回这个方法的直接引用,如果不通过则抛出访问非法异常。
③如果没找到,则按照继承关系从下往上依次对C的父类进行搜索。
④如果没找到,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接受者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java方法重写的本质。

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

3.单分派和多分派

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

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

看下面的代码:

public class Dispatch {
    static class QQ {
    }

    static class _360 {
    }

    public static class Father {
        public void hardChoice(QQ qq) {
            System.out.println("Father choose QQ");
        }

        public void hardChoice(_360 aa) {
            System.out.println("Father choose 360");
        }
    }

    public static class Son extends Father {
        public void hardChoice(QQ qq) {
            System.out.println("Son choose QQ");
        }

        public void hardChoice(_360 aa) {
            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)的符号引用。因为是根据两个宗量进行选择的,所以静态分派属于多分派类型。

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

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

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

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

8.4.1 解释执行

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

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

Java编译器输出的指令流基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。

与之相对的是基于寄存器的指令集,最典型的就是x86的二地址指令集,也就是主流PC机中直接支持的指令集架构。

基于栈的指令集计算“1+1”的结果。

iconst_1
iconst_1
iadd
istore_0

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

基于栈的指令集主要的有点是可移植,寄存器有硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地受到硬件的约束。如果使用栈架构的指令集,就可以由虚拟机来实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有其他一些优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

栈架构指令集的主要缺点是执行速度相对慢一点。

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

利用下面的代码来看看在虚拟机中实际是如何执行的:

public class Cal{
	public static void main(String [] args){
		foo();
	}
	
	public static int foo(){
		int a = 1;
         int b = 2;
         int c = (a + b) * 5;
         return c;
	}
}

使用javap -v命令来看看它的字节码指令:

public static int calc();
    descriptor: ()I
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=0
         0: iconst_1//把操作数压入操作数栈
         1: istore_0//将操作数栈顶元素弹出保存至局部变量表中
         2: iconst_2
         3: istore_1
         4: iload_0
         5: iload_1
         6: iadd
         7: iconst_5
         8: imul
         9: istore_2
        10: iload_2
        11: ireturn

在这里插入图片描述

上图中的foo()代码是class指令,存在方法区内。局部变量表的大小和操作数栈的栈深都是在编译时就已经确定分配好的(此时两者都是空的),运行过程中再根据每条字节码指令把值放入局部变量表和操作数栈。

假如进行的是i++自增操作,那么是直接在局部变量表的Slot完成,不需要到操作数栈去。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值