V8 引擎中的 JavaScript 数组实现分析与性能优化

数组是 JavaScript 语言的一项基本功能,几乎每 个 JavaScript 应用程序中都会大量应用数组。由于 V8 的源程序公开,其运行机制可以通过分析其源程 序来深入了解。通过分析 JavaScript 数组在 V8 中的 实现,可以在编写 JavaScript 程序时明显提高程序性 能。

1、JavaScript 数组的特点

在很多高级语言中都实现了数组,C 语言里面的数组是一个典型实现,其特点是一组有序数据的集 合 [ 6 ] ,用 索 引 表 示 数 据 的 序 号 ,数 组 中 的 每 一 个 元 素 都属于同一个数据类型。C 语言中的数组长度是定 义时指定的,数组不能动态扩展长度。

JavaScript 作为一种现代程序设计语言,对象是 基本的数据类型。对象实际上是一种把很多值聚合 在一起的方式,可以看作是属性名到值的映射。而 JavaScript 语言中的数组实际上是对象的特殊形式, 即属性名是整数的对象。JavaScript 数组也可以看作 值的有序集合,元素的位置即索引。

JavaScript 数组长度是动态的,在创建数组时无 须声明一个固定的长度( 但是也可以指定长度[7]) ,其 长度根据需要可以自动增长。JavaScript 数组可能是 数组索引不连续的稀疏数组。JavaScript 数组用 length 属性表示数组长度[8],对于非稀疏数组,该属性表示了 数组元素的个数。JavaScript 数组元素的值是无类型 的,同一个数组中的不同元素可能有不同的类型。

JavaScript 语言的对象、数组与 C 语言数组特点比较

 

JavaScript 对象

JavaScript 数组

C 数组

必须为整数键

必须具有 length 属性

动态改变长度

必须为同类型值

可以稀疏存储

 

通过 JavaScript 对数组的定义可以看出,数组的 整数索引的特性可以让数组在实现中进行优化,比如 在合适的条件下尽可能使用一块连续的存储空间来 按照 C 语言中的数组方式存储和查找。连续存储的 时间效率有可能极大地优于常规的对象属性处理方 式。然而,相对于 C 数组,JavaScript 数组的动态性特 点让其在某些条件下又必须改变连续存储方式,但是 实际应用程序中,确实需要利用动态性的场景相当有 限[9]。在实际的程序设计中,当这些改变并不必要 时 ,就 会 造 成 性 能 的 下 降 ,而 这 些 下 降 很 多 是 可 以 在 保持程序逻辑的前提下优化的。

2 V8 对 JavaScript 数组的实现分析

通过分析 V8 源程序,可知 V8 对 JavaScript 数组 的实现是高效和具有代表性的。按照 JavaScript 标 准 ,对 象 的 属 性 名 应 该 是 字 符 串 ,而 数 组 是 特 殊 的 对 象 ,这 意 味 着 对 象 和 数 组 的 属 性 都 可 以 通 过 “[]”来 访问。在很多情况下,V8 需要首先区分应该按照对 象还是数组进行操作。以下是 V8 对数组元素赋值 的代码片段,可以看到 JavaScript 数组的 key 值将转

换为字符串,然后判断其是否可以用作数组索引:

Handle<Object> converted;
ASSIGN_RETURN_ON_EXCEPTION( isolate,converted, Execution: : ToString( isolate,key) ,Object) ;
Handle < String > name = Handle < String > : : cast( conver- ted) ;
if ( name-> AsArrayIndex( &index) ) {
return JSObject: : SetElement( js_object,index,value,attr, strict_mode,true,set_mode) ;
} else{
return JSReceiver: : SetProperty( js_object,name,value,at-
tr,strict_mode) ; }

当 key 不能用作数组索引时,V8 将按照对象属 性的方式处理,不会更新数组的 length 属性。当 key 为适当大小的无符号整数,可以用作数组索引时,V8 将对数组分为 Fast Elements 和 Dictionary Elements 两 种存储方式进行存储[11]。Fast Elements 是传统的线 性存储方式,而 Dictionary Elements 使用的是 Hash 表 存储,在 V8 中需要用专门的类来进行处理。

2. 1 Fast Elements 模式

对于一个新创建的空数组赋值时,默认使用 Fast Elements 方式,数组的存储空间是可以动态增长的。 V8 代码中的 capacity 表示当前实际已经分配的空 间,例如使用 new Array( 100) 创建的数组,其 capacity 为 100,而使用 new Array 创建的数组,其 capacity 为 0。如果对一个更大的索引赋值,V8 会动态开辟更大 的内存。

Fast Elements 模式的一个扩展是 Fast Holey Ele- ments 模式。此模式适合于数组中只有某些索引存有 元素,而其他的索引都没有赋值的情况。在 Fast Holey Elements 模式下,没有赋值的数组索引将会存 储一个特殊的值,这样在访问这些位置时就可以得到 undefined。但是 Fast Holey Elements 同样会动态分配连 续的存储空间,分配空间的大小由最大的索引值决定。

