Smi能直接存整数而不分配堆内存,因V8利用地址对齐特性将指针末位(32位)或末两位(64位)用作类型标签,使小整数以标记指针形式存在,无需堆分配、GC或对象头开销。
因为 V8 利用了指针地址对齐的硬件特性:在 32 位系统中,所有堆对象地址末位必为 0(4 字节对齐),所以最低 1 位可复用作类型标签;64 位系统同理,最低 2 位空闲。V8 就把 kSmiTag 设为 0,用末位是 0 表示 Smi,末位是 1 表示 HeapObject 指针。
这意味着一个 32 位指针字长里,Smi 实际只用 31 位存值(含符号位),范围是 −230 到 230−1(即 −1073741824 到 1073741823);64 位下是 63 位有效载荷。只要数值落在这个范围内,42、-100、array.length 这类常见整数就完全不进堆,不触发 GC,也不占额外对象头开销。
a + b)若两个操作数都是 Smi,V8 可以直接用 CPU 整数指令完成,无需解包/装箱Math.pow(2, 31)),结果会自动转为 HeapNumber,此时才真正分配堆内存并存储 IEEE-754 双精度值每个 HeapObject 至少包含一个 map 字段(指向类型描述结构),用于运行时识别对象类型、属性布局、GC 标记等。在 32 位系统中,map 占 4 字节;64 位系统中占 8 字节。这是所有堆对象的强制头部,无法省略。
以最简单的 HeapNumber 为例:它除了 map 外,还需存一个双精度浮点值(8 字节)。但 V8 不会简单拼接 —— 它利用地址对齐,在 map 后偏移 1 字节开始存值(即 value_offset = kHeapObjectTagSize),这样既能节省空间,又能让 GC 快速跳过非指针字段。
HeapNumber 在 32 位系统实际占 12 字节(4 字节 map + 8 字节 value,但因对齐和 tag 机制,布局非线性)没有公开 API 直接暴露内部表示,但可通过内存快照或调试器间接确认。最实用的方法是结合 %DebugPrint(需启用 V8 内部调试):
const v8 = require('v8');// Node.js 环境下启动时加 --allow-natives-syntaxconsole.log(%DebugPrint(42)); // 输出含 "Smi: 0x2a"(十六进制)console.log(%DebugPrint(1e9)); // 若超出 Smi 范围,显示 "HeapNumber" 及地址
注意:%DebugPrint 是 V8 内部函数,仅限调试用途,不可用于生产环境。线上只能靠行为推断:频繁整数运算无 GC 峰值、内存快照中该值未出现在堆对象列表里,大概率是 Smi。
v8.getHeapStatistics() 对比不同数值规模下的 total_heap_size 变化,Smi 不会导致增长typeof 或 Object.prototype.toString,它们对 Smi 和 HeapNumber 都返回 "number"
根本差异来自指针宽度和对齐粒度:32 位系统按 4 字节对齐,64 位按 8 字节对齐,导致可用于 Smi 的有效位数不同。
32 位下 Smi 使用 31 位(末位 tag),最大正数为 230−1;64 位下使用 63 位(末两位 tag),最大正数为 262−1。这意味着同一段 JS 代码在两种架构上,某些大整数(如 0x40000000)在 32 位可能是 HeapNumber,在 64 位仍是 Smi。
kSmiTagSize 和 kSmiShiftSize 宏,不能硬编码位移