竞速(三):JavaScript编译器策略

363 查看

伯乐在线注:英文原文:John Dalziel,感谢@AvisBlume 的热心翻译。如果其他朋友也有不错的原创或译文,可以尝试推荐给伯乐在线。以下是译文。

JavaScript语言广受欢迎的原因很多。首先它分布广泛,其次从开发人员的角度来看,它很快很灵活。JavaScript中的一切都是类,因此快速的创建结构是件非常容易的事,并且完全不需要定义数据类型,因为所有的类型都是可推断的。但也正是这种通用性给编译工作带来了挑战。

 

隐藏类

虽然在JavaScript中创建类和层次结构十分简单,但是对于编译器来说要遍历这些复杂的结构速度会很慢。在C语言中,要存储和读取属性和属性值通常会使用哈希表或者字典。这是一种数组样的结构,通过检索唯一的表示属性名字的字符串,可以将对应的属性值找出来。而问题在于大型哈希表中这一过程可能会很慢。

为了提高检索速度,V8和SpiderMonkey都实现了隐藏类-类在后台的影子。Google将其称为map,Mozilla将其称为shape,但其实是差不多的东西。这种结构的搜索速度要比标准的字典结构快很多。

 

类型推论

JavaScript中的动态类型允许同一个属性在一个地方是Number类型的,而在另一个地方是String类型的。很不幸的是这种通用性会要求编译器创建更多的类型检查条件,而这些条件代码要比类型确定的代码大很多也慢很多。

解决这一问题的方法称为类型推论,现在所有的JavaScript编译器都使用这一方法。编译器检查代码并且对某个属性的数据类型做出一个假设,如果这个推论是正确的,那么就执行有类型的即时编译,将生成一段快速的数据类型确定的机器代码存根;如果类型推测不正确,那么这段代码将会“失效”而进行无类型的即时编译,利用速度较慢的条件代码来使其变得完整。

 

内联缓存

现在的JavaScript编译器中最常见的优化手段是内联缓存技术。该技术30年前在Smalltalk编译器中首次被实现,虽然年代久远,但还十分有用。

内联缓存技术需要用到我们已经讨论过的类型推论和隐藏类。编译器遇到一个新的对象时就会在缓存中生成它的隐藏类,类中的某些成员的类型可能是推论类型。之后如果在代码别处遇到,那么就可以将其和缓存中的版本进行快速的比较。如果两者匹配,那么之前生成的优化过的机器代码存根就能拿来重用。如果结构或者成员的数据类型已经发生改变,那么可以使用较慢的通用代码。或者像现在的有些编译器那样,甚至能够执行多态内联缓存,也就是会为每种数据类型都生成一个结构相同的机器代码存根。

如果你想对JavaScript中的内联缓存技术有进一步的了解,我推荐阅读下Google V8编译器的工程师Vyacheslav Egorov的文章。他用JavaScript写了一个Lua语法分析器,并非常详细地解释了内联缓存技术。

 

一旦编译器弄清代码结构以及其中的数据类型,它将能够进行全面的优化。下面是几个优化手段:

内联展开

函数调用的计算成本很高,因为它们需要执行某种查找,而查找可能会很慢。内联展开的思想是将被调用函数的函数体完整地插入调用该函数的地方。这能避免分支,生成更快速的代码,但是要付出额外的存储空间代价。

循环不变量代码移动

循环是主要的优化对象。将不必要的计算移出循环可以大幅提升表现。最常见的例子是在for循环中,每次循环都计算数组长度的话将会十分多余。长度可以预先计算出来存在一个变量里,计算长度的操作可以移出循环。

常量折叠

有些常量或变量的值在程序的生命周期中都不会发生改变,常量折叠会将这些量的表达式的值预先计算出来并直接使用这些值。

公共子表达式消除

和常量折叠类似,这项优化会在扫描代码之后将计算结果相同的表达式找出来,然后用一个存有该计算结果的变量代替这些表达式。

死代码消除

死代码是指不会用到的或者不会运行到的代码。如果在程序中有个函数从来没有被调用过,那么绝对没必要去对它进行另外的优化,可以直接安全地将其消除掉,这也能减少程序的大小。

 

这些优化手段仅仅是千里之行第一步。要让JavaScript跑得和native C一样快。这个目标真的能实现吗?在最后一节中我们将会看到一些已经跑得很快的JavaScript项目。

 

英文原文:John Dalziel,编译:@AvisBlume

译文链接:http://blog.jobbole.com/41918/

【非特殊说明,转载必须在正文中标注并保留原文链接、译文链接和译者等信息,谢谢合作!】