隐藏类:如何在内存中快速查找对象属性?
为什么静态语言的效率更高?
隐藏类借鉴了部分静态语言的特征,先分析下为什么静态语言比动态语言的执行效率更高。
先看下边的代码分析:
那么在运行时,这两段代码的执行过程有什么区别呢?
JavaScript在运行时,对象属性是可以修改的。所以当V8使用一个对象的时候,比如使用了start.x的时候,他并不知道该对象是否有x,也不知道x相对对象的偏移量是多少,也可以说V8并不知道该对象的具体性形状。
那么,当JAvaScript中要查询对象start中的x属性时,V8会按照具体的规则一步一步来查询,这个过程非常的慢且耗时。
这种动态查询对象属性的方式和C++这种静态语言不同,C++在声明一个对象之前需要定义该对象的结构,我们也可以称为形状,比如Point结构体就是一种形状,我们可以使用这个形状来定义具体的对象。
那么,在C++中访问一个对象的属性时,自然就知道该属性相对于该对象地址的偏移值了。
因为静态语言中,可以直接通过偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因。
什么是隐藏类(Hidden Class)?
目前所采用的一个思路就是将JavaScript中的对象静态化,也就是V8在运行JavaScript的过程中,会假设JavaScript中的对象是静态的,具体地讲,V8对每个对象做如下两点假设:
- 对象创建好了之后就不会添加新的属性;
- 对象创建好了之后也不会删除属性。
基于这两个假设,V8会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:
- 对象中所包含的所有的属性;
- 每个属性相对于对象的偏移量。
有了隐藏类之后,那么当V8访问对于它的对象的偏移量,有了偏移量和属性类型,V8就可以直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了V8查找对象的效率。
分析下,隐藏类是怎么工作的?
let point = {x:100,y:200}
当V8执行到这段代码时,会先为point对象创建一个隐藏类,在V8中,把隐藏类又称为map,每个对象都有一个map属性,其值指向内存中的隐藏类。
隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量,比如point对象的隐藏类就包括了x和y属性,x的偏移量是4,y的偏移量是8。
注意,这是point对象的map,它不是point对象本身。关于point对象和map之间的关系,你可以参看下图:
由上图可以看出,左边的point对象在内存中的布局,右边是point对象的map。point对象的第一个属性就指向了它的map。
有了map之后,当你再次使用point.x访问x属性时,V8会查询point的map中x属性相对point对象的偏移量,然后将point对象的起始位置加上偏移量,就得到了X属性的值在内存中的位置。这样查询x值的位置就省去了一个比较复杂的过程。
实践:通过d8查看隐藏类
了解了隐藏类的工作机制,我们可以使用d8提供的API DebugPrint来查看point对象中的隐藏类。
let point = {x:100,y:200};
%DebugPrint(point);
这里你需要注意,在使用d8内部API时,有一点很容易出错,就是需要为JavaScript代码加上分号,不然d8会报错,所以这段代码里面我都加上了分号。
然后再执行,d8 --allow-natives-syntax test.js
point对象的基本结构,打印出来的结果如下所示:
DebugPrint: 0x19dc080c5af5: [JS_OBJECT_TYPE]
- map: 0x19dc08284d11 <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x19dc08241151 <Object map = 0x19dc082801c1>
- elements: 0x19dc080406e9 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x19dc080406e9 <FixedArray[0]> {
#x: 100 (const data field 0)
#y: 200 (const data field 1)
}
0x19dc08284d11: [Map]
- type: JS_OBJECT_TYPE
- instance size: 20
- inobject properties: 2
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x19dc08284ce9 <Map(HOLEY_ELEMENTS)>
- prototype_validity cell: 0x19dc081c0451 <Cell value= 1>
- instance descriptors (own) #2: 0x19dc080c5b25 <DescriptorArray[2]>
- prototype: 0x19dc08241151 <Object map = 0x19dc082801c1>
- constructor: 0x19dc0824116d <JSFunction Object (sfi = 0x19dc081c55ad)>
- dependent code: 0x19dc080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
从这段point的内存结构中,我们可以看到,point对象的第一个属性就是map,它指向了0x19dc08284d11这个地址,这个地址就是V8为point对象创建的隐藏类,除了map属性之外,还有我们之前介绍过的prototype属性,elements属性和properties属性(传送门:快属性和慢属性:V8是怎样提升对象属性访问速度的? )
多个对象共用一个隐藏类
每个对象都有一个map属性,该属性值指向该对象的隐藏类。不过如果两个对象的形状是相同的,V8就会为其复用同一个隐藏类,这样有两个好处:
- 减少隐藏类的创建次数,也间接加速了代码的执行速度;
- 减少了隐藏类的存储空间。
那么,什么情况下两个对象的形状是相同的,要满足以下两点: - 相同的属性名称;
- 相等的属性个数。
重新构建隐藏类
关于隐藏类,还有一个问题你需要注意一下。在这节课开头我们提到了,V8为了实现隐藏类,需要两个假设条件:
- 对象创建好了之后就不会添加新的属性;
对象创建好了之后也不会删除属性。
但是JavaScript是动态语言,在执行过程中,对象的形状是可以改变的,如果对象的形状改变了,隐藏类也会随着改变,这意味着V8要为新改变的对象重新构建新的隐藏类,这对于V8的执行效率来说,是一笔大的开销。
通俗地理解,给一个对象添加新的属性,删除新的属性,或者改变某个属性的数据类型都会改变这个对象的形状,那么势必也就会触发V8为改变形状后的对象重建新的隐藏类。最佳实践
好了,现在我们知道了V8会为每个对象分配一个隐藏类,在执行过程中:
- 如果对象的形状没有发生改变,那么该对象就会一直使用该隐藏类;
- 如果对象的形状发生了改变,那么V8会重建一个新的隐藏类给该对象。
我们当然希望对象中的隐藏类不要随便被改变,因为这样会触发V8重构对象的隐藏类,直接影响了程序的执行效率。那么在实际工作中,我们应该尽量注意以下几点:
一,使用字面量初始化对象时,要保证属性的顺序是一致的。
二,尽量使用字面量一次性初始化完整对象属性。
三,尽量避免使用delete方法。
此文章为5月Day21学习笔记,内容来源于极客时间《图解 Google V8》,日拱一卒,每天进步一点点💪💪
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。