同样的对象,换个写法慢十倍:V8 的隐藏类、内联缓存与垃圾回收

前阵子 review 一段数据处理代码,我提了个特别像强迫症发作的意见:这个对象字面量里 x 和 y 的顺序,能不能跟上面那个保持一致。

同事回我一句"这也能影响性能?",我当时其实也不太确定。于是我跑了组基准,结果比我想的严重:一样的两个属性,只是顺序不同,热循环慢了将近 3 倍;而多写一行 delete,慢了 11 倍。

深蓝背景上发光的几何图形被分支箭头连成一张网,像引擎内部的结构图

这篇文章就是我把 V8 这一层重新翻了一遍之后的笔记:隐藏类、内联缓存、属性存储、数组的元素种类、分代垃圾回收,以及 TurboFan 到底在赌什么。文末有一份"哪些改动真的值,哪些只是自我感动"的清单。

所有数字都在 Node v24.16.0(V8 13.x)、200 万个对象的循环里跑出来的,代码贴在下面,你可以自己复现。

# 一、先跑个基准:三种写法,三种速度

先说结论。下面这段代码对 200 万个对象求和,唯一的区别是对象的"形状":

// 单态:200 万个对象长得一模一样
{ x: i, y: i + 1 }

// 多态:一半是 {x, y},一半是 {y, x}
i % 2 ? { x: i, y: i + 1 } : { y: i + 1, x: i }

// 超态:7 种不同的属性组合
{ x: i, y: 1 } + 随机额外挂一个 a/b/c/d/e/f

// 字典模式:{ x, y, z } 然后 delete 掉 z

同样的 for 循环、同样的 arr[i].x,结果:

写法 平均耗时 相对单态
单态(1 种形状) 1.91 ms 1x
多态(2 种形状) 1.92 ms 1.0x
多态(4 种形状) 2.30 ms 1.2x
超态(7 种形状) 5.64 ms 3.0x
字典模式(delete 过) 20.97 ms 11.0x

注意中间那行:2 种形状几乎不亏。这个结果推翻了我原本的印象——我以前以为"多态 = 慢",实际上 V8 对少量多态处理得很好,真正掉崖的是过了某个阈值之后。

要理解这个阈值,得先理解 V8 给对象办的那张身份证。

# 二、隐藏类:给动态对象办一张身份证

JavaScript 的对象是动态的,你随时可以往上面挂一个新属性。但 C++ 里访问一个字段是"基地址 + 固定偏移量",一条机器指令的事。V8 要的是:能不能让 JS 对象在大部分时候也享受这个待遇。

做法是给每个对象偷偷记一个"形状描述",V8 内部叫 Map(文档里也叫 hidden class 或 shape)。它记录了这个对象有几个属性、每个属性叫什么、存在哪个偏移量上、原型是谁。

关键点在于:形状是按"属性的添加顺序"定义的,不是按"属性集合"定义的。

深色蓝图上,圆角矩形节点被箭头连成一棵树,在某个节点处分成两条岔路

{}                  Map0
  │ 加 x
  ▼
{ x }               Map1
  │ 加 y
  ▼
{ x, y }            Map2   ◄─── 另一条路走不到这儿

{}                  Map0
  │ 加 y
  ▼
{ y }               Map1'
  │ 加 x
  ▼
{ y, x }            Map2'  ≠ Map2

{x, y} 和 {y, x} 属性一模一样,但在 V8 眼里是两个不同的 Map,因为它们是沿着 transition tree 的两条不同分支走出来的。

这个可以用 V8 的内部函数直接验证(加 --allow-natives-syntax):

const a = { x: 1, y: 2 }
const b = { x: 3, y: 4 }
const c = { y: 4, x: 3 }

%HaveSameMap(a, b) // true
%HaveSameMap(a, c) // false  ← 顺序不同就是不同的 Map

所以一个类里老老实实把字段顺序写死,是有实际收益的——不是因为"整齐",是因为所有实例会共享同一个 Map。

顺带一个推论:class 比散装字面量更容易被优化,因为字段声明顺序天然固定,编译器能提前知道布局。而如果你在构造函数外面零零散散地 obj.newField = ...,每加一个就多一条 transition 分支。

# 三、内联缓存:记住"上次在这儿看到的是什么"

有了 Map,属性访问可以很快。但每次都去查一遍 Map 里的属性表,还是不够快。V8 更进一步:把上次查的结果缓存在调用点自己身上。

