如何根据 Smi 与 HeapNumber 的存储差异优化海量数值运算的重点在于把前置条件、操作顺序和容易误判的地方分清楚。
Smi 与 HeapNumber 切换拖慢数值运算,因超出 2³¹−1 或浮点运算触发堆分配和去优化;可用 %HasSmiValue 和 %DebugPrint 检测类型;应使用 | 0、边界检查及传统 for 循环避免隐式升格。
V8 中小于 231−1 且能被精确表示的整数默认用 Smi(Small Integer)存储——它直接编码在指针里,不分配堆内存,加减乘除几乎无开销。一旦超出范围(比如 2147483647 + 1),或参与浮点运算(如 / 2、Math.floor),V8 就会悄悄把值转成 HeapNumber 对象,触发堆分配、GC 压力和指针解引用。这种隐式转换在循环中反复发生时,性能落差可达 3–5 倍。
常见诱因包括:
parseInt 但未指定基数,返回 NaN 或 HeapNumber;float(如 i / 2),导致索引变成 HeapNumber,后续访问触发去优化;Math.round 或 ~~ 处理大数时,结果溢出 Smi 范围却仍被当作整数误用。V8 内置调试工具能帮你绕过“我以为它是整数”的幻觉。在 node --allow-natives-syntax 下运行:
const x = 2147483647;console.log(%HasSmiValue(x)); // trueconsole.log(%DebugPrint(x)); // 显示 "Smi: 0x7fffffff"
关键点:
%HasSmiValue 返回 true 仅当值是 Smi 且未被装箱;%DebugPrint 输出中若出现 HeapNumber 或 0x... 后跟小数点,说明已脱离 Smi;const v = arr[i]; console.log(%HasSmiValue(v)),不能直接测 arr[i] 表达式。核心原则:让整数运算全程保持在 Smi 范围内,并杜绝任何触发装箱的操作。
| 0 替代 Math.floor 或 parseInt(前提是输入确定为数字):const idx = (i * 1.5) | 0 —— 注意 | 0 会截断,但结果仍在 Smi 范围内;if (a > 0x7fffffff || b > 0x7fffffff) { /* fallback to BigInt or typed array */ };for...of(其内部计数器可能升格),改用传统 for (let i = 0; i ;
Int32Array 存储中间结果,强制类型约束:const buf = new Int32Array(1e6); buf[i] = a + b; —— 此处赋值自动截断并保持 Smi-友好。当数值量级超 10⁵ 且需频繁随机读写时,Int32Array 或 Float64Array 开始明显胜出。原因不是“更快”,而是“可预测”:它们绕过 JS 对象模型,不产生 HeapNumber,也不受原型链查找影响。
但要注意:
Int32Array 的 push 很慢(需动态扩容),应预先 new Int32Array(n) 分配固定长度;arr[i] + floatVal)会让整个表达式升格为 Float64Array 计算,失去 Smi 优势;for 循环中 array[i] 的 Smi 优化非常敏感——只要数组曾存过 HeapNumber,后续所有访问都按对象属性处理,无法内联。真正卡住性能的往往不是单次运算,而是某次不经意的 arr.push(NaN) 或 arr[0] = 1e10,让整个数组从“可优化”退化为“不可优化”。这类污染一旦发生,无法回退。