V8中的JSObject

  1. function getObj(numKey=1,strKey=1){
  2. for(let i=0;i<strKey;i++){
  3. this['string'+i] = 'string'+i
  4. }
  5. for(let i=0;i<numKey;i+=2){
  6. this[i] = 'nums'+i
  7. }
  8. }
  9. let a = new getObj(10,10)
  10. let b = new getObj(10,20)

用调试工具Memory打快照结果:
image.png
image.png
可以看到以下几点

  • 里面稳定有一个map。
  • 数字key存在elements,字符串key存在properties里。
  • 有很多字符串key时才会产生properties并存入多出来的字符串key。

先给结论:
Map,也叫隐藏类,在很多文章里叫HiddenClass,源码里就是一个Map,它创建出来本质是映射和操作FixedArray,同时还有优化对象存取属性的作用。

properties,属性,分三种,对象内快properties、普通快properties、慢properties,其中两种快properties存取都是走Map-FixedArray这套,效率高,而慢properties直接走HashTable,效率略低。

elements,索引,js数组的存取就走这个,分为快elements和慢elements,快elements直接映射到内存区域,通过index存取,而慢elements则和慢properties一样走HashTable。

Map与FixedArray

FixedArray是V8 实现的一个类似于数组的类,它表示一段固定长度的连续的内存。大家都知道,连续内存的优势就是知道index,可以最快的拿到对应位置的值。

  1. let data = {
  2. name: "yin",
  3. age: 18
  4. }
  5. {name:l'',age:11}
  6. // 序列为
  7. // 0xdf9ed2aed19: [FixedArray]
  8. // – length: 6
  9. // [0]: 0x1b5ec69833d1 <String[4]: name>
  10. // [1]: 0xdf9ed2aec51 <String[3]: yin>
  11. // [2]: 0xdf9ed2aec71 <String[3]: age>
  12. // [3]: 18

但是这里存的是index和属性名、index和属性值的映射关系,我们对象是属性名和属性值的映射关系。所以我们还需要一个东西来存放属性名与属性下标的映射关系,就是隐藏类。
因为隐藏类存储的是对象的结构,所以隐藏类的基本设定就是对象具有相同的结构会共享相同的隐藏类。所以,当对象添加了一个属性时,我们要使用不同的隐藏类。
image.png
由图可见,他会构建一整个树来存储对象的变化情况,这样普通对象的一般变化就可以直接在树上找到对应的Map来复用。

总结:

  • 通过FixedArray按照解析结构线性缓存对象属性名、属性的内存地址。
  • 通过隐藏类(Map)缓存对象属性和FixedArray的映射关系,进而直接存取内存地址。
  • 具有相同结构(相同顺序的相同 properties)的对象具有相同的隐藏类。
  • 默认情况下,每个添加的新具名属性都会导致创建新的隐藏类。

对象内快properties、快properties、慢properties

快属性

快属性其实就是上面讲述的一套流程,因为数据存储在一个类数组里,所以读取速度更快。
但是我们知道,基础的数组结构的只有取值和原地修改值速度快,当增删属性时,插入和删除的复杂度都是O(n)。而且properties中的索引只有经过Map的映射访问到实际存储位置。所以必须构建隐藏类(Map)。当大量隐藏类构建时会占用大量内存。
为了减少这部分开销,V8 将这些本来会存储在线性结构中的快属性降级为慢属性。
(随着属性数目的增加,V8 会转回到传统的字典模式/哈希表模式:)

慢属性

FixedArray被置空,反而转用一个 Properties Dictionary,本质是一个哈希表。这样对对象的增删属性操作便不需更新 HiddenClass 了,而且复杂度也是O(1),但是取值效率降低。(虽然哈希取值也是O(1),但是有计算位置和重复位置遍历过程)

对象内快属性

