V8中的快速属性访问-Fast Properties in V8

12 篇文章 1 订阅
8 篇文章 1 订阅

V8中的快速属性访问

本文内容来来自V8项目官方博客内容,使用知识共享署名 3.0 未本地化版本许可协议,如果转载本文,需注明出处。本文主要介绍V8通过隐藏类技术,达到快速属性访问的效果。

  本文将向我们展示V8底层如何去处理JavaScript中的属性。从JavaScript的角度来看,不同类别的属性之间只有一些很少的必要的区别。大多数时候,JavaScript对象就像字典,字符串作为key,而任意对象作为值。在规范中,对于整数型属性如数组索引和其他属性的区别在于循环迭代时的处理。除此之外,属性之间的表现基本相同,无论是整数型还是其他。
  然而从V8的底层来看,由于优化性能和内存原因,V8实现了几种不同的属性分类处理方式。在本文将会解释V8如何在处理动态添加属性的时候提供高效的属性访问。从内部了解属性是如何工作的,也能够从本质上清楚V8中的优化机制,比如内联缓存是如何工作的。
  本文会介绍在处理整数型属性和命名属性的区别(integer-indexed properties 和named properties,在这里named properties是指字符串型属性)。接着我们会介绍V8通过在添加属性的时候维持一个隐藏类来快速确认对象的结构。我们也会清楚了解命名属性是如何被优化从而达到快速访问的效果,以及如何根据使用情况做出快速的修改。在最后,我们也会介绍一些V8处理整数型属性或者称作数组索引的细节。

一,命名属性 VS 元素

  让我们先来分析一个最简单的对象: {a: “foo”, b: “bar”}。这个对象有两个命名属性,“a”和“b”,并且没有整数值作为属性名的。而另一种数组索引属性,即整数型属性,通常也被称为元素(Elements ),多数存在于数组中。例如数组[“foo”, “bar”]拥有两个数组索引属性:0,对应值为“foo”;1,对应值为“bar”。一般来说,这两种分类也是V8处理属性时的第一个重要划分。
  下面这张图展示了JavaScript对象在内存中的结构。
这里写图片描述
  元素和大部分命名属性是储存在两个分开的数据结构中,能够在不同使用模式下访问或者添加属性和元素更加高效。
  和元素有关的操作通常是一些数组方法,比如pop或者slice。而且对于那些会被函数在连续范围内访问的属性,V8底层大部分时候也会把它们存成数组。稍后在这篇文章中会介绍在什么情况下,数组表示会被转换成一个稀疏字典来节省内存。

  命名属性也像元素一样,存在一个单独的数组中。然而与元素不同的是,我们无法通过它的key来推断出它在属性数组中的位置。为了确定它的位置,需要一些额外的metadata。在V8中,每一个JavaScript对象都有一个关联的的隐藏类(HiddenClass )。隐藏类中包括对象的结构信息,一个属性名映射到属性索引的map。为了应对那些复杂的情况,我们通常在隐藏类中会把属性存成字典而非一个简单数组,关于这一点的详细解释在下一部分。

总结下这一部分:
  • 数字型属性被存在一个单独的元素数组中 同样,命名属性被存在一个单独的属性数组,即properties store中。
  • 元素和命名属性的存储结构既可以是数组,也可以字典 每一个JavaScript对象都有一个关联的隐藏类来保存对象的结构

二,隐藏类和DescriptorArrays

  大致了解了元素和命名属性之间的区别后,我们需要看下隐藏类是如何在V8中工作的。隐藏类储存了一个对象的元信息,包括对象上属性的个数,对象的大小以及指向构造函数和原型的指针。从概念角度来看,隐藏类和传统面向对象语言中的类比较相似。然而,像JavaScript这种基于原型的语言是无法预先知道类的。因此,V8中隐藏类的创建是on the fly的,并且随着对象改变动而动态变化。隐藏类帮助我们了解对象结构,同时在V8的优化编译器和内联缓存中扮演着重要的角色。当通过隐藏类确定兼容的对象结构后(指的是对象属性不再增删,因此也不会去创建新的隐藏类,即属性对应的隐藏类确定,不再变化),优化编译器可以直接内联属性的访问(将地址内联)

  让我们看一下隐藏类的重要部分。

