V8中的快属性与内联缓存

1,449 阅读16分钟

本篇博客从 v8 引擎角度透析 JavaScript 中对象的本质,以及 v8 引擎是怎么借鉴编译型语言的某些特性(比如 结构体、地址偏移、预解析、内联缓存 等)来优化对象属性的访问性能的, 这也从另一个角度说明了项目使用 typescript 必要性的原因。

在 javascript 语言中,任何的对象都是由属性名称和属性值两部分组成,对于属性名称有字符串类型和数字类型,对于属性值来说可以是任意的类型。或者你会听说 JavaScript 中的对象又称为字典,以键值对的方式存储和获取。这些是我们一开始学习 JavaScript 的认知,这就抛出了第一个问题:

字典的查找属性是非线性的,和编译语言在编译阶段就会将变量替换成偏移量或者直接寻址获取到值相比是非常慢的,那如何优化动态语言的查找属性的方式使得性能逼近与编译语言呢?

要回答这个问题我们要从 v8 对于对象的内部表示开始说起,然后分别对 命名属性、元素属性、隐藏类、in-object 进行介绍。最终介绍函数中如何借鉴对象的隐藏类来实现内联缓存来优化函数性能的。

结构与特性


V8 解析

安装jsvu

  1. 本地预备安装 node、npm。
  2. 全局安装 jsvu: npm install jsvu -g
  3. 将 ~/.jsvu (如果不存在创建)路径添加到系统环境变量中:export PATH="${HOME}/.jsvu:${PATH}"
  4. 安装 v8-debug 环境: jsvu --os=mac64 --engines=v8-debug
  5. 在 ~ 目录下执行:.jsvu/v8-debug --allow-natives-syntax [ js 文件绝对地址 ]

执行编译

定义 index.js 文件:

const obj = {
    1: 'frist',
    2:'second',
    first: 1,
    second: 2
};
%DebugPrint(obj);

在 ~ 目录下 执行 .jsvu/v8-debug --allow-natives-syntax .../index.js

会得到如下结构信息:

DebugPrint: 0x5e308148d61: [JS_OBJECT_TYPE]
 - map: 0x05e30830736d <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x05e3082c3be9 <Object map = 0x5e3083021b5>
 - elements: 0x05e308148d75 <FixedArray[19]> [HOLEY_ELEMENTS]
 - properties: 0x05e308042229 <FixedArray[0]> {
    0x5e3080436cd: [String] in ReadOnlySpace: #first: 1 (const data field 0)
    0x5e308043b4d: [String] in ReadOnlySpace: #second: 2 (const data field 1)
 }
 - elements: 0x05e308148d75 <FixedArray[19]> {
           0: 0x05e308042429 <the_hole>
           1: 0x05e3082d24f9 <String[5]: #frist>
           2: 0x05e308043b4d <String[6]: #second>
        3-18: 0x05e308042429 <the_hole>
 }
0x5e30830736d: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 20
 - inobject properties: 2
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x05e308307345 <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x05e308242445 <Cell value= 1>
 - instance descriptors (own) #2: 0x05e308148de5 <DescriptorArray[2]>
 - prototype: 0x05e3082c3be9 <Object map = 0x5e3083021b5>
 - constructor: 0x05e3082c3821 <JSFunction Object (sfi = 0x5e308248cd1)>
 - dependent code: 0x05e3080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0
 

