V8引擎分析

Google 的 Chrome 中的 V8 JavaScript 引擎,由于性能良好吸引了相当的注目。它是 Google 特别为了 Chrome 可以高速运行网页应用 (WebApp) 而开发的。 Chrome 利用 Apple 领导的 WebKit 研发计划作为渲染引擎( Rendering engine )。 WebKit 也被用在 Safari 浏览器中。 WebKit 的标准配备有称为 JavaScriptCore 的 JavaScript 引擎,但 Chrome 则以 V8 取代之


V8 开发小组是一群程序语言专家。核心工程师 Lars Bak 之前研发了 HotSpot ,这是用在 Sun Microsystems 公司开发的 Java 虚拟机器( VM )之加速技术。他也在美国的 Animorphic Systems 公司(于 1997 年被 Sun Microsystems 所并购)研发了称为 Strongtalk 的实验 Smalltalk 系统。 V8 充分发挥了研发 HotSpot 和 Strongtalk 时所获得的知识。

开发自己的 JavaScript 引擎   Apple 的 Safari 和 Google 的 Chrome 使用相同的渲染引擎。配有 JavaScriptCore 的 WebKit 渲染引擎在 JavaScript 引擎中是标准配备,但在 Chrome 却被 V8 取代了 .

高速引擎的需求

Google 研发小组在 2006 年开始研发 V8 ,部分的原因是 Google 对既有 JavaScript 引擎的执行速度不满意。我认为当时 JavaScript 引擎很慢是有两个原因的:开发的历史背景,以及 JavaScript 语言的复杂性。

 

JavaScript 存在至少 10 年了。在 1995 年,它出现在网景 (Netscape Communications) 公司所研发的网页浏览器 Netscape Navigator 2.0 中。然而有段时间人们对于性能的要求不高,因为它只用在网页上少数的动画、交互操作或其它类似的动作上。 ( 最明确的是为了减少网络传输,以提高效率和改善交互性! ) 浏览器的显示速度视网络传输速度以及渲染引擎( rendering engine )解析 HTML 、风格样式表( cascading style sheets, CSS )及其他代码的速度而定。浏览器的开发工作优先提升渲染引擎的速度,而 JavaScript 的处理速度不是太重要。同时出现的 Java 有相当大的进步,它被做得愈来愈快,以便和 C++ 竞争。

 

然而,在过去几年, JavaScript 突然受到广泛使用。原因是之前被当成桌面应用的软件(其中包括 Office 套件等),现已成为可以在浏览器中执行的软件。

 

Google 本身就推出了好几款 JavaScript 网络应用,其中包括它的 Gmail 电子邮件服务、 Google Maps 地图数据服务、以及 Google Docs office 套件。

 

这些应用表现出的速度不仅受到服务器、网络、渲染引擎以及其他诸多因素的影响,同时也受到 JavaScript 本身执行速度的影响。然而既有的 JavaScript 引擎无法满足新的需求,而性能不佳一直是网络应用开发者最关心的。

 

语言本身的问题

JavaScript 语言的规范现在性能压力巨大。例如,这在当它判定变量类型时就相当显而易见。如 C++ 和 Java 等主流语言采用静态类型 (static typing)。当代码编译时,就可宣告变量类型。由于不需要在执行期间检查数据类型,因此静态类型占有性能上的优势。

 

在例如 C++ 和 Java 等一般处理系统中, fields* 和 methods* 等的内容是以数组储存,以 1:1 位移( offset )对应 fields 和 methods 等的名称(图 2)。个别变量和 methods 等储存的位置,是针对各个类定义的。在 C++ 和 Java 等语言中,已事先知道所存取的变量(类)类型,所以语言解释系统 (Interpreting system) 只要利用数组和位移来存取 field 和 method 等。位移使它只要几个机器语言指令,就可以存取 field 、找出 field 或执行其他任务。


 JavaScript 和 C++ 、 Java 的不同   C++ 、 Java 及其他处理系统将 fields 和 methods 等,以它们的名称以 1:1 对应数组内的位移值储存在数组中。会事先知道要存取的变量类型(类),因此可以只用数组和位移就可以存取fields 和 methods 等。然而在 JavaScript ,个别的对象都有自己属性和方法等的表格。每一次程序存取属性或是呼叫方法时,都必须检查对象的类型并执行适当的处理。

