Google V8引擎浅析-面向对象

278次阅读  |  发布于2年以前

继续上次分享的主旨,再来谈一谈V8引擎的一些细节优化的处理,对第一期感兴趣的同学可以看下:Google V8引擎浅析[1]

写在前面

JavaScript 中的对象扮演着举足轻重的角色,本次分享希望能从底层机制的角度,来探究下V8引擎在对象处理上,又做了哪些性能方面的优化,又能给我们日常工作上带来哪些比较有趣启迪或者值得遵守的建议呢?

对象属性优化

Fast properties in V8 · V8[2]

从语言的角度来看,JavaScript中的对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值。本次快慢属性的讨论范围,只限于单一对象的内部属性查找,不涉及到从原型链向上查找这一场景。

V8并没有完全采用字典的结构来存储数据,因为我们知道,非线性结构查找数据的效率是低于线性结构的(连续内存空间分配后的查找明显是优于非连续空间的),而是为了提升存储和查找的性能,采用了一套较为复杂的策略。

我们先以代码示例来看下:

function testV8() { // 随意定义一些key和value的组合,有整数的、小数、字符串等key

    this[1] = 'test-1'

    this["B"] = 'foo-B'

    this[50] = 'test-50'

    this[8] = 'test-8'

    this[3] = 'test-3'

    this[5] = 'test-5'

    this["4"] = 'test-4'

    this["A"] = 'foo-A'

    this["C"] = 'foo-C'

    this[4.5] = "foo-4.5" 

}

const testObj = new testV8();

for (let key in testObj) {

  console.log(`${key}:${testObj[key]}`);

}

控制台输出的key-value的结果:

多次打印后,发现输出的顺序是一致的,并没有随机性,但同样没有按照我们定义的顺序来输出,根据这一个现象,我们就要探究下,在V8引擎中,对象属性内部的设计思想。

在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存两种属性。

origin_img_v2_b7194174-62a0-4909-8bbc-a22f89d350bg.png

如果在数组属性(排序属性)和命名属性(常规属性)同时存在的情况下,优先数组属性排序,上面的例子中将"4"转换成了数字整型,而浮点数"4.5"转换成了字符串,V8 会先从 elements 属性中按照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。我们通过chrome调试工具snapshot来佐证下:

但为什么上面的快照中的对象却没有显示properties的属性呢?这就和V8的另外一个优化策略有关了:对象内属性In-object Properties)。

对象内属性In-object Properties

当采用两种线性结构存储后,在查询属性的时候,就会明显多出了一个步骤,要先去查询到Properties对应的对象(多了一次寻址的过程),再从Properties对象中查到对应的某个key的值,V8为了提升这个过程的效率,提出来了对象内属性的思路:当对象的属性数量小于10个的情况,直接将属性key存在对象内的属性上,如果需要查询某个key的值时,直接中对象内中获取对应key的值就可以了。

我们再来验证下:

function testV8(properties, elements) {
  //添加可索引属性

  for (let i = 0; i < elements; i++) {
    this[i] = `element${i}`;
  }

  //添加常规属性

  for (let i = 0; i < properties; i++) {
    const prop = `property${i}`;

    this[prop] = prop;
  }
}

const testobj = new testV8(15, 10);

for (let key in testobj) {
  console.log(`${key}:${testobj[key]}`);
}

快属性

保存在线性数据结构中的属性,通过索引就可以访问到对应的属性值,但也存在一个缺点,就是在删除的时候效率不高。

慢属性

属性过多的时候,V8会采用"慢属性"的处理,属性的对象内部会有独立的非线性数据结构(字典)

当属性数量不是特别多的情况下,Properties的索引是有序的(快属性),但当属性数量特别多的时候,就会变成无序的字典类型的存储(慢属性)。

const testobj = new testV8(10, 10);

%DebugPrint(testobj); 

const testobj1 = new testV8(150, 100);

%DebugPrint(testobj1); 

为什么还要用慢属性,而不用全部线性结构的快属性呢?

简单来讲:在属性的数量比较大的时候,快属性的访问的速度就不一定比慢属性的速度快了。

我们可以粗略的计算一次:假定一次哈希运算的成本,等于n次简单位运算的时间,一个对象有M个属性值,如果全部使用快属性,查询速度等于M次的简单位运算之和;如果使用慢属性,只需要一次哈希运算(n次位运算的成本) + 二维的线性比较(对比key值后取到对应key的value值),线性比较的成本远低于一次哈希运算的成本,这样估算的情况下,M > 2n 的情况下,慢属性的查询速度就会快于快属性的查询速度。

还有一种场景,当对一个对象频繁的插入属性或者删除属性的时候,如果一直用快属性,线性结构的查询效率是O(1),但插入或者删除时的效率就变成了O(n),这个时候V8就会降级处理成慢属性。

隐藏类(Hidden Class)