这里写图片描述
  V8将JavaScript对象中的第一字段指向隐藏类(事实上,这是针对所有在V8堆上以及被GC管理的对象)。而在隐藏类中,对命名属性而言,最重要的信息是第三个字段(bit field 3),里面保存了这个对象上属性的个数,以及一个指向描述符数组(descriptor array)的指针。描述符数组里面包含了命名属性相关的信息:属性名以及对应值所在的位置。需要注意,这个数组里面不会保存任何整数型属性,只记录了命名属性。
  比如,一个隐藏类结构可能像这样(仅列出部分内容):

HiddenClass H1
         object size: 20 (表示两个属性的空间)
		"a": FIELD at offset 12
		"b": FIELD at offset 16

  很多时候,比如同一个构造函数生成的对象会拥有相同的结构,即拥有以相同顺序赋值的相同属性。对于这些相同结构的对象,就可以用相同隐藏类来描述,这也是共享隐藏类的基本条件。所以,给一个对象添加属性后,就会指向另一个隐藏类。下面这张图展示了从一个空对象到逐次增加三个属性的过程。
这里写图片描述

  每添加一个属性,对象的隐藏类就会发生改变。在底层,V8会创建一个Transition树将隐藏类之间链接在一起。当你添加属性后,V8会知道此时应该指向哪个隐藏类。例如,上图中给空对象添加属性“a”后,Transition树会确保拥有相同赋值顺序及相同属性的对象最终指向相同的隐藏类。
   下面这张图也会告诉我们,即便在中间添加了整数型属性,也不会干扰指向的隐藏类。

这里写图片描述
也可以用伪代码来表示这个过程:

 HiddenClass H0
    "a": TRANSITION to H1 at offset 12//o的隐藏类本身是H0,再添加属性a后,即告诉引擎如果添加了属性a,将隐藏类指向H1,以下同理

o.a = "foo"

HiddenClass H1
    "a": FIELD at offset 12
    "b": TRANSITION to H2 at offset 16

o.b = "bar";

HiddenClass H2
    "a": FIELD at offset 12
    "b": FIELD at offset 16
   "c": TRANSITION to H3 at offset 20

o.c = "baz";

HiddenClass H3
    "a": FIELD at offset 12
    "b": FIELD at offset 16
    "c": FIELD at offset 20

  然而,如果我们创建一个空对象后,用不同的顺序添加属性,比如先添加“d”,这时V8就会创建一个分支指向一个新的隐藏类。
这里写图片描述
  在一个大型的JavaScript程序中,生成的隐藏类之间看起来像这样:
这里写图片描述

概括下这一部分:
  • 相同的对象(拥有以相同顺序赋值的相同属性)拥有相同的隐藏类
  • 每添加一个新的属性,对象也会指向一个新的隐藏类(不一定是新创建的)
  • 添加一个整数型属性不会导致对象的隐藏类发生改变。

三, 三种不同的命名属性

  在大致了解了V8如何用隐藏类来track对象的结构,接下来去看看那些属性究竟是如何被保存的。在上面我们已经解释过,总共有两种最基本的属性类型:命名属性和整数型属性。下面的内容主要面向命名属性。
  在V8内部,一个简单的对象如 {a: 1, b: 2} 可以有许多种表示方法。虽然JavaScript对象从表现看或多或少和字典类似,但V8会尽力避免字典模式,因为字典会阻碍一些优化,比如内联缓存。关于内联缓存,本文也会介绍。下面开始介绍几种不同的命名属性分类。

