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) 來省物件記憶體?

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