19

JavaScript 语言的角度来看,JavaScript 对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值。

然而在 V8 实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典是非线性的数据结构,查询效率会低于线性的数据结构,V8 为了提升存储和查找效率,采用了一套复杂的存储策略

今天我们了解一下v8为了提升对象的访问性能都采用了那些策略

首先我们来分析一下下边的这段代码


function Foo() {
    this[100] = 'test-100'
    this[1] = 'test-1'
    this["B"] = 'bar-B'
    this[50] = 'test-50'
    this[9] =  'test-9'
    this[8] = 'test-8'
    this[3] = 'test-3'
    this[5] = 'test-5'
    this["A"] = 'bar-A'
    this["C"] = 'bar-C'
}
var bar = new Foo()

for(key in bar){
    console.log(`index:${key}  value:${bar[key]}`)
}

在上面这段代码中,我们利用构造函数 Foo 创建了一个 bar 对象,在构造函数中,我们给 bar 对象设置了很多属性,包括了数字属性和字符串属性,然后我们枚举出来了 bar 对象中所有的属性,并将其一一打印出来,下面就是执行这段代码所打印出来的结果:

index:1  value:test-1
index:3  value:test-3
index:5  value:test-5
index:8  value:test-8
index:9  value:test-9
index:50  value:test-50
index:100  value:test-100
index:B  value:bar-B
index:A  value:bar-A
index:C  value:bar-C

观察这段打印出来的数据,我们发现打印出来的属性顺序并不是我们设置的顺序,我们设置属性的时候是乱序设置的,比如开始先设置 100,然后又设置了 1,但是输出的内容却非常规律,总的来说体现在以下两点:

设置的数字属性被最先打印出来了,并且是按照数字大小的顺序打印的;

设置的字符串属性依然是按照之前的设置顺序打印的,比如我们是按照 B、A、C的顺序设置的,打印出来依然是这个顺序

之所以出现这样的结果,是因为在 ECMAScript 规范中定义了

数字属性应该按照索引值大小升序排列

字符串属性根据创建时的顺序升序排列

排序属性&常规属性&内属性

在这里我们把对象中的数字属性称为排序属性,在 V8 中被称为 elements

段落引用字符串属性就被称为常规属性,在 V8 中被称为 properties。

image.png

在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存

数字属性存储在排序属性( elements)中

字符串属性存放在常规属性(properties)中

我们可以通过chrome浏览器的Memory来看一下之前的案例的存储状态

image.png

我们可以看到在内存快照中我们只看到了 排序属性(elements)
却没有 常规属性(properties)

这是因为将不同的属性分别保存到 elements和 properties 中,无疑简化了程序的复杂度。

但是在查找元素时,却多了一步操作

比如执行 bar.B这个语句来查找 B 的属性值,需要先查找出 properties 属性所指向的对象 properties,然后再在 properties 对象中查找 B 属性,这种方式在查找过程中增加了一步操作,因此会影响到元素的查找效率。

所以V8 采取了一个权衡的策略以加快查找属性的效率,

将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties)

image.png

接下来我们在通过chrome的内存快照来进一步了解一下,对象的在内存中的分布

我们在控制台输入下边的代码

function Foo(property_num,element_num) {
    
    //添加可索引属性
    for (let i = 0; i < element_num; i++) {
        this[i] = `element${i}`
    }

    //添加常规属性
    for (let i = 0; i < property_num; i++) {
        let ppt = `property${i}`
        this[ppt] = ppt
    }
}

var bar = new Foo(10,10)

将 Chrome 开发者工具切换到 Memory 标签,然后点击左侧的小圆圈捕获当前的内存快照

在搜索框里面输入构造函数 Foo,Chrome 会列出所有经过构造函数 Foo 创建的对象
image.png
我们在内存快照中观察一下此时的布局

10 个常规属性作为对象内属性,存放在 bar 函数内部;

10 个排序属性存放在 elements 中。

接下来我们可以将创建的对象属性的个数调整到 20 个

var bar2 = new Foo(20,10)

这时候属性的内存布局是这样的:

10 属性直接存放在 bar2 的对象内 ;

10 个常规属性以线性数据结构的方式存放在 properties 属性里面 ;

10 个数字属性存放在 elements 属性里面。

由于创建的常用属性超过了 10 个,所以另外 10 个常用属性就被保存到 properties 中了

注意因为 properties 中只有 10 个属性,所以依然是线性的数据结构

那么如果常用属性太多了,比如创建了 100 个,我们再来看看其内存分布

var bar3 = new Foo(100,10)

image.png这时候属性的内存布局是这样的:

10 属性直接存放在 bar3 的对象内 ;
90 个常规属性以非线性字典的这种数据结构方式存放在 properties 属性里面 ;
10 个数字属性存放在 elements 属性里面。

这时候的 properties 属性里面的数据并不是线性存储的,而是以非线性的字典形式存储的

接下来再看一下删除一个属性后的布局

var bar4 = new Foo(5,5);

delete bar4.property0

image.png
我们会发现这时候虽然只设置了5个个常规属性,但是因为我们执行了delete操作,properties属性中的存储结构也会变成非线性的结构

因此我们可以总结如果对象中的属性过多时
或者存在反复添加或者删除属性的操作,V8 就会将线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查找速度,但是却提升了修改对象的属性的速度

隐藏类

刚才我们讲了是V8对于对象的存储方式上做了那些提升
接下来我们再来说一下查找对象属性的时候,v8又采取了什么策略来提升查询效率呢

我们知道 JavaScript 是一门动态语言,其执行效率要低于静态语言,

V8 为了提升 JavaScript 的执行速度,借鉴了很多静态语言的特性,比如实现了 JIT 机制,为了提升对象的属性访问速度而引入了隐藏类,为了加速运算而引入了内联缓存。

我们来重点分析下 V8 中的隐藏类,看看它是怎么提升访问对象属性值速度的。

隐藏类-静态语言特征

在开始研究隐藏类之前我们就先来分析下为什么静态语言比动态语言的执行效率更高
image.png
静态语言在声明一个对象之前需要定义该对象的结构,也称为形状,编译时每个对象的形状都是固定的,无法被改变的。那么访问一个对象的属性时,自然就知道该属性相对于该对象地址的偏移值了,比如在使用 start.x 的时候,编译器会直接将 x 相对于 start 的地址写进汇编指令中,那么当使用了对象 start 中的 x 属性时,CPU 就可以直接去内存地址中取出该内容即可,没有任何中间的查找环节。

JavaScript 在运行时,对象的属性是可以被修改的,所以当 V8 使用了一个对象时,比如使用了 start.x 的时候,它并不知道该对象中是否有 x,也不知道 x 相对于对象的偏移量是多少,也可以说 V8 并不知道该对象的具体的形状。

那么,当在 JavaScript 中要查询对象 start 中的 x 属性时,V8 会先查找properties,再在properties中中查找x属性,这个过程非常的慢且耗时

什么是隐藏类 (Hidden Class)?

根据静态语言的特征,v8采用的一个思路就是将 JavaScript 中的对象静态化,也就是 V8 在运行 JavaScript 的过程中,会假设 JavaScript 中的对象是静态的,具体地讲,V8 对每个对象做如下两点假设

对象创建好了之后就不会添加新的属性;

对象创建好了之后也不会删除属性。

V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点

对象中所包含的所有的属性;

每个属性相对于对象的偏移量。

这样V8 访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,然后直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了 V8 查找对象的效率。

结合一段代码来分析下隐藏类是怎么工作的:

let point = {x:100,y:200}

image.png

V8 执行到这段代码时,会先为 point 对象创建一个隐藏类(又称为 map),每个对象都有一个 map 属性,其值指向内存中的隐藏类。

隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量;
比如 point 对象的隐藏类就包括了 x 和 y 属性,x 的偏移量是 4,y 的偏移量是 8
image.png

上图左边的是 point 对象在内存中的布局,point 对象的第一个属性就指向了它的 map;