* Field :属对象的变量。 C++ 中称为成员变量。

* Method :属对象的处理类型。 C++ 中称为成员函式。

* Property 属性: JavaScript 属性是对象自己拥有的变量。在 JavaScript 中,属性中不只可以是标准的值,也可以是 methods 。

* Hash table 哈希表:一种数据结构会传回与特定关键相关之对应值。它有一个内部数组,使用键值 (key) 所产生之 Hash 值作为数组中特定位置清单值的位移。如果刚好在相同的位置上产生不同关键之 Hash 值时,清单位置会储存多个值,这意味着在传回任何值之前必须先检查 Hash 值是否符合。

而另外一方面, JavaScript 则是利用动态类型 (dynamic typing) 。 JavaScript 变量没有类型,而所指定对象的类型在第一次执行时(换言之,动态地)就已判定了。每次在 JavaScript 中存取属性 (property) ,或是寻求方法等,必须检查对象的类型,并照着进行处理。

许多 JavaScript 引擎都使用哈希表( hash table )来存取属性和寻找方法等。换言之,每次存取属性或是寻找方法时,就会使用字符串作为寻找对象哈希表的键 (key) 



属性存取时的内部 JavaScript 处理    使用对象 x 哈希表的字符串「 foo 」作为搜寻「 foo 」内容的关键字。

 

搜寻哈希表是一个连续动作,包含从散列 (hashing) 值中判定数组内位置,然后查看该位置的键值( key )是否符相等。然后可以使用位移直接读取数据的数组比较起来,利用此方法存取较费时。

 

使用动态类型的其他语言,还有 Smalltalk 和 Ruby 等。这些语言基本上也是搜寻哈希表,但它们利用类来缩短搜寻时间。然而, JavaScript 没有类。除了「 Numbers 」指示数字值、「 Strings 」为字符串以及其他少数几种类型外,其他对象都是「 Object 」型。程序员无法宣告类型(类),因此无法使用明确的类型来加速处理。

 

JavaScript 的弹性允许在任何时间,在对象上新增或是删除属性和方法等(请参阅附录)。 JavaScript 语言非常动态,而业界的一般看法是动态语言比 C++ 或 Java 等静态语言更难加速。尽管有困难,但 V8 利用好几项技术来达到加速的目的 :

 

1. JIT 编译 (JIT Compile)

不用字节码 (bytecode) 生成机器语言

 

从性能的角度来看, V8 具有 4 个主要特性。首先,它在执行时以称为及时( just-in-time, JIT )的编译方法,来产生机器语言。这是个普遍用来改善解释速度的方法,在 Java 和 .NET 等语言中也可以发现此方法。 V8 比 Firefox 中的 SpiderMonkey JavaScript 引擎,或 Safari 的 JavaScriptCore 等竞争引擎还要早的实践了这一技术。

 

V8 JIT 编译器在产生机器语言时,不会产生中间码(图 4 )。例如,在 Java 编译器先将原始码转换成一个以虚拟中间语言(称为字节码, bytecode) 表示的 一类文件 (class file) 。 Java 编译器和字节码编译器产生字节码,而非机器语言。 Java VM 按顺序地在执行中解释字节码。此执行模式称为字节码解释器 (bytecode interpreter) 。 Firefox 的 SpiderMonkey 具有一个内部的字节码编译器和字节解释器,将 JavaScript 原始码转换成它自家特色的字节代码,以便执行。


V8 的 JIT 编译器直接输出机器语言 程序语言系统先使用语法分析器将原始码转换成抽象语法树( abstract syntax tree, AST )。之前有几种方式来处理。字节码编译器将抽象语法树编译为中间代码,然后在编译器中执行。如 Java JIT 等混合模式将这中间代码的一部分编译成机器语言,以改善处理性能。 Chrome 不使用中间代码, JIT 直接从抽象语法树来编译机器语言。也有抽象语法树解释器,直接解析抽象语法树。

 

