JSON 工具箱

JSON 的内存账:字符串、对象与大数组各占多少

结论:一份 10MB 的 JSON 文本,解析后可能变成三到五倍大小的对象图,峰值还要再翻一倍。本文把字符串、对象、数组与数字在 V8 里的实际开销逐项拆开,给出用 process.memoryUsage 做基线测量的方法,并说明流式解析到底省在哪一段内存。

实战约 11 分钟发布于 2026-10-31更新于 2026-10-31
JSON 的内存账:字符串、对象与大数组在 V8 堆中的占用对比

结论:先用 process.memoryUsage 分段量出「文本」和「对象图」两笔增量,再优化——先减少一次全量拷贝(别用 JSON.parse(JSON.stringify(x)),别把大对象包进响应式),再切长短生命周期引用,最后才考虑流式;只有当文件大到装不下、或只需要少数字段时,流式的平内存曲线才值得换掉随机访问。

一份 10MB 的 JSON 文本,JSON.parse 之后在堆里可能变成 30MB 到 50MB,解析瞬间的峰值还要再高一截。算不清这笔账,所谓优化就只能靠猜。

一、文本和对象图不是一回事

JSON 在磁盘上、在请求体里都是一串字节;一旦进了内存,它变成两样东西:一段字符串,以及由对象、数组、数字、布尔值组成的对象图。这两笔账分开记,才能解释「文件才 10MB,进程怎么吃了 80MB」。

数据形态 解析后对象图相对文本的大小 主要开销
扁平对象数组(每条约 8 个字段) 约 2.5–4 倍 每个元素的对象头与属性槽
深层嵌套配置 约 3–5 倍 大量小对象,每个都有自己的头
超长字符串数组 约 1.2–2 倍 字符串头加上字符数据
大量浮点数字 约 2–3 倍 每个数字是一个独立的 HeapNumber
重复键名很多的文档 与键数量正相关 解析期的键名哈希与属性建表

倍数只是量级参考,具体取决于 V8 版本、是否开启指针压缩、字段数量与字符串内容。表格想说明的只有一件事:对象图的体积由「节点数量 × 每节点固定开销」决定,而不是由文本字节数决定。

二、字符串:最容易被低估的一笔

V8 的字符串对象不是裸的字符数组,它自带对象头(map 指针、哈希值、长度字段)。在 64 位、开启指针压缩的常见配置下,这个头大约十几字节,之后才是字符数据。字符本身有两种存法:

  • 单字节字符串:只含 Latin-1 范围内的字符,每字符 1 字节;
  • 双字节字符串:含中文、emoji 或其他非 Latin-1 字符时,每字符 2 字节。

一份 UTF-8 文件里的汉字占 3 字节,解析成双字节字符串后在堆里是 2 字节一个字符,再加上头部。所以「文本多大」和「堆里多大」既不是同一件事,也不一定同向变化。

三个与 JSON 处理直接相关的细节:

  1. 切片会保留原串。 V8 对较长子串使用 SlicedString,它只记录「父串 + 偏移 + 长度」。从 80MB 的日志文本里截 200 个字符存进缓存,那 80MB 原文会一起活着,直到切片被释放。
  2. 拼接会产生 ConsString。 反复用 += 拼接大字符串,得到的是一棵由片段组成的树,只有真正需要读取字符时才会被扁平化成连续内存。拼接一万次再读一次,那一刻的分配峰值会很可观。
  3. 重复值不会自动共享。 一万条记录里都写着 "status": "paid",解析器通常为每个值分配独立的字符串对象,不会自动指向同一个常量。要省这笔钱,得自己把高频字符串映射成枚举或数字。

需要长期保留一小段摘要时,先让它变成独立字符串,再释放原文:

// 反例:切片引用整段原文,logText 无法回收
const summary = logText.slice(0, 200);

// 正例:需要长期保留时生成独立字符串,随后可以释放 logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');

三、对象:每个键都要占一格

