了解V8(一) V8采用了哪些策略提高了对象属性的访问速度

JavaScript 语言的角度来看,JavaScript 对象像一个字典,字符串做为键名,任意对象能够做为键值,能够经过键名读写键值。chrome

然而在 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 规范中定义了优化

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

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

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

在这里咱们把对象中的数字属性称为排序属性,在 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())

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

相关文章
相关标签/搜索