虚拟机的前世今生

上节我们提到了 LISP 中, 因为 eval 的原因, 发展出了运行时环境这样一个概念。基于这个概念,日后发展出了虚拟机技术。但这段历史并不是平铺直叙的,实际上,这里面还经历了一个非常漫长而曲折的过程, 说起来也是非常有意思的。 这一节我们就着重解释虚拟机的历史。

我们 21 世纪的程序员,凡要是懂一点编程技术的,基本上都知道_虚拟机_和_字节码_这样两个重要的概念。 所谓的字节码 (bytecode),是一种非常类似于机器码的指令格式。这种指令格式是以二进制字节为单位定义的(不会有一个指令只用到一个字节的前四位),所以叫做字节码。所谓的虚拟机,就是说不是一台真的计算机,而是一个环境,其他程序能在这个环境中运行,而不是在真的机器上运行。现在主流高级语言如 Java, Python, PHP, C#,编译后的代码都是以字节码的形式存在的, 这些字节码程序, 最后都是在虚拟机上运行的。

1. 虚拟机的安全性和跨平台性

虚拟机的好处大家都知道,最容易想到的是安全性和跨平台性。安全性是因为现在可执行程序被放在虚拟机环境中运行,虚拟机可以随时对程序的危险行为,比如缓冲区溢出,数组访问过界等等进行控制。跨平台性是因为只要不同平台上都装上了支持同一个字节码标准的虚拟机,程序就可以在不同的平台上不加修改而运行,因为虚拟机架构在各种不同的平台之上,用虚拟机把下层平台间的差异性给抹平了。我们最熟悉的例子就是 Java 了。Java 语言号称 一次编写,到处运行(Write Once, Run Anywhere),就是因为各个平台上的 Java 虚拟机都统一支持 Java 字节码,所以用户感觉不到虚拟机下层平台的差异。

虚拟机是个好东西,但是它的出现,不是完全由安全性和跨平台性驱使的。

2. 跨平台需求的出现

我们知道,在计算机还是锁在机房里面的昂贵的庞然大物的时候,系统软件都是硬件厂商附送的东西(是比尔盖茨这一代人的出现,才有了和硬件产业分庭抗礼的软件产业),一个系统程序员可能一辈子只和一个产品线的计算机打交道,压根没有跨平台的需求。应用程序员更加不要说了,因为计算机很稀有,写程序都是为某一台计算机专门写的,所以一段时间可能只和一台庞然大物打交道,更加不要说什么跨平台了。 真的有跨平台需求,是从微型计算机开始真的普及开始的。因为只有计算机普及了,各种平台都被广泛采用了,相互又不互相兼容软件,才会有软件跨平台的需求。微机普及的历史,比 PC 普及的历史要早10年,而这段历史,正好和 UNIX 发展史是并行重叠的。

熟悉 UNIX 发展史的读者都知道, UNIX 真正普及开来,是因为其全部都用 C,一个当时绝对能够称为跨平台的语言重写了一次。又因为美国大学和科研机构之间的开源共享文化,C 版本的 UNIX 出生没多久,就迅速从原始的 PDP-11 实现,移植到了 DEC,Intel 等平台上,产生了无数衍生版本。随着跨平台的 UNIX 的普及, 微型计算机也更多的普及开来,因为只需要掌握基本的 UNIX 知识,就可以顺利操作微型计算机了。所以,微机和 UNIX 这两样东西都在 1970年 到 1980 年在美国政府,大学,科研机构,公司,金融机构等各种信息化前沿部门间真正的普及开来了。这些历史都是人所共知耳熟能详的。