事实上, Java VM 目前使用一个以 HotSpot 为基础的 JIT 编译器。它扮演字节码解释器的角色,来解析代码,将常执行的代码区块转换成机器语言然后执行,这就是混合模式 (hybrid model) 。

 

字节码解释器、混合模式等等,具有制作简单且有绝佳可移植性的优点。只要是引擎可以编译的原始码,那么就可以在任何 CPU 架构上执行字节码,这正是为什么该技术被称为「虚拟机 (VM) 」的原因。即使在产生机器代码的混合模式中,可以借由编写字节码的解释器开始进行开发,然后实现机器语言生成器。通过使用简单的位元码,在机器代码产生时,要将输出最佳化就变得容易许多。

 

V8 不是将原始程序转换成中间语言,而是将抽象语法直接产生机器语言并加以执行。没有虚拟机,且因为不需要中间表示式,程序处理会更早开始了。然而,另一方面,它也丧失了虚拟机的好处,例如透过字节码解释器和混合模式等,所带来的高可移植性 (portability) 和优化的简易性等。

 

2. 垃圾 回收管理

Java 标准特性的精妙实现

 

第二个关键的特性是, V8 将垃圾回收管理( garbage collection, GC* )实作为「精确的 GC* 」。相反的,大部分的 JavaScript 引擎、 Ruby 及其他语言编译器都是使用保守的 GC*(conservative GC) ,因为保守的 GC 实作简单许多。虽然精确的 GC 更为复杂,但也有性能上的优点。 Oracle(Sun) 的 Java VM 就是使用精确 GC 。

 

* Garbage collection ( GC )垃圾回收管理:自动侦测被程序保留但已不再使用的存储器空间并释放。

* 保守 ( conservative ) GC :没有分别严格管理指标器和数字值之存储器回收管理。此方法是如果它可以成为指标,那就以指标来看待它,即使它可能个数值。此方法防止对象被意外回收,但它也无法释出可能的存储器。

 

虽然精确 GC 本身就是高效率的,但以精确 GC 为基础的高级算法,如分代 (Generational) GC 、复制 (copy) GC 以及标记和精简处理( mark-and-compact processing )等在性能上有明显的改善。分代 (Generational) GC 藉由分开管理「年青分代 (Young Generational) 」对象(经常收集)和「旧分代 (Old Generational) 」对象(相对长寿的对象)而提升了 GC 效率。

 

V8 使用了分代 (Generational)GC ,在新分代 (Generational) 处理上使用轻度 (light-load) 复制 GC ,而在旧 GC 上使用标记和精简 GC ,因为它须在内存空间内移动对象。这很难在保守 GC 中执行。在对象的复制中,压缩( compaction )(在硬盘方面称为 defrag )和类似动作时,对象的地址会改变,且基于这个原因,最普遍的方法是用「句柄」( handles )间接地引用地址。然而, V8 不使用句柄 (handles) ,而是重写该对象引用的所有数据。不使用句柄 (handles) 会使实现更困难,但却能改善性能因为少了间接引用。 Java VM HotSpot 也使用相同的技术。

 

3. 内嵌缓存 (inline cache)

JavaScript 中不可用?

 

V8 目前可以针对 x86 和 ARM 架构产生适合的机器语言。虽然没采用 C++或 Java 中传统的优化方式, V8 还是有动态语言与生俱来的速度。

 

其中一项良好范例是内嵌缓存( inline cache ),这项技巧可以避免方法呼叫和属性存取时的哈希表搜寻。它可以立即缓存之前的搜寻结果,因此称为「内嵌」。人们知道此技术已有一段时间了,已经被应用在 Smalltalk 、 Java 和 Ruby 等语言中。

 

内嵌缓存假设对象都有类型之分,但在 JavaScript 语言中却没有。直到 V8 出现后,而这就是为什么以前的 JavaScript 引擎都没有内嵌缓存的原因。

 