可以看出,对于 obj 对象来说经过 v8 编译之后生成了带有如下属性的结构:

  • map:隐藏类或者我们称之为 hiddenClass。用来描述该对象的一些结构的性质,类似于编译语言中的结构体。但隐藏类仅仅与命名属性有关,与元素属性没有关系。在隐藏类中会存在如下几个属性:
    • instance size:该对象实例的大小,这里就类似于 c语言中一个结构体所占内存的大小是一样的。这个属性在初始化对象的时候就声明创建,从而决定 命名属性存储结构是否是线性的,是快访问还是慢访问。
    • inobject properties:in-object 属性的个数,in-object是一种优化属性快速访问的手段。
    • back pointer:在对象的属性进行增加删除操作的时候会生成 隐藏类链表,用来维护链表的链接关系。
    • instance descriptors:指向描述符数组的指针,这里面维护者命名属性的信息,如名称本身和存储值的位置,当在快访问时以便于能够快速定位命名属性的位置。
    • prototype:对象的原型属性。
  • prototype: 对象的原型
  • elements:该对象的元素属性,元素属性定义为属性名是数字的属性,这里的数字也有可能是数字字符串。一般 elements的结构是数组, 但在手动添加属性的时候会退化成字典。比如 obj 对象中的 1 2 属性。
  • properties:该对象的命名属性,命名属性定义为属性名称是字符串的属性,一般 properties 属性的结构是数组和字典,如果是数组多半在隐藏类中 instance descriptors 中有相应属性值的存储位置信息,可以根据存储位置 通过偏移量直接获取到。比如 obj 属性中的 frist second 属性。

如上述 obj来说,elements、properties 属性都是 FixedArray 也就是数组类型,在 map 隐藏类的 instance descriptors 中存储着 frist second 命名属性的地址映射。同时 frist second 两个命名属性也都是 in-object 的。unused property fields 为 20 , 如果在增加超过 20 大小的属性,则 properties 会退化成 NameDictionary 也就是字典。

可以修改代码为:

const obj = {
    '1': 'frist',
    '2':'second',
    first: 1,
    second: 2
};
%DebugPrint(obj);

obj[3] = 'three';
%DebugPrint(obj);

for (let index = 0; index < 25; index++) {
    obj['s'+index] = index;
}
%DebugPrint(obj);

重新运行后,得到的结构中,如果增加了一个 3 属性, 那么命名属性和元素属性还都是 FixedArray 数组类型并没有导致对象的退化,但是因为命名属性已经没有预留空间了, 所以增加 s 开头属性(这些属性大小超过 20)会使得命名属性退化成 NameDictionary 也就是字典,并且 instance descriptors 变成了 0 ,也就是不存在通过隐藏类快速找到的快属性了。

浏览器查看


具体操作可以查看 developers.google.com/web/tools/c…
image.png
从创建的 Foo 对象来看,也是和 v8 编译出的结构类似,都是有map elements properties,具体的分析和上述 v8 一致。

上述我们介绍了 从 v8 和浏览器角度看了一个 javascript 对象的内部表示形态,那为什么会有 properties 和 elements 将属性分为两种类型?每一种类型中 v8 是怎么利用一些特性了来优化属性访问速度的?接下来将会分别介绍 properties 和 elements。

properties


本节将会介绍对象内部标识中很重要的一个字段 properties。这个字段描述对象中的命名属性,因为命名属性与elements 属性在操作上的不同,所以区分对待。 比如说, elements 元素更多是调用 Array.prototype 上的方法去操作每一个元素,并且只要人为的设置 length,几乎所有的Array方法都会可以使用被执行。同时, elements 由于存储数据类型几乎一致并且连续的特性,使得与 properties 属性在处理和使用上有很大的不同。

可能接触过 C、C++ 这些语言,这些语言中都有 结构体 数组指针寻址 这些概念,因为这些语言在编译的时候就确定了对象的属性或者数组中某些元素的位置地址, 所以在执行的时候能够相比解析型语言要快许多。那对于 v8 对 javascript 对象的优化而言,elements 正式借鉴 数组指针寻址方式快速的确定属性的位置,properties 更是借鉴 隐藏类、内联缓存、in-object 等特性优化命名属性的访问速度,使得性能更加逼近于编译语言。

接下来我们将主要介绍 properties 属性,从默认创建对象后 v8 为其默认为快属性,并创建隐藏类,并且对于高频访问的元素会作为 in-object 方式处理,对于增加删除属性的时候退化成慢访问模式。

