JVM学习笔记(四)—— 虚拟机字节码执行引擎

虚拟机字节码执行引擎

  • 1. 概述
  • 2. 运行时栈帧结构
    • 2.1 局部变量表
    • 2.2 操作数栈
    • 2.3 动态连接
    • 2.4 方法返回地址
  • 3. 方法调用
    • 3.1 解析
    • 3.2 分派
      • 3.2.1 静态分派(方法重载的本质)
      • 3.2.2 动态分派(方法重写的本质)
      • 3.2.3 单分派与多分派
      • 3.2.4 虚拟机动态分派的实现
  • 4. 动态类型语言支持
    • 4.1 动态类型语言
    • 4.2 Java与动态类型
    • 4.3 java.lang.invoke包
    • 4.4 invokedynamic 指令
    • 4.5 实战:掌控方法分派规则
  • 5. 基于栈的字节码解释执行引擎
    • 5.1 解释执行
    • 5.2 基于栈的指令集与基于寄存器的指令集
    • 5.3 基于栈的解释器执行过程

1. 概述

物理机与虚拟机的区别:物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。但从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。

2. 运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VrwPCfpu-1609652589251)(/Users/guisc/Documents/JVM/图片/栈帧的概念结构.png)]

2.1 局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。方法表的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

局部变量表的容量以变量槽为最小单位,每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与32位虚拟机中的一致

reference类型表示对一个对象实例的引用,没有说明它的长度,也没有明确指出这种引用应有的怎样的结构。但是一般来说,虚拟机实现至少都应当能通过这个引用做到两件事情,一是根据引用直接或间接查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息(Java能提供反射的原因)。

对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。不过由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。而且对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,如果出现了,会在类加载的检验阶段抛出异常。

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

局部变量表中的变量槽是可以重用的,方法体中定义的某些变量,其作用域不一定覆盖整个方法体,如果当前字节码PC计数器的值已经操过了某个变量的作用域,那该变量占用的变量槽就可以交给其他变量来重用了。不过,这样做会带来一些副作用,在某些情况下会直接影响到系统的垃圾收集行为。

// placeholder的内存没有被回收
public static void main(String[] args) {
     
  {
     
    byte[] placeholder = new byte[64*1024*1024];
  }
  System.gc();
}

// placeholder的内存被回收了
public static void main(String[] args) {
     
  {
     
    byte[] placeholder = new byte[64*1024*1024];
  }
  int a = 0;
  System.gc();
}

原因分析:placeholder能否被回收的根本原因在于局部变量表中的变量槽是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder原本占用的变量槽还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。

2.2 操作数栈

操作数栈的最大深度在编译阶段也被写入到方法表的Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型,32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。

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

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。

2.3 动态连接

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

2.4 方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”。另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理(只要在本方法的异常表中没有搜索到匹配的异常处理器),就会导致方法退出,这种退出方法的方式称为“异常调用完成”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

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

3. 方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。

Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

3.1 解析

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

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

5条不同的方法调用字节码指令,分别是:

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

前四条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。

只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言中符合这个条件的方法共有静态方法私有方法实例构造器父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用,但它无法被覆盖,无其他版本的可能),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法被称为“非虚方法”,其他方法就被称为“虚方法”。

3.2 分派

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

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 sd = new StaticDispatch();
    sd.sayHello(man);
    sd.sayHell0(woman);
  }
}

// 程序输出结果(这里是重载,而不是多态)
// hello,guy!
// hello,guy!
// 在这里,两个sayHello()方法的接受者是sd,然后选择它的重载方法,选择哪个重载版本,取决于传入参数的数量和数据类型(这里的数据类型是指的是参数的静态类型),man和woman的静态类型都是Human,所以选择的重载版本是sayHello(Human guy)。

Human man = new Man();

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

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

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

虚拟机(更准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了使用哪个重载版本。

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派最典型的应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

编译期间选择静态分派目标的过程,实际上是Java语言实现方法重载的本质。

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(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');
  }
}
/* 
	程序输出结果:
	'a'是一个char类型的数据,首先肯定会先寻找参数类型为char的重载方法 char arg,如果注释这个方法,
	则会找 int arg ,此时发生了一次自动类型转换,'a'转换成数字97,
	接下来 long arg,此时发生了两次自动类型转换,转换成int后再转换成long,
	(char>int>long>float>double的顺序转型进行匹配,但不会匹配到byte和short)
	接下来 Character arg,发生了一次自动装箱,
	接下来 Serializable arg,java.lang.Serializable是java.lang.Character类实现的一个接口,
	(char可以转型成int,但是Character是绝对不可能转型为Integer的)
	接下来 Object arg,char装箱后转型为父类了,
	最后是 char... arg,可变长参数的重载优先级是最低的
*/

静态方法会在编译期确定、在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

3.2.2 动态分派(方法重写的本质)

public class DynamicDispatch {
     
  static abstract Human {
     
    protected abstract void sayHello();
  }
  
  static class Man extends Human {
     