一个普通对象至少包含三样东西:指向隐藏类(map)的指针、指向属性存储的指针、指向元素存储的指针。在指针压缩下每个指针 4 字节,加上堆对象头,一个空对象也要十几到二十几字节;未开启指针压缩的 64 位环境会翻倍。

具名属性有两种落点:

  • 对象内联槽(in-object properties):属性直接排在对象头后面,访问最快;
  • 属性数组(PropertyArray):对象内联槽用完后,属性转移到独立的数组里,对象再持有一个指针。

每个属性槽本身占 4 或 8 字节,键名字符串另算。这就是为什么「字段名很长」在几十万条记录上会变成真实的成本,而不仅仅是不好看。

好消息是隐藏类会被共享:一百万个键名、键序都相同的对象共享同一个 map,不会各自复制一份结构信息。坏消息是下面几种写法会破坏这种共享,或者直接退回更贵的存储方式:

  • 同一种记录,有的对象带 remark 字段、有的不带,会形成两种形状,访问时多一次形状判断;
  • 键的插入顺序不同,也可能形成不同的形状;
  • 属性数量极多(超过约一千个)或频繁 delete,V8 会把对象退回字典模式,每个属性额外背一份哈希表项;
  • Object.create(null) 从创建起就是字典模式。它适合当查找表,不适合当高频访问、字段固定的记录对象。

还要澄清一个常见误解:JSON 允许重复键,{"a":1,"a":2} 是合法文档,解析结果是 a 等于 2。最终对象只保留一份值,但解析器仍要为每一次出现做扫描与属性写入,「重复键省内存」是不成立的。

四、数组与数字:元素种类决定每格宽度

V8 给数组元素分了多种「元素种类」,每种的每格宽度不同:

数组内容 元素种类 每格近似开销
小整数(Smi 范围内) PACKED_SMI_ELEMENTS 4 字节
小数、超大整数 PACKED_DOUBLE_ELEMENTS 8 字节
字符串或对象 PACKED_ELEMENTS 4 字节指针,值本身另算
用 delete 挖过洞 HOLEY_* 同上,外加空洞检查
极度稀疏 DICTIONARY_ELEMENTS 哈希表项,通常 20 字节以上

有两个规则值得记住:

第一,种类只会单向退化。 一个从小整数开始的数组,只要混进一个 0.5,就变成双精度数组;再混进一个字符串,就退化成通用元素数组。反过来不会自动恢复,除非重新建数组。

第二,JSON 数字的落点取决于它的值。 在 Smi 范围内(约 ±10.7 亿)的整数直接以 Smi 存储,只占一个指针槽;带小数、指数或超出范围的数字会分配一个 16 字节左右的 HeapNumber,数组里再存一个指向它的指针。所以 [1, 2, 3] 和 [0.1, 0.2, 0.3] 在堆里的差距不只是数值不同,而是「一格 4 字节」与「一格 8 字节再加一个堆对象」。

顺带一提,这也是大整数 ID 会丢精度的同一个根源:超过安全整数范围后,JSON 数字被当成双精度浮点数,末位直接消失。JSON 数组本身是连续的、没有洞的,所以不要在拿到数据后用 delete 去删元素,那会把整条数组推向更贵的存储形态。

五、解析与序列化的峰值:真正压垮进程的那一刻

稳态内存是一笔账,峰值是另一笔账。JSON.parse(text) 执行期间,进程同时持有三样东西:

  1. 输入字符串 text;
  2. 正在构建、尚未完成的对象图;
  3. 解析器自身的临时结构。

所以解析峰值大致等于「文本 + 对象图 + 临时开销」。解析完成后,如果 text 还挂在某个变量、闭包、模块级缓存或响应式 store 上,那笔文本内存不会自动归还。

序列化方向同样有峰值。JSON.stringify(data) 需要持有完整对象图,同时构建输出字符串,中间还会有拼接缓冲。输出接近 V8 的字符串长度上限(约 5 亿字符)时会直接抛 RangeError,这类失败通常出现在「把整个数据库导成一个 JSON」的脚本里。