静态语言中,当创建类型后,就不能再次改变了,属性可以通过固定的偏移量来访问,但在js中却不是,对象的属性的类型、值等信息是可以随时改变的,也就是说运行的时候才能拿到最后的属性内存偏移量,V8为了提升对象的属性获取性能,设计了Hidden Class 隐藏类的概念,每一个对象都有对应的隐藏类,当每次对象的属性发生改变时,V8会动态更新对应的内存偏移量更新到隐藏类中。

const testobj1 = new testV8(2, 3);

const testobj2 = new testV8(2, 3);

testobj2.new = "new";

const testobj1 = new testV8(2, 3);

const testobj2 = new testV8(2, 3);

const testobj3 = new testV8(2, 3);

testobj2.new = "new"; // 

testobj3.new = "new"; // testobj2 3的隐藏类使用的是一个,不是新创建一个

在引擎的底层,V8 创建了一个将隐藏类连接在一起的转换树(transiton tree),相同顺序增加属性,会保证隐藏类的引用是同一个。

带孔(hole)的数组

当我们去查询一个数组里面的元素时,如果数组本身并不存在,按照原型链的原理,会逐一向上查询,这样就增加了“多余”的开销,在V8中增加了对数组是否全部充满(packed)的判断,如果数组是packed的情况下,再查不到对应的值,就不会再沿着数组的原型链查询了,而是在当前作用域中直接查询。

const a = [1, 2, 3];

delete a[1];

a.__proto__ = {1:2};

console.log(a[1]); //2

a最开始是完全填满的数组(Paked-Array), 但行2把第二位的元素删除掉了,V8同时增加了一个_hole来标记缺失的元素,表明a已经不再是充满的数组了,再当去查询的时候才会去按照原型链的原理去查询。这个策略对于数组的查询至关重要。

const a = new Array(10); // HOLEY_SMI_ELEMENTS

const b = [1,2,3,4] // PACKED_SMI_ELEMENTS

%DebugPrint(a);

%DebugPrint(b);

为什么数组也是对象类型的?

const c = [1, "hello", true, function () { // 数组内部也是用key-value的存储形式

  return 1;

}];

%DebugPrint(c);// PACKED_ELEMENTS

快慢数组

const a = [];

a[9999] = "9999";

如果V8要按照正常的逻辑处理声明的话,会新开10000个数组长度,这种是对空间相当浪费的,V8这个时候会把数组降级的慢数组,改用Object.defineProperty(object, key, descriptor)的Api来定义。

那究竟什么是快数组和慢数组呢?我们看下V8底层对于数组的定义:https://source.chromium.org/chromium/chromium/src/+/master:v8/src/objects/js-array.h

数组扩容

空数组预分配的大小为4

    // v8/src/objects/js-objects.h 551 
  static const uint32_t kMinAddedElementsCapacity = 16;


  // Computes the new capacity when expanding the elements of a JSObject.
  static uint32_t NewElementsCapacity(uint32_t old_capacity) {
    // (old_capacity + 50%) + kMinAddedElementsCapacity
    // 扩容公式:原有内存容量(1.5倍)+ 16
    return old_capacity + (old_capacity >> 1) + kMinAddedElementsCapacity;
  }
template <typename TIndex>

// 尝试扩容元素空间
TNode<FixedArrayBase> CodeStubAssembler::TryGrowElementsCapacity(
    TNode<HeapObject> object, TNode<FixedArrayBase> elements, ElementsKind kind,
    TNode<TIndex> key, TNode<TIndex> capacity, Label* bailout) {
  static_assert(
      std::is_same<TIndex, Smi>::value || std::is_same<TIndex, IntPtrT>::value,
      "Only Smi or IntPtrT key and capacity nodes are allowed");
  Comment("TryGrowElementsCapacity");
  CSA_SLOW_DCHECK(this, IsFixedArrayWithKindOrEmpty(elements, kind));


  // If the gap growth is too big, fall back to the runtime.

  TNode<TIndex> max_gap = IntPtrOrSmiConstant<TIndex>(JSObject::kMaxGap);

  TNode<TIndex> max_capacity = IntPtrOrSmiAdd(capacity, max_gap);

  GotoIf(UintPtrOrSmiGreaterThanOrEqual(key, max_capacity), bailout);



  // Calculate the capacity of the new backing store. 计算出新的存储空间

  TNode<TIndex> new_capacity = CalculateNewElementsCapacity(

      IntPtrOrSmiAdd(key, IntPtrOrSmiConstant<TIndex>(1)));

  // 执行扩容

  return GrowElementsCapacity(object, elements, kind, kind, capacity,
                              new_capacity, bailout);
}

扩容后将数组拷贝到新的内存空间中

数组缩容

https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/elements.cc;l=739

数组的pop方法会触发PopImpl方法,PopImpl又会调用RemoveElement,RemoveElement最后会调用到SetLengthImpl方法,判断是否要缩容,如果容量大于等于实际内容的length*2 + 16, 则进行缩容调整,否则就用上面提到的_hole标记来填充未初始化的位置,elements_to_trim是来计算要裁剪掉的大小,通过length + 1 和 old_length来判断是将空出来的空间全部裁掉还是留一半。

快慢转换

static const uint32_t kMaxGap = 1024;


