虚拟机执行字节码

javac编译原理中我们了解了Java源码被javac编译器编译成能被JVM装载的Java字节码的过程。在类文件结构中我们知道了Java程序如何存储于字节码文件中(Class文件),了解了Class文件的基本结构,掌握了阅读字节码的基本工具(javap)和如何去阅读字节码。在类的加载流程类加载器ClassLoader中我们了解了JVM如何加载Class文件。现在,我们将去探讨JVM引擎如何去执行字节码文件。

前言

执行引擎是Java虚拟机的核心部件,用于执行字节码文件。"虚拟机"是相对于"物理机"的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器,硬件,指令集和操作系统层面上的,而虚拟机的执行引擎由自己实现,因此可以自行指定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

栈帧的结构

什么是栈帧?一直以来这个问题获取都在困惑着Java初学者们,我们会在很多场合听说栈帧这个名词,但是我们却不知道它到底是什么,有什么用,怎么用?现在,我们好好去探索一番。

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

 在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定,并且写入到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期间变量数据的影响,而是取决于具体的虚拟机实现。Java虚拟机以栈帧为单位保存线程的运行状态,每当启动一个新线程的时候Java虚拟机都会为该线程分配一个虚拟机栈,Java虚拟机只会对虚拟机栈执行两种操作:以栈帧为单位的入栈和出栈。也就是说栈帧的数量由线程来决定。

 对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与当前栈帧关联的方法为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。下图是栈帧的概念模型:

image

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表建立在线程的堆栈上,是线程私有的数据。在Java源程序编译为Class文件时,会在方法的Code属性的max_locals数据项中确定该方法所需分配的局部变量表的最大容量。如下示例代码:

public class TestClass {
	
	public int inc(int a) {
		int n = 1;
		return n +1;
	}
}

使用 javap -v 打印出字节码内容:

 public int inc(int);
    descriptor: (I)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=2
         0: iconst_1
         1: istore_2
         2: iload_2
         3: iconst_1
         4: iadd
         5: ireturn
      LineNumberTable:
        line 4: 0
        line 5: 2

 根据字节码内容所示,locals=3。为什么是3呢?参数列表个数为1,方法内部定义了一个局部变量,应该是2呀?其实,除了静态方法,每个方法参数列表的第一个参数都是 this 引用,表示调用该方法的类实例的引用,所以局部变量的容量为3。

 局部变量表的容量以**变量槽(Slot)**为最小单位。一个Slot可以存放一个32位以内的数据类型,Java中占用32为以内的数据类型有boolean,byte,char,short,int,float,reference,returnAddress 8种类型。reference类型表示对一个对象实例的引用,虚拟机规范没有明确说明它的长度。一般来说,虚拟机实现至少应当通过这个引用做到两点:

  • 从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引
  • 从此引用中直接或间接地查找到对象所属数据类型在方法区中存储的类型信息

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

下表是基本数据类型的值域:

类型 字节数 值域 默认值 虚拟机内部符号
boolean 4(数组类型:1) {false, true} false Z
byte 1 [-128, 127] 0 B
short 2 [-32768, 32767] 0 S
char 2 [0, 65535] ‘\u0000’ C
int 4 [-2^31, 2^31 - 1] 0 I
long 8 [-2^63, 2^63 - 1] 0L J
float 4 ~[-3.4E38, 3.4E38] +0.0F F
double 8 ~[-1.8E308, 1.8E308] +0.0D D

 为了节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码的PC计数器值已经超出了某个变量的作用域,那么该变量对应的Slot就可以交给其他变量使用。但是,Slot的复用可能会影响到垃圾收集行为。

示例代码:

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

 在上面的代码中,建立了一个byte数组,向内存中填充了64M的数据,然后通知虚拟机进行垃圾收集,我们看到placeholder的作用域是代码块之中,也就是说正常情况,出了代码块之后placeholder已经不可能再被访问了,应该被垃圾回收器回收。我们在虚拟机运行参数中加上“-verbose:gc”来查看垃圾收集的过程:

[GC (System.gc())  68405K->66352K(110080K), 0.0047257 secs]
[Full GC (System.gc())  66352K->66207K(110080K), 0.0140048 secs]

 观察结果我们会发现内存并没有被回收,现在我们修改代码,再观察垃圾收集过程:

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

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

垃圾收集结果如下:

