【个人思考】 Java为什么解释执行时不直接解释源码?
起因
最近学习JVM,产生一个问题:Java为什么解释执行时不直接解释源码?
众所周知,Java 字节码是跨平台的,因此 Java 才能一次编译处处运行。但是,Java 源码本身也是跨平台的啊,为什么不可以省略编译为字节码这一步,直接将源码运行在虚拟机上?如果是效率问题,可不可以在设计 Java 语言的时候解决?
对于此问题,大部分回答诸如:“采用字节码的好处主要包括跨平台性、安全性、性能优化、易于维护以及支持动态性等。这些优势使得字节码成为现代编程语言中不可或缺的一部分。”等回答,完全答非所问。
经过长时间搜索,找到一些答案,个人感觉回答的内容较为合理。
搜索到的感觉比较靠谱的回答
一、字节码相对于源码的一些优势
- 字节码解析速度更快
- 字节码size压缩的更小
- 字节码格式版本更稳定,语法改变大部分情况下不影响字节码格式
- 字节码可以在编译时提前做编译优化,节省运行时编译优化时间
- 字节码能保护源码,增加反编译成本
- 字节码能支持多语言,在其平台上开发新语言更容易(字节码不一定非要java源码生成,其它一些语言比如scala也可以编译生成字节码。这样其它语言就可以利用上经过多年发展的JVM。)
- 编译器前端和虚拟机可以独立开发,互不影响。有中间格式也更容易debug
二、xx上一个高赞回答
为什么执行Java程序必须先用Java源码编译器(例如javac)编译为Java字节码,然后在用JVM执行Java字节码;就不能直接输入源码就得到执行结果么?
答案是:当然可以。分离的编译-执行模型是可以封装的。
完全可以写一个程序接受Java源码输入,内部悄悄调用javac把源码编译为字节码,然后交给JVM去执行得到结果。事实上通过Java SE 6开始提供的Java Compiler API非常容易在Java里实现这点,都不必调用外部的“javac”命令。
像Ruby(CRuby 1.9或以上)、Python(CPython),内部都是先把源码编译到字节码然后再解释执行的,但从用户的角度看就只有一个ruby / python命令,并没有分离的编译-执行步骤,看似是“直接解释执行源码”。
===========================================
这是一个“语言处理器”(language processor)的话题。
用于执行编程语言的语言处理器,从解释器到编译器这两个极端之间有整个系列的选择。
Java作为一种“古老”的编程语言,实现其执行的语言处理器也有全系列可选。
其中一个是
DynamicJava
,它就是一种源码解释器,直接在Java源码上解释执行而不编译到Java字节码再解释执行。具体说它是先把Java源码通过词法+语法分析转换为抽象语法树(AST)之后再在抽象语法树上做解释执行的:
TreeInterpreter
从Java 9开始,Oracle JDK / OpenJDK将自带一个“jshell”命令,同样可以直接解释执行Java源码。详细请参考:
- JEP 222: jshell: The Java Shell (Read-Eval-Print Loop)
- OpenJDK: Kulla
- JShell and REPL in Java 9 (The Java Source)
- Java 9 Early Access: A Hands-on Session with JShell
从用户的角度看,源码进去,执行结果就出来了,中间经过了怎样的步骤其实都不重要 ;-)
回到“全系列”的选择,那到底有些什么选择呢?
我们可以从一个比较简单的编译器的处理步骤看起:
编译流程:
源码 [字符流]
- 词法分析 -> 单词(token)流
- 语法分析 -> 语法树 / 抽象语法树
- 语义分析 -> 标注了属性的抽象语法树
- 代码生成 -> 目标代码
执行流程:
目标代码
- 操作系统/硬件 -> 执行结果
这描述的是一个分离的编译-执行流程:编译生成目标代码,目标代码持久化到例如磁盘上,然后执行时把目标代码再加载起来并执行出结果。
(注:这里假定目标代码是硬件可以直接执行的机器码)
在上面的流程中,我们可以从后向前逐步把处理融合起来。每融合一个处理步骤,在“执行”之前的处理部分看起来就更少更简单了一些,但在“执行”时要做的冗余动作就更多了一些。
例如说我们可以不要求用分离的编译-执行流程,而是直接在编译出目标代码之后让目标代码直接放在内存里,然后直接让硬件开始执行目标代码:
编译+执行流程:
源码 [字符流]
- 词法分析 -> 单词(token)流
- 语法分析 -> 语法树 / 抽象语法树
- 语义分析 -> 标注了属性的抽象语法树
- 代码生成 -> 目标代码
- 操作系统/硬件 -> 执行结果
与之前的分离流程相比,这里从输入源码到得到执行结果只有一步,从使用角度看似乎简单了一些,但同时也意味着每次重新执行同样的源码都必须重新经过从源码到生成目标代码之间的编译流程,冗余变多了。
然后我们可以进一步从后向前融合,不生成目标代码,而是让程序维持在一种中间形式上就开始解释执行。例如说:
编译+解释执行流程:
源码 [字符流]
- 词法分析 -> 单词(token)流
- 语法分析 -> 语法树 / 抽象语法树
- 语义分析 -> 标注了属性的抽象语法树
- 不做类型检查的抽象语法树解释器 -> 执行结果
这里我们通过实现一个能在硬件上执行的抽象语法树解释器(AST interpreter,或者就叫tree interpreter)来实现源程序的执行。
要留意的是:由于在解释执行前做了语义分析(其中包括但不限于类型检查),我们可以相信输入到解释器的抽象语法树的类型是正确的,所以解释器里不必重复做类型检查。
其它可能在语义分析阶段做的处理诸如:
- 变量的确定性赋值:变量必须在使用前先得到初始赋值;
- 变量的确定性不重复赋值:不可变变量(例如Java的final变量)最多只能被赋值一次
- 控制流的正确性校验:例如Java的continue语句只能用在循环体内、continue的跳转标签只能向更外围作用域而不能向更深的嵌套作用域跳转,等等;
- ⋯
在解释执行之前做好这些分析,就意味着在解释执行过程中完全不必关心这些检查,因而解释执行的效率就可以更高。
然后可以进一步去掉解释执行前的语义分析,变为:
编译+解释执行流程:
源码 [字符流]
- 词法分析 -> 单词(token)流
- 语法分析 -> 语法树 / 抽象语法树
- 需要做类型检查的抽象语法树解释器 -> 执行结果
没有了解释执行前的语义分析,要维持语言的语义正确,就必须在解释执行过程中融入语义分析本来应该完成的动作。例如:
- 在看到一个“赋值”动作时,必须检查赋值目标(“左手边”)
- 在作用域内是否存在
- 类型是否匹配
- 是否是final变量并且已经得到过赋值
- ⋯等等
- 在运行时必须维护一个“循环嵌套栈”,在执行“continue语句”时必须检查当前是否在循环里,并且要动态查找continue的跳转目标
- ⋯等等
这些解释执行时做的语义分析的结果都不会被保存下来,所以多次执行到同一块代码时就得重复做这些分析。
这样,同样是在抽象语法树上解释执行,这个解释器就比上一个版本的解释器要重复做更多处理,因而会更复杂以及更慢。
我们可以进一步把语法分析也融合到解释执行中,变为:
编译+解释执行流程:
源码 [字符流]
- 词法分析 -> 单词(token)流
- 需要做语法分析+类型检查的单词流解释器 -> 执行结果
此时解释器就不是在抽象语法树,而是在单词流上做解释执行了。为了保证我们只接受符合语法规则的程序,我们还是得做语法分析——只是把它融合到了解释器里而已。
与上一个版本的解释器最大的不同时,这个版本在解释器不会保留语法树/抽象语法树,所以解释器会一边做语法分析一边解释执行,如果多次执行同一块代码就得重复做语法分析。
最后,我们可以把词法分析也融合到解释执行中:
解释执行流程:
源码 [字符流]
- 需要做词法分析+语法分析+类型检查的字符流解释器 -> 执行结果
有了前面的讲解,相信这一步是怎么回事不必多说了。
要实现一门编程语言,上面说的所有可能性都可以实现“执行”这一目标,但是从运行效率上看明显大有不同。
我以前做的一套演讲稿里,第6到第9页就是讲这个话题的:
http://www.valleytalk.org/wp-content/uploads/2011/05/Java_Program_in_Action_20110727.pdf
使用解释器实现的编程语言实现里,通常:
- 至少会在解释执行前做完语法分析,然后通过树解释器来实现解释执行;
- 兼顾易于实现、跨平台、执行效率这几点,会选择使用字节码解释器实现解释执行。
在树解释器与字节码解释器中也各自有许多不同的变种,这里就不多展开说了。
在这两大类解释器中的取舍,请参考另一个回答:
为什么大多数解释器都将AST转化成字节码再用虚拟机执行,而不是直接解释AST? - RednaxelaFX 的回答
碰到这种话题我总是忍不住想放俩老传送门:
虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩[讨论]CPython能否用V8的方式优化性能