- 前言
- 第2版与第1版的区别
- 本书面向的读者
- 如何阅读本书
- 语言约定
- 内容特色
- 参考资料
- 第一部分 走近 Java
- 第1章 走近 Java
- 第二部分 自动内存管理机制
- 第2章 Java 内存区域与内存溢出异常
- 第3章 垃圾收集器与内存分配策略
- 第4章 虚拟机性能监控与故障处理工具
- 第5章 调优案例分析与实战
- 第三部分 虚拟机执行子系统
- 第6章 类文件结构
- 第7章 虚拟机类加载机制
- 第8章 虚拟机字节码执行引擎
- 第9章 类加载及执行子系统的案例与实战
- 第四部分 程序编译与代码优化
- 第10章 早期(编译期)优化
- 第11章 晚期(运行期)优化
- 第五部分 高效并发
- 第12章 Java 内存模型与线程
- 第13章 线程安全与锁优化
- 附录
- 附录A 编译 Windows 版的 OpenJDK
- 附录B 虚拟机字节码指令表
- 附录C HotSpot 虚拟机主要参数表
- 附录D 对象查询语言(OQL)简介[1]
- 附录E JDK 历史版本轨迹
8.4 基于栈的字节码解释执行引擎
虚拟机是如何调用方法的内容已经讲解完毕,从本节开始,我们来探讨虚拟机是如何执行方法中的字节码指令的。上文中提到过,许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在本章中,我们先来探讨一下在解释执行时,虚拟机执行引擎是如何工作的。
8.4.1 解释执行
Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK 1.0时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出了可以直接生成本地代码的编译器[如GCJ[1](GNU Compiler for the Java)],而C/C++语言也出现了通过解释器执行的版本(如CINT[2]),这时候再笼统地说“解释执行”,对于整个Java语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。
不论是解释还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就获得了执行能力。大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图8-4中的各个步骤。如果读者对编译原理的相关课程还有印象的话,很容易就会发现图8-4中下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。
如今,基于物理机、Java虚拟机,或者非Java的其他高级语言虚拟机(HLLVM)的语言,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行器。
图 8-4 编译过程
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
[1]GCJ:http://gcc.gnu.org/java/。
[2]CINT:http://root.cern.ch/drupal/content/cint。
8.4.2 基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上[1]是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?
举个最简单的例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这样子的:
iconst_1 iconst_1 iadd istore_0
两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。
如果基于寄存器,那程序可能会是这个样子:
mov eax,1 add eax,1
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。
了解了基于栈的指令集与基于寄存器的指令集的区别后,读者可能会有进一步的疑问,这两套指令集谁更好一些呢?
应该这么说,既然两套指令集会同时并存和发展,那肯定是各有优势的,如果有一套指令集全面优于另外一套的话,就不会存在选择的问题了。
基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供[2],程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如,现在32位80x86体系的处理器中提供了8个32位的寄存器,而ARM体系的CPU(在当前的手机、PDA中相当流行的一种处理器)则提供了16个32位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
[1]使用“基本上”,是因为部分字节码指令会带有参数,而纯粹基于栈的指令集架构中应当全部都是零地址指令,也就是都不存在显式的参数。Java这样实现主要是考虑了代码的可校验性。
[2]这里说的是物理机器上的寄存器,也有基于寄存器的虚拟机,如Google Android平台的Dalvik VM。即使是基于寄存器的虚拟机,也希望把虚拟机寄存器尽量映射到物理寄存器上以获取尽可能高的性能。
8.4.3 基于栈的解释器执行过程
初步的理论知识已经讲解过了,本节准备了一段Java代码,看看在虚拟机中实际是如何执行的。前面曾经举过一个计算“1+1”的例子,这样的算术题目显然太过简单了,笔者准备了四则运算的例子,请看代码清单8-16。
代码清单8-16 一段简单的算术代码
public int calc(){ int a=100; int b=200; int c=300; return(a+b)*c; }
从Java语言的角度来看,这段代码没有任何解释的必要,可以直接使用javap命令看看它的字节码指令,如代码清单8-17所示。
代码清单8-17 一段简单的算术代码的字节码表示
public int calc(); Code: Stack=2,Locals=4,Args_size=1 0:bipush 100 2:istore_1 3:sipush 200 6:istore_2 7:sipush 300 10:istore_3 11:iload_1 12:iload_2 13:iadd 14:iload_3 15:imul 16:ireturn }
javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,笔者根据这些信息画了图8-5~图8-11共7张图,用它们来描述代码清单8-17执行过程中的代码、操作数栈和局部变量表的变化情况。
图 8-5 执行偏移地址为0的指令的情况
图 8-6 执行偏移地址为1的指令的情况
图 8-7 执行偏移地址为11的指令的情况
图 8-8 执行偏移地址为12的指令的情况
图 8-9 执行偏移地址为13的指令的情况
图 8-10 执行偏移地址为14的指令的情况
图 8-11 执行偏移地址为16的指令的情况
上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述……更准确地说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,例如,在HotSpot虚拟机中,有很多以“fast_”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多[1]。
不过,我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。
[1]具体可以参考第11章中的相关内容。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论