V8看Object元素的自动排序

1,424 阅读8分钟

为什么Object中的元素会自动排序

为什么Object中的元素会自动排序,而不是按照我们输入顺序呢?

var a = { 0: 1, 3: 3, 2: 1, 'b': "b", 1000: 100, 99: 99, 'c': "c", 'a': "a" }
{0: 1, 2: 1, 3: 3, 99: 99, 1000: 100, b: "b", c: "c", a: "a"}
可以看到数字的键值被排序了,但是非数字的键值是按照添加顺序显示的

我们先看for-in的ES规范

O是目标对象

1,参数是一个对象

2,返回一个迭代器对象,它的下一个方法遍历O可枚举属性的所有字符串值键。枚举属性的机制和顺序没有指定,但必须符合下面指定的规则。

现在,规范说明通常在需要的确切步骤方面是精确的。但是在这种情况下,它们引用了一个简单的散文列表,甚至执行的顺序也留给了实现者。

1,迭代器的抛出和返回方法为空,并且永远不会被调用。

2,迭代器的下一个方法处理对象属性,以确定属性键是否应该作为迭代器值返回。

3,返回的属性键不包括Symbol。

var a = {}
a[Symbol(1)] = 233
for(let i in a)console.log(i)
什么都没输出

4,目标对象的属性可能在枚举期间被删除。

5,被迭代器的下一个方法处理之前删除的属性将被忽略。如果在枚举期间向目标对象添加新属性,则不能保证在活动枚举中处理新添加的属性。

for-in过程中删除属性
    var a = { a: 111, b: 222, c: 333 }
    for (let i in a) {
        delete a.b
        console.log(i)
    }
    输出a c
    
 在for-in过程中添加属性
    var a = { a: 111, b: 222, c: 333 }
    for (let i in a) {
        a.d=888
        console.log(i)
    }
    输出a b c,并没有d   

6,在任何枚举中,迭代器的下一个方法最多只返回一次属性名。

7,目标对象的属性枚举包括原型的属性枚举、原型的原型的属性枚举等,递归;但是,如果原型的属性与迭代器的下一个方法已经处理过的属性具有相同的名称,则不会处理原型的属性。

for-in过程中会遍历原型链上的可枚举属性,但已经遍历过得就不会处理
    var a = { a: 111, b: 222, c: 333 }
    a.__proto__.f = "__proto__.f"
    a.__proto__.a = "__proto__.a"
    for (let i in a) {
        console.log(i)
    }

8,在确定原型对象的属性是否已被处理时,不考虑[[Enumerable]]属性的值。

9,原型对象的可枚举属性名必须通过调用将原型对象作为参数传递的EnumerateObjectProperties来获得。

10,EnumerateObjectProperties必须通过调用它的[[OwnPropertyKeys]]内部方法来获得目标对象的自身属性键。

EnumerateObjectProperties示例
function* EnumerateObjectProperties(obj) {
  const visited = new Set();
  for (const key of Reflect.ownKeys(obj)) {
    if (typeof key === 'symbol') continue;
    const desc = Reflect.getOwnPropertyDescriptor(obj, key);
    if (desc && !visited.has(key)) {
      visited.add(key);
      if (desc.enumerable) yield key;
    }
  }
  const proto = Reflect.getPrototypeOf(obj);
  if (proto === null) return;
  for (const protoKey of EnumerateObjectProperties(proto)) {
    if (!visited.has(protoKey)) yield protoKey;
  }
}
你自己细品一下

出于性能考虑,V8会预先收集所有的键值。规范文本明也没有声明的顺序。

康康内部实现For-in

使用伪c++风格来解释如何在内部实现For-in

FixedArray* keys = nullptr;
Map* original_map = object->map();
if (original_map->HasEnumCache()) {
  if (object->HasNoElements()) {
    keys = original_map->GetCachedEnumKeys();
  } else {
    keys = object->GetCachedEnumKeysWithElements();
  }
} else {
  keys = object->GetEnumKeys();
}