####3.1 对象内和普通属性(In-object 和 Normal Properties):
V8支持所谓的对象内属性,它们被直接保存在对象上,和对象在同一块内存区域。这种属性是V8中速度最快的属性,能够被直接访问。对象内属性的个数由对象初始化大小决定。如果要添加的属性超出对象的大小,这些属性就会被放入properties store中(即对象上properties 指针指向的数组)。properties store会增加一层属性访问的消耗,但是不受大小的限制。
这里写图片描述
####3.2 快速属性和慢速属性(Fast vs. Slow Properties):
下一个重要的区分是快速属性和慢速属性。一般来说,我们会把属性按顺序保存在properties store中,作为快速属性。快速属性可以通过简单的索引在properties store中访问。不过为了从属性名从properties store获取实际位置,我们要去HiddenClass上的 descriptor array中查询 。具体来讲,这一步骤是先根据属性名搜寻隐藏类的descriptor array,然后得到到数组的偏移地址,然后根据偏移地址到properties store上读取属性信息。实际上,第一次查找属性的时候难免会经历一次隐藏类的哈希查找,但下次一般会结合内联缓存,直接采用缓存的位移来存取属性。
这里写图片描述
  然而,若是有很多属性从对象上添加或者删除,就需要花费很多时间和内存来维护descriptor array以及隐藏类。因此,V8也支持所谓的慢属性。拥有慢属性的对象会拥有一个自给的字典作为它的properties store(如上图所示)。这种情况下,所有属性的元信息不再储存在隐藏类的descriptor array中,而是直接保存在属性字典上。因此,此时属性的添加和移除不用再去更新指向的隐藏类。因为内联缓存不适用保存在字典中的属性,所以慢属性一般要比快属性慢。

概括下这一部分:
  • 列表内容有三种不同类型的命名属性:对象内属性,快属性,以及慢/字典属性。
      1. 对象内属性直接储存在对象自身上,提供最快的属性访问。
      2. 快属性活跃在properties store上,所有相关的元信息都在隐藏类的descriptor array中。
      3. 慢属性保存在一个自给的属性字典上,属性的元信息不再与隐藏类有关。
  • 慢属性提供高效的属性添加删除,但是访问速度要慢于快速属性和对象内对象。

##四, 元素和数组索引属性
  目前为止我们介绍了命名属性并且忽略了通常出现在数组中的整数索引属性。处理这种整数型属性要比命名属性简单很多。即使所有的整数型属性都被单独保存在一个elements store中,并且有多大20种元素类型。

4.1 Packed 或者 Holey Elements:

V8中第一个主要区分就是elements store是否是充满连续还是拥有空洞。如果你删除某个索引中的元素,或者在这个位置没有定义它,你就会得到一个空洞。一个简单的例子[1,3],这个例子中第二个位置是一个空洞。下面这个例子阐明了这个问题。

const o = ["a", "b", "c"];
console.log(o[1]);          // Prints "b".

delete o[1];                // Introduces a hole in the elements store.
console.log(o[1]);          // Prints "undefined"; property 1 does not exist.
o.__proto__ = {1: "B"};     // Define property 1 on the prototype.

console.log(o[0]);          // Prints "a".
console.log(o[1]);          // Prints "B".
console.log(o[2]);          // Prints "c".
console.log(o[3]);          // Prints undefined

这里写图片描述
  简言之,如果属性在接收者上找不到(接受者可以理解为要访问属性的对象或者方法所属的那个对象),那么就会继续到原型链上找。这些元素是自给自足的,换言之,我们不需要在隐藏类上储存这些索引属性。另外,我们需要一个特殊的值,称之为空洞,来标记那些不存在属性。这点对于数组方法的性能很关键。若是我们知道elements store没有空洞,是被填满的,我们可以提高本地操作(指无需原型链参与)的性能,不需要再去花费昂贵的代价查找原型链。

4.2 快速或者字典元素(Fast or Dictionary Elements):

  第二个关于元素的主要的区分为是否是快速或是字典模式。快速元素就是VM内部简单的将数组索引与elements store中的索引映射。然而,这种简单表示在那种有很大的空洞以及很少位置被占用的数组上是相当浪费的。在这种情况下,我们将转换成字典模式,这会减小内存但轻微的牺牲性能。

