虚拟机字节码执行引擎(二)

虚拟机字节码执行引擎(二)

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

关于Java虚拟机是如何调用方法、进行版本选择的内容以及全部讲解完毕,从本节开始,我们来看看虚拟机是如何执行方法里面的字节码指令的。概述中曾提到过,许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在本节中,我们将会分析在概念模型下的Java虚拟机解释执行字节码时,其执行引擎是如何工作的。作者在文章多次强调了 概念模型 ,是因为实际的虚拟机实现,譬如HotSpot的模板解释器工作的时候,并不是按照下文中的动作一板一眼地进行机械式计算,而是动态产生每条字节码对应的汇编代码来运行,这与概念模型中执行过程的差异很大,但是结果却能保证是一致的。

解释执行

Java语言经常被人们定位为 解释执行 的语言,在Java初生的JDK1.0时代,这种定义还是算比较准确的,但当主流的虚拟机中包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的是。再后来,Java也发展出可以直接生成本地代码的编译器(如Jaoct、FCJ、Excelsior JET),而C/C++语言也出现了通过解释器执行的版本(如CINT),这时候再笼统地说 解释执行 ,对于真个Java语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较合理确切。

无论是解释还是编译,也无论是物理机还是虚拟机,对于应用程序,机器都不可能如人一般阅读,理解,然后获得执行能力。大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图的各个步骤。下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;而中间的那条分支,自然就是解释执行的过程:

如今,基于物理机,Java虚拟机,或者是非Java的其他高级语言虚拟机(HLLVM)的代码执行过程,大体上都会遵循这种符合现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为 抽象语法树,(Abstract Syntax Tree,AST) 。对于一门具体语言的实现来说,词法、语法分析以至后面的优化器和目标代码生成器都可以独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成一个抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行引擎。
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

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

Javac编译输出的字节码指令流,基本上(因为部分字节码指令会带有参数,而纯粹基于栈的指令集架构中应当全部都是零地址指令,也就是都不存在显式的参数。Java这样实现主要是考虑了代码的可校验性)是一种基于栈的 指令集架构(Instruction Set Architecture,ISA) ,字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,如果说的更通俗一些就是现在我们主流PC机中物理硬件直接支持的指令集架构,这些指令依赖寄存器进行工作。那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?

举个最简单的例子,分别使用这两种指令集去计算 1+1 的结果,基于栈的指令集会是这样子的:

iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令吧栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。如果用基于寄存器的指令集,那程序可能会是这个样子:

mov	eax, 1
add eax, 1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器中。这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和存储数据。

了解了基于栈的指令集与基于寄存器的指令集的区别后,大家可能会想,这两种哪个更好呢?

应该说,既然两套指令集会同时并存和发展,那肯定是各有优势的,如果有一套指令集全面优于另外一套的话,那就是直接替代而不存在选择的问题了。

基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供(这里说的是物理机上的寄存器。也有基于寄存器的虚拟机,如Google Android平台的Dalvik虚拟机。即使是基于寄存器的虚拟机,也会希望把虚拟机寄存器尽量映射到物理寄存器上以获取尽可能高的性能),程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如现在32位80x86体系的处理器能提供8个32位的寄存器,而ARMv6体系的处理器(在智能手机、数码设备中相当流行的一种处理器)则提供了30个32位的通用寄存器,其中前16个在用户模式中可以使用。如果使用栈架构的指令集,用户程序不会直接用到这些寄存器,那就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更简单一点。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构(这里说的是物理机器上的寄存器。也有基于寄存器的虚拟机,如Google Android平台的Dalvik虚拟机。即使是基于寄存器的虚拟机,也会希望把虚拟机寄存器尽量映射到物理寄存器上以获取尽可能高的性能)也从侧面印证了这一点。不过这里的执行速度是要局限在解释执行的状态下,如果经过即时编译输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了。

在解释执行时,栈架构指令集的代码虽然紧凑,但是完全相同功能所需的指令数量会比寄存器架构来的更多,因为出栈、入栈操作本身就产生了相当大量的指令。更重要的是栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的优化方法,把最常用的操作映射到寄存器中避免直接内存访问,但这也只是优化措施而不是解决本质问题的方法。因此由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度会相对慢上一点。

基于栈的解释器执行过程

关于栈架构执行引擎的必要前置知识已经全部讲解完毕了,下面有一段Java代码,用于向大家实际展示在虚拟机里字节码是如何执行的:

public int calc(){
        int a = 100;
        int b = 200;
        int c = 300;
        return (a+b) * c;
    }

从javap命令可以看到它的字节码指令:

javap提示这段代码需要深度为2的操作数栈和四个变量槽的局部变量空间,作者根据这些信息画了七张图片,来描述上面代码执行过程中的代码、操作数栈和局部变量表的变化情况。

  • 首先,执行偏移地址为0的指令,Bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,但跟随有一个参数,指明推送的常量值,这里是100:

执行偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第一个局部变量槽中。后四条指令(直到偏移为11的指令为止)都是做一样的事情,也就是在对应代码中把变量a、b、c赋值为100,200,300。这四条指令的图示略过:

执行偏移地址为11的指令,iload_1指令的作用是将局部变量表第一个变量槽中的整型值复制到操作数栈顶:

执行偏移地址为12的指令,iload_2指令的执行过程与iload_1类似,把第二个变量槽的整型值入栈。画出这个指令的图示主要是为了显示下一条iadd指令执行前的堆栈情况:

执行偏移地址为13的指令,iadd指令的作用是将数栈中头两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200被出栈,它们的和300被重新入栈:

执行偏移地址为14的指令,iload_3指令把存放在第三个局部变量槽中的300入栈到操作数栈中。这时操作数栈为两个整数300,。下一条指令imul是将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,与iadd完全类似:

执行偏移地址为16的指令,ireturn指令是方法返回指令之一,它将结束方法执行,并将操作数栈栈顶的整型值返回给方法的调用者。到此为止,这段方法执行结束:


上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做出一系列优化来提高性能,实际的运作过程并不会完全符合概念模型的描述。更确切地说,实际情况和上面描述的概念模型差距非常大,距离产生的根本原因是虚拟机中解析器和即时编译器都会对输入的字节码优化,即使解释器中也不是按照字节码指令逐条执行的。例如在HotSpot虚拟机中,就有很多以 falst_ 开头的非标准字节码指令用于合并、替换输入的字节码来提升解释执行性能,即时编译器的优化手段更是花样繁多。

不过我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈出栈、入栈为信息交换途径,符合我们在前面分析的特点。

书中的第八章内容就到这里了。

第一部分请查看 虚拟机字节码执行引擎(一)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值