[GC (System.gc())  68405K->66320K(110080K), 0.0038947 secs]
[Full GC (System.gc())  66320K->671K(110080K), 0.0163487 secs]

 观察结果我们发现,内存已经被回收了。其实,placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。在示例代码中,代码虽然已经离开了placeholder的作用域,但在此之后没有任何局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以GC Roots一部分的局部变量表仍然保持着对它的关联,由于这种关联没有被打断,所以垃圾收集器没有对齐关联的内存进行回收。如下就是进行修改后为什么内存被回收的原因:

  • 修改一:在placeholder的作用域后面对局部变量进行读写操作,使其释放占用的Slot
  • 修改二:将placeholder赋值为null,把变量对应的局部变量表的Slot清空

 我们知道类变量(实例变量和静态变量)具有两次赋初始值的过程:第一次是在准备阶段赋予系统初始值;第二次是在初始化阶段赋予程序员定义的初始值。因此即使程序员在初始化阶段没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量不一样,如果一个局部变量定义了但没有赋初始值是无法使用的。

操作数栈

 操作数栈也称为操作栈,是一个后入先出栈。与局部变量表相同,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值。如下是前面示例代码的字节码,其中 stack=2 就是栈的深度:

 public int inc(int);
    descriptor: (I)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=2
         0: iconst_1
         1: istore_2
         2: iload_2
         3: iconst_1
         4: iadd
         5: ireturn
      LineNumberTable:
        line 4: 0
        line 5: 2

 当一个方法刚开始执行的时候,该方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,这就是入栈和出栈操作。 例如:当执行整数加法的字节码指令iadd时,会将操作数栈中最接近栈顶的两个元素出栈并相加,然后将结果存入栈中。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,例如:以iadd指令为例,该指令用于整数加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int类型,不能出现类型不匹配的情况。Java虚拟机的解释执行引擎被称为基于栈的执行引擎,其中栈就是指操作数栈

动态连接和方法返回地址

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

 当一个方法执行时,有两种方式可以退出这个方法:第一种是执行引擎遇到了任意一个方法返回的字节码指令,这时候会有方法返回值返回给调用者;第二种是在方法执行中遇到了异常,并且该异常没有在方法体中得到处理,则会导致方法退出。无论是何种方式退出,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时需要在栈帧中保存一些信息,用来帮助上层方法恢复它的执行状态。

 一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能保存这个计数值。而方法异常退出时,返回地址是通过异常处理器来确定的,栈帧中一般不会保存这部分信息。方法退出实际上就是把当前栈帧出栈,因此方法退出时执行的操作为:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值压入调用者栈帧的操作数栈中
  • 调用PC计数器的值以指向方法调用指令后面的一条指令

方法调用

 方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,并不涉及方法内部的具体运行过程。Class文件的编译过程中不包含传统编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用(符号引用:一种标记,并非实际的内存入口地址,需要解析为直接引用才能分配内存空间),而不是方法在实际运行时内存布局的入口地址(直接引用)。

解析

 所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在城西真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。也就是说,调用目标在程序代码写好,编译器进行编译时就必须确定下来,这类方法的调用才称为解析。在Java语言中,符合“编译期可知,运行期不可变”要求的方法,主要包括静态方法私有方法两大类,前者与类型关联,后者在外部不可访问,这两种方法各自的特点决定了它们不可能通过继承或其他方式重写其他版本,因此都适合在类加载阶段进行解析:

  • invokestatic:调用静态方法

  • invokespecial:调用实例构造器方法,私有方法和父类方法

  • invokevirtual:调用所有虚方法(实例方法)

  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象

  • invokedynamic:调用动态方法

 只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器,父类方法4类,它们在类加载的时候会把符号引用解析为该方法的直接引用,这些方法称为非虚方法。其中还有一种方法比较特殊,final方法虽然是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以该方法是一个非虚方法。

分派

 解析调用一定是一个静态过程,在编译期就能完全确定下来,在类装载的解析阶段就会把涉及的符号引用全部转化成可却定的直接引用(内存布局),不会延迟到运行期去完成。分派调用则可能是静态的也可能是动态的,根据**宗量数(方法的调用者和方法的参数统称为宗量)**可分为单分派和多分派。

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, Man!");
    }

    public void sayHello(Woman guy) {
        System.out.println("Hello, Woman!");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();

        StaticDispatch st = new StaticDispatch();
        st.sayHello(man);
        st.sayHello(woman);
    }
}

//结果如下:
Hello, guy!
Hello, guy!

结果显而易见,这其实就是考察对重载的理解程度。现在我们来分析一下:

Human man = new Man()

 Human 称为静态类型,Man 称为实际类型。它俩的区别就是:静态类型在编译期是可知的,且在编译期已经确定;实际类型变化的结果需要在运行期才可以确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。虚拟机在重载时是通过参数的静态类型而不是实际类型作为判断依据的。并且静态类型是编译期可知的,因此在编译阶段javac编译器会根据参数的静态类型决定使用哪个重载版本

 所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上并不是由虚拟机完成的,而是编译器完成的。另外,编译器虽然能够确定方法的重载版本,但在很多情况下这个重载版本却不是唯一的,往往只能确定一个更加合适的版本,下面是示例代码:

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(float arg) {
        System.out.println("hello float!");
    }

    public static void sayHello(double arg) {
        System.out.println("hello double!");
    }

    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(int... arg) {
        System.out.println("hello int....");
    }

    public static void sayHello(Serializable arg) {
        System.out.println("hello serializable!");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}

 毫无疑问,代码运行结果是:hello char!。但是如果我们依次将 sayHello() 方法进行注释,我们将会发现结果如下:

//注释sayHello(char arg)
输出:hello int!

//注释sayHello(int arg)
输出:hello long!

//注释sayHello(long arg)
输出:hello float!

//注释sayHello(float arg)
输出:hello double!

//注释sayHello(double arg)
输出:hello Character!

//注释sayHello(Character arg)
输出:hello serializable!

//注释sayHello(Serializable arg)
输出:hello Object!

//注释sayHello(Object arg)
输出:hello char....

//注释sayHello(char... arg)
输出:hello int....

其实通过结果我们将大体明白编译器选择重载版本的规律:

  • 先找到匹配的数据类型,如果找不到就一次上转为其他基本数据类型,按照 char->int->long->float->double 进行匹配(不会匹配short和byte的重载,因为转型不安全)。

  • 如果没有基本数据类性进行匹配,它会将其自动装箱去匹配它的包装类型

  • 如果无法匹配到包装类型,将会去匹配包装类型的接口类型或父类型(Character实现了Serializable接口)

  • 如果无法匹配到包装类型的接口类型或父类型,将会去匹配 java.lang.Object 类型

  • 如果还是无法匹配到,将去匹配它的变长参数类型(依次匹配:char->int->long->float->double),可见变长参数类型的优先级是最低的

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!

 如果对Java里面的多态有所了解的话,结果也是不言而喻的。请看下面的部分字节码:

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1        //将Man对象的引用压入操作数栈
        17: invokevirtual #6                  // Method DynamicDispatch$Human.sayHello:()V
        20: aload_2        //将Woman对象的引用压入操作数栈
        21: invokevirtual #6                  // Method DynamicDispatch$Human.sayHello:()V
        24: new           #4                  // class DynamicDispatch$Woman
        27: dup
        28: invokespecial #5                  // Method DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #6                  // Method DynamicDispatch$Human.sayHello:()V
        36: return

 0 ~ 15行的字节码为准备动作,作用是建立man和woman的内存空间,调用Man和Woman类型的实例构造器,将两个实例的引用存放在第1,2个局部变量表Slot中。接下来得 16 ~ 21 句才是关键部分,16,20两句分别将刚刚创建好的两个对象的引用压入到栈顶。17,21句是调用指令。现在来说一下 invokevirtual 指令的多态查找过程:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C

  2. 如果在类型C中找到与常量中描述的描述符和简单名称都相符合的方法,则进行访问权限的校验,如果通过则返回这个方法的直接引用,查找结束;如果不通过,则返回 java.lang,IllegalAccessError 异常

  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程

  4. 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常

 由于 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令把常量池中的类方法符号引用解析到不同的直接引用上,这个过程就是Java语言中方法重写的本质在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

Java是解释执行吗?

 Java语言经常被人们定义为解释执行的语言,在Java诞生之初这种定义还是比较准确的,但是当主流的虚拟机都包含了即时编译器之后,Class文件中的代码到底会被解释执行还是编译执行,已经成了虚拟机自己才能准确判断的事情。

 从硬件视角来看,Java字节码无法直接执行,因此Java虚拟机需要将字节码翻译成机器码。在HotSpot里面,上述翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。

image

解释执行的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,然后将其中反复执行的热点代码以方法为单位进行即时编译。

总结

 从一段Java源程序到能够被计算机底层识别使用的机器代码,其中需要经历的复杂过程是很难想象的。或许这就是为什么有那么多Java程序员孜孜以求,不知疲倦的去探索Java虚拟机,矢志要弄懂Java语言的底层细节的原因:因为曲折,所以唯美。总的来说,探索Java虚拟机的过程是一种令人愉悦的过程,现在的我初窥门径,往后我也将保持对Java最执着的热爱,不断去探索与发现。下图是自己总结的一张 Java源程序到计算机底层指令 的过程图:

image

参考

《深入理解Java虚拟机》

猜你喜欢

转载自blog.csdn.net/king123456man/article/details/82911685