const sparseArray = [];
sparseArray[9999] = "foo"; // Creates an array with dictionary elements.

  在这个例子中,如果给这个数组分配完整的10k个空间将会造成极大浪费。而实际上V8是创建了一个key-value-descriptor的triplets。这个例子中的key是“9999”,value是“foo”,而descriptor是使用的默认值。考虑到无法在隐藏类上储存这些描述符的信息,所以,无论何时你在索引属性上定义了自定义描述符,V8都会把它变成慢元素。

const array = [];
Object.defineProperty(array, 0, {value: "fixed", configurable: false});
console.log(array[0]);      // Prints "fixed".
array[0] = "other value";   // Cannot override index 0.
console.log(array[0]);      // Still prints "fixed".

  在上面例子中,我们给数组添加了一个non-configurable属性。这个信息就会被保存慢元素字典的triplet中的descriptor 部分。需要切记一点,数组方法在那些拥有慢元素的对象上的性能会相当慢。

4.3 小整数和双精度元素(Smi and Double Elements):

对于快元素来说,V8仍然存在一个重要的区分。例如如果你只在数组上储存整数,这种情况下GC不会监视数组,因为这里整数会被直接编码成小整数。另外一个特殊的情况是数组只包含浮点数,与小整数不同,浮点数通常会被表示为占用几个字符的完整对象(就像普通对象)。. However, V8 stores raw doubles for pure double arrays to avoid memory and performance overhead 下面是四个小整数和双精度的例子:

const a1 = [1,   2, 3];  // Smi Packed
const a2 = [1,    , 3];  // Smi Holey, a2[1] reads from the prototype
const b1 = [1.1, 2, 3];  // Double Packed
const b2 = [1.1,  , 3];  // Double Holey, b2[1] reads from the prototype
4.4 特殊元素:

目前为止我们介绍20种不同元素中的7种。对于其他的,其中7种我们简单的划分为TypedArrays一类,另外两个归类到String wrappers。最后但同样重要的,两个特殊属于
参数对象(arguments objects)的元素。

####4.5 The ElementsAccessor:
你可以想到开发人员根本不愿意为这20种元素在C++中对应重复写20遍数组方法。这里就是体现C++神奇的地方了。为了不实现数组方法一遍又一遍,我们建立了一个属性存取器(ElementsAccessor),在它里面大部分都仅仅是简单的属性访问相关的方法。这个属性访问其依赖C++中的CRTP来实现不同种类需求的数组方法。所以,有时如果我们调用例如数组的slice,V8会调用C++中的代码,然后通过属性存取器来选择所需函数,如slice的专门版本。
这里写图片描述

概况下这一部分:
  • 整数型,或者索引属性分为快速和字典模式 快速模式属性下可以是充满的,也可以是拥有空洞的,比如在某些时候通过delete删除了某个索引属性。
  • 元素本身能够加速数组方法访问,并且减少GC开销

  V8中很多优化都取决于属性的工作机制。对于JavaScript开发者来说,虽然内部的实现并不能直接看见,但是了解这些能够解释为什么某一模式的代码会比其他的快。改变属性或者是元素的类型有时会导致创建一个新的隐藏类或改变隐藏类指向,这种行为会让V8生成优化代码的机制难以工作。

五,最后

  最后,不同的浏览器引擎针对属性的实现或许不同,有些优化在不同浏览器效果可能天差地别。但是,有几点应该是被记住的:

  • 尽可能少的去修改你的对象结构
  • 不要去删除对象的属性,如果你想要把对象当作一个字典,那么最好选择Map或者Set之类。
  • 按照固定的顺序添加属性
  • 尽可能初始化结构中所有部分。

原文引用:
Fast Properties in V8

扩展阅读
A tour of V8: object representation
多态内联缓存PIC



知识共享许可协议
本作品采用知识共享署名 3.0 未本地化版本许可协议进行许可。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值