为了突破此限制, V8 在执行时就分析程序操作,并利用「隐藏类」( hidden classes )为对象指定暂时的类。有了隐藏类,即使是 JavaScript 也可以使用内嵌缓存。但是这些类是提升执行速度之技巧,不是语言规范的延伸。所以它们无法在 JavaScript 代码中引用。

 

4. 隐藏 

储存类型转换信息

 

隐藏类为没有类之分的 JavaScript 语言规范带来有趣的挑战,同时也是 V8 用来提升速度最独特的技巧。它们值得更深入的探究。

 

在 V8 中建立类有两个主要的理由,即( 1 )将属性名称相同的对象归类,及( 2 )识别属性名称不同的对象。前一类中的对象有完全相同的对象描述,而这可以加速属性存取。

 

在 V8 ,符合归类条件的类会配置在各种 JavaScript 对象上。对象引用所配置的类(图 5 )。然而这些类只存在于 V8 作为方便之用,所以它们是「隐藏」的。



V8 对象有隐藏类的引用   如果对象的描述是相同的,那么隐藏类也会相同。在此范例中,对象 p 和 q 都属于相同的隐藏类

我上面提到随时可以在 JavaScript 中新增或删除属性。然而当此事发生时会毁坏归类条件(归纳名称相同的属性)。 V8 借由建立属性变化所需的新类来解决。属性改变的对象透过一个称为「类型转换 (class transition) 」的程序纳入新级别中。

第二个目标-识别属性名称不同的对象-则是借由建立新类来达成。然而,如果每一次属性改变就建立一个新类的话,那就无法持续达到第一个目标了(归纳名称相同的属性)。


配置新类:类型转换 属性改变的对象会被归为新类。当对象 p 增加了新属性 z 时,对象 p 就会被归为新类。

V8 将变换信息储存在类内,来解决此问题。考量图 7 ,它说明了图 6 中所示的情形,当隐藏类 Point 有 x 和 y 属性时,新属性 x 就会新增至 Point级的对象 p 中。当新属性 z 加到对象 p 时, V8 会将「新增属性 p ,建立 Point2 类」的信息储存在 Point 级的内部表格中


在类中储存类变换信息当在对象 p 中加入新属性 z 时, V8 会在 Point 类内的表格上记录「加入属性 z ,建立类 Point2 」(步骤 1 )。当同一 Point 类的对象 q 加入属性 z 时, V8 会先搜寻 Point 类表。如果它发现了 Point2 类已加入属性 z 时,就会将对象 q 设定在 Point2 类(步骤 2 )。

 

当新属性 z 新增至也是 Point 级的对象 q 时, V8 会先搜寻 Point 级的表格,并发现 Point2 级已加入属性 z 。在表格中找到类时,对象 q 就会被设定至该类( Point2 ),而不建立新类(图 7 ,步骤 2 )。这就达到了归纳属性名称相同的对象之目的。

 

然而此方法,意味着与隐藏类对应的空对象会有庞大的转换表格。 V8 透过为各个建构函数建立隐藏类来处理。如果建构函数不同,就算对象的陈述(layout) 完全相同,也会为它建立一个新的隐藏类。

 

内嵌缓存

其它的 JavaScript 引擎和 V8 不同,它们将对象属性储存在哈希表中,但 V8 则将它们储存在数组中。位移信息-指定个别属性在数组中的位置-是储存在隐藏类的哈希表中。同一隐藏类的对象具有相同的属性名称。如果知道对象类,那么就可以利用位移依数组操作存取属性。这比搜寻哈希表快许多。

 

然而,在 JavaScript 等动态语言中,很难事先知道对象类型。例如,图 8的原始码为对象类型 p 和 q 呼叫 lengthSquared() 函数。对象类型 p 和 q 的属性不同,隐藏类也不同。因此无法判定 lengthSquared() 函数代码的参数(arguments )类型。

 

