说明
图解 Google V8 学习笔记
在 Chrome 中查看内存快照
1、首先我们 f12 在控制台运行下面这段程序
function Student(name, gender) { this.name = name; this.gender= gender; } var kaimo = new Student('kaimo', '男');
2、切换到 Memory 中,点击左侧的小圆圈就可以捕获当前的内存快照。点开快照后,在过滤器中输入 Student,即可找到
V8 中对象的结构
在 V8 中,对象主要由三个指针构成,分别是隐藏类(Hidden Class)
,Property
还有 Element
。
- 隐藏类用于描述对象的结构。
- Property 和 Element 用于存放对象的属性,它们的区别主要体现在键名能否被索引。
命名属性的不同存储方式
V8 中命名属性有三种的不同存储方式:对象内属性(in-object)、快属性(fast)和慢属性(slow)。
- 对象内属性保存在对象本身,提供最快的访问速度。
- 快属性比对象内属性多了一次寻址时间。
- 慢属性与前面的两种属性相比,会将属性的完整结构存储,速度最慢。
隐藏类
上面提到的描述命名属性是怎么存放的,在 V8 中被称为 Map,更出名的称呼是 隐藏类(Hidden Class)
。
在 SpiderMonkey (火狐引擎)中,类似的设计被称为 Shape
。
为什么要引入隐藏类?
1、进行访问更快:
通过哈希表的方式存取属性,需要额外的哈希计算。为了提高对象属性的访问速度,实现对象属性的快速存取,V8 中引入了隐藏类。
2、节省了内存空间:
在 ECMAScript 中,对象属性的 Attribute 被描述为以下结构。
[[Value]]:属性的值
[[Writable]]:定义属性是否可写(即是否能被重新分配)
[[Enumerable]]:定义属性是否可枚举
[[Configurable]]:定义属性是否可配置(删除)
因为一般情况下,对象的 Value 是经常会发生变动的,而 Attribute 是几乎不怎么会变的。隐藏类的引入,将属性的 Value 与其它 Attribute 分开。这样的话可以减少内存的浪费。
隐藏类的创建
对象创建过程中,每添加一个命名属性,都会对应一个生成一个新的隐藏类。在 V8 的底层实现了一个将隐藏类连接起来的转换树,如果以相同的顺序添加相同的属性,转换树会保证最后得到相同的隐藏类。
具体可以看下面【实践3】的例子。
实践1:可索引属性和命名属性的存放
我们先去控制台执行下面代码
function Foo1 () {} var a = new Foo1() var b = new Foo1() a.name = 'aaa' a.text = 'aaa' b.name = 'bbb' b.text = 'bbb' a[1] = 'aaa' a[2] = 'aaa'
上面代码中,a、b 都有命名属性 name 和 text,另外 a 还额外多了两个可索引属性。
打开快照可以明显的看到,可索引属性是存放在 elements 中的。
V8 还为每个对象实现了 map 属性和 __proto__属性。
__proto__ 属性就是原型,是用来实现 JavaScript 继承的。
map 则是隐藏类
我们在上面的代码中再加入一行:
a[1111] = 'aaa'
打开快照可以看到此时隐藏类发生了变化,Element 中的数据存放也变得没有规律了。
原因:当我们添加了 a[1111] 之后,数组会变成稀疏数组。为了节省空间,稀疏数组会转换为哈希存储的方式,而不再是用一个完整的数组描述这块空间的存储。
哈希存储亦称“散列存储”,专用于集合结构的一种存储方式。
数据元素存放在一块连续的存储区域中。数据元素的存放位置是通过一个哈希函数计算而得的。哈希函数将数据元素作为自变量,计算得到的函数值是数据元素的存储地址。
实践2:三种不同类型的 Property 存储模式
我们先去控制台执行下面代码
function Foo2() {} var a = new Foo2() var b = new Foo2() var c = new Foo2() for (var i = 0; i < 10; i ++) { a[new Array(i+2).join('a')] = 'aaa' } for (var i = 0; i < 12; i ++) { b[new Array(i+2).join('b')] = 'bbb' } for (var i = 0; i < 30; i ++) { c[new Array(i+2).join('c')] = 'ccc' }
a、b 和 c 分别拥有 10 个,12 个和 30 个属性
对象内属性
先看 a 有10 个属性,对象内属性是在对象创建时就固定分配的,空间有限,数量固定为十个,空间大小相同(可以理解为十个指针)。
快属性
然后看 b 有 12 个属性,当对象内属性放满之后,会以快属性的方式,在 properties
下按创建顺序存放。相较于对象内属性,快属性需要额外多一次 properties
的寻址时间,之后便是与对象内属性一致的线性查找。
慢属性
最后看 c 有 30 个属性,和 b (快属性)相比,properties
中的索引变成了毫无规律的数,意味着这个对象已经变成了哈希存取结构了。
实践3:增加属性对隐藏类的影响
我们先依次执行下面的例子:
先执行:
function student(){} let s = new student()
再执行:
s.name = 'kaimo'
最后执行:
s.text = '男'
我们从上面可以清晰的看到 s 在空对象时、添加 name 属性后、添加 gender 属性后会分别对应不同的隐藏类。
大致示意图如下:里面 Offset 不懂的可以参考下面文章:
并且我们可以从内存快照中看到 Hidden Class2 的 back_pointer 指针指向 Hidden Class1。
隐藏类创建时的优化
例子:下面代码 a 和 b 的区别是,a 首先创建一个空对象,然后给这个对象新增一个命名属性 name。而 b 中直接创建了一个含有命名属性 name 的对象。
let a = {}; a.name = 'kaimo' let b = { name: 'kaimo313' }
a 和 b 的隐藏类不一样,back_pointer 也不一样。因为在创建 b 的隐藏类时,省略了为空对象单独创建隐藏类的一步。
要生成相同的隐藏类,更为准确的描述是 —— 从相同的起点,以相同的顺序,添加结构相同的属性(除 Value 外,属性的 Attribute 一致)。
实践4:delete 操作对隐藏类的影响
按照添加属性的顺序删除属性
现在控制台执行下面代码:
function Foo4 () {} var a = new Foo4() var b = new Foo4() for (var i = 1; i < 8; i ++) { a[new Array(i+1).join('a')] = 'aaa' b[new Array(i+1).join('b')] = 'bbb' }
内存快照如下
然后执行删除操作:
delete a.a
查看内存快照如下:
删除了 a.a
后,a 变成了慢属性,退回哈希存储了。
按照添加属性的顺序逆向删除属性
在控制台执行下面代码
function Foo5() {} var a = new Foo5() var b = new Foo5() a.name = 'kaimo' a.gender = '男' a.age = '8' b.name = 'kaimo' b.gender = '男'
内存快照如下:
然后再执行删除
delete a.age
内存快照如下:
我们发现删除了 a.age
后,a 和 b 的隐藏类相同,a 也没有退回哈希存储。
为什么说在 JS 中要避免使用 delete?
delete 很多时候删不掉。
delete 返回true的时候,也不代表一定删除成功。 比如原型上的属性。
delete 某些场景下会导致隐藏类改变,可能导致性能问题。
因为对象的形状是可以被改变的,如果某个对象的形状改变了,隐藏类也会随着改变,这意味着 V8 要为新改变的对象重新构建新的隐藏类。而 delete 方法会破坏对象的形状,同样会导致 V8 为该对象重新生成新的隐藏类。我们应尽量避免使用 delete 方法。