最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 浏览器原理系列-V8引擎对象存储的优化

    正文概述 掘金(头号前端)   2020-12-26   485

    在开始本文前,我们来思考一个问题,在JS语言中,访问一个对象的属性复杂度是多少?是O(1)吗?如果是O(1),为什么能做到O(1)的复杂度?让我们带着这些问题继续往下看。

    V8如何存储JS对象

    我们知道JS一门动态语言,这意味着在代码执行过程中,变量的类型是不定的,可以被改变,非常灵活,这也是JS语言的特性之一。但这也带来了一个问题,我们访问一个JS对象的某个属性时不能直接根据偏移量计算出需要访问的存储位置,这在静态语言里是很容易做到的。那JS是怎么存储对象呢?

    JS对象存储在堆中,它更像一个字典,字符串作为键名,任意对象都可以作为键值,通过键名读写键值。然而在 V8 实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典是非线性的数据结构,查询效率会低于线性的数据结构,V8 为了提升存储和查找效率,采用了一套复杂的存储策略。线性结构是一块连续的内存,如线性表和数组,非线性结构一般占用非连续性内存,如链表和树。

    常规属性(properties)和排序属性(element)

    首先我们来看一下什么是常规属性和排序属性,这里有一段代码:

    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]}`)
    }
    

    我们来观察这段代码的执行结果:

    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。

    在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性,具体结构如下图所示:

    浏览器原理系列-V8引擎对象存储的优化

    如图所示: bar 对象包含了两个隐藏属性: elements 属性和 properties 属性, elements 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性, properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存了常规属性。

    分解成这两种线性数据结构之后,如果执行索引操作,那么 V8 会先从 elements 属性中按照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。

    快属性和慢属性

    将不同的属性分别保存到 elements 属性和 properties 属性中,无疑简化了程序的复杂度,但是在查找元素时,却多了一步操作,比如执行 bar.B 这个语句来查找 B 的属性值,那么在 V8 会先查找出 properties 属性所指向的对象 properties ,然后再在 properties 对象中查找 B 属性,这种方式在查找过程中增加了一步操作,因此会影响到元素的查找效率。

    基于这个原因,V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为 对象内属性 (in-object properties),即快属性。

    浏览器原理系列-V8引擎对象存储的优化

    不过对象内属性的数量是固定的,默认是 10 个,虽然常规属性存储多了一层间接层,但可以自由地扩容。 通常,我们将 保存在线性数据结构中的属性称之为“快属性” ,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果 从线性结构中添加或者删除大量的属性时,则执行效率会非常低 ,这主要因为会产生大量时间和内存开销。 因此, 如果一个对象的属性过多时,V8 就会采取另外一种存储策略,那就是“慢属性”策略,但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。

    浏览器原理系列-V8引擎对象存储的优化

    我们来实践一下,看看下面的代码:

    function Foo(property_num,element_num) {
        //添加可索引属性
        for (let i = 0; i < element_num; i++) {
            this[i] = `${i}-element${i}`
        }
        //添加常规属性
        for (let i = 0; i < property_num; i++) {
            let ppt = `${i}-property${i}`
            this[ppt] = ppt
        }
    }
    var bar = new Foo(10,10)
    

    此时内存中的结构如下图:

    浏览器原理系列-V8引擎对象存储的优化

    此时bar对象有10个常规属性,全都升级为快属性,没有 properties 属性。

    我们修改调用 Foo 函数的参数为:

    var bar = new Foo(100,10)
    

    此时 bar 属性的常规属性超过10个,由于快属性有10个数量的限制,可以看到 properties 属性。

    浏览器原理系列-V8引擎对象存储的优化

    浏览器原理系列-V8引擎对象存储的优化

    结合上图,我们可以看到,这时候的 properties 属性里面的数据并不是线性存储的,而是以非线性的字典形式存储的,所以这时候属性的内存布局是这样的:

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

    • 90 个常规属性以非线性字典的这种数据结构方式存放在 properties 属性里面 ;

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

    隐藏类

    前文我们提到,由于JS语言是一门动态语言,不像静态语言可以通过偏移量来查询对象的属性值,那V8是否可以把这种高查询效率的方法引入呢?

    答案是V8的确这样做了。一个思路是将JavaScript 中的对象静态化,也就是 V8 在运行 JavaScript 的过程中,会假设 JavaScript 中的对象是静态的,具体地讲,V8 对每个对象做如下两点假设:

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

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

    符合这两个假设之后,V8 就可以对 JavaScript 中的对象做深度优化了,那么怎么优化呢? 具体地讲, V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:对象中所包含的所有的属性;每个属性相对于对象的偏移量。 回到前文的截图,可以看到每个对象下有一个 map 属性,这个属性就是隐藏类。

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

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

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

    当 V8 执行到这段代码时,会先为 point 对象创建一个隐藏类,在 V8 中,把隐藏类又称为 map,每个对象都有一个 map 属性,其值指向内存中的隐藏类。隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量,比如 point 对象的隐藏类就包括了 x 和 y 属性,x 的偏移量是 4,y 的偏移量是 8。

    浏览器原理系列-V8引擎对象存储的优化

    在这张图中,左边的是 point 对象在内存中的布局,右边是 point 对象的 map。有了 map 之后,当你再次使用 point.x 访问 x 属性时,V8 会查询 point 的 map 中 x 属性相对 point 对象的偏移量,然后将 point 对象的起始位置加上偏移量,就得到了 x 属性的值在内存中的位置,有了这个位置也就拿到了 x 的值,这样我们就省去了一个比较复杂的查找过程。

    这就是将动态语言静态化的一个操作,V8 通过引入隐藏类,模拟 C++ 这种静态语言的机制,从而达到静态语言的执行效率。在 V8 中,每个对象都有一个 map 属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类。这样有两个好处:

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

    • 减少了隐藏类的存储空间。

    那么,什么情况下两个对象的形状是相同的,要满足以下两点: 1、相同的属性名称;2、相等的属性个数。

    重新构建隐藏类

    关于隐藏类我们之前有两个假设,对象创建之后不会添加或删除属性,但是JS是动态语言,在执行过程中,对象的形状是可以被改变的,如果某个对象的形状改变了,隐藏类也会随着改变,这意味着 V8 要为新改变的对象重新构建新的隐藏类,这对于 V8 的执行效率来说,是一笔大的开销。通俗地理解, 给一个对象添加新的属性,删除新的属性,或者改变某个属性的数据类型都会改变这个对象的形状,那么势必也就会触发 V8 为改变形状后的对象重建新的隐藏类。

    这解释了为什么不推荐使用 delete 关键字删除对象的属性。解答我多年的一个疑惑呀~

    对象操作的最佳实践

    前文我们知道频繁改变对象的属性或属性值的数据类型会导致频道重新构建隐藏类的性能问题,基于此我们可以推测出操作对象的最佳实践。

    使用字面量初始化对象时,尽量保证属性的顺序一致。

    我们看一个?:

    // bad
    let point = {x:100,y:200};
    let point2 = {y:100,x:200};
    
    // good
    let point = {x:100,y:200};
    let point2 = {x:100,y:200};
    

    为什么不推荐第一种做法呢?因为两个对象的形状不同,会生成不同的隐藏类。

    尽量使用字面量一次性初始化完整对象属性。

    因为每次为对象添加属性时V8都需要为该对象重新设置隐藏类。

    避免使用delete方法

    同样的,删除对象的属性会导致V8会重新构建隐藏类。

    内联缓存(Inline Cache)

    首先我们来看一个代码片:

    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 函数会被反复执行,那么获取 o.x 流程也需要反复被执行。我们有没有办法再度简化这个查找过程,最好能一步到位查找到 x 的属性值呢?答案是,有的。

    V8通过 内联缓存 策略压缩这个查找过程,提升对象的查找效率。那什么是内联缓存呢?它具体是怎么工作的呢?

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

    接下来我们以示例代码为例详细看看IC的工作流程:

    IC 会为每个函数维护一个 反馈向量 (FeedBack Vector),反馈向量记录了函数在执行过程中的一些关键的中间数据。反馈向量其实就是一个表结构,它由很多项组成的,每一项称为一个插槽 (Slot),V8 会依次将执行 loadX 函数的中间数据写入到反馈向量的插槽中。 代码片中 return o.x 是一个调用点,因为它使用了对象和属性,那么V8会在 loadX 函数的反馈向量中为这个调用点分配一个插槽,每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量,当V8再次调用 loadX 函数执行到 return o.x 时,它会在对应的插槽中查找 x 属性的偏移量,之后 V8就能直接去内存中获取 o.x 的属性值了,可以大大提升执行效率。

    多态和超态

    好了,通过缓存执行过程中的基础信息,就能够提升下次执行函数时的效率,但是这有一个前提,那就是多次执行时,对象的形状是固定的,如果对象的形状不是固定的,那 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)
    }
    

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

    第一次执行时 loadX 时,V8 会将 o 的隐藏类记录在反馈向量中,并记录属性 x 的偏移量。那么当再次调用 loadX 函数时,V8 会取出反馈向量中记录的隐藏类,并和新的 o1 的隐藏类进行比较,发现不是一个隐藏类,那么此时 V8 就无法使用反馈向量中记录的偏移量信息了。

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

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

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

    • 如果一个插槽中包含了 2~4 个隐藏类,那我们称这种状态为多态 (polymorphic);

    • 如果一个插槽中超过 4 个隐藏类,那我们称这种状态为超态 (magamorphic)。

    如果函数 loadX 的反馈向量中存在多态或者超态的情况,其执行效率肯定要低于单态的,比如当执行到 o.x 的时候,V8 会查询反馈向量的第一个插槽,发现里面有多个 map 的记录,那么 V8 就需要取出 o 的隐藏类,来和插槽中记录的隐藏类一一比较,如果记录的隐藏类越多,那么比较的次数也就越多,这就意味着执行效率越低。

    所以我们得出一个结论是 尽量保持单态,因为单态的性能优于多态和超态

    到此为止我们已经了解了V8对访问对象属性做的优化,这三个优化方案环环相扣,不得不感叹设计者的智慧。回到本文开始的问题,访问对象属性的复杂度是什么?我认为答案是约等于O(1),但是它和静态语言的访问对象属性的O(1)复杂度原理完全不同。

    最后的话

    最后打个广告,不知不觉浏览器原理系列已有4篇文章,马上迎来终结篇--《V8编译流水线》。笔者才疏学浅,不免有错误和疏漏之处,望大家不吝赐教。

    往期回顾

    浏览器原理系列-浏览器渲染流程详解

    浏览器原理系列-JS执行上下文详解

    浏览器原理系列-JS内存机制和垃圾回收


    起源地下载网 » 浏览器原理系列-V8引擎对象存储的优化

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元