文章

Hidden Class 和 Inline Cache

Hidden Class 和 Inline Cache

Hidden Class 和 Inline Cache:JavaScript 引擎为什么能把动态对象跑快

如果说 Inline Cache 是 JavaScript 引擎里最容易被误解的“缓存”,那 Hidden Class 往往是最容易被误解的“类”。

很多文章会把它们拆开介绍。但一旦分开讲,读者很容易产生两个偏差:

  • 觉得 Hidden Class 只是一个独立的数据结构名词
  • 觉得 Inline Cache 只是某种局部访问优化技巧

实际上,这两个概念在现代 JavaScript 引擎里是强耦合的。一个负责给对象结构提供稳定标签,另一个把这个标签转成访问点上的快速路径。单独理解都不完整,合起来才更接近对象访问性能的核心链路。

这篇文章重点回答 4 个问题:

  1. Hidden Class 到底是什么,它和传统面向对象语言里的 class 有什么不同
  2. 它为什么会出现,又解决了什么问题
  3. Inline Cache 如何利用 Hidden Class
  4. 这些机制对工程实践意味着什么

先说结论:它们的关系是什么?

可以先记住一句最短的结论:

Hidden Class 负责回答“这个对象长什么样”,Inline Cache 负责回答“既然它长这样,我在这个访问点上该怎么最快地取值”。

再换一种更工程化的说法:

Hidden Class 提供结构身份,Inline Cache 消费这个身份,在具体访问点上做特化分派。

这是全文最重要的一句话。

为什么 JavaScript 需要 Hidden Class?

先看一个很普通的对象:

1
const p = { x: 1, y: 2 };

在源码层面,我们看到的是两个属性:xy。但对引擎来说,真正棘手的问题是:

  • 这个对象内部怎么布局
  • x 放在哪
  • y 放在哪
  • 另一个“长得一样”的对象能不能复用这套布局

如果每个对象都完全按哈希表或字典来管理属性,JavaScript 语义当然可以成立,但访问成本会更高。引擎希望做得更进一步:

如果一批对象拥有相同的属性集合和相同的添加顺序,那它们就可以共享同一种内部结构描述。

于是 Hidden Class 就出现了。

它不是语言层面的 class,用户代码里也看不到它。它是引擎内部给对象结构贴的一个“形状标签”。

不同引擎的叫法不同:

  • V8 常说 Hidden Class,内部结构里常见 Map
  • SpiderMonkey 常用 Shape
  • JavaScriptCore 常见 Structure

虽然名字不一样,但表达的是同一类思想:对象的内部结构可以被建模、共享和比较。

一个直观例子:为什么“字段一样”不等于“形状一样”

看两段代码:

1
2
3
4
5
6
7
const a = {};
a.x = 1;
a.y = 2;

const b = {};
b.x = 3;
b.y = 4;

对引擎来说,这两个对象通常很适合共享同一种 Hidden Class,因为它们的属性添加轨迹一致:

空对象 -> 加 x -> 加 y

但下面这段就不一定了:

1
2
3
const c = {};
c.y = 2;
c.x = 1;

虽然最终也有 xy,但它的属性添加路径变成了:

空对象 -> 加 y -> 加 x

这很可能对应另一条不同的 Hidden Class 转换链。

所以要特别注意:

“字段看起来一样”不等于“内部结构一定一样”。

这也是很多 V8 性能文章会强调“保持对象初始化顺序一致”的原因。

Hidden Class 本质上在解决什么问题?

它在解决的是:如何把动态对象的访问,尽可能转成接近静态布局的访问。

如果引擎知道:

  • 这个对象属于某个 shape
  • 对于这个 shape,x 位于固定槽位

那访问 obj.x 时,就不必总是按名字进行通用查找,而可以更接近:

load obj[slot_0]

当然,JavaScript 的完整语义比这个复杂得多,但这正是引擎努力靠近的方向。

换句话说,Hidden Class 给引擎提供了一种可能:

把“按名字找属性”这件事,压缩成“先识别结构,再按固定位置读取”。

但这里只有“结构信息”还不够。引擎还需要知道:

  • 在哪个代码位置应用这些结构信息
  • 如何把结构匹配转成真正的访问路径

这就轮到 Inline Cache 出场了。

Inline Cache 如何利用 Hidden Class?

先看代码:

1
2
3
function getX(obj) {
  return obj.x;
}

对于这个访问点,Inline Cache 关心的是:

  • 这里过去来过哪些对象
  • 它们对应哪些 Hidden Class
  • 针对这些 Hidden Class,取 x 的最快方式是什么

一个简化的运行过程可以写成这样。

第一次执行

1
getX({ x: 1, y: 2 });

引擎可能先走慢路径,完整查找 x。同时它发现:

  • 当前对象有某个 Hidden Class,记作 H1
  • 对于 H1x 在某个固定槽位

于是这个访问点的 IC 会记录:

if (obj.hiddenClass === H1) {
  return load slot_for_x
}

第二次执行

1
getX({ x: 10, y: 20 });

如果这个对象仍然是 H1,那就直接命中这条快速路径。

第三次执行

1
getX({ x: 7, z: 9 });

如果它的 Hidden Class 不再是 H1,原有路径就会失效,引擎会回退慢路径,并可能把 IC 扩展成能处理多种 Hidden Class 的形式。

所以可以把两者的协作理解成:

  • Hidden Class 负责给对象提供一个可比较的结构身份
  • Inline Cache 负责在访问点上对这个身份做分支和特化

没有 Hidden Class,IC 很难低成本判断“这是不是我熟悉的对象”;没有 IC,Hidden Class 也很难在热点代码位置直接兑换成执行速度。

一个更形象的比喻

如果把对象访问比作过收费站:

  • Hidden Class 像车辆分类标签
  • Inline Cache 像收费站的快速识别通道

收费站不是看“你今天装了什么货”,而是先看“你是哪类车”。一旦识别出车辆类别,就可以直接导向对应的快速通道。

同样地,IC 不是在缓存对象属性值,而是在判断:

  • 你是不是我熟悉的那种结构
  • 如果是,我知道你该走哪条读取路径

为什么光有 Hidden Class 还不够?

理解到 Hidden Class 这一步时,很多人会觉得问题已经解决了:既然对象结构都能分类了,那不就能直接快读属性了吗?

还差一步。因为结构信息本身只是“可利用的事实”,并不会自动变成“访问点上的快速分派”。

引擎仍然需要回答:

  • 这个函数里的这个 .x,通常见到哪几种 Hidden Class
  • 这些 Hidden Class 对应的访问逻辑是否稳定
  • 是值得生成一条单态快路径,还是多态分派,还是干脆走通用逻辑

这正是 IC 的职责。

所以 Hidden Class 更像静态的“结构描述”,IC 更像动态的“执行反馈 + 访问特化”。

为什么光有 Inline Cache 也不够?

反过来,如果没有 Hidden Class 这类结构抽象,IC 也很难做到低成本匹配。

因为它需要一个稳定、可比较、足够便宜的判断条件,来回答:

这次来的对象和我上次优化过的那种对象,是不是同一种结构?

如果每次都要重新深度分析对象布局,那快速路径本身就失去了意义。

所以从实现角度看,IC 和 Hidden Class 不是“顺手配合”,而是天然互补。

IC 的状态变化,本质也是 Hidden Class 分布的变化

我们常说一个访问点会从:

  • uninitialized
  • monomorphic
  • polymorphic
  • megamorphic

逐步演化。

从 Hidden Class 的视角看,这其实就是在说:

  • 一开始还没看到任何结构
  • 后来只看到一种 Hidden Class
  • 再后来看到少量几种 Hidden Class
  • 最后看到太多 Hidden Class,已经不值得继续做精细特化

所以 monomorphic 的真正含义之一是:

这个访问点长期只面对一种结构身份。

megamorphic 则意味着:

