V8 中的 Maps(隐藏类)
让我们看看 V8 如何构建它的隐藏类。主要数据结构是
Map
:隐藏类本身。它是对象中的第一个指针值,因此可以轻松比较以查看两个对象是否具有相同的类。DescriptorArray
:此类具有的所有属性的完整列表,以及有关它们的的信息。在某些情况下,属性值甚至在此数组中。TransitionArray
:从此Map
到兄弟Map
的“边”数组。每个边都是一个属性名称,应该被认为是“如果我要向当前类添加具有此名称的属性,我会过渡到哪个类?”
由于许多 Map
对象只有一个到另一个对象的过渡(即,它们是“过渡”映射,仅在通往其他事物的过程中使用),因此 V8 并不总是为其创建完整的 TransitionArray
。相反,它只会直接链接到这个“下一个”Map
。该系统必须在 Map
的 DescriptorArray
中进行一些探索,以确定与过渡相关的名称。
这是一个非常丰富的主题。它也可能发生变化,但是,如果您理解本文中的概念,未来的变化应该是逐步可理解的。
为什么要有隐藏类? #
当然,V8 可以没有隐藏类。它会将每个对象视为一个属性包。但是,一个非常有用的原则会被遗漏:智能设计的原则。V8 推测,您只会创建这么多不同类型的对象。并且每种类型的对象将在最终被视为典型的方式中使用。我说“最终被视为”,因为 JavaScript 语言是一种脚本语言,而不是预编译语言。因此,V8 永远不知道接下来会发生什么。为了利用智能设计(即,假设代码背后存在一个思维),V8 必须观察和等待,让结构感渗透进来。隐藏类机制是实现此目的的主要手段。当然,它预先假设了一种复杂的监听机制,这些机制就是内联缓存 (IC),关于它们已经写了很多。
所以,如果您相信这是一项好的且必要的工作,请跟随我!
一个例子 #
function Peak(name, height, extra) {
this.name = name;
this.height = height;
if (isNaN(extra)) {
this.experience = extra;
} else {
this.prominence = extra;
}
}
m1 = new Peak("Matterhorn", 4478, 1040);
m2 = new Peak("Wendelstein", 1838, "good");
通过这段代码,我们已经从根映射(也称为初始映射)获得了有趣的映射树,该映射附加到函数 Peak
每个蓝色框都是一个映射,从初始映射开始。这是如果我们设法在不添加任何属性的情况下运行函数 Peak
所返回的对象的映射。后续映射是通过添加映射之间边上给出的名称的属性而产生的。每个映射都包含与该映射的对象关联的属性列表。此外,它描述了每个属性的确切位置。最后,从这些映射中的一个,例如 Map3
,它是如果您在 Peak()
中为 extra
参数传递了一个数字时将获得的对象的隐藏类,您可以沿着所有反向链接一直回到初始映射。
让我们用这些额外的信息再次绘制它。注释 (i0),(i1) 表示对象内字段位置 0,1 等
现在,如果您在创建至少 7 个 Peak
对象之前花时间检查这些映射,您将遇到松弛跟踪,这可能会令人困惑。我有一篇关于它的另一篇文章。只需再创建 7 个对象,它就会完成。此时,您的 Peak 对象将恰好有 3 个对象内属性,并且无法直接在对象中添加更多属性。任何其他属性都将被卸载到对象的属性支持存储中。它只是一个属性值的数组,其索引来自映射(实际上,来自附加到映射的 DescriptorArray
)。让我们在新的行上向 m2
添加一个属性,并再次查看映射树
m2.cost = "one arm, one leg";
我在这里偷偷摸摸地加了一些东西。请注意,所有属性都标有“const”,这意味着从 V8 的角度来看,自构造函数以来没有人更改过它们,因此一旦它们被初始化,它们就可以被视为常量。TurboFan(优化编译器)非常喜欢这一点。假设 m2
被函数引用为一个常量全局变量。那么 m2.cost
的查找可以在编译时完成,因为该字段被标记为常量。我将在本文后面再回到这一点。
请注意,属性“cost”被标记为 const p0
,这意味着它是一个常量属性,存储在属性支持存储的索引零处,而不是直接存储在对象中。这是因为我们在对象中没有更多空间。此信息在 %DebugPrint(m2)
中可见
d8> %DebugPrint(m2);
DebugPrint: 0x2f9488e9: [JS_OBJECT_TYPE]
- map: 0x219473fd <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x2f94876d <Object map = 0x21947335>
- elements: 0x419421a1 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x2f94aecd <PropertyArray[3]> {
0x419446f9: [String] in ReadOnlySpace: #name: 0x237125e1
<String[11]: #Wendelstein> (const data field 0)
0x23712581: [String] in OldSpace: #height:
1838 (const data field 1)
0x23712865: [String] in OldSpace: #experience: 0x237125f9
<String[4]: #good> (const data field 2)
0x23714515: [String] in OldSpace: #cost: 0x23714525
<String[16]: #one arm, one leg>
(const data field 3) properties[0]
}
...
{name: "Wendelstein", height: 1, experience: "good", cost: "one arm, one leg"}
d8>
您可以看到我们有 4 个属性,全部标记为 const。前 3 个在对象中,最后一个在 properties[0]
中,这意味着属性支持存储的第一个槽。我们可以看看它
d8> %DebugPrintPtr(0x2f94aecd)
DebugPrint: 0x2f94aecd: [PropertyArray]
- map: 0x41942be9 <Map>
- length: 3
- hash: 0
0: 0x23714525 <String[16]: #one arm, one leg>
1-2: 0x41942329 <undefined>
这些额外的属性只是为了以防您突然决定添加更多属性。
真实结构 #
此时我们可以做很多不同的事情,但是由于您一定非常喜欢 V8,读到这里,我想尝试绘制我们使用的真实数据结构,即开头提到的 Map
、DescriptorArray
和 TransitionArray
。现在您已经对隐藏类概念在幕后构建了一些了解,您不妨通过正确的名称和结构将您的思维更紧密地绑定到代码。让我尝试用 V8 的表示来重现最后那个图。首先,我将绘制DescriptorArrays,它们包含给定 Map 的属性列表。这些数组可以共享——关键是 Map 本身知道它允许在 DescriptorArray 中查看多少个属性。由于属性是按添加时间顺序排列的,因此这些数组可以被多个映射共享。看
请注意,Map1、Map2 和 Map3 都指向 DescriptorArray1。每个 Map 中“descriptors”字段旁边的数字表示 DescriptorArray 中属于 Map 的字段数量。因此,Map1 仅了解“name”属性,它只查看 DescriptorArray1 中列出的第一个属性。而 Map2 有两个属性,“name”和“height”。因此,它查看 DescriptorArray1 中的第一个和第二个项目(name 和 height)。这种共享节省了大量空间。
当然,我们不能在有分割的地方共享。如果添加了“experience”属性,则从 Map2 过渡到 Map4,如果添加了“prominence”属性,则过渡到 Map3。您可以看到 Map4 和 Map4 以与 DescriptorArray1 在三个 Map 中共享的方式共享 DescriptorArray2。
我们的“真实”图中唯一缺少的是 TransitionArray
,它在此时仍然是隐喻性的。让我们改变一下。我冒昧地删除了反向指针线,这使事情变得更清晰了一些。请记住,从树中的任何 Map,您也可以向上遍历树。
该图值得研究。问题:如果在“name”之后添加一个新的属性“rating”,而不是继续添加“height”和其他属性,会发生什么?
答案:Map1 将获得一个真正的 TransitionArray,以便跟踪分叉。如果添加了属性 height,我们应该过渡到 Map2。但是,如果添加了属性 rating,我们应该转到一个新的映射 Map6。此映射需要一个新的 DescriptorArray,其中提到了 name 和 rating。此时,对象在对象中还有额外的空槽(三个中只有一个被使用),因此属性 rating 将被分配到这些槽中的一个。
我在 %DebugPrintPtr()
的帮助下检查了我的答案,并绘制了以下内容
无需恳求我停止,我看到这是这种图表的极限!但我认为您可以了解各个部分是如何移动的。想象一下,在添加这个 ersatz 属性 rating 之后,我们继续添加 height、experience 和 cost。好吧,我们必须创建映射 Map7、Map8 和 Map9。因为我们坚持在已建立的映射链的中间添加此属性,所以我们将复制很多结构。我没有勇气绘制那个图——不过如果你把它发给我,我会把它添加到这个文档中:)。
我使用方便的 DreamPuf 项目轻松地制作了这些图。这是一个指向先前图表的链接 到先前图表。
TurboFan 和 const 属性 #
到目前为止,所有这些字段都在 DescriptorArray
中被标记为 const
。让我们玩玩这个。在调试版本上运行以下代码
// run as:
// d8 --allow-natives-syntax --no-lazy-feedback-allocation --code-comments --print-opt-code
function Peak(name, height) {
this.name = name;
this.height = height;
}
let m1 = new Peak("Matterhorn", 4478);
m2 = new Peak("Wendelstein", 1838);
// Make sure slack tracking finishes.
for (let i = 0; i < 7; i++) new Peak("blah", i);
m2.cost = "one arm, one leg";
function foo(a) {
return m2.cost;
}
foo(3);
foo(3);
%OptimizeFunctionOnNextCall(foo);
foo(3);
您将获得优化函数 foo()
的打印输出。代码非常短。您将在函数末尾看到
...
40 mov eax,0x2a812499 ;; object: 0x2a812499 <String[16]: #one arm, one leg>
45 mov esp,ebp
47 pop ebp
48 ret 0x8 ;; return "one arm, one leg"!
TurboFan,作为一个狡猾的家伙,只是直接插入了 m2.cost
的值。好吧,你喜欢这样吗!
当然,在最后一次调用 foo()
之后,您可以插入这一行
m2.cost = "priceless";
您认为会发生什么?有一点可以肯定,我们不能让 foo()
保持原样。它将返回错误的答案。重新运行程序,但添加标志 --trace-deopt
,以便在优化代码从系统中删除时通知您。在打印优化后的 foo()
之后,您将看到这些行
[marking dependent code 0x5c684901 0x21e525b9 <SharedFunctionInfo foo> (opt #0) for deoptimization,
reason: field-const]
[deoptimize marked code in all contexts]
哇。
如果您强制重新优化,您将获得代码,这些代码不如以前好,但仍然从我们一直在描述的 Map 结构中受益匪浅。请记住,从我们的图中可以看出,属性 cost 是
对象属性支持存储中的第一个属性。好吧,它可能失去了 const 标识,但我们仍然有它的地址。基本上,在一个具有映射 Map5 的对象中,我们肯定会验证全局变量 m2
仍然具有,我们只需要——
- 加载属性支持存储,以及
- 读出第一个数组元素。
让我们看看。在最后一行下方添加此代码
// Force reoptimization of foo().
foo(3);
%OptimizeFunctionOnNextCall(foo);
foo(3);
现在看看生成的代码
...
40 mov ecx,0x42cc8901 ;; object: 0x42cc8901 <Peak map = 0x3d5873ad>
45 mov ecx,[ecx+0x3] ;; Load the properties backing store
48 mov eax,[ecx+0x7] ;; Get the first element.
4b mov esp,ebp
4d pop ebp
4e ret 0x8 ;; return it in register eax!
为什么呢。这正是我们所说的应该发生的事情。也许我们开始知道。
如果变量 m2
更改为不同的类,TurboFan 也足够聪明,可以进行反优化。您可以使用类似以下的无聊内容来观察最新的优化代码再次反优化
m2 = 42; // heh.
下一步去哪里 #
有很多选项。地图迁移。字典模式(也称为“慢速模式”)。在这个领域有很多东西可以探索,我希望你像我一样享受它——感谢你的阅读!