当对数组上不连续的索引位置赋值时,V8 可能将 Fast Elements 转化为 Fast Holey Elements 模式以处理带“空洞”的数组,对应的 V8 源程序如下:

bool introduces_holes = true;
if ( object-> IsJSArray( ) ) {
CHECK( Handle < JSArray > : : cast( object) -> length( ) ->
ToArrayIndex( &array_length) ) ; introduces_holes = index > array_length; if( index > = array_length) { must_update_array_length = true; array_length = index + 1;
}
} else{
introduces_holes = index > = capacity;
}
/ / If the array is growing,and it's not growth by a single ele-
ment at the end,make sure that the ElementsKind is HOLEY. ElementsKind elements _ kind = object-> GetElementsKind
();
if ( introduces _ holes && IsFastElementsKind ( elements _ kind) &&! IsFastHoleyElementsKind( elements_kind) ) {
ElementsKind transitioned _ kind = GetHoleyElementsKind ( elements_kind) ;
TransitionElementsKind( object,transitioned_kind) ; }

2.2 Dictionary Elements 模式

在 Dictionary Elements 模式下,数组的值存储于 Dictionary < Derived,Shape,Key > 类的子类中[13],如 SeededNumberDictionary,而 Dictionary 类继承于 Hash- Table 类,实际上就是使用 Hash 方式存储。此方式最 适合于存储稀疏数组,它不用开辟大块连续的存储空 间,节省了内存,但是由于需要维护这样一个 Hash- Table,其存储特定值的时间开销一般要比 Fast Ele- ments 模式大很多。

一种由 Fast Elements 转换为 Dictionary Elements 的典型情况是对数组赋值时使用远超当前数组大小 的索引值,这时候要对数组分配大量空间则将可能造 成存储空间的浪费。在 Fast Elements 模式下,capaci- ty 用于指示当前内存占用量大小,通常根据数组当前 最大索引的值确定。在数组索引过大,超过 capacity 到一定程度( 由 kMaxGap 决定,其值为 1024) ,数组将 直接转化为 Dictionary Elements 模式,其对应的 V8 源 程序如下:

/ / Check if the capacity of the backing store needs to be in- creased,or if a transition to slow elements is necessary.
if ( index > = capacity) {
bool convert_to_slow = true;
if ( ( index-capacity) < kMaxGap) {
new_capacity = NewElementsCapacity( index + 1) ;
ASSERT( new_capacity > index) ;
if ( ! object-> ShouldConvertToSlowElements ( new _ capaci-
ty) ) {
convert_to_slow = false;
}
}
if ( convert_to_slow) {
NormalizeElements( object) ;
return SetDictionaryElement( object,index,value,NONE,
strict_mode,check_prototype) ; }
}

3 运行于 V8 中的 JavaScript 数组性 能优化

要优化 JavaScript 程序性能,实际上就是在保证 程序正确的前提下,通过调整程序的逻辑结构提高执 行效率[14],而基于对 JavaScript 引擎实现的深入分 析,可以更有针对性地改进程序结构。从 V8 对数组 的 实 现 可 知 ,要 优 化 数 组 性 能 ,基 本 的 原 则 就 是 让 数 组少动态分配内存,尽可能保持数组的 Fast Elements 方式,尽量避免数组从 Fast Elements 方式切换为其他 方式。在 V8 引擎下 JavaScript 对数组的赋值及访问 操作中,从以下的例子中可以看到一些简单的数组操 作方式改动会带来时间性能的明显提升。以下程序运 行时间统计于一台 CPU 为 Intel Core i7 2. 93 GHz,内 存 16 GB,操作系统为 64 位 Windows 7 企业版的电脑。

3. 1 避免使用负整数作为索引

JavaScript 的数组实际上并不支持负数作为索 引,但是由于 JavaScript 是弱类型的,当程序试图在数 组中使用负数索引时,JavaScript 并不会有任何出错 提示,而是认为需要将其按照一般的对象属性进行处 理。对应用程序来说,负数索引似乎可以工作,但是 V8 无法将这样的对象属性利用数组的特点进行线性 存 储 ,一 般 情 况 下 ,其 时 间 和 空 间 开 销 都 将 比 数 组 大 很多。以代码 1 为例:

var tempArray = []; var i;
for(i=0; i>-5; i--) {
tempArray[i]= 1; }

在代码 1 中,实际上只有 tempArray[0]是数组元 素,从 - 1 开始,所有的值都作为属性值添加到 tem- pArray 上了,而且 V8 不会更新 tempArray 的 length 属 性,执行完之后,tempArray. length 还是 1。由于 V8 将为新添加的属性创建 hidden class[15],其速度比常 规数组慢得多。如果使用 0 到 5 作为索引,则性能会 有明显提高,因为对于这样的常规数组,V8 可以使用 Fast Elements 模式进行线性存储,其详见代码 2。

var tempArray = []; var i;
for(i=0; i<5; i++) {
tempArray[i]= 1; }

表 2

代码 1 与代码 2 循环执行 1,000,000 次时间比较

代码1

代码2

代码 2 与代码 1 时间百分比

2001. 000 ms

63.000 ms

3.15%