这就是内联缓存(Inline Cache,IC)。Ignition 解释器生成的属性访问字节码长这样:

LdaNamedProperty a0, [0], [4]
                      │    └─ feedback vector 槽位 #4
                      └────── 常量池里的属性名 "x"

每个函数有一个 feedback vector(反馈向量),是一排小格子,每个格子记录某个调用点"历史见过什么"。属性访问的格子会经历这几个状态:

信息图:一条单车道、三条并行车道、再到挤满车道的高速公路,分别代表缓存的三种状态

UNINITIALIZED  →  MONOMORPHIC  →  POLYMORPHIC  →  MEGAMORPHIC
   没见过          见过 1 种 Map     见过 2~4 种      见过 > 4 种
                 直接比指针+取偏移   逐个比 Map       走通用查表路径
  • 单态:IC 里就存了一个 Map 指针和一个偏移量。运行时比一下指针,相等就直接 load [obj + offset],几条指令。
  • 多态:V8 允许一个 IC 里挂 最多 4 个 Map(内部常量 kMaxPolymorphicMapCount = 4),线性比对后取对应偏移量。我的基准里 4 种形状只慢了 20%,这个线性比对其实很便宜。
  • 超态:超过 4 个就放弃逐个比对,退化成一次通用的属性查找(V8 会走一个带哈希缓存的通用 stub)。我的基准里这里是 3 倍。

所以基准里"2 种形状几乎不亏"就说得通了:它还在多态区间里,V8 处理得很好。真正要躲的是"热循环里塞进 5 种以上形状"。

这在真实代码里最常见的翻车方式是:一个数组里混进了不同来源的对象。

// 坏例子:同一个数组里混了两种构造来源
const list = [
  ...users.map(u => ({ id: u.id, name: u.name })),        // Map A
  ...groups.map(g => ({ name: g.name, id: g.id })),       // Map B
]
// 再往后如果还有第三、第四、第五种来源,render(list) 里的 .id 访问就超态了

顺带说一句:IC 是按调用点记的,不是按函数。同一个 render() 被调用了一万次,如果它内部的 item.id 每次见到的 Map 都不一样,那一万次全都在给这个槽位"投票"。这就是为什么 benchmark 一定要预热——不预热的数字测的是解释器 + 没收敛的 IC,不是你线上真实跑的东西。

# 四、属性存在哪:in-object、fast、dictionary

Map 描述布局,那属性值本身存在哪?V8 有三档:

内存布局示意图:一个对象盒子内部有若干内联属性槽,另有指针指向独立的属性后备存储数组和元素数组

1. in-object properties   直接存在对象头里,最快
2. fast properties        存在对象后面一块线性后备存储里,按 index 取,也快
3. slow / dictionary      退化成哈希表(NameDictionary),慢

从 1 到 2 是自然的(对象创建时预留的槽位用完了就往后备存储里放)。从 2 到 3 是单行的,回不来。触发条件有几个,最要命的是 delete:

const d = { x: 1, y: 2, z: 3 }
delete d.z
%HasFastProperties(d) // false  ← 已经进字典模式了

我的基准里字典模式是 11 倍。所以要清空一个字段,用 d.z = undefined(我验证过,赋 undefined 不会改变 Map,对象仍然是 fast)。

另外两个会退化的情况我也实测了:

const n = Object.create(null)
n.x = 1
%HasFastProperties(n) // false —— 无原型对象默认就是字典模式

const o = {}
for (let i = 0; i < 20; i++) o['p' + i] = i
%HasFastProperties(o) // false —— 动态挂太多属性也会退

第一条值得单独说:很多人用 Object.create(null) 当"更纯的 Map"用,结果它一开始就是慢路径。ES6 之后这件事该交给 Map 了,别再用无原型对象做字典。

# 五、数组是另一套:elements kind 的单向滑坡

数组的下标属性(V8 里叫 elements)跟具名属性是分开存的,而且有自己的一套类型标签,叫 elements kind:

PACKED_SMI_ELEMENTS      → 全是小整数,没洞
PACKED_DOUBLE_ELEMENTS   → 全是双精度,没洞
PACKED_ELEMENTS          → 混合类型,没洞
HOLEY_SMI_ELEMENTS       → 小整数,有洞
HOLEY_DOUBLE_ELEMENTS    → 双精度,有洞
HOLEY_ELEMENTS           → 混合,有洞
DICTIONARY_ELEMENTS      → 哈希表(稀疏到一定程度)

