1 概述
1.1 JavaScript语言
说起JavaScript语言,又要讲一个典型的从弱小到壮大的奋斗史。起初,它只是一个非常不起眼的语言,用来处理非常小众的问题。所以,从设计之初,它的目标就是解决一些脚本语言的问题,因为设计的能力有限,性能不需要重点考虑。因为使用场景少,所以用户对于性能的要求也比较低。在过去几年,由于Web时代的来临和HTML5的兴起,这一切让JavaScript前所未有地成为焦点,自然它的功能和性能在大家的努力下都有了长足的进步。
JavaScript是一种脚本语言,主要用在Web的客户端(这样说也不准确,Node.js和其他一些用法的出现就是例外),它的出现是为了控制网页客户端的逻辑,例如同用户的交互、异步通信等需求。当然,在HTML5高速发展的今天,它的作用越来越大,被广泛地使用在各种其他技术中。
本质上它是一种解释型语言(不知道按照现在的实现来看,这么说是不是准确),函数是它的第一等公民,也就是函数也能够当作参数或者返回值来传递。示例代码9-1是一个简单的例子,读者可以看到一个简单的函数“getOperation”,它的返回值就是一个匿名函数。
示例代码9-1 函数作为函数的返回值
function getOperation() { return function () { print("JavaScript"); }; }
JavaScript语言的另一个重大特点就是,它是一种无类型语言,或者说是动态类型语言。相比较而言,C++或者Java等语言都是静态类型语言,它们在编译的时候就能够知道每个变量的类型。但是,JavaScript的语言特性让我们没有办法在编译的时候知道变量的类型,所以只能在运行的时候才能确定,这导致JavaScript语言的规范面临着性能方面的巨大压力。在运行时计算和决定类型,会带来很严重的性能损失,这导致了JavaScript语言的运行效率比C++或者Java都要低很多。
先看示例代码9-2所示的一个简单的JavaScript代码,它是一个简单得不能再简单的包含两个参数的JavaScript函数,其目的就是计算参数a的属性为x的值与参数b的属性为y的值的和。
示例代码9-2 一个简单的JavaScript函数
function add(a, b) { return a.x*a.y + b.x*b.y; // 这里对象a和b的类型未知 }
问题来了,当JavaScript引擎分析到该段代码的时候,根本没有办法知道a和b是什么类型,唯一的办法就是运行的时候根据实际传递过来的对象再来计算。读者可能会好奇,这好像并没什么特别的嘛,事实上这会导致严重的性能问题。
让我们来简单解释一下为什么静态类型能够大量地节省运行时间。示例代码9-3是一个简单的C++函数,它同9-2类似,不同之处在于参数必须指定类型。
示例代码9-3 一个简单的C++函数
int add(Class1 a, Class1 b) { | class Class1 { |
return a.x*a.y + b.x*b.y; | int x; |
} | int y; |
} |
当编译示例代码9-3中左边部分的时候,根据右边部分类型Class1的定义,获取对象a的属性x的时候,其实就是对象a的地址,大小是一个整形。同时获取对象b的属性y的时候,其实就是对象b的地址加上4个字节(不同的平台上可能不同,但是一旦平台确定,其值是固定的),这些都是在生成本地代码的时候确定的,无须在运行本地代码的时候决定它们的地址和类型是什么,这显然能够节省时间。
图9-1 示例代码9-3中的类和对象的结构表示
图9-1中最右侧表示的是类Class1的属性对应的地址信息,在编译阶段,编译器根据int类型来决定属性占用4个字节,地址就是对象的地址,因为偏移量为0。所以对于y来说,访问它只需要将对象地址加上4个字节即可,也就是偏移量为4。所以在编译的时候,能够确定访问对象a中属性的偏移量,根据这些信息,可以生成相应的汇编代码。其中的符号信息,例如字符“x”和“y”运行时都不再需要,因为不再需要额外的查找这些属性地址的工作。在C++和Java等语言中,已事先知道所存取的成员变量(类)类型,所以语言解释系统(Interpreting System)只要利用数组和位移来存取这些变量和方法的地址等。位移信息使它只要几个机器语言指令,就可以存取变量、找出变量或执行其他任务。
现在继续回到JavaScript代码中来,对于传统的JavaScript解释器,所有这一切都是解释执行,所以效率不会高到哪去。不管是解释器还是现在更为高效的JIT(Just-In-Time)技术,面临的难题都是类型问题。现在我们也将JavaScript代码的处理分成两个阶段,就是编译阶段(虽然跟传统的编译有些不同)和执行阶段。对于JavaScript引擎来说,因为没有C++或者Java这样的强类型语言的类型信息,所以JavaScript引擎通常的做法就是如图9-2所表示的方法来存储每一个对象。
图9-2 示例代码9-2的对象a和b的结构表示
基本的工作方式是这样,当创建对象a的时候(这个当然是执行阶段),如果它包含两个属性(根据JavaScript的语言特性,没有类型,而且这些属性都是动态创建的,属性就是前面说的C++的类成员变量),那么引擎会为它们创建如图9-2左边所示的结构,也就是属性名-属性值对,需要强调的是这些属性名(典型的做法就是采用字符串)都是会被保存的,因为之后访问该对象的属性值时需要通过属性名匹配来获取相应的值。读者看到对象b是同样的结构,也同样保存相同的属性,因为JavaScript没有类型,所以每个对象需要自己保存这些信息。在降低性能的同时,读者也会发现它们存在内容冗余的部分,比如对象a和对象b都保存相同的属性名,随着对象的增多,这显然会带来空间上的巨大浪费。
追根究底,这里的目的获取对象属性值的具体位置,也就是相对于对象基地址的偏移位置。从这个角度来看,JavaScript和C++语言(下面的解释需要对C++语言有一些基本的认识)上的区别包括以下几个部分。
- 编译确定位置 :C++有明确的两个阶段,而编译这些位置的偏移信息都是编译器在编译的时候就决定了的,当C++代码编译成本地代码之后,对象的属性和偏移信息都计算完成。因为JavaScript没有类型,所以只有在对象创建的时候才有这些信息,因而只能在执行阶段确定,而且JavaScript语言能够在执行时修改对象的属性(不是属性值,而是添加或者删除属性本身)。
- 偏移信息共享 :C++因为有类型定义,所以所有对象都是按照该类型来确定的,而且不能在执行的时候动态改变类型,因为这些对象都是共享偏移信息的。访问它们只需要按照编译时确定的偏移量即可。而对于C++模板的支持,其实是多份代码,因为本质上其道理是相同的。JavaScript则不同,每个对象都是自描述,属性和位置偏移信息都包含在自身的结构中。
- 偏移信息查找 :C++中查找偏移地址很简单,都是在编译代码时,对使用到某类型的成员变量直接设置偏移量。而对于JavaScript,使用到一个对象则需要通过属性名匹配才能查找到对应的值,这实在太费时间了。
对于这个问题读者可能觉得其对性能的影响不大,其实不是这样。因为对象属性的访问非常普遍而且次数非常频繁,而通过偏移量来访问值并且知道该值的类型,使用少数两个汇编指令就能完成,但是,对于图9-2中的通过属性名来匹配对于性能造成的影响可能会多很多倍,因为属性名匹配需要特别长的时间,而且额外浪费很多内存空间。
有方法解决这一问题吗?答案是肯定的。当然要达到跟C++和Java一样的效率很难,但是已经有很多方法能够逐步接近了,笔者在介绍JavaScriptCore引擎和V8引擎的时候再论述它们,因为这些新技术的确带来了性能上的巨大进步。
推动JavaScript运行速度提高的另一大利器是JIT(Just-In-Time)技术,它不是一项全新的技术,其作用是解决解释性语言的性能问题,主要思想是当解释器将源代码解释成内部表示的时候(Java字节码就是一个典型例子),JavaScript的执行环境不仅是解释这些内部表示,而且将其中一些字节码(主要是使用率高的部分)转成本地代码(汇编代码),这样可以被CPU直接执行,而不是解释执行,从而极大地提高性能。JIT技术被广泛地使用在各种语言的执行环境中,例如Java虚拟机,经过长时间的演进之后,目前使用在JavaScript的众多引擎中,例如JavaScriptCore、V8、SpiderMonkey等中。
下面要说的是JavaScript的作用域链和闭包等概念,它们非常重要,这两个概念带来了编程上的便易性和模块化,本节主要讲述它们的原理,后面会介绍它们是如何被实现的。
首先介绍一个学术解释,“闭包是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分”。通俗来说,就是当执行到一条语句的时候,哪些对象(或者其他环境因素)能够被使用。JavaScript使用作用域链来实现闭包,作用域链由执行环境维护,JavaScript中所有的标识符都是通过作用域链来查找值的。用示例来解释它们比较清楚,示例代码9-4是两段功能类似但是影响却不同的常见JavaScript代码,下面结合闭包和作用域链来分析它们。
假设这一段代码被保存在一个单独的JS文件中,当某个包含该JS文件的网页运行在浏览器中的时候,JavaScript已经预先创建好一个全局的域,该域会包含一个全局的上下文,该上下文可能包含window、navigator(网页中)等内置的对象,同时也包含当前执行位置的一些信息。例如代码9-4中的第一行,当执行到该行时,就定义了“me”并赋值1,上下文就包含了一个“me”变量,接下来的语句就能够使用该变量。图9-3中的全局上下文就包含这些信息。
图9-3 示例代码9-4中左侧所涉及的作用域链
示例代码9-4 使用闭包技术的JavaScript函数
var me = 1; | var me = 1; |
function add(x) { | (function (x) { |
var me = 2; | var me = 2; |
function internal() { | function internal() { |
return me + x; | return me + x; |
} | } |
return internal() + 3; | return internal() + 3; |
} | })(1); |
add(1); |
当执行到左边第二行的时候,函数“add”也被加入全局上下文中(事实上,这有两个阶段,在之前的阶段,“add”已经被加入上下文中,所以在“add”函数声明之前使用它也是可以的),所有的代码都能够使用它。如果不巧在它之前也有同样名为“add”的函数,那么之前的函数会被覆盖。所以,假如我们并不希望“add”被其他地方使用,而且不要覆盖之前的函数,因为这样会污染全局空间,造成不必要的麻烦。一个正确的做法是示例代码9-4中右侧的使用方法,稍后做介绍。
在“add”函数中,执行环境同样会建立一个该函数的上下文,包含该函数中的心理,例如第三行,又是一个变量“me”。该上下文同时会指向全局上下文。继续看代码,到函数“internal”内部,同样如此,执行环境也会为它建立一个上下文,如图9-3中右下角的上下文,指向它的父上下文,这样其实就形成了一个作用域链。在“internal”函数内部,它使用了变量“me”。首先,执行环境检查当前的上下文,查找有无变量“me”,当然在本例中无法找到,于是它会接着在它的父上下文中查找,显然“add”函数的上下文中包含“me”,所以不需要继续向上查找。如果“add”函数上下文中没有包含该变量,那么执行环境会不停向上查找,直到找遍全局上下文为止。
下面解释示例代码9-4右侧函数的好处。当包含一个.js文件的时候,它的全局函数在其他.js文件中也可见,这直接导致名字冲突和模块化问题。因为没有C++的名空间机制和Java的包机制,每个.js文件中的函数命名可能相同,这直接导致名冲突。当开发者只是希望该“add”函数在内部使用的时候,那么他可以像右侧一样使用一个匿名函数,然后直接调用它,这样这个函数就不会污染全局空间。同时,匿名函数内部也使用了一个内部函数“internal”。根据前面介绍的作用域链技术,“internal”函数只在该匿名函数内部有效,完全不会影响其他代码,这里使用的就是闭包技术。
1.2 JavaScript引擎
什么是JavaScript引擎?简单来讲,就是能够将JavaScript代码处理并执行的运行环境。要解释这一概念,需要了解一些编译原理的基础概念和现代语言需要的一些新编译技术。
首先来看C/C++语言。由前面描述可知,处理该语言通常的做法实际上就是使用编译器直接将它们编译成本地代码,这一切都是由开发人员在代码编写完成之后实施的,如图9-4所示。用户只是使用这些编译好的本地代码,被系统的加载器加载执行,这些本地代码由操作系统调度CPU直接执行,无须额外处理。
图9-4 C++编译器生成本地代码的过程
其次,来看看Python等脚本语言。处理脚本语言通常的做法是开发者将写好的代码直接交给用户,用户使用脚本的解释器将脚本文件加载然后解释执行,如图9-5所示。当然,现在Python也可以支持将脚本编译生成中间表示。但是,通常情况下,脚本语言不需要开发人员去编译脚本代码,这主要是因为脚本语言对使用场景和性能的要求与其他类型的语言不同。
图9-5 解释器解释执行过程
然后来看看Java语言。其做法是明显的两个阶段,首先是像C++语言一样的编译阶段,但是,同C++编译器生成的本地代码结果不同,Java代码经过编译器编译之后生成的是字节码,字节码是跨平台的一种中间表示,不同于本地代码。该字节码与平台无关,能够在不同操作系统上运行。在运行字节码阶段,Java的运行环境是Java虚拟机加载字节码,使用解释器执行这些字节码。如果仅是这样,那Java的性能就比C++差太多了。现代Java虚拟机一般都引入了JIT技术,也就是前面说的将字节码转变成本地代码来提高执行效率。图9-6描述这两个阶段,第一阶段对时间要求不严格,第二阶段则对每个步骤所花费的时间非常敏感,时间越短越好。
图9-6 Java代码的编译和执行过程
最后回到JavaScript语言上来。前面已经说了它是一种解释性脚本语言。但是,众多工程师不断投入资源来提高它的速度使得它能够使用Java虚拟机和C++编译器中的众多技术来改进工作方式。早期也是由解释器来解释即可,就是将源代码转变成抽象语法树,然后在抽象语法树上解释执行。随着将Java虚拟机的JIT技术引入,现在的做法是将抽象语法树转成中间表示(也就是字节码),然后通过JIT技术转成本地代码,这能够大大地提高执行效率。当然也有些直接从抽象语法树生成本地代码的JIT技术,例如V8。这是因为JavaScript跟Java还是有以下一些区别的。
其一是类型。JavaScript是无类型的语言,其对于对象的表示和属性的访问比Java存在更大的性能损失。不过现在已经出现了一些新的技术,参考C++或者Java的类型系统的优点,构建隐式的类型信息,这些后面将逐一介绍。
其二,Java语言通常是将源代码编译成字节码,这同执行阶段是分开的,也就是从源代码到抽象语法树再到字节码这段时间的长短是无所谓的(或者说不是特别重要),所以尽可能地生成高效的字节码即可。而对于JavaScript而言,这些都是在网页和JavaScript文件下载后同执行阶段一起在网页的加载和渲染过程中来实施的,所以对它们的处理时间也有着很高的要求。
图9-7描述了JavaScript代码执行的过程,这一过程中因为不同技术的引入,导致其步骤非常复杂,而且因为都是在代码运行过程中来处理这些步骤,所以每个阶段的时间越少越好,而且每引入一个阶段都是额外的时间开销,可能最后的本地代码执行效率很高,但是如果之前的步骤耗费太多时间,最后的执行结果可能并不会好。所以不同的JavaScript引擎选择了不同的路径,这里先不仔细介绍,后面再描述它们。
图9-7 JavaScript代码的编译和执行过程
所以一个JavaScript引擎不外乎包括以下几个部分。
- 编译器 。主要工作是将源代码编译成抽象语法树,在某些引擎中还包含将抽象语法树转换成字节码。
- 解释器 。在某些引擎中,解释器主要是接收字节码,解释执行这个字节码,同时也依赖垃圾回收机制等。
- JIT工具 。一个能够能够JIT的工具,将字节码或者抽象语法树转换成本地代码,当然它也需要依赖牢记
- 垃圾回收器和分析工具(ProfiIer) 。它们负责垃圾回收和收集引擎中的信息,帮助改善引擎的性能和功效。
1.3 JavaScript引擎和渲染引擎
前面介绍了网页的工作过程需要使用两个引擎,也就是渲染引擎和JavaScript引擎。从模块上看,它们是两个独立的模块,分别负责不同的事情:JavaScript引擎负责执行JavaScript代码,而渲染引擎负责渲染网页。JavaScript引擎提供调用接口给渲染引擎,以便让渲染引擎使用JavaScript引擎来处理JavaScript代码并获取结果。这当然不是全部,事情也不是这么简单,JavaScript引擎需要能够访问渲染引擎构建的DOM树,所以JavaScript引擎通常需要提供桥接的接口,而渲染引擎则根据桥接接口来提供让JavaScript访问DOM的能力。在现在众多的HTML5能力中,很多都是通过JavaScript接口提供给开发者的,所以这部分同样需要根据桥接接口来实现具体类,以便让JavaScript引擎能够回调渲染引擎的具体实现。图9-8描述了两种引擎之间的相互调用关系。
图9-8 渲染引擎和JavaScript引擎的关系
在WebKit中,两种引擎通过桥接接口来访问DOM结构,这对性能来说是一个重大的损失因为每次JavaScript代码访问DOM都需要通过复杂和低效的桥接接口来完成。鉴于访问DOM树的普遍性,这是一个常见的问题。