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) 來省物件記憶體?
不划算。它從建立起就是字典模式,每個屬性額外背一份雜湊表項,適合當查找表,不適合高頻存取、欄位固定的記錄物件。省物件記憶體更有效的是別做無意義的深複製、別把原始大物件包進響應式系統,並把高頻字串歸一化成數字列舉。