有了 map 之后,当你再次使用 point.x 访问 x 属性时,
V8 会查询 point 的 map 中 x 属性相对 point 对象的偏移量

然后将 point 对象的起始位置加上偏移量,就得到了 x 属性的值在内存中的位置,有了这个位置也就拿到了 x 的值,这样我们就省去了一个比较复杂的查找过程。

多个对象共用一个隐藏类

我们在控制台输入下面的代码,然后查看内存快照

function Foo1 () {}
var a = new Foo1()
var b = new Foo1()

a.name = 'aaa'
a.text = 'aaa'
b.name = 'bbb'
b.text = 'bbb'

a[1] = 'aaa'
a[2] = 'aaa'

image.png

a、b 都有命名属性 name 和 text,此外 a 还额外多了两个可索引属性。从快照中可以明显的看到,可索引属性是存放在 elements 中的,此外,a 和 b 具有相同的结构(map后边我标红的位置)

每个对象都有一个 map 属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类,这样有两个好处:

减少隐藏类的创建次数,也间接加速了代码的执行速度;
减少了隐藏类的存储空间。

什么情况下两个对象的形状是相同的,要满足以下两点:

相同的属性名称;

相等的属性个数。

那么对于前边的案例你可能会有点好奇,前边的两个对象的属性不一样(b比a多了两个数字属性),怎么会有相同的结构呢?要理解这个问题,首先可以思考下边三个问题。

为什么要把对象存起来?当然是为了之后要用。

要用的时候需要做什么?找到这个属性。

描述结构是为了做什么呢?按图索骥,方便查找

那么,对于可索引属性来说,它本身已经是有序地进行排列了,我们为什么还要多次一举通过它的结构去查找呢。既然不用通过它的结构查找,那么我们也不需要再去描述它的结构了。这样,应该就不难理解为什么 a 和 b 具有相同的结构了,因为它们的结构中只描述了它们都具有 name 和 text 这样的情况。

重新构建隐藏类

在开头我们提到了,V8 为了实现隐藏类,需要两个假设条件:

对象创建好了之后就不会添加新的属性;

对象创建好了之后也不会删除属性。

但是,JavaScript 依然是动态语言,在执行过程中,对象的形状是可以被改变的,如果某个对象的形状改变了,隐藏类也会随着改变,这意味着 V8 要为新改变的对象重新构建新的隐藏类,这对于 V8 的执行效率来说,是一笔大的开销。

通俗地理解,给一个对象添加新属性,删除属性,或者改变性的类型都会改变这个对象的形状,那么势必也就会触发 V8 为改变形状后的对象重建新的隐藏类。

比如之前的案例,我们可以试一下执行(delete a.name)
image.png

这样我们会发现a和b的map就不相同了,并且会将字符串属性以非线性的字典的结构存储在properties中,也就是由内属性变为了慢属性

最佳实践

结合上边说如果希望查找效率更高,我们希望对象中的隐藏类不要随便被改变,因为这样会触发 V8 重构该对象的隐藏类,直接影响到了程序的执行性能。

那么在实际工作中,我们应该尽量注意以下几点:

一,初始化对象时,要保证属性的顺序是一致的。
比如不要先通过字面量 x、y 的顺序创建了一个 point 对象,然后通过字面量 y、x 的顺序创建一个对象 point2

二,尽量一次性初始化完整对象属性。
因为每次为对象添加一个属性时,V8 都会为该对象重新设置隐藏类。

三,尽量避免使用 delete 方法。
delete 方法会破坏对象的形状,同样会导致 V8 为该对象重新生成新的隐藏类。

内联缓存

我们来分析一下下边的代码

function loadX(o) { 
    return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3 ,y:6}
for (var i = 0; i < 90000; i++) {
    loadX(o)
    loadX(o1)
}

我们定义了一个 loadX 函数,它有一个参数 o,该函数只是返回了 o.x。

通常 V8 获取 o.x 的流程是这样的:查找对象 o 的隐藏类,再通过隐藏类查找 x 属性偏移量,然后根据偏移量获取属性值。

在这段代码中 loadX 函数会被通过for循环反复执行,那么获取 o.x 流程也需要反复被执行。