这套标签的演化是单向的,只能往更通用的方向滑,滑下去就回不来。往一个全是整数的数组里塞一个小数,整个数组从 PACKED_SMI 变成 PACKED_DOUBLE;再塞一个对象,变成 PACKED_ELEMENTS。没有回头路,哪怕你马上把那个元素删掉。

实测(同样是 200 万元素求和):

数组 平均耗时
全整数,无洞 1.03 ms
全小数,无洞 0.76 ms
有洞(new Array(N) 后跳着赋值) 2.63 ms
稀疏到字典模式(2000 万长度里撒 20 万个) 24.13 ms

两个观察:

"有洞"比"无洞"慢 2.5 倍,因为每次读取都要检查这个位置是不是 hole(那个 the hole 哨兵值),而且 [1, , 3] 这种数组会让 V8 无法假设下标连续。

稀疏数组是灾难。注意上面的字典模式例子我特意把长度放大到 2000 万——V8 判断"该不该转字典"看的是索引的密集程度,不是元素个数。所以 const a = []; a[1000000] = 1 这种写法,哪怕你只放了一个元素,也可能直接把数组推进哈希表。想用稀疏结构,请用 Map。

顺带一个反直觉的点:全小数(0.76 ms)比全整数(1.03 ms)还快一点。这不是因为浮点更快,而是我的求和里整数版本需要一个 Smi 溢出检查、最后累加值也溢出了 Smi 范围。别拿这个当"浮点数更快"的结论——它说明的是微基准很容易骗人,测之前先想清楚自己在测什么。

# 六、垃圾回收:Orinoco 的分代赌注

前面讲的都是"读得快",这一节是"收得掉"。

V8 的 GC 代号 Orinoco,核心是分代假设:大部分对象活不过一次 GC。所以堆被分成两代,用完全不同的策略收。

深色信息图:新生代的两个半空间与一片大的老生代区域,中间是复制晋升的箭头和三色标记点

新生代(young generation) 是两个同样大小的 semi-space,一个在用(from),一个空着(to)。分配对象就是 from-space 上把指针往后拨一下,快到没有"分配开销"这个概念。

装满了就 scavenge(Cheney 复制算法):从根出发遍历,把活着的对象复制到 to-space,然后整个 from-space 一次性丢掉。这里有个很妙的性质——复制的代价只跟存活对象成正比,死亡对象是白送的。而分代假设说死亡对象占绝大多数,所以 minor GC 非常便宜。

幸存下来的对象会被复制到 to-space,并且熬过两次 minor GC 之后晋升到老生代。之所以要两次,是因为第一次存活时 to-space 已经变成下一轮的 from-space,需要再活一轮才能确认它真的不是临时对象。

但这里有个洞:如果遍历只从根出发,怎么知道老生代里某个对象指着新生代的对象?答案是写屏障(write barrier)+ remembered set:每次往老生代对象里写一个指针,V8 都会检查这个指针是不是指向新生代,是就把这个位置记下来。minor GC 时除了根,还要扫这张表。

老生代(old generation) 不能这么玩——它太大了,复制一遍活不起。用的是标记-整理(mark-compact):

  1. 标记:三色标记(白=未访问、灰=已发现未展开、黑=已展开)。为了不让主线程停太久,标记是增量做的(切成小片和 JS 交替执行),并且大部分工作挪到并发线程上。增量期间 JS 还在改对象图,所以又需要一个写屏障来维护三色不变式(不能出现"黑对象直接指向白对象",否则白对象会被误收)。
  2. 清扫:标记完就知道哪些是死的,但 V8 不急着清——lazy sweeping,等到某个页面真的要分配时才清它,把开销摊到后续分配里。
  3. 整理:碎片太多了才做,把还活着的页面压实。整理必须暂停,所以是按需、按页做的,不是每次 major GC 都整理。

对写代码的人来说,有用的推论是:

  • 短命对象几乎不要钱,别为了"减少分配"把代码写得难读。真正贵的是活到老生代的对象。
  • 老生代里改引用比你想的贵:每次写指针都要过写屏障。一个巨大的、频繁被改的老生代对象图(比如常驻内存的缓存),代价不是内存本身,是每次写入的屏障。
  • 想调的话:--max-old-space-size 改老生代上限,--max-semi-space-size 改 semi-space。semi-space 默认是 MB 量级,如果你的服务会周期性分配一大批中期对象,调大它能显著减少晋升和 major GC 次数。
  • --trace-gc 看 GC 日志,--trace-gc-verbose 能看到每次 GC 的原因("Scavenge"、"Mark-Compact"、"Incremental marking")。