若要读取函数中的对象属性,必须先检查对象的隐藏类,并有搜寻类的哈希表,以找出该属性的位移。然后利用位移存取数组。尽管是在数组中存取属性,要先搜寻哈希表的需求就毁掉了使用数组的优点。

 

然而,从不同的观点来看,情况有所不同。在实际的程序中,依赖代码执行判断类型的情况并不多。例如,在图 8 的 lengthSquared() 函数甚至假设大部分通过成为参数的值,都是 Point 类对象,而一般而言这是正确的。

 

function lengthSquared(p) {
   return p.x* p.x+ p.y* p.y;
}

function LabeledLocation(name, x, y) {
   this.name= name;
   this.x= x;
   this.y= y;
}

var p= new Point(10, 20);

var q= new LabeledLocation("hello", 10, 20);

 
var plen= lengthSquared(p);
var qlen= lengthSquared(q);


图 8 代码样本: JavaScript 无法判断函数参数类型 在执行之前根本无法判断参数是 Point 型或是 lengthSquared() 函数的 LabeledLocation 型。

 

内嵌缓存是一项加速技术,此设计是为了利用程序中局部( local )类别的方法。若要程序化的属性存取, V8 会产生一个指令串来搜寻隐藏类列表(图 9 )。此代码称为 premonomorphic stub 。此 stub 是为了在函数存取属性(图 10 )。 Premonomorphic stub 拥有两个信息:搜寻用的隐藏类,以及取自隐藏的位移。最后会产生新代码以缓存此信息(图 11 )。

 

Object* find_x_for_p_premorphic(Object* p) {
    Class* klass= p->get_class();
    int offset = klass->lookup_offset("x");
    update_cache(klass, offset);
    return p->properties[offset];
}

图 9 在伪代码( pseudocode )中的 premonomorphic stub   从隐藏类中取得属性位移。


premonomorphic stub 呼叫存取函数中的属性时会呼叫 premonomorphic stub 。

 

Object* find_x_for_p_monomorphic(Object* p) {
    if (CACHED_KLASS == p->get_class()) {
        return p->properties[CACHED_OFFSET];
    } else {
        return lookup_property_on_monomorphic(p, "x");
    }
}

图 11 伪代码的 monomorphic stub   处理直接嵌入代码中的位移是用来存取属性的常数。

 

在搜寻表格之前,带有属性的对象之隐藏类会与缓存隐藏类比较。如果相符就不需要再搜寻,且可以使用缓存的位移来存取属性。如果隐藏类不相符,就透过隐藏类哈希表以一般方式判断位移。

 

新产生的代码被称为 monomorphic stub 。「内嵌」这个字的意思是查询隐藏类所需的位移,是以立即可用的形式嵌入在所产生的代码中。当第一次叫出 monomorphic stub 时,它会将功能从 pre-monomorphic stub 位址中所叫出的第一个位址重写成 monomorphic stub 位址(图 12 )。自此,使用高速的 monomorphic stub ,单靠类比较和数组存取就可以处理属性存取。


monomorphic stub 呼叫   当呼叫 monomorphic stub 时,它会将功能从 premonomorphic stub 位址中叫出的第一个位址,重写成 monomorphic stub 位址。

 

如果只有一个具有属性的对象, monomorphic stub 的效率就会很高。然而,如果类型愈多,缓存失误就会更频繁,进而降低 monomorphic stub 的效率。

 

当缓存失误时, V8 藉由产生另一个称为 megamorphic stub 的代码来解决(图 13 )。与个别类对应的 monomorphic stub 都写在哈希表中,其在执行时搜寻和叫出 stub 。如果没有类型对应的 monomorphic stub 时,就会从类型哈希表中搜寻位移。

Object* find_x_for_p_megamorphic(Object* p) {
    Class* klass= p->get_class();
    //内嵌处理实际的搜寻
    Stub* stub= klass->lookup_cached_stub("x")

    if (NULL != stub) {
        return (*stub)(p);
    } else {
        return lookup_property_on_megamorphic(p, "x");
    }
}