在创建新的对象时,V8 会在对象内创建一个空间存储属性,我们暂时叫他in-order 。 当你打算向对象中添加某个新属性时,V8 首先会尝试放入所谓的 in-order 槽位中,当 in-object 槽位过载之后,V8 会尝试将新的属性添加到 单独的properties属性列表。 如果在对象内的属性取值时,就减少了一次去properties中取值的过程,稍微快一点点。

快数组、慢数组

我们知道js中数组只是一种特殊的js对象,确实jsArray是继承jsObject。所以数组和使用数字作为key的存取逻辑是一样的,上面我们说到elements,我们这里就直接通过数组分析一下 elements 。
试验:

  1. let arr1 = [...Array(1000).keys()]
  2. let arr2 = [...Array(1000).keys()]
  3. arr2[3000]=1
  4. console.log(arr2);
  5. console.time("arr1")
  6. for(let i=0;i<1000;i++) arr1[i]=i+'11'
  7. console.timeEnd("arr1")
  8. console.time("arr2")
  9. for(let i=0;i<1000;i++) arr2[i]=i+'11'
  10. console.timeEnd("arr2")
  11. let arr3 = new Array(1000)
  12. let arr4 = new Array(1024*1024*1024)
  13. console.time("arr3")
  14. for(let i=0;i<1000;i++) arr3[i]=i+'11'
  15. console.timeEnd("arr3")
  16. console.time("arr4")
  17. for(let i=0;i<1000;i++) arr4[i]=i+'11'
  18. console.timeEnd("arr4")

结果:
image.png
根据前面说的快慢属性,我们可以想象到数组肯定也是默认快数组,使用数组存取,当达到一定的情况会转为hashTable的形式去存取数据。

快数组

我们在上面的案例中,除了发现在某种情况下数组的存取效率会大大降低,还发现当数组中某个位置没有值时,当前位置会被empty占位。这其实就是带孔数组,数组带孔的一个主要原因是要保证正常有值的index处在正确的内存位置。

我们都知道,数组在物理结构上应该是一种固定长度的线性数据结构。所以按照js中数组任意扩容的机制,肯定有对应的处理方案,在V8中就是利用动态扩容与收缩的方式来处理的。
首先,数组在初始化时就存在4个位置,所以 [] 与 [1, 2, 3, 4] 占用一样多的内存。

  1. // Number of element slots to pre-allocate for an empty array.
  2. static const int kPreallocatedArrayElements = 4;

其次,JSObject 对elements扩容,是当数组已满时,会按照一定的规则增加容量。
举例:向数组 [1, 2, 3, 4] push 5 时,首先判断到当前容量已满,需要计算新容量。old_capacity = 4,new_capacity = 4 + 4 >> 1 + 16 = 22,得出 [1, 2, 3, 4, 5] 的容量为 22 个字节,V8 向操作系统申请一块连续大小为 22 字节的内存空间,随后将老数据一一 copy,再新将新增元素写入。
而当删除数组元素,直到当前数组应占位置小于内存的1/2时,又会按照一定的规则收缩容量。

快数组转为慢数组

由上面我们知道,JSObject 对elements有两种处理机制,一种是空位占位,另一种是动态扩容。而这两种都是空间换时间的操作,当产生大量的empty占位,或者再次扩容需要很大的无用空间时,就不能再对空间浪费下去了,此时数组就自动转换为慢数组。

  • 当出现超过1024个连续孔时,数组会转变为慢数组
  • 当下一次扩容容量过大时,会变为慢数组

慢数组

慢数组是一种哈希表的内存形式。不用开辟大块连续的存储空间,节省了内存,但是由于需要维护这样一个 HashTable,其效率会比快数组低。
快数组就是以空间换时间的方式,申请了大块连续内存,提高效率。 慢数组以时间换空间,不必申请连续的空间,节省了内存,但需要付出效率变差的代价。

个人认为以上内容平时不用刻意去记,但有这个意识可以为产生问题尤其是性能问题时提供一个思考方向。