    @Override
    protected void sayHello() {
     
      System.out.println("man say hello");
    }
  }
  
  statci 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指令,是如何确定调用方法版本、如何实现多态查找的?

invokevirtual指令的运行时解析过程大致分为以下几步:

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

正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。

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

字段不参与多态,因为字段不使用invokevirtual指令。

// 字段不参与多态
public class FieldHasNoPolymorphic {
     
  static class Father {
     
    public int money = 1;
    public Father {
     
      money = 2;
      showMeTheMoney();
    }
    public void showMeTheMoney() {
     
      System.out.println("I am Father, i have $" + money);
    }
  }
  
  static class Son extends Father {
     
    public int money = 3;
    public Son {
     
      money = 4;
      showMeTheMoney();
    }
    public void showMeTheMoney() {
     
      System.out.println("I am Son, i have $" + money);
    }
  }
  
  public static void main(String[] args) {
     
    Father guy = new Son();
    System.out.println("This guy has $" + guy.money);		// guy.money结果是2。字段时,看变量的静态类型。
  }
}
/*
	程序输出结果:
	I am Son, i have $0
	I am Son, i have $4
	This guy has $2
*/

Son类在创建的时候,首先隐式调用了Father的构造函数,而Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是Son::showMeTheMoney()方法,所以输出“I am Son”,但这时候虽然父类的money字段已经被初始化成2了,但Son::showMeTheMoney()方法中访问的却是子类的money字段,这时候自然还是0,因为它要到子类的构造函数执行时才会被初始化。main()的最后一句通过静态类型访问到了父类中的money,输出了2。

3.2.3 单分派与多分派

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

// 单分派、多分派
public class Dispatch {
     
  static class QQ {
     }
  static class _360 {
     }
  
  public static class Father {
     
    public void hardChoice(QQ arg) {
     
      System.out.println("father choose qq");
    }
    
    public void hardChoice(_360 arg) {
     
      System.out.println("father choose 360");
    }
  }
  
  public static class Son extends Father {
     
    public void hardChoice(QQ arg) {
     
      System.out.println("son choose qq");
    }
    
    public void hardChoice(_360 arg) {
     
      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
*/

原因分析:

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

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

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

3.2.4 虚拟机动态分派的实现

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。优化手段:为类型在方法区中建立一个虚方法表(Virtual Method Table,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table),使用虚方法表索引来代替元数据查找以提高性能。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XINYg4nO-1609652589253)(/Users/guisc/Documents/JVM/图片/虚方法表结构.png)]

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

虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

4. 动态类型语言支持

4.1 动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是在编译器进行的,满足这个特征的语言有:Groovy、JavaScript、Lua、PHP、Python、Ruby等等。在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言

Java能够做到类型检查的原因:Java在编译期间已将方法完整的符号引用生成出来,并作为方法调用指令的参数存储到Class文件中。如:invokevirtual #4; //Method java/io/PrintStream.println;(Ljava/lang/String;) V 这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。而动态类型语言的变量本身并没有类型,变量的值才具有类型,所以编译器在编译时最多只能确定方法名称、参数、返回值等信息,而不会去确定方法所在的具体类型。“变量无类型而变量值才有类型”是动态类型语言的一个核心特征。

静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题在编码时就能及时发现。而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性。

4.2 Java与动态类型

4.3 java.lang.invoke包

JDK 7时加入的java.lang.invoke包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”。

方法句柄类似于C/C++中的函数指针,比如用函数指针来把谓词传递到排序方法中,void sort(int list[], const int size, int (*compare)(int,int)) 但在Java中没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数,如:void sort(List list, Comparator c)。

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
// 方法句柄演示
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 mt = MethodType.methodType(void.class, String.class);
    return lookup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver);
  }
}

有了MethodHandle就可以写类似C/C++那样的函数声明了:void sort(List list, MethodHandle compare)

4.4 invokedynamic 指令

invokedynamic指令是为了解决原有4条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户有更高的自由度。

每一处含有invokedynamic指令的位置都被称为“动态调用点”。

4.5 实战:掌控方法分派规则

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

5.1 解释执行

当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事。再后来,Java业发展出可以直接生成本地代码的编译器(如Jaotc、GCJ、Excelsior JET)。所以,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式,谈解释执行还是编译执行才会比较合理确切。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cdpDnzLx-1609652589254)(/Users/guisc/Documents/JVM/图片/编译过程.png)]

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

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

Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构(ISA),字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作,与之相对的就是基于寄存器的指令集。

分别使用这两种指令集去计算“1+1”的结果,

// 1、基于栈的指令集会是这样的:
iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加 ,然后把结果放回栈顶 ,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。
  
// 2、基于寄存器的指令集:
mov eax ,1 
add eax ,1
mov指令把EAX寄存器的值设为1 ,然后add指令再把这个值加1 ,结果就保存在EAX寄存器里面。

两种指令集的优缺点对比:

  • 基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
  • 栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作 ) 等。
  • 栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。

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

你可能感兴趣的:(JVM)