快属性与隐藏类

我们需要新假设一个约定,那就是创建的对象不会增加新的属性和随意删除属性。再次约定前提下,v8 使用隐藏类来优化命名属性的访问速度。那是怎么优化的呢?先从例子开始说起:
假如有以下代码:

const obj = {
  first: 1,
  second: 2,
  three: 3
};

%DebugPrint(obj);

编译后的结果为:

DebugPrint: 0x139008148789: [JS_OBJECT_TYPE]
- map: 0x139008307345 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1390082c3bb9
- elements: 0x139008042229 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x139008042229 <FixedArray[0]> {
   0x1390080436cd: [String] in ReadOnlySpace: #first: 1 (const data field 0)
   0x139008043b4d: [String] in ReadOnlySpace: #second: 2 (const data field 1)
   0x1390082d2459: [String] in OldSpace: #three: 3 (const data field 2)
}
0x139008307345: [Map]
- type: JS_OBJECT_TYPE
- instance size: 24
- inobject properties: 3
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x13900830731d <Map(HOLEY_ELEMENTS)>
- prototype_validity cell: 0x139008242445
- instance descriptors (own) #3: 0x1390081487e5 <DescriptorArray[3]>
- prototype: 0x1390082c3bb9
- constructor: 0x1390082c37f1 <JSFunction Object (sfi = 0x139008248cfd)>
- dependent code: 0x1390080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0

可以看出,properties 属性是一个数组的线性结构,使用 map 这个隐藏类去描述了命名属性的结构, instance descriptors 属性记录了 三个命名属性的结构信息:命名字段名称,值得偏移量等等。如果查找属性 的时候,就可以直接通过隐藏类定位属性的位置,根据值偏移量快速找到了。
具体来说,我们可以认为该对象 obj 是 如下类型的实例:
image.png
properties 是一个数组, 也就是一块连续内存的存储空间,里面存储着 first second three 三个属性的值,分别是 1 2 3. 当我获取 second 属性的时候,根据隐藏类中对于 properties 结构的描述,也就是去查 instance descriptors 哈希表 就可以直接得到second 位置可以获取值。从要获取 second 到得到值,其中需要经过 哈希表查询地址 读取地址,整个过程都是线性的,而如果使用普通的字典,那则是非线性的查询效率有所提升。那,如果增加属性,隐藏类会怎么变化呢?

image.png
image.png
如图所示,

  • 一开始创建对象的时候,会为该对象的结构创建对应的隐藏类,如果该对象增加了属性,也就是对象的形状发生改变,则会创建一个新的隐藏类, 新的隐藏类通过 back pointer 指针 指向上一个隐藏类,管理着隐藏类的关系。
  • 具有相同结构的对象会复用相同的隐藏类,这里的相同是指: 相同的属性名称,相同的属性个数,相同的属性添加顺序。
  • 删除属性的时候,会重新创建隐藏类,创建的隐藏类与之前的隐藏类没有任何关系,所以频繁的删除操作会影响操作的性能。如果使用将字段设置为 null 改变属性的值时,不会创建新的隐藏类可以提高访问属性的性能。

in-object

当然上述访问 虽然使用隐藏类将普通的字典的非线性查询改为了隐藏类中哈希表定位地址的线性查询。 但是还是需要经过 map 隐藏类多查询一步, 那能否直接可以一步定位到元素值呢?v8 使用 in-object 方式优化高频操作的属性,一般在对象创建声明的时候,会将初始的属性默认为in-object 的, in-object 是指这些属性直接作为 v8 处理处理对象的属性,也就是在 v8 编译对象后,对象的属性会直接作为编译后 v8 内部表示对象的属性。这样子就可以高效的访问属性了,in-object的数量在第一次创建隐藏类的时候就预设置了大小,在字段 inobject properties 中说明了内联属性的个数, 至于新增加的属性则可能不会 内联处理,直接放在 properties 属性中。

