解释执行
步骤
大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过以下几个步骤:
- 程序源码
- 词法分析
- 单词流
- 语法分析
- 抽象语法树
-
6-1. 程序代码到目标机器代码的生成过程
- 指令流(可选)
- 解释器
- 解释执行
-
6-2. 解释执行的过程
- 优化器(可选)
- 中间代码
- 生成器
- 目标代码
-
这里并不详细进入到每个步骤了,感兴趣的话可以阅读龙书(《编译原理》)。
现今近乎所有基于物理机、Java虚拟机或者是非Java的其他高级语言虚拟机(HLLVM)的语言,都遵循这种基于现代经典编译原理的思路:
- 在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree, AST)。
- 词法和语法分析乃至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。
- 也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。
- 或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的JavaScript执行器。
Java中
- Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的
- 解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture, ISA),指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,更通俗一些,就是现在我们主流PC中直接支持的指令集架构,这些指令依赖寄存器进行工作。
栈的指令集与基于寄存器的指令集
分别使用这两种指令集去计算“1+1”的结果:
-
基于栈的指令集:
两条iconst_1指令连续地把两个常量1压入栈后,iadd指令把栈顶的两个值出栈并相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中iconst_1 iconst_1 iadd istore_0
-
基于寄存器的指令集:
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。mov eax,1 add eax,1
栈的指令集与寄存器的指令集对比
- 基于栈的指令集可移植性更好
(寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单。) - 栈架构的指令集代码相对更紧凑
(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等 - 栈架构指令集所需的指令数量一般会比寄存器架构多,在完成相同功能时,出栈入栈等操作本身就产生了相当多的指令。
- 栈实现在内存之中,由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度相对较慢。
频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中以避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。
基于栈的解释器执行过程
java代码
public int calc(){
int a = 100;
int b = 200;
int c = 300;
return(a + b)* c;
}
使用javap命令看看它的字节码指令
public int calc();
Code:
Stack=2,Locals=4,Args_size=1 //需要深度为2的操作数栈和4个Slot
0:bipush 100 //将100推入操作数栈顶
2:istore_1 //将栈顶的整型值出栈并存放到第1个局部变量Slot中
3:sipush 200 //将200推入操作数栈顶
6:istore_2 //将栈顶的整型值出栈并存放到第2个局部变量Slot中
7:sipush 300 //将300推入操作数栈顶
10:istore_3 //将栈顶的整型值出栈并存放到第3个局部变量Slot中
11:iload_1 //将局部变量表第1个Slot中的整型值入栈到栈顶。
12:iload_2 //将局部变量表第2个Slot中的整型值入栈。
13:iadd //将前两个元素出栈,做整型加法,然后把结果入栈
14:iload_3 //第3个Slot中的元素入栈
15:imul //将前两个元素出栈,做整型乘法,把结果入栈
16:ireturn //将操作数栈顶的整型值返回给此方法的调用者
}
从上到下:
- javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间
- 首先,执行偏移地址为0的指令,bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,后跟一个参数,指明推送的常量值,这里是100。
- 执行偏移地址为1的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量Slot中。后续四条指令(直到偏移为11的指令为止)都是做同样的事情,也就是在对应代码中把变量a、b、c赋值为100、200、300。
- 执行偏移地址为11的指令,iload_1指令的作用是将局部变量表第1个Slot中的整型值复制到操作数栈顶。
- 执行偏移地址为12的指令,iload_2指令的执行过程与iload_1类似,把第2个Slot的整型值入栈。
- 执行偏移地址为13的指令,iadd指令的作用是将操作数栈中前两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200出栈,它们的和300重新入栈。
- 执行偏移地址为14的指令,iload_3指令把存放在第3个局部变量Slot中的300入栈到操作数栈中。这时操作数栈为两个整数300。
- 执行偏移地址为15的指令,imul是将操作数栈中前两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,这里与iadd完全类似。
- 执行偏移地址为16的指令,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给此方法的调用者。到此为止,这段方法执行结束。
上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做出一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述,更准确地说,实际情况会和上面描述的概念模型有非常大的差距,差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,例如,HotSpot虚拟机中就有很多以“fast_”开头的非标准字节码指令用于合并和替换输入的字节码以提升解释执行性能,即时编译器的优化手段更是花样繁多。