最贵的一种常见写法是拿序列化做深拷贝:

// 峰值约等于:原对象图 + 临时文本 + 新对象图
const safeCopy = JSON.parse(JSON.stringify(bigData));

同一份数据在这一行里被完整地存在了三遍。只读场景直接传引用就够了,确实需要隔离时再考虑 structuredClone,并接受它同样要重建整棵对象图。

六、用 process.memoryUsage 做基线

Node 里的 process.memoryUsage() 一次返回五个数字:rss(进程占用的物理内存)、heapTotal、heapUsed(JS 堆已用)、external(C++ 对象持有的内存)、arrayBuffers(ArrayBuffer 与 Buffer)。观察 JSON 处理时,heapUsed 适合看对象图,rss 适合看整体是否失控。

测量脚本要遵守三条纪律:先 global.gc() 把垃圾清掉、分段采样、每段只改一个变量。下面这个脚本分别记录「读入文本」和「解析成对象」两段的增量:

// 运行:node --expose-gc measure-json-memory.mjs
import { readFileSync } from 'node:fs';

global.gc();
const baseline = process.memoryUsage().heapUsed;

const text = readFileSync('big.json', 'utf8');
const afterRead = process.memoryUsage().heapUsed;

const data = JSON.parse(text);
const afterParse = process.memoryUsage().heapUsed;

console.log({
  textOnHeap: afterRead - baseline,
  graphOnHeap: afterParse - afterRead,
  parsePeak: afterParse - baseline,
});

几个容易踩的坑:

  • --expose-gc 必须显式打开,否则 global.gc 不存在;
  • heapUsed 只统计 JS 堆,读文件产生的 Buffer 在 external 与 arrayBuffers 里,别拿一个数字解释全部;
  • 采样点之间不要顺手 console.log 大对象,那会临时持有引用;
  • 首次运行包含 JIT 预热与惰性编译,至少跑三轮取中位数;
  • 想在浏览器里对比,用 DevTools 的 Memory 面板拍堆快照,看 Retained Size,不要只看 Allocation Timeline 的锯齿。

没有这组基线,任何「换成流式就好了」「加个缓存会更快」的判断都只是感觉。

七、流式解析省下的是峰值,不是总时间

流式解析的核心交易很明确:用「只能顺序访问」换「内存与文件大小无关」。

方式 峰值内存 随机访问 适用场景
全量 JSON.parse 文本 + 完整对象图 支持 小于约 100MB,且要用大部分字段
stream-json 等 SAX 解析器 一个数据块 + 当前元素 不支持 文件很大,或只要少数字段
JSON Lines 逐行读 一行对应的对象 不支持 数据本身是一批独立记录
外部索引 / 数据库 不加载全量 支持 需要反复查询同一份数据
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';

const pipeline = createReadStream('huge.json')
  .pipe(parser())
  .pipe(pick({ filter: 'items' }))
  .pipe(streamArray());

for await (const { value } of pipeline) {
  handleItem(value);
}

这段代码不会构建完整对象树,所以内存曲线基本是平的。代价同样真实:不能回头取已经跳过的字段,无法做需要全局聚合的统计,错误恢复要自己处理,而且纯 JS 的逐字符扫描通常比原生 JSON.parse 慢一个量级。

判断标准不是「文件有没有超过 10MB」,而是两个问题:解析后我是否要用到大部分字段?我能否接受顺序访问? 两个都答「是」,全量解析就是正确选择,优化应该放在减少拷贝和渲染上;只要有一个答「否」,再考虑流式。关于分层策略的完整讨论,见《大 JSON 的解析、渲染与流式处理》。

八、减少拷贝与及时释放