内联缓存(IC)

以上 无论是 隐藏类 还是 in-object 都是在代码预编译的阶段为了优化而生成的一些前置条件,其根本是为了执行的性能做准备的,那么 内联缓存就是在处理执行代码的时候利用隐藏类高效的优化代码执行的一种手段。

对于如下函数,v8 会如何处理呢?

function foo(obj) {
  obj.x += 1;
  return obj.y;
}

目前来看,每次执行函数 foo 的时候, 都会获取 对象obj 得到 obj 的隐藏类,然后执行代码 obj.x 的时候,会通过隐藏类获取到 x 属性的位置,从properties 属性中根据偏移量获取到值。蒸锅过程虽然比非线性的字典要快, 但是对于频繁执行的函数并且 obj 的类型是固定的。每次都需要执行上述过程是非常浪费的, 所以,v8 为每一个函数都会有对应的 反馈向量 维护内部对象的一些插槽。在函数内部调用对象属性的位置我们称之为 调用点。所以对于函数 foo,调用点为 obj.x, obj.y。则插槽维护者 x y 的一些信息方便快速获取。
image.png
对于 属性 x,y 都会维护者 属性所对应隐藏类的 地址 和偏移量。内次执行的时候,根据插槽编号 快速找到 map地址比对如果一致则直接根据偏移量获取到值。但是, 对于多态函数来说, 传入的对象属性相同但是内容,对应的 隐藏类可能不同, 那么 可以缓存成多态的形式比如:

image.png
这种一个插槽对应2~4个隐藏类称之为多态的插槽,1个称之为单态的插槽,4个以上就是超态了, 对于多态超态,如果一个插槽对应的隐藏类很多在比对查找隐藏类的过程中也会造成性能问题, 所以尽可能的不要让统一个函数超过2 个态射。更好的情况下是保持单态。

退化的慢属性

对属性进行频繁的增加或者删除的时候,会破坏属性中隐藏类的支持,并且properties 属性会降级成字典的模式。并且也会影响内联缓存的工作。

const obj = {
  first: 1,
  second: 2
};
%DebugPrint(obj);

for (let index = 0; index < 25; index++) {
  obj['s'+index] = index;
}
%DebugPrint(obj);

得到的结果为:

DebugPrint: 0x1706081487d9: [JS_OBJECT_TYPE]
 - map: 0x1706083072f5 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x1706082c3bb9 <Object map = 0x1706083021b5>
 - elements: 0x170608042229 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x170608042229 <FixedArray[0]> {
    0x1706080436cd: [String] in ReadOnlySpace: #first: 1 (const data field 0)
    0x170608043b4d: [String] in ReadOnlySpace: #second: 2 (const data field 1)
 }
0x1706083072f5: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 20
 - inobject properties: 2
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x1706083072cd <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x170608242445 <Cell value= 1>
 - instance descriptors (own) #2: 0x170608148809 <DescriptorArray[2]>
 - prototype: 0x1706082c3bb9 <Object map = 0x1706083021b5>
 - constructor: 0x1706082c37f1 <JSFunction Object (sfi = 0x170608248cfd)>
 - dependent code: 0x1706080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