图 13 伪代码中的 Megamorphic stub 处理 与类型对应的 monomorphic stub 事先储存在哈希表中,并在执行时被搜寻和叫出。如果无法找到对应的 monomorphic stub ,就会在类型哈希表中搜寻位移。

 

当 monomorphic stub 发生缓存失误时, monomorphic stub 会将功能从 monomorphic stub 位址叫出的第一个位址以 megamorphic stub 位址重写。在代码搜寻方面, megamorphic stub 的性能比 monomorphic stub 低,但是 megamorphic 代码却比使用缓存更新、代码生成及其他辅助处理的 premonomorphic stubs 快许多。

 

涵盖多种类的内嵌缓存称为多型态内嵌缓存 ( polymorphic inline cache )。 V8 内嵌缓存系统被用来呼叫方法以及存取属性。

 

机器语言的特性

如以上所述, V8 在设计时使用了例如内嵌缓存等,来达到动态语言中天生的速度。创建使用于内嵌缓存之 stub 的机器语言生成模块密切地与 JIT 编译器连结。

 

一些经常使用的方法也被写成机器语言以达到与内嵌拓展相同的效果,使它们成为「内在」的。 V8 原始码列出了内在转换的候选名单。

V8 所含的 shell 程序可以用来检查 V8 所产生的机器语言。所产生的指令串可以和 V8 代码比较,以便显出它的特性。

例如,在执行图 14a 所示的 JavaScript 函数时,就会产生一个如图 14b 所示的 x86 机器语言指令串。此函数在第 39 个指令中被呼叫,是个「 n+one」加法。在 JavaScript 中,「 + 」操作数指示数字变量的加法,以及字符串的连续性。编译器不是产生代码来判决这是哪一种,而是呼叫函数来负责判断。


V8 从 JavaScript 代码产生的机器语言 加法处理被转换成函数呼叫的机器语言( a 、 b )。

 

如果图 14 的函数稍做更改(图 15 ),那图 14b 的函数呼叫就会消失,但会有个加法指令(第 20 ),及分支指令( JNZ 的若不是零就跳出,第 31)。当使用整数作为「 + 」操作数的操作数, V8 编译器在不呼叫函数下会产生一个有「加法」指令的指令串。如果发现操作数(在此为「 n 」)成了Number 对象或 String 对象等的指标( pointer ),

就会叫出函数。「加法」只会发生在当两个「 + 」运算的操作数都是整数时。在这种情况下,因为可以跳过函数呼叫所以执行就会比较快。


V8 从图 14 之 JavaScript 中所产生的机器语言,经小幅修改

 

此外, 0x2 会加上「加法」指令,因为为最低有效位( least significant bit, LSB )被用来区别整数( 0 )和指标( 1 )。加 0x2 (二进制中的十)就如同在该值加上 1 , LSB 除外。在 jo 指令的溢位( overflow )处理中,利用测试和 jnz 指令来判定指标,跳到下游处理(注 1 )。

 

这类的窍门在编译器中到处都有。然而,产生器代码也透露了编译器的限制。具传统最佳化的编译器可以针对图 14 和 15 产生完全一样的机器语言,这是由于常数进位的关系。然而 V8 编译器是在抽象语法树 * ( abstract syntax tree )单元中产生代码,因此在处理延伸多个节点时就没有最佳化。这在大量的 push 和 pop 指令也非常明显。


显示了 C 语言里相同的处理提供参考。由于 C 和 JavaScript 之间的语言规范不同,因此所产生的机器语言是图 14 和图 15 的不同,这和编译器的性能无关。

图 16 C 编译器从 C 代码所产生的机器语言 所产生的机器语言比 V8 所产生的干净许多( a 、 b ),大部分是因为 C 和 JavaScript 语言规范的差异所致。

注 1 :当溢位信号出现时, jo 指令会跳至特定的位址。测试指令将逻辑AND 结果反映成零和符号指标等。除非零信号出现,否则 jnz 指令会跳至特定的位址。

* Abstract syntax tree 抽象语法树:在树状架构中代表程序架构的数据。




  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值