static const uint32_t kPreferFastElementsSizeFactor = 3;


class NumberDictionaryShape : public NumberDictionaryBaseShape { 

  public: 

    static const int kPrefixSize = 1; 

    static const int kEntrySize = 3; 

};

  1. 如果快数组新的容量>= 3 * 扩容后的容量 * 2,意味着它比 HashTable 形式存储占用更大的内存,快数组会转换为慢数组;
  2. 如果快数组新增的索引与原来最大索引的差值大于等于 1024,中间全部是_hole标记,快数组会被转换会慢数组。
const a  = [1]

a[1025] = 3;

%DebugPrint(a); 

元素能存放在快数组中并且长度不在smi之间(64位-2^31到2^32-1),并且当前慢数组空间相比快数组节省值小于等于50%,则转变成为快数组。

const a  = [1]

a[1025] = 3;

for (let i = 300; i < 1025; i++) {

    a[i] = i;

}

%DebugPrint(a); 

总结:

js的数组看似不同,其实只是V8 在底层实现上做了一层封装,使用两种数据结构实现数组,并且通过时间和空间2个纬度的取舍,优化了数组的性能。

内联缓存策略 (Inline Cache)

虽然隐藏类能够加速查找对象的速度,但是在 V8 查找对象属性值的过程中,依然有查找对象的隐藏类和根据隐藏类来查找对象属性值的过程。

function getXProps(o) {

    return o.x;

}

const o1 = {x:1, y:2, z:3};

const o2 = {x:3, y:100, m:10, n: -3};

for(let i = 0; i < 10000; i++) {

    getXProps(o1);

    getXProps(o2);

}

上段代码中getXProps的方法会被反复执行,也就是代表着:查找o对象的隐藏类,再通过隐藏类查找x属性的偏移量,最后再通过偏移量获取属性值的这个过程会被反复执行,V8对这种过程是否有优化呢?

答案就是内联缓存 (Inline Cache) ,简称为 ****IC,这个技术已经很古老了,最初的应用是在Smalltalk虚拟机上[5],原理简单讲就是在代码运行过程中,收集一些关键数据信息,将这部分信息缓存起来,再次执行的时候直接用这些信息,有效的节省了再次获取这些信息的消耗,从而提高了性能。

V8中利用IC的机制,为每个函数维护了一个反馈向量(FeedBack Vector),其实就是用了一个表结构来存储关键信息:

slot(插槽索引) type(插槽类型) state(状态) map(隐藏类地址) offset(属性偏移量)
0 Load(加载对象属性) mono(单态) xxxxxxxx 8
1 Store(属性赋值) poly(多态) xxxxxxxx 13
n ..... maga(超态) ...... ......

function setProps(o) {

    o.y = 4;

    return o.x;

}

setProps({x:1,z:5});

setProps({x:100,z:20});

执行过程分析:

  1. V8识别出来有两个调用点,o.y 和return o.x
  2. 在执行的时候,每个调用点会向反馈向量(FeedBack Vector)表中插入一条缓存数据
  3. 当再次调用setProps方法的时候,每次执行到对应点的时候,V8就直接先去对应的插槽中寻找对应属性的偏移量(offset),之后就直接可以从内存中获取对应的属性值就可以了,大大提升了V8的执行效率
  4. 上面的{x:1,z:5}和{x:100,z:20} 属性名和顺序都是一致的,所以隐藏类是同一个,属于单态,而最开始的例子中,隐藏类是2个,所以对应的是多态。多态的情况下,要在map中的多个隐藏类进校一一对比
let data = [1, 2, 3, 4];

let data1 = ['1', 2, '3', 4];

data.forEach((item) => console.log(item.toString());

data1.forEach((item) => console.log(item.toString());

// 哪个效率更高,why?

总结

下期预告

V8垃圾回收器&内存管理

消息队列&异步编程

协程与进程

参考资料

[1]Google V8引擎浅析: https://juejin.cn/post/7018468848886579214

[2]Fast properties in V8 · V8: https://v8.dev/blog/fast-properties

[3]FixedArray : https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/fixed-array.h;l=101;bpv=0;bpt=1

[4]HashTable: https://source.chromium.org/chromium/chromium/src/+/main:third_party/swiftshader/third_party/llvm-10.0/llvm/include/llvm/DebugInfo/PDB/Native/HashTable.h;l=103;drc=1b51a630d5f980e6d1b22c90d1891ddc809313e1?q=%20HashTable&ss=chromium%2Fchromium%2Fsrc

[5]在Smalltalk虚拟机上: https://zh.wikipedia.org/zh-hans/%E5%86%85%E8%81%94%E7%BC%93%E5%AD%98

[6]JavaScript 引擎基础:Shapes 和 Inline Caches: https://zhuanlan.zhihu.com/p/38202123

[7]V8 Hidden class - LINE ENGINEERING: https://engineering.linecorp.com/en/blog/v8-hidden-class/

[8]Fast properties in V8 · V8: https://v8.dev/blog/fast-properties

[9]Explaining JavaScript VMs in JavaScript -: https://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8