内存优化里,减少一次全量拷贝往往比换解析器更划算:

  • 只读就传引用。 不要为了「安全」先深拷贝,尤其是 JSON.parse(JSON.stringify(x));
  • 别把原始大对象包进响应式系统。 Vue 的 reactive 会递归代理整棵对象图,代理层本身也有内存成本;
  • 派生数据单独算、单独缓存。 列表页只需要 id、标题、状态,就不要把整条记录塞进 store;
  • 高频字符串归一化。 把 "paid"、"pending" 这类值映射成数字枚举,既省字符串对象,也省比较成本;
  • 切断长生命周期引用。 大对象用完置空、清缓存、注销回调,别让闭包顺手把它带进下一个页面;
  • 注意 Worker 通信的复制成本。 postMessage 默认走结构化克隆,把 50MB 对象图发回主线程,等于再建一份。能只回摘要就只回摘要。

字符串与体积优化的另一半账在《JSON 压缩的顺序别搞反:minify、gzip、brotli 实测》里:磁盘与传输字节和堆内对象图是两套账本,不能用一个百分比同时解释。至于编辑器、命令行工具各自的内存行为,见《编辑器、命令行与自动化工具链》。

九、收尾清单

现象 更可能的原因 先做什么
10MB 文件解析后进程涨到 50MB 对象图开销与文本同时存活 分段测量,确认文本是否被保留
解析瞬间 OOM,稳态正常 解析峰值叠加了旧数据 先释放旧对象,再解析新文件
内存缓慢上涨不回落 切片、缓存或闭包持有引用 拍堆快照,按 retained size 排序
数组比我预估的更大 混入小数导致元素种类退化 统一数值类型,避免 delete
加了缓存反而更慢 缓存键与值都占内存,GC 压力上升 给缓存设上限,只缓存摘要

为什么 JSON.parse 之后内存涨了三四倍?

因为文本只是输入,解析产物是对象图。每个对象有头、每个属性有槽、每个字符串有独立的对象头,节点数量一多,总开销自然超过文本字节数。倍数取决于数据形状,扁平对象数组与深层嵌套的差异很大。

10MB 的 JSON 该全量解析还是流式?

先看用途。需要随机访问、要用大部分字段、文件能完整放进内存,就用全量 JSON.parse;只需要少数字段、或者文件大到装不下,再上流式。10MB 本身并不是分界线,100MB 以上才通常必须考虑流式。

process.memoryUsage().heapUsed 为什么和系统监控对不上?

heapUsed 只是 JS 堆里已使用的部分,不含 external、arrayBuffers 和代码段。系统监控看的是 rss,两者本来就不是一个口径。排查内存问题时要同时看 heapUsed 与 rss,差得很多就去看 Buffer 与原生对象。

用 JSON.parse(JSON.stringify(x)) 做深拷贝有问题吗?

有。它会在同一时刻持有原对象图、临时文本和新对象图,峰值大约是稳态的三倍,而且会丢掉 undefined、函数、Date 等类型信息。只读场景直接传引用,需要隔离时用 structuredClone 并接受同样的重建成本。

常见问题

如何分段测量文本与对象图各自占多少?

用 node --expose-gc 运行脚本,先 global.gc() 清一次,再分别记录「读入文本」与「解析成对象」前后的 heapUsed 增量。heapUsed 只统计 JS 堆,读文件产生的 Buffer 落在 external 与 arrayBuffers,别拿一个数字解释全部。采样点之间不要 console.log 大对象,首次运行包含 JIT 预热,至少跑三轮取中位数。

为什么数组里混进一个小数会让整条数组变贵?

V8 的元素种类只会单向退化:从小整数数组混进一个 0.5 就变成双精度数组,每格从 4 字节变 8 字节;再混进一个字符串就退化成通用元素数组,而且不会自动恢复。所以统一数值类型、不要在拿到数据后用 delete 挖洞,是省这笔开销的直接办法。

是否应该用 Object.create(null) 来省对象内存?

不划算。它从创建起就是字典模式,每个属性额外背一份哈希表项,适合当查找表,不适合高频访问、字段固定的记录对象。省对象内存更有效的是别做无意义的深拷贝、别把原始大对象包进响应式系统,并把高频字符串归一化成数字枚举。