Categories

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

map_trans_a

执行函数 Point 中的第一条语句(this.x = x;)会为对象 Point 创建一个新的属性 x。此时,V8 会:

  • C0 的基础上创建另一个隐藏类 C1,并将属性 x 的信息添加到 C1 中:这个属性的值会被存储在距 Point 对象的偏移量为 0 的地方。
  • C0 中添加适当的类转移信息,使得当有另外的以其为隐藏类的对象在添加了属性 x 之后能够找到 C1 作为新的隐藏类。此时对象 Point 的隐藏类被更新为 C1

map_trans_b

执行函数 Point 中的第二条语句(this.y = y;)会添加一个新的属性 y 到对象 Point 中。同理,此时 V8 会:

  • C1 的基础上创建另一个隐藏类 C2,并在 C2 中添加关于属性 y 的信息:这个属性将被存储在内存中离 Point 对象的偏移量为 1 的地方。
  • C1 中添加适当的类转移信息,使得当有另外的以其为隐藏类的对象在添加了属性 y 之后能够找到 C2 作为新的隐藏类。此时对象 Point 的隐藏类被更新为 C2

map_trans_c

咋一看似乎每次添加一个属性都创建一个新的隐藏类非常低效。实际上,利用类转移信息,隐藏类可以被重用。下次创建一个 Point 对象的时候,就可以直接共享由最初那个 Point 对象所创建出来的隐藏类。例如,如果又一个 Point 对象被创建出来了:

  • 一开始 Point 对象没有任何属性,它的隐藏类将会被设置为 C0
  • 当属性 x 被添加到对象中的时候,V8 通过 C0C1 的类转移信息将对象的隐藏类更新为 C1 ,并直接将 x 的属性值写入到由 C1 所指定的位置(偏移量 0)。
  • 当属性 y 被添加到对象中的时候,V8 又通过 C1C2 的类转移信息将对象的隐藏类更新为 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 会更新所有指向这个对象的指针到新的地址。