有没有办法再度简化这个查找过程,最好能一步到位查找到 x 的属性值呢?

答案是:

V8 会想尽一切办法来压缩这个查找过程,以提升对象的查找效率。
这个加速函数执行的策略就是内联缓存 (Inline Cache),简称为 IC。接下来我们来看一下,V8 是怎么通过 IC,来加速函数 loadX 的执行效率的。

什么是内联缓存?

V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键的中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率。

IC 会为每个函数维护一个反馈向量 (FeedBack Vector),反馈向量记录了函数在执行过程中的一些关键的中间数据。

比如下面这段函数:

function loadX(o) { 
    o.y = 4
    return o.x
}

image.png

当 V8 执行这段函数的时候,它会判断 o.y = 4 和 return o.x 这两段是调用点 (CallSite),因为它们使用了对象和属性,那么 V8 会在 loadX 函数的反馈向量中为每个调用点分配一个插槽。每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量,

比如上面这个函数中的两个调用点都使用了对象 o,那么反馈向量两个插槽中的 map 属性也都是指向同一个隐藏类的,因此这两个插槽的 map 地址是一样的。

当 V8 再次调用 loadX 函数时,比如执行到 loadX 函数中的 return o.x 语句时,它就会在对应的插槽中查找 x 属性的偏移量,之后 V8 就能直接去内存中获取 o.x 的属性值了。这样就大大提升了 V8 的执行效率。

多态和超态

通过缓存执行过程中的基础信息,就能够提升下次执行函数时的效率。
但是这有一个前提,那就是多次执行时,对象的形状是固定的,如果对象的形状不是固定的,那 V8 会怎么处理呢?

我们调整一下上面这段 loadX 函数的代码,调整后的代码如下所示:

function loadX(o) { 
    return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3, y:6,z:4}
for (var i = 0; i < 90000; i++) {
    loadX(o)
    loadX(o1)
}

我们可以看到,对象 o 和 o1 的形状是不同的,这意味着 V8 为它们创建的隐藏类也是不同的。

面对这种情况,V8 会选择将新的隐藏类也记录在反馈向量中,同时记录属性值的偏移量,这时,反馈向量中的第一个槽里就包含了两个隐藏类和偏移量
image.png
当 V8 再次执行 loadX 时,同样会查找反馈向量表,此时插槽中记录了两个隐藏类。这时,V8 需要额外做一件事,拿这个新的隐藏类和第一个插槽中的两个隐藏类来一一比较,如果找到相同的,那么就使用该隐藏类的偏移量。如果没有相同的呢?同样将新的信息添加到反馈向量的第一个插槽中。

所以一个反馈向量的一个插槽中可以包含多个隐藏类的信息:

插槽中只包含 1 个隐藏类,我们称这种状态为单态 (monomorphic);

插槽中包含了 2~4 个隐藏类,称这种状态为多态 (polymorphic);

插槽中超过 4 个隐藏类,称这种状态为超态 (magamorphic)。

因为多态存在比较的环节,所以多态或者超态的情况,其执行效率肯定要低于单态的。

单态的性能优于多态和超态,所以我们需要稍微避免多态和超态的情况。

最后我还想强调一点,虽然我们分析的隐藏类和 IC 能提升代码的执行速度

但是在实际的项目中,影响执行性能的因素非常多,

找出那些影响性能瓶颈才是至关重要的,

你不需要过度关注微优化,你也不需要过度担忧你的代码是否破坏了隐藏类或者 IC 的机制,

因为相对于其他的性能瓶颈,它们对效率的影响可能是微不足道的。

思考题### 三级标题
观察下面两段代码:

let data = [1, 2, 3, 4]
data.forEach((item) => console.log(item.toString())
let data = ['1', 2, '3', 4]
data.forEach((item) => console.log(item.toString())

你认为这两段代码,哪段的执行效率高,为什么?欢迎你在留言区与我分享讨论。


LeapFE
1.1k 声望2.3k 粉丝

字节内推,发送简历至 zhengqingxin.dancing@bytedance.com