既然 UNIX 是跨平台的,那么,UNIX 上的语言也应当是跨平台的 (注: 本节所有的故事都和 Windows 无关,因为 Windows 本身就不是一个跨平台的操作系统)。UNIX 上的主打语言 C 的跨平台性,一般是以各平台厂商提供编译器的方式实现的,而最终编译生成的可执行程序,其实不是跨平台的。所以,跨平台是源代码级别的跨平台,而不是可执行程序层面的。 而除了标准了 C 语言外,UNIX 上有一派生机勃勃的跨平台语言,就是脚本语言。(注:脚本语言和普通的编程语言相比,在能完成的任务上并没有什么的巨大差异。脚本语言往往是针对特定类型的问题提出的,语法更加简单,功能更加高层,常常几百行C语言要做的事情,几行简单的脚本就能完成

3. 解释和执行

脚本语言美妙的地方在于,它们的源代码本身就是可执行程序,所以在两个层面上都是跨平台的。不难看出,脚本语言既要能被直接执行,又要跨平台的话,就必然要有一个“东西”,横亘在语言源代码和平台之间,往上,在源代码层面,分析源代码的语法,结构和逻辑,也就是所谓的“解释”;往下,要隐藏平台差异,使得源代码中的逻辑,能在具体的平台上以正确的方式执行,也就是所谓的“执行”。

虽说我们知道一定要这么一个东西,能够对上“解释”,对下“执行”,但是 “解释” 和 “执行” 两个模块毕竟是相互独立的,因此就很自然的会出现两个流派:把解释和执行设计到一起把解释和执行单独分开来 这样两个设计思路,需要读者注意的是,现在这两个都是跨平台的,安全的设计,而在后者中字节码作为了解释和执行之间的沟通桥梁,前者并没有字节码作为桥梁。

4. 解释和执行在一起的方案

我们先说前者,前者的优点是设计简单,不需要搞什么字节码规范,所以 UNIX 上早期的脚本语言,都是采用前者的设计方法。 我们以 UNIX 上大名鼎鼎的 AWK 和 Perl 两个脚本语言的解释器为例说明。 AWK 和 Perl 都是 UNIX 上极为常用的,图灵完全的语言,其中 AWK, 在任何 UNIX 系统中都是作为标准配置的,甚至入选 IEEE POSIX 标准,是入选 IEEE POSIX 卢浮宫的唯一同类语言品牌,其地位绝对不是 UNIX 下其他脚本语言能够比的。这两个语言是怎么实现解释和运行的呢? 我从 AWK 的标准实现中摘一段代码您一看就清楚了:

int main(int argc, char *argv[]) {
  ...
  syminit();
  compile_time = 1;
  yyparse();
  ...
    if (errorflag == ) {
      compile_time = ;
      run(winner);
    }
  ...
}

其中, run 的原型是

run(Node *a)   /* execution of parse tree starts here */

winner 的定义是:<br /> Node    *winner ;    /* root of parse tree */

熟悉 Yacc 的读者应该能够立即看出, AWK 调用了 Yacc 解析源代码,生成了一棵语法树。按照 winner 的定义, winner 是这棵语法树的根节点。 在“解释”没有任何错误之后,AWK 就转入了“执行” (compile_time 变成了 0),将 run 作用到这棵语法树的根节点上。 不难想像,这个 run 函数的逻辑是递归的(事实上也是),在语法树上,从根依次往下,执行每个节点的子节点,然后收集结果。是的,这就是整个 AWK 的基本逻辑: 对于一段源代码, 先用解释器(这里awk 用了 Yacc 解释器),生成一棵语法树,然后,从树的根节点开始,往下用 run 这个函数,遇山开山,遇水搭桥,一路递归下去,最后把整个语法树遍历完,程序就执行完毕了。(这里附送一个小八卦,抽象语法树这个概念是 LISP 先提出的,因为 LISP 是最早像 AWK 这样做的,LISP 实在是属于开天辟地的作品!)Perl 的源代码也是类似的逻辑解释执行的,我就不一一举例了。

5. 三大缺点

现在我们看看这个方法的优缺点。 优点是显而易见的,因为通过抽象语法树在两个模块之间通信,避免了设计复杂的字节码规范,设计简单。但是缺点也非常明显。最核心的缺点就是性能差,需要资源多,具体来说,就是如下三个缺点。

缺点1因为解释和运行放在了一起,每次运行都需要经过解释这个过程。假如我们有一个脚本,写好了就不修改了,只需要重复的运行,那么在一般应用下尚可以忍受每次零点几秒的重复冗余的解释过程,在高性能的场合就不能适用了。 **

缺点2因为运行是采用递归的方式的,效率会比较低。 我们都知道,因为递归涉及到栈操作和状态保存和恢复等,代价通常比较高,所以能不用递归就不用递归。在高性能的场合使用递归去执行语法树,不值得。

缺点3,因为一切程序的起点都是源代码,而抽象语法树不能作为通用的结构在机器之间互传,所以不得不在所有的机器上都布置一个解释+运行的模块。 在资源充裕的系统上布置一个这样的系统没什么,可在资源受限的系统上就要慎重了,比如嵌入式系统上。 鉴于有些语言本身语法结构复杂,布置一个解释模块的代价是非常高昂的。本来一个递归执行模块就很吃资源了,再加一个解释器,嵌入式系统就没法做了。所以,这种设计在嵌入式系统上是行不通的。

当然,还有一些其他的小缺点,比如有程序员不喜欢开放源代码,但这种设计中,一切都从源代码开始,要发布可执行程序,就等于发布源代码,所以不愿意公布源代码的商业公司很不喜欢这些语言等等。但是上面的三个缺点,是最致命的,这三个缺点,决定了有些场合,就是不能用这种设计。

6. 分开解释和执行

前面的三个主要缺点,恰好全部被第二个设计所克服了。在第二种设计中, 我们可以只解释一次语法结构,生成一个结构更加简单紧凑的字节码文件。这样,以后每次要运行脚本的时候, 只需要把字节码文件送给一个简单的解释字节码的模块就行了。因为字节码比源程序要简单多了,所以解释字节码的模块比原来解释源程序的模块要小很多;同时,脱离了语法树,我们完全可以用更加高性能的方式设计运行时,避免递归遍历语法树这种低效的执行方式;同时,在嵌入式系统上,我们可以只部署运行时,不部署编译器。 这三个解决方案,预示了在运行次数远大于编译次数的场合,或在性能要求高的场合,或在嵌入式系统里,想要跨平台和安全性,就非得用第二种设计,也就是字节码+虚拟机的设计。

讲到了这里,相信对 Java, 对 PHP 或者对 Tcl 历史稍微了解的读者都会一拍脑袋顿悟了: 原来这些牛逼的虚拟机都不是天才拍脑袋想出来的,而是被需求和现实给召唤出来的啊!

我们先以 Java 为例,说说在嵌入式场合的应用。Java 语言原本叫 Oak 语言,最初不是为桌面和服务器应用开发的,而是为机顶盒开发的。SUN 最初开发 Java 的唯一目的,就是为了参加机顶盒项目的竞标。嵌入式系统的资源受限程度不必细说了,自然不会允许上面放一个解释器和一个运行时。所以,不管Java 语言如何,Java 虚拟机设计得直白无比,简单无比,手机上,智能卡上都能放上一个 Java 运行时(当然是精简版本的)。 这就是字节码和虚拟机的威力了。

SUN 无心插柳,等到互联网兴起的时候, Java 正好对绘图支持非常好,在 Flash 一统江湖之前,凭借跨平台性能,以 Applet 的名义一举走红。然后,又因为这种设计先天性的能克服性能问题,在性能上大作文章,凭借 JIT 技术,充分发挥上面说到的优点2,再加上安全性,一举拿下了企业服务器市场的半壁江山,这都是后话了。

再说 PHP。PHP 的历史就包含了从第一种设计转化到第二种设计以用来优化运行时性能的历史。 PHP 是一般用来生成服务器网页的脚本语言。一个大站点上的PHP脚本, 一旦写好了,每天能访问千百万次,所以,如果全靠每次都解释,每次都递归执行,性能上是必然要打折扣的。 所以,从 1999年的 PHP4 开始, Zend 引擎就横空出世,专门管加速解释后的 PHP 脚本, 而对应的 PHP 解释引擎,就开始将 PHP 解释成字节码,以支持这种一次解释,多次运行的框架。 在此之前, PHP 和 Perl, 还有 cgi, 还算平分秋色的样子,基本上服务器上三类网页的数量都差不多,三者语法也很类似,但是到了 PHP4 出现之后,其他两个基于第一种设计方案的页面就慢慢消逝了, 全部让位给 PHP。 你读的我的这个 WordPress 博客,也是基于 PHP 技术的,底层也是 Zend 引擎的。 著名的 LAMP 里面的那个 P, 原始上也是 PHP,而这个词真的火起来,也是 99年 PHP4 出现之后的事情。

第二种设计的优点正好满足了实际需求的事情,其实不胜枚举。比如说 在 Lua 和 Tcl 等宿主语言上也都表现的淋漓尽致。像这样的小型语言,本来就是让运行时为了嵌入其他语言的,所以运行时越小越好,自然的,就走了和嵌入式系统一样的设计道路。

7. 结语

其实第二种设计也不是铁板一块,里面也有很多流派,各派有很多优缺点,也有很多细致的考量,下一节,如果不出意外,我将介绍我最喜欢的一个内容: 下一代虚拟机:寄存器还是栈。

说了这么多,最后就是一句话,有时候我们看上去觉得一种设计好像是天外飞仙,横空出世,其实其后都有现实,需求等等的诸多考量。虚拟机技术就是这样,在各种需求的引导下,逐渐的演化成了现在的样子。