30 comments to V8 Javascript 引擎设计理念

  • tbmvp

    虽然我的英语不好,也没看过原文,不过你的翻译很通俗易懂。
    谢谢。

  • […] 而Google也直接改善浏览器。Chrome具有一个多重处理架构,当消耗较多内存时,会因效能和安全因素,自行隔离每个分页。Chrome还有V8 JavaScript引擎,加速JavaScript程序执行。最后,它有内建Gears,一种离线存取网络软件数据的技术。透过Gears,Gmail和Google Docs都有不同程度的离线支持。 […]

  • devil

    翻译很好的说

  • car123472

    您好,非常感谢您的翻译,我是在搜node.js中应用的v8引擎时搜到您的文章的,但是我是个菜鸟,非常初级的初学者,好多概念都不明白,如果您有空的话,能给我介绍一些,像内联缓存这种技术的入门知识可以看什么书获得呢?谢谢!

  • hi,博主的文章非常给力,想转载一下

    另我想问一下具体是如何减少 JavaScript 中访问属性所花的时间,
    因为例子都是创建,没有演示查询

    假设一个查询 this.y 的过程, v8是查询到 C2中,返回 offset 1, 然后读取 内存offset 1地址的数据。

    在Hidden class C2 中,
    For x see offset 0, for y see offset 1

    为何比传统的
    进行一次动态的字典查找来获取属性在内存中的位置
    for x see 内存地址a, for y see 内存地址b

    快?
    渴望解惑中 ^ ^ thx

    • 你好,因为有类的信息的话,实际上属性的(相对)位置是固定的,直接读写即可,例如 this.y 实际上对应到 read_addr(y + 1) 这样类似的执行代码,偏移量 1 是事先计算好的(在编译的时候)。而传统的动态语言里则是要这样(伪代码):

      offset = hash_lookup(this.variable_table, 'y')
      read_addr(y + offset)

      每次访问都要在运行是多一次额外的 hash 表查询,所以会慢。

  • 四不象

    那么创建隐藏类这个工作应该是在语法分析的时候进行的咯?

    如果是运行时进行的也谈不上效率了

    • 见本文第一段:V8 并不是解释执行的,而是编译为本地机器码执行的,所以这个过程是在编译的时候完成的。

      • 四不象

        问题是javascript不是强类型语言,只有在运行时才能判断对象类型,编译时如何决定偏移量呢?

        • JS 是 prototype based ,一个对象的内存布局根据它从哪里 “clone” 过来可以推测出来。再说你如果仔细看本文的话,就知道 V8 虽然是编译型的,但是是 Just-in-time 动态编译的,会有指令替换以及内联之类的机制来实现加速。所以并不是像传统的静态语言那样一次编译就把运行时的东西完全确定下来。

        • 四不象

          明白了,非常感谢。
          PS:评论的回复深度不能操过三层啊?

      • 四不象

        再看了下伪汇编代码,似乎明白了。我是这么理解的:
        V8引擎对于每次对象成员调用都进行了缓存,第一次调用时仍然查询隐藏类信息,第二次调用,如果对象类型未变,那么直接通过第一次缓存的信息,通过偏移量直接调用成员。也就是说,对于使用了duck typing的代码,这个优化是没有用的。比如如下代码:
        function duck_typing(obj) {
        obj.call_same_name_func();
        }
        如果每次传递的obj类型都不同,那么V8的优化就不起作用了。

        • 嗯,毕竟只是优化机制,不可能完全静态化。有些情况也许是无法处理的,还有的情况肯能可以处理但是并不是一两句话能说清楚,具体还得看具体的实现。比如就这里同样可以对参数 obj 做一个缓存的隐藏类,当类型匹配的时候缓存的内联代码就可以用了。甚至可以做更复杂的 statistics ,比如,传到这个函数里次数最多的对象的类型,而不是仅仅是“上一次”传过来的类型。一般牵涉到 JIT 之后情况就会变得好复杂。

        • 四不象

          完全明白了,再次非常感谢,真是热心呀。

  • Spout

    非常感谢大师的,翻译的挺好的,让我这个菜鸟也能基本看懂了,大师,小弟转走一部分文字不要介意啊

  • 谢谢你的翻译,先看了英文的,再看了你的翻译,然后再去看原文,收获很大。
    有一个疑问,如果多个对象共享隐藏类的时候它们怎么能分区各自的偏移量呢?
    比如:
    var point1 = new Point(1,2);
    var point2 = new Point(2,3);
    这个时候point1,与point2 两个对象共享隐藏类。按照上面所说的,这个时候的隐藏类的偏移值所对应的应该是point2对象的值。
    这个时候如果我访问point1的属性的话,这个是怎么处理的呢,因为文章中没有说到这个,或者我思考有误,始终没有想明白这点。

    • 你好,我不确信有没有明白你的意思。类里存放的应该是相对偏移,比如一个属性的偏移是 2 ,而对象本身的地址是 p ,那么对象的这个属性的实际地址应该是 p+2 ,所以不会重复吧?

      • Anghoo

        首先非常感谢你的回复,我再说下我的问题:
        我的意思是一个隐藏类只会存放一个对象的相对偏移
        比如:
        var point1 = new Point(1,2);
        var point2 = new Point(2,3);
        这个时候point1,与point2 两个对象共享一个隐藏类C2,C2只保存了point2对象的偏移值,也就是通过隐藏类C2只找得到point2对象的属性,那么我想知道point1对象的属性,通过这个隐藏类C2并不能得到我们想要的结果。
        是我没有理解清楚呢,还是这篇文章没有解释这个问题呢?

  • Anghoo

    我还是没有理解透,如果两个对象对存在C2里面,并且他们的偏移值都是一样的,
    那系统怎么通过C2来分别找到point1,与point2
    这两个对象系统是用什么来区分

  • 虽然我英文不好,但我觉得本文通俗易明。