Set基于哈希表实现,插入查找平均O(1)但需哈希计算与堆分配;数组在快速模式下为连续内存、索引访问O(1),但空洞或类型混杂会降级为字典模式导致性能骤降。

Set 和数组在 V8 中根本不是同一种内存结构——数组是索引可寻址的线性容器,Set 是基于哈希表实现的键值集合。这种底层差异直接决定了它们在插入、查找、遍历等操作上的性能分水岭,而识别影响的关键,在于理解“存储方式”和“访问路径”如何被 V8 实际执行。
数组:连续内存 + 直接偏移寻址(快但有前提)
当数组满足“快速模式”条件(元素类型一致、无空洞、索引连续),V8 会将其存储为一块连续堆内存,比如 [1, 2, 3, 4] 可能布局如下:
- 起始地址处是对象头(含 map 指针)
- 紧接着是 length 字段(4 字节或 8 字节)
- 然后是连续的元素槽位,每个槽存一个 tagged 值(Smi 或 HeapObject 指针)
- 访问
arr[2]就是:基地址 + header_size + 2 × element_size,一次内存读取完成
一旦出现空洞(arr[0] = 1; arr[1000] = 2;)或混入对象(arr.push({})),V8 立即降级为字典模式:元素不再连续,而是以哈希表形式存在 properties 区域,每次访问都要哈希计算+链表查找,性能骤降。
Set:哈希桶数组 + 链式冲突处理(稳定但有开销)
Set 底层始终使用哈希表(非连续内存),其布局包含三部分:
- 一个固定大小的桶数组(bucket array),每个桶存一个指针,指向该哈希槽的首节点
- 多个独立分配的 Entry 节点,每个含 key(即值本身)、next 指针;key 为 Smi 时直接编码,为对象时存堆地址
- 没有 length 字段,而是维护一个 size 计数器
这意味着:
- 插入
set.add(x):先算 hash → 找桶 → 遍历链表比对 → 无重复则 new Entry 分配堆内存 - 查找
set.has(x):同样需哈希+链表遍历,平均 O(1),最坏 O(n) - 不支持随机索引访问,
set[0]是 undefined —— 它压根没设计下标语义
性能影响的典型识别场景
真正影响你代码表现的,往往不是理论复杂度,而是这些具体行为:
-
去重大量数字时:Set 插入要分配 Entry 对象(哪怕只是 Smi,也要建堆节点),而数组用
filter((v, i) => arr.indexOf(v) === i)虽然 O(n²),但全走栈上整数比较,无 GC 压力;实际百万级数据下,Set 反而可能更慢 -
频繁按索引读写:数组
arr[i] = v是纯内存写;Set 没有索引,强行用Array.from(set)[i]会触发全量遍历+新数组分配,开销爆炸 - 内存占用敏感场景:10 万个数字存 Set,至少 10 万个 Entry 对象(每个约 16–24 字节)+ 桶数组;同样数据存数组(快速模式),仅需约 800KB 连续内存,无额外元数据
- 迭代顺序保障:数组保证插入顺序;Set 在 V8 中也保证插入顺序(因链表结构),但这不是靠内存连续,而是靠 Entry 的 next 指针链,遍历时仍需跳转,缓存局部性不如数组
怎么验证你遇到的是哪种影响
不靠猜,用工具看真实行为:
- Chrome DevTools → Memory tab → “Take heap snapshot”,筛选
JSArray和JSSet,对比 shallow size 和 retained size - 用
%DebugPrint(arr)和%DebugPrint(set)(需启动 Node.js 加--allow-natives-syntax),观察是否显示PACKED_SMI_ELEMENTS或HASH_TABLE等内部标签 - 对关键路径加
console.time(),分别测试arr.includes(x)vsset.has(x),注意 warmup(避免 TurboFan 未优化干扰)


