这个访问点面对的结构分布已经太分散了。

换个角度看,IC 的状态变化就不再那么抽象了。

为什么对象结构会变化?

因为 JavaScript 允许对象在运行时自由演化。

例如:

1
2
3
4
const obj = {};
obj.x = 1;
obj.y = 2;
obj.z = 3;

每次新增属性时,对象都可能经历一次 Hidden Class 迁移。你可以把它理解成:

空结构 -> 带 x 的结构 -> 带 x,y 的结构 -> 带 x,y,z 的结构

如果大量对象都按同一路径构造,这些 Hidden Class 转换就很容易复用。 如果对象构造方式到处都不一样,结构就会迅速分散,IC 的命中率也会跟着下降。

因此,很多“保持对象形状稳定”的建议,说到底都是在保护 Hidden Class 的可复用性,从而保护 IC 的收益。

哪些写法会破坏这种配合?

1. 对同类对象采用不一致的初始化顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
function makeA() {
  const obj = {};
  obj.x = 1;
  obj.y = 2;
  return obj;
}

function makeB() {
  const obj = {};
  obj.y = 2;
  obj.x = 1;
  return obj;
}

从业务层面看结果类似,但结构轨迹不同。

2. 在热点路径频繁增删属性

1
2
obj.flag = true;
delete obj.flag;

这种写法会破坏对象结构的稳定性,使 Hidden Class 和对应 IC 难以持续命中。

3. 让同一个热点函数接收太多完全不同的数据形态

1
2
3
function readValue(obj) {
  return obj.value;
}

如果这个函数一会儿接收 UI state,一会儿接收网络响应,一会儿接收埋点对象,它很容易进入 megamorphic。

一个很容易误解的地方:Hidden Class 不是语言层面的类系统

很多人第一次看到这个词,会误会成“JavaScript 虽然没有 class,但引擎偷偷给对象造了 class”。这不准确。

更合理的理解是:

  • 它不是面向对象建模意义上的类
  • 它不是源码层面的类型声明
  • 它只是引擎为了优化对象布局和访问而建立的内部结构描述

它不关心你的业务抽象是不是“用户对象”“订单对象”,它只关心:

  • 这个对象现在有哪些属性
  • 这些属性是按什么顺序形成的
  • 它们位于什么位置

所以不要把 Hidden Class 理解成“隐藏版 TypeScript 类型”。两者不是一个层面的东西。

对工程实践有什么启发?

这套机制不会要求你为了性能去手写 VM 风格代码,但它确实解释了很多经验性建议背后的根本原因。

建议一:同类对象尽量一致地初始化

尽量让同一类业务对象在相同位置、按相同顺序创建相同属性。这有助于共享 Hidden Class,也有助于热点访问点保持 monomorphic。

建议二:减少热点路径上的结构扰动

避免在高频路径随意 delete 属性、动态加奇怪字段,尤其不要让对象在热路径中不停变形。

建议三:让热点函数职责更专一

一个高频函数如果只服务于一两种稳定数据结构,通常更容易被 IC 和 JIT 优化;如果它变成一个“万能入口”,性能上往往更难得到稳定收益。

总结

如果要把全文压缩成一段最值得记住的话,可以记住这句:

Hidden Class 解决的是“对象结构如何被建模”,Inline Cache 解决的是“这些结构信息如何在具体访问点上转化成快速路径”。

没有 Hidden Class,IC 难以低成本识别“这是不是熟悉的对象”;没有 IC,Hidden Class 只是内部结构元数据,难以在热点代码位置直接转化成性能。

现代 JavaScript 引擎之所以能把一个看似完全动态的对象访问跑快,很大程度上就是因为这两者形成了配合:

  • 用 Hidden Class 压缩对象结构的多样性
  • 用 Inline Cache 捕捉访问点的局部稳定性

最终把一部分动态对象访问,变成了“结构判断 + 固定槽位读取”的特化执行。

本文由作者按照 CC BY 4.0 进行授权