# 七、TurboFan 的赌注:投机优化与去优化

Ignition 解释器一边跑一边收集 feedback。一个函数跑热了,TurboFan 就接手,拿着那些 feedback 做投机优化:

"这个调用点历史上只见过 Map2,那我假设它永远是 Map2,直接把偏移量硬编码进机器码。"

于是优化后的代码里,obj.x 变成了一条 load [obj + 16],前面只挂一个 Map 检查。这就是单态能那么快的原因——连查表的那一步都没了。

赌注总有输的时候。如果运行时进来一个 Map7,那个检查失败,触发 deoptimization(去优化):丢弃优化代码,回滚到解释器状态,重新用字节码跑,并把新见到的 Map 记进 feedback vector。

跑 --trace-deopt 能看到这些,常见的 reason 有 wrong map、not a heap number、overflow。如果同一个函数反复 deopt,V8 会认定"这函数没法优化",彻底放弃它。

几个会直接让 TurboFan 举手投降的东西:

  • with 语句 —— 作用域在运行时才能确定
  • eval(直接调用)—— 同理
  • arguments 逃逸出函数、或者用了 arguments.callee

(try/catch 在老 Crankshaft 时代是著名的 bailout,TurboFan 之后已经能处理了,不用特意躲。真正该躲的是拿异常当控制流——抛异常的路径一定会掉出优化代码。)

# 八、所以到底该怎么写

按"投入产出比"排个序,从上往下做:

值得做的

  1. 一个类里,字段按固定顺序一次性声明完。构造函数里就把未来会有的属性都写上(哪怕先赋 null),别在外面零散地加。这一条几乎零成本,收益最大。
  2. 别用 delete。用 obj.field = undefined(不会改变 Map,我验证过)。11 倍的差距不是闹着玩的。
  3. 同一个数组/同一条处理管线里,保持对象同构。混进 5 种以上形状就会掉进超态。
  4. 别用 new Array(n) 然后跳着赋值,别用 a[999999] = x 造稀疏数组。要么 push,要么用 Map。
  5. 别用 Object.create(null) 当字典,它默认是慢路径。用 Map。

不值得做的

  1. 为了单态而单态。我的数据里 2 种形状是 0% 损耗、4 种是 20%。只有过了 4 这个阈值才明显。别为了消灭第二个形状把代码拧成麻花。
  2. 为了"减少 GC"而复用对象池。短命对象在 V8 里几乎是免费的,对象池反而会让对象活到老生代、吃写屏障,还更容易出 bug。
  3. 在没预热的情况下跑微基准。IC 没收敛、TurboFan 还没接手,你测的是另一个世界。
  4. 为了"帮编译器"牺牲可读性。这些优化只在已经被 profile 证明是热路径的地方才成立。先量,再改。

怎么量:node --trace-ic 看 IC 状态迁移,--trace-deopt 看去优化原因,--trace-gc 看 GC,--allow-natives-syntax 加上 %HaveSameMap() / %HasFastProperties() 直接问 V8。

# 九、写在最后

我把这些搞明白之后,最大的感受不是"原来可以这么优化",而是V8 的整个设计都是在赌。

赌大部分对象活不长 → 分代 GC。赌一个调用点见过的形状不会太多 → 内联缓存。赌历史上见过什么,将来还见到什么 → 投机优化。赌输了就 deopt,退回安全的慢路径。

有意思的是,这套思路在生活里也成立。我们每天都在做"基于历史推断未来"的事:默认明天的地铁还是那条线、默认同事回复的消息还是那个语气、默认自己写代码的路数还够用。这些假设让我们不必每一步都从头算起,代价是假设失效时要付一次"去优化"的罚款。

所以真正要紧的可能不是"把假设做得更准",而是让去优化的成本足够低:留一条能安全退回来的慢路径,别把赌注下在唯一的假设上。V8 之所以敢这么激进地投机,恰恰是因为它随时可以认输。

至于我那个 review 意见——改成什么顺序其实没那么重要,2 种形状根本不亏。真正该做的是确认那条管线里到底混进了几种形状。我后来查了一下,是四种。刚好在线内。

(文中数据均来自 Node v24.16.0 / V8 13.x,200 万元素、预热 5 轮后取 10 轮平均。不同 V8 版本、不同机器上的绝对值会有出入,量级关系应当稳定。)