[置顶] JVM字节码执行模型及字节码指令集

时间:2022-12-13 17:05:00

    一个Java类的生命周期概括来说需要经过加载、验证、准备、解析以及初始化、使用及卸载的过程。这里不展开加载Class 的过程以及Class文件格式(后期会陆续探讨)。在执行过程中,JVM是如何把Class文件里的字节码转换成我们的虚拟机栈的操作指令,以及整个虚拟机栈的内部数据结构是怎样的,这篇文章后续会详细介绍,并且稍微扩展下JVM规范中的一些字节码指令集。

    其实这篇文章的主要目的还是为了引入后续要介绍的ASM框架的CoreApi 中的Method接口和组件来做一个铺垫。我们知道,Class文件是编译后的以8byte为单位存储的二进制字节流,想要生成和解析一个Class文件,那么我们需要更好地了解在JVM中他是怎样被解析和执行的。整篇主要参考和总结了《Java Virtual Machine SpecificationJavaSE7 Version》以及《ASM 4.0

A Java bytecode engineering library》关于虚拟机执行模型及字节码执行的部分。

一、字节码执行

    方法调用在JVM中转换成的是字节码执行,字节码指令执行的数据结构就是栈帧(stack frame)。也就是在虚拟机栈中的栈元素。虚拟机会为每个方法分配一个栈帧,因为虚拟机栈是LIFO(后进先出)的,所以当前线程正在活动的栈帧,也就是栈顶的栈帧,JVM规范中称之为“CurrentFrame”,这个当前栈帧对应的方法就是“CurrentMethod”。字节码的执行操作,指的就是对当前栈帧数据结构进行的操作。

   栈帧的数据结构主要分为四个部分:局部变量表、操作数栈、动态链接以及方法返回地址(包括正常调用和异常调用的完成结果)。下面就一一介绍下这四种数据结构。

1、局部变量表(local variables)

    当方法被调用时,参数会传递到从0开始的连续的局部变量表的索引位置上。栈帧中局部变量表的长度存储在类或接口的二进制表示中。阅读Class文件会找到Code属性,所以我们能知道local variables的最大长度是在编译期间决定的。一个局部变量表的占用了32位的存储空间(一个存储单位称之为slot,槽),所以可以存储一个boolean、byte、char、short、float、int、refrence和returnAdress数据,long和double需要2个连续的局部变量表来保存,通过较小位置的索引来获取。如果被调用的是实例方法,那么第0个位置存储“this”关键字代表当前实例对象的引用。

 

2、操作数栈(operand stack)

    操作数栈同局部变量表一样,也是编译期间就能决定了其存储空间(最大的单位长度),通过 Code属性存储在类或接口的字节流中。操作数栈也是个LIFO栈。

    操作数栈是在JVM字节码执行一些指令(第二部分会介绍一些指令集)时创建的,主要是把局部变量表中的变量压入操作数栈,在操作数栈中进行字节码指令的操作,再将变量出操作数栈,结果入操作数栈。同局部变量表,除了long和double,其他类型数据都只占用一个栈的单位深度。

3、动态链接

    每个栈帧指向运行时常量池中该栈帧所属的方法的引用,也就是字节码的发放调用的引用。动态链接就是将符号引用所表示的方法,转换成方法的直接引用。加载阶段或第一次使用时转化为直接引用的(将变量的访问转化为访问这些变量的存储结构所在的运行时内存位置)就叫做静态解析。JVM的动态链接还支持运行期转化为直接引用。也可以叫做Late Binding,晚期绑定。

4、方法返回地址

   方法正常退出会把返回值压入调用者的栈帧的操作数栈,PC计数器的值就会调整到方法调用指令后面的一条指令。这样使得当前的栈帧能够和调用者连接起来,并且让调用者的栈帧的操作数栈继续往下执行。

    方法的异常调用完成,主要是JVM跑出的异常,如果异常没有被不货主,或者遇到athrow字节码指令显示抛出,那么就没有返回值给调用者。

 

二、字节码指令集

    了解了栈帧的数据结构之后,继续扩展到字节码指令集的扩展。那么字节码指令又是由哪些元素构成,以及会怎样地影响我们的当前方法的栈帧的出栈、入栈操作的呢。从结构到用途开始详述一部分指令集(主要是从作用范围将指令集划分为两类:局部变量表和操作数栈传递数据的指令集,只在操作数栈中操作的指令集)。

1、构成元素

    字节码的指令,是由一个字节长度的助记符表示的操作码(Opcode)以及其随后的需要操作的若干参数构成。有的指令并不一定需要参数。但这里注意不要混淆一个概念,这里的参数和操作数(oprends)不是同一个概念。这里的arguments(参数)是静态的值,编译期就存储在编译后的字节码中,而Oprends(操作数)的值第一节介绍的操作数栈中运行期才知道值的数据结构。不知道讲清楚没有,但发现很多译文以及文章都会混淆指令集的”参数”和操作数栈的”操作数”。如果这里不够清晰,那么下面继续看,后面具体的指令集的例子,就清楚了。

    对于操作参数的数量及长度都是由Opcode决定的,如果需要操作的长度超出了一个字节,就会按照高位在前的字节序存储。并且字节码指令流都是单字节对齐,所以超出单字节的操作参数会需要预留“位置”来实现对齐。

    这里还需要我们记住的一点是,Opcode是由一个字节长度的助记符表示,JVM 规范制定中需要很谨慎小心得“节约”指令的命名。对于一些boolean、short、byte、char的操作都是讲数据转化成int数据进行操作的,这样就可以使用同一条指令来操作更多的数据类型。下面可以看到一些指令的例子。