可以看出两者性能的差异非常大。因此,在运行 于 V8 的 JavaScript 应用程序中,在能使用数组的场 合 下 ,不 要 使 用 对 象 ,而 且 需 要 尽 量 避 免 使 用 负 数 作 为数组索引。同理,浮点数、普通字符串也最好不要 用作数组索引。

3.2 预先指定数组大小

虽然 JavaScript 规定应用程序可以不用预先指定 数组大小,但是在 V8 的实现中,如果程序预先指定 了使用的数组大小,而数组大小适合使用 Fast Ele- ments 进行连续存储时,则可以避免额外分配内存空 间。以代码 3 为例:

var tempArray = new Array( ) ; var i;
for(i=0; i<100; i++){ tempArray[i]= 1;
}

V8 将在 tempArray 增长的过程中不断动态分配

空间。如果使用 new Array( 100) 预先分配数组大小, 则性能会有大幅度提高。这是因为指定大小后,数组 capacity 一开始就为 100,V8 不用频繁更新数组的长 度,减少了不必要的内存操作,如代码 4 所示:

var tempArray = new Array( 100) ; var i;
for(i=0; i<100; i++){ tempArray[i]= 1;
}

表 3  代码 3 与代码 4 循环执行 1,000,000 次时间比较

代码3

代码4

代码 4 与代码 3 时间百分比

810. 000 ms

326. 000 ms

40.25%

从结果可以看出,在已经知道需要使用的数组大 小的情况下,应该考虑在程序中预先分配数组大小, 在不必要的情况下,尽量不要使用超过数组大小的索 引以导致数组空间的动态分配。

3.3 避免使用不连续的索引值

在一个未指定长度的数组中,对于索引的增加最 好是连续的,因为不连续的索引可能会改变数组存储 方式。考虑如下在数组中 5 个位置赋值的代码 5:

var tempArray = [];
var index = 0;
for(vari=0; i<5; i++){ tempArray[index]= 1;
index +=20; }

如果将这里的 20 替换为 1,则可以明显改善数 组性能。这是因为 20 将触发数组由默认的 Fast Ele- ments 向 Fast Holey Elements 模式的转换,V8 需要处 理“空洞”的情况,带来时间上的开销,而 1 不会。而且在这 2 种方式下,由于创建时未指定数组长度,数 组 的 空 间 都 是 连 续 动 态 分 配 的 ,最 大 的 索 引 越 大 ,分 配的空间越多,使用 1 可以尽可能减少分配的数组空 间,如代码 6 所示。

var tempArray = [];
var index = 0;
for(vari=0; i<5; i++){ tempArray[index]= 1;
index +=1; }

表 4    代码 5 与代码 6 循环执行 1,000,000 次时间比较

代码5

代码6

代码 6 与代码 5 时间百分比

288. 000 ms

51.000 ms

17.71%

代码 3 与代码 4 循环执行 1,000,000 次时间比较

从表 4 结果可以看出,在一般情况下,最好是使 用从零开始连续的整数索引,减少数组动态分配的空 间,避免数组切换到 Fast Holey Elements 模式。

3.4 避免逆序对数组赋值

对于数组中每个元素赋值是数组初始化的常见 操作。在一个未指定长度的数组中,如果按照由大到 小 的 索 引 对 数 组 赋 值 ,在 数 组 长 度 较 大 的 情 况 下 ,数 组会直接切换为比较慢的 Dictionary Elements 模式, 从而引起不必要的性能降低。考虑如下对 10000 个 数组元素赋值的代码 7:

var tempArray = [];
var i;
for(i=9999; i>=0; i--) {
tempArray[i]= 1; }

原因是对 tempArray[9999]赋值时,由于 9999 已 经足够大,V8 将使用 Dictionary Elements 方式存储数 组,导致存储速度变慢( 虽然 Dictionary Elements 方式 还能在某些情况下转换为 Fast Elements 方式) 。解 决的方法就是使用从零开始的从小到大的顺序对数 组赋值。以下的代码 8 不会引发数组转换为 Diction- ary Elements 方式:

var tempArray = [];
var i;
for(i=0; i<=9999; i++) {
tempArray[i]= 1; }

表 5  代码 7 与代码 8 循环执行 1,000 次需要的时间

代码7

代码8

代码8 与代码7 时间比

687. 000 ms

102. 000 ms

14.85%

由表 5 可以看出,代码 8 的时间性能提升比较明 显。因此,对数组赋值时尽量使用从零开始的从小到大的索引比较有利,特别是在数组长度比较大的情况下。

4 总结:

本文从分析 JavaScript 数组的特点入手,结合 Chrome V8 引擎的源程序,重点分析了 V8 对于 JavaScript 数组的 2 种实现模式以及每种模式适用的 条件,提出使用 JavaScript 编写应用程序使用数组时 在性能优化上需要注意的基本原则,并且给出几个在 实际情况中比较容易进行优化的程序片段,统计优化 前后的执行时间作为例证。在实际应用中,不仅可以 使用本文的原则改善数组性能,还可以参照本文的方 式,通过分析 V8 引擎的实现从而对 JavaScript 程序 的其他方面进行分析与优化。

 

 

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页