// For-In Body:
for (size_t i = 0; i < keys->length(); i++) {
  // For-In Next:
  String* key = keys[i];
  if (!object->HasProperty(key) continue;
  EVALUATE_FOR_IN_BODY();
}
forin可以分为三个主要步骤:
准备遍历键
获取下一个键
评估for-in主体

有三个关键点
object->map() 隐藏类
object->Elements()排序队列
object->Propertyies()属性队列

排序队列(Elements)和属性队列(Propertyies)

V8在处理属性时分了两个队列

排序队列(Elements)

用来保存数字键值的属性,以栈来保存。

var a = { 0: 1, 3: 3, '2': 1, '1000': 100, '99': 99 }
{0: 1, 2: 1, 3: 3, 99: 99, 1000: 100}
字符串类型的数字一样会被保存到排序队列中
顺序按照键值升序排序

属性队列(Propertyies)

用来保存非数字键值的属性,以栈来保存(数量少的情况)。

var a =  var a = { b: 111, c: 222, a: 333 }
{b: 111, c: 222, a: 333}
顺序按照键值插入顺序排序

这里来说说属性队列的保存方式 数量少的时候propertyies使用栈来保存数据

当数量超过指定数量,就会使用hash结构来保存数据。这个指定数量我没试出来,有兴趣的自己去试试,试出来的回复我秋梨膏。

Chrome的Memory可以截取当前的内存快照,就可以查看。

    function Foo(element_num = 10) {
        //添加可索引属性
        for (let i = 0; i < element_num; i++) {
            this[i] = `element${i}`
        }
        //添加常规属性
        this['a'] = 'a'
        this['b'] = 'b'
        this['c'] = 'c'
        this['d'] = 'd'
    }
    var bar = new Foo

为什么没有propertyies只有elements呢?这个下面说

obj['a']比obj.a谁快

obj.a访问时先遍历属性队列,没找到再去查找排序队列

obj['a']访问时先遍历排序队列,没找到再去查找属性队列

所以obj.a快

V8对propertyies的优化

快属性

属性队列与排序队列使用栈结构保存时都叫快属性,栈结构只需要遍历就能快速查找到属性。

慢属性

但是属性队列在数据当数量超过指定数量时会使用hash结构保存数据,这时属性队列内的属性就叫慢属性

in-Object对象内属性

以属性方式访问对象属性是很频繁的

var obj={a:1}
obj.a

那每次V8还要去属性队列中取数据,就多了一层查找。

obj->propertyies->a

大多情况对象内的属性不会太多,无需使用propertyies,会直接保存在对象内。

这也就是这里面没有propertyies的原因

对象内属性的数量限制是10个(10个是使用new Object或{}时默认10个,如果你创建时带属性的var a={a:1,b:2}那就是两个)。 多余的数据依旧存在propertyies中。

这样访问就是

obj->a

好了这样就能看出Object元素的自动排序规则

先访问Elements的数据,再去访问propertyies中的数据。因为保存时就已经自动排序了,所以输出的时候就是排序完成的格式。

隐藏类

FixedArray* keys = nullptr;
Map* original_map = object->map();
if (original_map->HasEnumCache()) {
  if (object->HasNoElements()) {
    keys = original_map->GetCachedEnumKeys();
  } else {
    keys = object->GetCachedEnumKeysWithElements();
  }
} else {
  keys = object->GetEnumKeys();
}

// For-In Body:
for (size_t i = 0; i < keys->length(); i++) {
  // For-In Next:
  String* key = keys[i];
  if (!object->HasProperty(key) continue;
  EVALUATE_FOR_IN_BODY();
}

object->map() 隐藏类

那隐藏类和propertyies有什么关系呢?

propertyies数量过多是以hash结构保存的,读取数据很慢远远不如栈结构

隐藏类来了

简单说一个对象创建时根据形状创建对应的隐藏类

属性 偏移值
x 1
y 2
z 3

根据偏移值就可以直接获取到属性值的指针地址

取propertyies值时会直接去Map隐藏类中获取,即使propertyies使用hash结构也能快速获取属性值。

隐藏类在新增属性删除属性时都会重新构建隐藏类。

var a={a:1,b:2}
var b={a:1,b:2}
相同形状的对象会共享一个隐藏类
1,属性数量得一样
2,属性名称得一样
3,属性顺序得一样

Object的优化

   function foo(obj) {
       for (let i = 1; i <= 10000; i++) console.log(obj.a)
   }
   foo({ a: 1 })

这样会输出obj.a1w次,虽然有隐藏类但一样会遍历隐藏类根据偏移值去查找数据

那V8对着有什么优化呢?

内联缓存

每次对函数预处理时V8都会记下函数中的调用点

在上面的调用点就是obj.a

在执行obj.a时向内联缓存中添加反馈向量 (FeedBack Vector)

slot 插槽位置 type 插槽类型 state插槽的状态 map隐藏类地址 offset 偏移值
1 LOAD MONO 3321556FD63 4
...
n

type 插槽类型

1,存储 (STORE) 类型

2,函数调用 (CALL) 类型

3,加载 (LOAD) 类型

在每次执行console.log(obj.a)时都会通过内联缓存的反馈向量的插槽位置进行操作

先看详细的执行过程自己可以安装d8,解析出执行时的中间代码

单态、多台、超态

   function foo(obj) {
       for (let i = 1; i <= 10000; i++) console.log(obj.a)
   }
   foo({ a: 1 })

每次执行foo时,obj可能不是同一对象就有不同的隐藏类。

但是插槽位置依旧是哪个位置,那如何解决内联缓存的的反馈向量呢?

会存在同一插槽的隐藏类中,当触发调用点时遍历隐藏类是否有相同的,有就通过偏移值取值,没有就添加进这个隐藏类中

  • 如果一个插槽中只包含 1 个隐藏类,那么我们称这种状态为单态 (monomorphic)
  • 如果一个插槽中包含了 2~4 个隐藏类,那我们称这种状态为多态 (polymorphic)
  • 如果一个插槽中超过 4 个隐藏类,那我们称这种状态为超态 (magamorphic)

隐藏类超过四个就使用hash结构来保存,所以保持单态有助于执行速度。

参考资料

大家可以看看《图解 Google V8》,v8.dev有最新的V8动态