[译]JavaScript引擎中的延迟解析
原文:http://ariya.ofilabs.com/2012/07/lazy-parsing-in-javascript-engines.html
现代的JavaScript引擎可以将函数体的解析操作延迟到真正需要的时候再进行,下面我将讲一下为什么要这样做以及引擎具体是如何实现的.
IE团队在最近的一篇博文"IE10和Windows 8中JavaScript性能的发展"中提到了他们使用延迟解析来提升IE10的性能.实际上,IE9正式版已经实现了这种优化手段,只不过IE10更进一步改进了它.文中有这样一句话(其中Chakra是IE的JavaScript引擎的名字):
为了进一步降低指令首次执行的时间,Chakra只有在那个函数即将运行的时候才对它进行解析并生成字节码,这种机制就称之为延迟解析.
让我们通过一个简单的例子看看延迟解析到底是什么,完整的示例代码如下:
function add(x, y) { return x + y; }
function mul(x, y) { return x * y; }
alert(add(40, 2));
声明一个函数add.它接受x和y两个参数.它只包含一条return语句.返回值是x和y的和.
声明一个函数mul.它接受x和y两个参数.它只包含一条return语句.返回值是x和y的积.
调用函数alert.参数是调用函数add的返回值,其中参数是40和2.
有了这个语法树,就能进行后续一系列的操作.一直到最终解释器执行代码,弹出框中显示结果42.你也许已经注意到了,在上面的几步解析操作中,函数mul的解析是完全徒劳的,因为最终alert函数只调用了add函数,mul函数根本没用到.除了这个简单的演示例子以外,其实在真实的Web中,也的确有很多被声明过的函数一次也没被调用过(依据微软的JSMeter research).
不过现代的JavaScript引擎不会这么"尽职尽责"的去一次性地解析所有代码,而是使用了延迟解析(lazy parsing)的方式.相同的代码,解析器的解析结果会变成下面这样:
声明一个函数add,函数体为"{ return x + y; }".
声明一个函数mul,函数体为"{ return x * y; }".
调用函数alert.参数是调用函数add的返回值,其中参数是40和2.
也就是说,解析器没有解析每个函数的函数体中的每句代码,而仅仅是把整个函数体保存下来,到了这个函数真正要运行的时候,再进行解析:
调用函数add.发现它还没有被解析,于是启动解析器解析"{ return x + y; }",解析结果为:
它接受x和y两个参数.它只包含一条return语句.返回值是x和y的和.
大体上讲就是,解析那个函数源码的任务被延迟了,只有在必要的时候,也就是那个函数(add函数)马上要执行的时候才会去解析它.不过此时延迟解析器仍然需要去解析这段函数源码,但这次解析和正常解析的区别是,这次解析的解析过程是被简化的.只有通过这次解析,才能正确的找到整个函数的函数体,也就是function add(x, y) {
,和函数体尾部的}
之间的代码.这项任务不能通过正则表达式或者其他任何形式的扫描来实现,那样不靠谱.由于这种解析是被简化的,只需要快点找到函数结尾的大括号,而不需要做其他的无关操作,这就意味着可以省略掉一些正常解析过程中所必须的操作.首先一个就是,我们不需要去生成语法树,因为这时没人需要这份语法树.另外,不再需要为代码路径在堆内存中分配内存空间,分配内存需要消耗系统资源,避免这项操作可以提高引擎运行速度.
下面举一个现实生活中的例子.比如你偶然发现一篇很好的文章(有可能就是这篇文章),但你决定不马上看,而是在以后真正需要了解这方面知识的时候再来看.所以你需要把文章的正文保存到你的笔记软件里.那么你就需要快速的浏览一下这篇文章,找到它正文的起始处和结束处,这个过程是很快的(比起阅读整篇文章来说).一旦找到了正文的起始处和结束处,你就可以选择这些文字,复制到剪切板里,切换到笔记软件的窗口,最终粘贴到里面.过几天,到了你真正需要用到那篇文章中所讲的内容的时候,你就可以打开那篇笔记,完完整整的读一遍.
让我们通过解析一个while语句来看看正常解析器和延迟解析器两者之间的区别,你肯定已经知道了,while语句的语法如下:
'while' '(' Expression ')' Statement
正常解析器需要解析这些代码并且要生成一个代表该代码结构的抽象语法树.如果把解析器中解析while语句的代码用JavaScript实现(引擎中本来是C++),会是这样:
function realParseWhileStatement()
{
expect('while');
expect('(');
var expression = parseExpression();
expect(')');
var statement = parseStatement();
// 返回AST return {
type: 'WhileStatement',
test: expression,
body: statement
};
}
如果是延迟解析,我们就不再需要保存返回的结果,那么代码就可以简化成:
function lazyParseWhileStatement()
{
expect('while');
expect('(');
parseExpression();
expect(')');
parseStatement();
}
显然,还有其他一些函数来解析各种各样的语法结构.
如果在延迟解析的过程中又遇到一个嵌套的函数声明该怎么办?同样的规则,该函数也会被延迟解析.函数里的盗梦空间,有没有?
实际上,真实的延迟解析器会更复杂一点,它不光需要妥善处理严格模式和解析错误,还得避免堆栈溢出等其他一些细节问题.
接下来,让我们看一下延迟加载在两个主流的JavaScript引擎中是如何实现的.
首先看一下JavaScriptCore (JSC),它被内置在Safari中的Webkit渲染引擎中.JSC的源代码存放在Webkit源码中的Source/JavaScriptCore
目录中,与延迟加载相关的源文件有如下几个:
parser/Parser.h
parser/Parser.cpp
parser/SyntaxChecker.h
TreeBuilder
来做.JSC中有两个可用的TreeBuilder
,ASTBuilder
和SyntaxChecker
.后一个本质上什么都不做,它由解析器驱动,解析器可以一直向前解析直到停下来为止.SyntaxChecker
扮演的实际上是一个类似语法检查器的东西,所以它的名字才会叫成SyntaxChecker
.当SyntaxChecker
在函数体的尾部结束运行时,函数体起始处(左大括号处)和结束处(右大括号处)的位置将被保存下来,这个存储的范围值将在随后真正的解析开始的时候被用到,也就是函数被真正调用的时候.由于JSC已经保存了一份完整的源码,所以只保存范围就可以,没必要再复制一份源码字符串.
V8(使用在Chrome和Node.js中)中的情况也很类似.V8中与延迟解析相关的源码文件有:
src/preparser.cc
src/preparser.h
src/preparser-api.cc
和JSC的不同的是,V8中的正常解析器和延迟解析器使用了两份不同的代码(但有着类似的接口).后者在V8中的术语叫PreParser.当正常解析器解析到一个函数体时,就会调用PreParser,也就是在执行Parser::ParseFunctionLiteral
方法的时候.有意思的是,V8为一种特例做了专门的优化,这个特例就是立即调用函数表达式(immediately invoked function expression 简称IIFE),一种能提供更好的命名空间(namespacing)而不用担心污染全局变量的写法,很适合用来实现模块,比如:
var foobar = (function() {
// 实现代码 // 返回模块对象 })();
function
关键字前面有一个(
,则不要考虑延迟解析,直接对该函数进行真正的解析,比如上面的这个IIFE示例.还有SpiderMonkey(Firefox中的JavaScript引擎)又如何呢?SpiderMonkey目前还没有实现延迟解析,不过已经在实现中了,查看bug 678037可以了解到最新的进展,这一举措可以进一步提升Firefox的性能.
最后要说的是,因为这篇文章很长,你可能一下消化不了,可以大概扫几眼以后再细读.这就叫做"延迟阅读"!