V8 Javascript 引擎设计理念
本文翻译自 Google 的开源 Javascript 引擎 V8 的在线文档。其实我都没有真正翻译过什么东西,本来我的英文就比较一般,中文语言组织也很弱。而且许多文档(比如这篇)基本上如果是对此感兴趣的人,直接阅读英文原文文档肯定都是没有问题的。不过既然突然心血来潮,就试一试吧,能力总是要锻炼才会有的。我自己对 Language VM 比较感兴趣,V8 其实并不是一个 VM ,因为它是直接编译为本地机器码执行的,但是也有不少相通的地方。废话少说,下面是译文。
Netscape Navigator 在 90 在年代中期对 JavaScript 进行了集成,这让网页开发人员对 HTML 页面中诸如 form 、frame 和 image 之类的元素的访问变得非常容易。由此 JavaScript 很快成为了用于定制控件和添加动画的工具,到 90 年代后期的时候,大部分的 JavaScript 脚本仅仅完成像“根据用户的鼠标动作把一幅图换成另一幅图”这样简单的功能。
随着最近 AJAX 技术的兴起,JavaScript 现在已经变成了实现基于 web 的应用程序(例如我们自己的 Gmail)的核心技术。JavaScript 程序从聊聊几行变成数百 KB 的代码。JavaScript 被设计于完成一些特定的任务,虽然 JavaScript 在做这些事情的时候通常都很高效,但是性能已经逐渐成为进一步用 JavaScript 开发复杂的基于 web 的应用程序的瓶颈。
V8 是一个全新的 JavaScript 引擎,它在设计之初就以高效地执行大型的 JavaScript 应用程序为目的。在一些性能测试中,V8 比 Internet Explorer 的 JScript 、Firefox 中的 SpiderMonkey 以及 Safari 中的 JavaScriptCore 要快上数倍。如果你的 web 程序的瓶颈在于 JavaScript 的运行效率,用 V8 代替你现在的 JavaScript 引擎很可能可以提升你的程序的运行效率。具体会有多大的性能提升依赖于程序执行了多少 JavaScript 代码以及这些代码本身的性质。比如,如果你的程序中的函数会被反复执行很多遍的话,性能提升通常会比较大,反过来,如果代码中有很多不同的函数并且都只会被调用一次左右,那么性能提升就不会那么明显了。其中的原因在你读过这份文档余下的部分之后就会明白了。
V8 的性能提升主要来自三个关键部分:
快速属性访问
JavaScript 是一门动态语言,属性可以在运行时添加到或从对象中删除。这意味着对象的属性经常会发生变化。大部分 JavaScript 引擎都使用一个类似于字典的数据结构来存储对象的属性,这样每次访问对象的属性都需要进行一次动态的字典查找来获取属性在内存中的位置。这种实现方式让 JavaScript 中属性的访问比诸如 Java 和 Smalltalk 这样的语言中的成员变量的访问慢了许多。成员变量在内存中的位置离对象的地址的距离是固定的,这个偏移量由编译器在编译的时候根据对象的类的定义决定下来。因此对成员变量的访问只是一个简单的内存读取或写入的操作,通常只需要一条指令即可。
为了减少 JavaScript 中访问属性所花的时间,V8 采用了和动态查找完全不同的技术来实现属性的访问:动态地为对象创建隐藏类。这并不是什么新的想法,基于原型的编程语言 Self 就用 map 来实现了类似的功能(参见 An Efficient Implementation of Self, a Dynamically-Typed Object-Oriented Language Based on Prototypes )。在 V8 里,当一个新的属性被添加到对象中时,对象所对应的隐藏类会随之改变。
下面我们用一个简单的 JavaScript 函数来加以说明:
function Point(x, y) { this.x = x; this.y = y; }
当 new Point(x, y)
执行的时候,一个新的 Point
对象会被创建出来。如果这是
Point
对象第一次被创建,V8 会为它初始化一个隐藏类,不妨称作 C0
。因为这个对象还没有定义任何属性,所以这个初始类是一个空类。到这个时候为止,对象
Point
的隐藏类是 C0
。
执行函数 Point
中的第一条语句(this.x = x;
)会为对象 Point
创建一个新的属性
x
。此时,V8 会:
- 在
C0
的基础上创建另一个隐藏类C1
,并将属性x
的信息添加到C1
中:这个属性的值会被存储在距Point
对象的偏移量为 0 的地方。 - 在
C0
中添加适当的类转移信息,使得当有另外的以其为隐藏类的对象在添加了属性x
之后能够找到C1
作为新的隐藏类。此时对象Point
的隐藏类被更新为C1
。
执行函数 Point
中的第二条语句(this.y = y;
)会添加一个新的属性 y
到对象
Point
中。同理,此时 V8 会:
- 在
C1
的基础上创建另一个隐藏类C2
,并在C2
中添加关于属性y
的信息:这个属性将被存储在内存中离Point
对象的偏移量为 1 的地方。 - 在
C1
中添加适当的类转移信息,使得当有另外的以其为隐藏类的对象在添加了属性y
之后能够找到C2
作为新的隐藏类。此时对象Point
的隐藏类被更新为C2
。
咋一看似乎每次添加一个属性都创建一个新的隐藏类非常低效。实际上,利用类转移信息,隐藏类可以被重用。下次创建一个 Point
对象的时候,就可以直接共享由最初那个
Point
对象所创建出来的隐藏类。例如,如果又一个 Point
对象被创建出来了:
- 一开始
Point
对象没有任何属性,它的隐藏类将会被设置为C0
。 - 当属性
x
被添加到对象中的时候,V8 通过C0
到C1
的类转移信息将对象的隐藏类更新为C1
,并直接将x
的属性值写入到由C1
所指定的位置(偏移量 0)。 - 当属性
y
被添加到对象中的时候,V8 又通过C1
到C2
的类转移信息将对象的隐藏类更新为C2
,并直接将y
的属性值写入到由C2
所指定的位置(偏移量 1)。
尽管 JavaScript 比通常的面向对象的编程语言都要更加动态一些,然而大部分的 JavaScript 程序都会表现出像上述描述的那样的运行时高度结构重用的行为特征来。使用隐藏类主要有两个好处:属性访问不再需要动态字典查找了;为 V8 使用经典的基于类的优化和内联缓存技术创造了条件。关于内联缓存的更多信息可以参考 Efficient Implementation of the Smalltalk-80 System 这篇论文。
动态机器码生成
V8 在第一次执行 JavaScript 代码的时候会将其直接编译为本地机器码,而不是使用中间字节码的形式,因此也没有解释器的存在。属性访问由内联缓存代码来完成,这些代码通常会在运行时由 V8 修改为合适的机器指令。
在第一次执行到访问某个对象的属性的代码时,V8 会找出对象当前的隐藏类。同时,V8 会假设在相同代码段里的其他所有对象的属性访问都由这个隐藏类进行描述,并修改相应的内联代码让他们直接使用这个隐藏类。当 V8 预测正确的时候,属性值的存取仅需一条指令即可完成。如果预测失败了,V8 会再次修改内联代码并移除刚才加入的内联优化。
例如,访问一个 Point
对象的 x
属性的代码如下:
point.x
在 V8 中,对应生成的机器码如下:
; ebx = the point object cmp [ebx, <hidden class offset>], <cached hidden class> jne <inline cache miss> mov eax, [ebx, <cached x offset>]
如果对象的隐藏类和缓存的隐藏类不一样,执行会跳转到 V8 运行系统中处理内联缓存预测失败的地方,在那里原来的内联代码会被修改以移除相应的内联缓存优化。如果预测成功了,属性
x
的值会被直接读出来。
当有许多对象共享同一个隐藏类的时候,这样的实现方式下属性的访问速度可以接近大多数动态语言。使用内联缓存代码和隐藏类实现属性访问的方式和动态代码生成和优化的方式结合起来,让大部分 JavaScript 代码的运行效率得以大幅提升。
高效的垃圾收集
V8 会自动回收不再被对象使用的内存,这个过程通常被称为“垃圾收集(Garbage Collection)”。为了保证快速的对象分配和缩短由垃圾收集造成的停顿,并杜绝内存碎片,V8 使用了一个 stop-the-world, generational, accurate 的垃圾收集器,换句话说,V8 的垃圾收集器:
- 在执行垃圾回收的时候会中断程序的执行。
- 大部分情况下,每个垃圾收集周期只处理整个对象堆的一部分,这让程序中断造成的影响得以减轻。
- 总是知道内存中所有的对象和指针所在的位置,这避免了非 accurate 的垃圾收集器中普遍存在的由于错误地把对象当作指针而造成的内存溢出的情况。
在 V8 中,对象堆被分成两部分:用于为新创建的对象分配空间的部分和用于存放在垃圾收集周期中生存下来的那些老的对象的部分。如果一个对象在垃圾收集的过程中被移动了,V8 会更新所有指向这个对象的指针到新的地址。