2、指令

    按照JVM规范中,将字节码指令按照用途划分成加载和存储指令、运算指令、类型转换指令、对象创建指令、操作数栈管理指令、方法调用和返回指令以及同步指令等等。看起来颇多。这里我们按照指令的操作范围划分为两种:局部变量表和操作数栈传递数据的指令以及只在操作数栈中操作的指令。

   本文不打算详细把所有字节码指令全部列出来,所有的指令及规范可以阅读《Java Virtual Machine Specification》。因为我们先要概念上了解一个字节码指令是如何在JVM栈帧上操作的。

   这里先简单列举一下Class文件中对于Java的类型描述。以下是Java基础类型和数组、Object的表述。对于类或者接口,类型描述其实是将如java.lang.String 变成了java/lang/String 。用斜线分隔。

[置顶]        JVM字节码执行模型及字节码指令集

    编译后的方法描述对应关系如下:

 [置顶]        JVM字节码执行模型及字节码指令集

   1、局部变量表和操作数栈传递数据的指令:

    ILOAD, LLOAD, FLOAD, DLOAD以及ALOAD指令都是从局部变量表中获取参数压入到操作数栈的,其中ILOAD包括了load boolean、char、short、byte和int类型的操作。FLOAD, DLOAD 指令操作的数据需要占用两个槽(slot i 及i+1)。ALOAD 是load 对象或者数组类型。ISTORE,LSTORE,ASTORE等操作是从操作数栈栈顶压入局部变量表的指令。

  2、只在操作数栈操作数据的指令:

  2.1 栈操作:POP 指令把值压到栈顶。还有DUP、SWAP指令

  2.2 常量值推入栈顶:ACONST_NULL 把null值推入,ICONST_0 把int 0推入栈顶,其他指令不一一列举了

  2.3运算操作:xADD, xSUB, xMUL, xDIV 以及xREM。对应着+,-,*,/ ,%的运算。X分别对应前面提到的基本数据类型。

  2.4 类型转换:I2F, F2D, L2D 等等是对类型转换的操作。

  2.5 对象操作:如NEW 指令就将一个对象引用入栈。

  2.6 读写Fields:GETFIELD,PUTFIELD。对于static属性的操作有:GETSTATIC ,PUTSTATIC

  2.7 调用Methods:对方法的调用,构造函数操作的时候,会操作所有方法参数入栈。如INVOKESTATIC、INVOKEINTERFACE等。

  2.8 读写数组值:xALOAD以及xASTORE 。x对应的是I, L, F, D ,A,  B, C , S等类型数据的数组的索引、值入栈出栈的操作。

  2.9 跳转操作:TABLESWITCH、LOOKUPSWITCH 指令对应的是switch的操作指令。作为条件判断if、do while、continue 等的跳转指令也是直接在操作数栈中进行的。

  2.10 返回指令:RETURN 以及xRETURN、前者是对应方法返回void类型的操作,后者是对应x类型的返回值,返回给方法调用者的指令。

 

三、例子

   下面来结合例子来看下字节码指令在虚拟机栈中的操作,更进一步理解部分字节码指令的含义。

package bytecode;

/**
* Created by yunshen.ljy on 2015/6/16.
*/
public class Coffee {

int bean;

public int getBean() {
return this.bean;
}

public void setBean(int bean) {
this.bean = bean;
}

}

  然后查看字节码:

  1、getBean 方法如下:
   0:aload_0
1:getfield#2; //Field bean:I
4:ireturn

  第一行指令是当方法被调用,也就是方法的栈帧创建时,将获取局部变量表索引值为0 的值(也就是this),入操作数栈。

  第二行指令,将这个值(也就是this对应的值)出栈,赋给this对象的 bean field。

  第三行指令,将this.f 出栈,并且将值返回给调用者(这里ireturn 是int类型)。

 

 2、 setBean() 方法字节码:
   0:aload_0
1:iload_1
2:putfield#2; //Field bean:I
5:return

    第一行指令和getBean 方法一样,都是将this如操作数栈。

    第二行指令是将已经初始化(栈帧创建,也就是方法调用时初始化)的参数bean 的值入操作数栈。

    第三行指令将这两个值出栈,并且存储这个int值存储到到bean属性的引用,也就是this.bean中。

    第四行指令,在源码中没有return 语句,但是在编译后的字节码中,会自动生成一个return 指令,消除当前方法(current method)的栈帧并且返回给调用者。

 

   当然,如果没有程序实现自己的构造器的话,编译后的类还有个默认的public 构造器。Coffee () { super(); }

  3、构造器的字节码如下:

   0:aload_0
1:invokespecial#1; //Method java/lang/Object."<init>":()V
4:return
    第一行指令和getBean 方法一样,都是将this如操作数栈。

    第二行指令,将值(this)出栈,并调用Object class的<init>方法,其实也就是因为隐式调用了super()方法,而Coffee的父类是Object。这里的<init>方法是编译后的类对应的构造器的方法,编译器会为每个构造器生成一个<init>方法。

    第三行指令,同之前的几个return命令一样,返回给调用者。


四、后话

    至此,我们了解了虚拟机执行模型,线程执行会独有一个方法栈,每个方法在调用时创建一个栈帧,当前正在执行的方法对应的栈帧是在栈顶,所有执行的分析都是针对当前方法进行的。后续又了解了部分字节码指令,结合例子分析了指令集是如何对栈帧进行操作的。如果想更深入地了解虚拟机可以看看《深入理解JVM》,《JVM规范》,《EffectiveJava》都是不错的参考资料。后续会继续写一下Class 文件结构,JVM中的类加载过程,GC收集及编译、运行时调优等方面的内容。