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

结论:先用 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 处理直接相关的细节:
- 切片会保留原串。 V8 对较长子串使用
SlicedString,它只记录「父串 + 偏移 + 长度」。从 80MB 的日志文本里截 200 个字符存进缓存,那 80MB 原文会一起活着,直到切片被释放。 - 拼接会产生
ConsString。 反复用+=拼接大字符串,得到的是一棵由片段组成的树,只有真正需要读取字符时才会被扁平化成连续内存。拼接一万次再读一次,那一刻的分配峰值会很可观。 - 重复值不会自动共享。 一万条记录里都写着
"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) 执行期间,进程同时持有三样东西:
- 输入字符串
text; - 正在构建、尚未完成的对象图;
- 解析器自身的临时结构。
所以解析峰值大致等于「文本 + 对象图 + 临时开销」。解析完成后,如果 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) 来省对象内存?
不划算。它从创建起就是字典模式,每个属性额外背一份哈希表项,适合当查找表,不适合高频访问、字段固定的记录对象。省对象内存更有效的是别做无意义的深拷贝、别把原始大对象包进响应式系统,并把高频字符串归一化成数字枚举。