DebugPrint: 0x1706081487d9: [JS_OBJECT_TYPE]
 - map: 0x17060830533d <Map(HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x1706082c3bb9 <Object map = 0x1706083021b5>
 - elements: 0x170608042229 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x1706081490a9 <NameDictionary[197]> {
   s19: 19 (data, dict_index: 22, attrs: [WEC])
   s8: 8 (data, dict_index: 11, attrs: [WEC])
   s2: 2 (data, dict_index: 5, attrs: [WEC])
   ...
 }
0x17060830533d: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 12
 - inobject properties: 0
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - dictionary_map
 - may_have_interesting_symbols
 - back pointer: 0x1706080423b1 <undefined>
 - prototype_validity cell: 0x170608242445 <Cell value= 1>
 - instance descriptors (own) #0: 0x1706080421bd <DescriptorArray[0]>
 - prototype: 0x1706082c3bb9 <Object map = 0x1706083021b5>
 - constructor: 0x1706082c37f1 <JSFunction Object (sfi = 0x170608248cfd)>
 - dependent code: 0x1706080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

会发现添加大量属性的时候,properties 退化成 NameDictionary 的字面模式, 同时隐藏类中 instance descriptors也没清空成 0.同理大量的 deleete 删除也会影响对象的性能,因为每一次删除会创建一个对应的隐藏类。

elements


介绍完 properties 后, 值得注意的是 element 是不被 隐藏类管理的, 那对于元素属性,通常的存储类型是数组模式,即通过地址偏移的方式定位到具体的属性的。同时,元素属性有天然的下标,可以构建哈希表的方式维护 下标预地址的映射。那 v8 在此基础上对元素属性还做了一些优化:

Holey与声明元素

对于初始化定义元素属性的时候, 会对 element 创建数组维护者下标预地址的关系,但是对于稀疏的元素属性来说,v8 会增加一个 hole 标识,表明该元素空缺, 避免去原型中查找浪费资源:

const obj = {
  1: 'a',
  40:'b'
};
%DebugPrint(obj);
DebugPrint: 0x49708148779: [JS_OBJECT_TYPE]
 - map: 0x0497083072f5 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0497082c3bb9 <Object map = 0x497083021b5>
 - elements: 0x0497081487ed <FixedArray[77]> [HOLEY_ELEMENTS]
 - properties: 0x049708042229 <FixedArray[0]> {}
 - elements: 0x0497081487ed <FixedArray[77]> {
           0: 0x049708042429 <the_hole>
           1: 0x0497080caa31 <String[1]: #a>
        2-39: 0x049708042429 <the_hole>
          40: 0x0497080cad0d <String[1]: #b>
       41-76: 0x049708042429 <the_hole>
 }
0x497083072f5: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 4
 - enum length: invalid
 - stable_map
 - back pointer: 0x0497080423b1 <undefined>
 - prototype_validity cell: 0x049708242445 <Cell value= 1>
 - instance descriptors (own) #0: 0x0497080421bd <DescriptorArray[0]>
 - prototype: 0x0497082c3bb9 <Object map = 0x497083021b5>
 - constructor: 0x0497082c37f1 <JSFunction Object (sfi = 0x49708248cfd)>
 - dependent code: 0x0497080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

可以看出,除了 1 和 40之外 其他的部分都是 hole 标识表明是空置的位置。但是如果通过声明的方式创建的话,则会将 elements 退化成字典比如:

const obj = {
  1:'a',
  40:'b'
};

Object.defineProperty(obj, '60', {
  value: 'c',
  enumerable:false,
  configurable: false,
  writable: false
});
%DebugPrint(obj);

enumerable/configurable/writable 其中之一为 false,都会导致 elements 退化成字典:

 - elements: 0x143008148a71 <NumberDictionary[16]> {
   - requires_slow_elements
   1: 0x1430080caa31 <String[1]: #a> (data, dict_index: 0, attrs: [WEC])
   40: 0x1430080cad0d <String[1]: #b> (data, dict_index: 0, attrs: [WEC])
   60: 0x1430082d2479 <String[1]: #c> (data, dict_index: 0, attrs: [___])
 }

代码实践

  • 不要大量的对对象添加删除操作,避免命名属性退化成慢属性。
  • 同类型的对象,对象的属性、顺序、个数要保持一致,避免创建过多的隐藏类。
  • 尽可能的保持函数的单态性质。
  • 对于元素属性避免使用声明的方式创建。
  • 请使用 typescript。