JSON のメモリ台帳:文字列、オブジェクト、大きな配列が実際にいくらかかるか
要点:10MB の JSON ファイルは V8 ヒープで 30〜50MB になりえます。文字列、オブジェクト、数値の実際のコスト、process.memoryUsage による基準値の取り方、ストリーミングが効く場面を学びます。

要点:まず process.memoryUsage で基準値を取り、テキストとオブジェクトグラフを二つの別々の増分として記録しましょう。次に、他のことを試す前に完全なコピーを一つ除去します。JSON.parse(JSON.stringify(x)) は使わず、生の文書をリアクティブで包まないこと。その次に長寿命の参照を切り、ストリーミングは最後に検討します。その平坦なメモリ曲線は、ファイルが収まらないか、必要なフィールドがごく一部のときだけ、ランダムアクセスを失う価値があります。
10MB の JSON 文書は
JSON.parseの後、ヒープで 30MB から 50MB になり、過渡的なピークはさらに高くなります。この算術をしない「最適化」は当て推量です。
1. テキストとオブジェクトグラフは二つの別の台帳
ディスク上でもリクエストボディでも、JSON はバイトの並びです。メモリに入ると二つのものになります。文字列と、オブジェクト、配列、数値、真偽値からなるオブジェクトグラフです。二つの台帳を分けて保つことが、10MB のファイルがプロセスに 80MB を消費させる理由を説明する唯一の方法です。
| データの形 | オブジェクトグラフ対テキストのサイズ | 支配的なコスト |
|---|---|---|
| 平坦なオブジェクト配列(各約 8 フィールド) | およそ 2.5〜4 倍 | 要素ごとのオブジェクトヘッダーとプロパティスロット |
| 深くネストした設定 | およそ 3〜5 倍 | 多数の小さなオブジェクト。それぞれにヘッダー |
| 非常に長い文字列の配列 | およそ 1.2〜2 倍 | 文字列ヘッダーと文字データ |
| 小数を含む大きな数値 | およそ 2〜3 倍 | 値ごとに一つの HeapNumber |
| 繰り返しキーの多い文書 | キー数に比例 | パース時のキーハッシュとプロパティ設定 |
これらの倍率は桁感の参考値で、V8 のバージョン、ポインタ圧縮、フィールド数、文字列の中身に依存します。表が言いたいことは一つです。オブジェクトグラフのサイズは「ノード数 × ノードあたりのオーバーヘッド」で決まり、テキストのバイト数では決まりません。
2. 文字列:最も過小評価される項目
V8 の文字列は裸の文字配列ではありません。オブジェクトヘッダー(マップポインタ、ハッシュフィールド、長さ)を持ち、ポインタ圧縮付きの典型的な 64 ビットビルドでは文字の前に十数バイトかかります。文字は二つの方法のどちらかで保存されます。
- 1 バイト文字列:Latin-1 範囲の文字で、1 文字 1 バイト。
- 2 バイト文字列:内容に CJK 文字、絵文字、Latin-1 外のものが含まれる場合で、1 文字 2 バイト。
日本語の文字は UTF-8 ファイルでは 3 バイトを占めますが、2 バイト文字列では 1 文字 2 バイトにヘッダーが加わります。テキストのサイズとヒープのサイズは同じ測定ではなく、常に同じ方向に動くわけでもありません。
JSON 作業に直接関係する三つの詳細:
- スライスは親を生かし続ける。 V8 は長めの部分文字列を
SlicedStringとして表し、親参照とオフセット、長さを保存します。80MB のログから 200 文字を切り出して保持すると、そのスライスが解放されるまで 80MB 全体が到達可能なままです。 - 連結は
ConsStringを作る。 大きな文字列への+=の繰り返しは断片の木を作り、文字を実際に読むときにだけ連続メモリに平坦化されます。一万回連結してから一度読むと、その瞬間に大きな割り当てのスパイクが起きます。 - 繰り返しの値は自動共有されない。
"status": "paid"を含む一万レコードは通常、一万個の独立した文字列オブジェクトになります。パーサーはそれらを一つの定数に向けません。そのコストを節約するには、頻出文字列を自分で enum や数値にマッピングします。
短い抜粋を長く保持する必要があるときは、独立した文字列にして元を解放します。
// 悪い例:スライスが文書全体を参照するので、logText が回収できない
const summary = logText.slice(0, 200);
// 良い例:独立した文字列を作り、その後 logText を解放する
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. オブジェクト:すべてのキーがスロットを占める
素のオブジェクトは少なくとも三つを含みます。隠れクラス(マップ)へのポインタ、プロパティストアへのポインタ、要素ストアへのポインタ。ポインタ圧縮では各ポインタが 4 バイトで、ヒープオブジェクトヘッダーがさらに加わるので、空のオブジェクトでもおよそ 16〜20 数バイトかかります。ポインタ圧縮のない 64 ビットビルドでは倍です。
名前付きプロパティは二つの場所のどちらかに落ちます。
- オブジェクト内プロパティ:オブジェクトヘッダーの直後に並ぶスロットで、最速のアクセス。
PropertyArray:オブジェクト内スロットを使い尽くすと、プロパティは別の配列に移り、オブジェクトはそこへのポインタを持ちます。
各スロットは 4 または 8 バイトで、キー文字列は別に計上されます。だから長いフィールド名は数十万レコードにわたって実際のコストになり、単なる見栄えの問題ではありません。
良い知らせは隠れクラスが共有されることです。同じキーを同じ順序で持つ百万個のオブジェクトは、構造情報をコピーせず一つのマップを共有します。悪い知らせは、次のパターンがその共有を壊すか、オブジェクトをより高コストな表現に押し込むことです。
- 一部のレコードに
remarkがあり他にはないと、二つの形状ができ、アクセスごとに余分な形状チェックが入る。 - 挿入順が異なると異なる形状ができる。
- プロパティ数が非常に多い(およそ千を超える)か
deleteが頻繁だと、オブジェクトは辞書モードに入り、すべてのプロパティがハッシュテーブルエントリも持つ。 Object.create(null)は生まれから辞書モードです。ルックアップテーブルとしては良いですが、ホットループでアクセスする固定スキーマのレコードには不向きです。
よくある誤解を一つ訂正します。JSON は重複キーを許し、{"a":1,"a":2} は有効な文書で結果は a === 2 です。最終オブジェクトは一つの値を保ちますが、パーサーはすべての出現を走査して代入します。重複キーはメモリを節約せず、パース時間だけを消費します。
4. 配列と数値:要素種別が幅を決める
V8 は配列のいくつかの「要素種別」を追跡し、それぞれスロットあたりの幅が異なります。
| 配列の中身 | 要素種別 | スロットあたりの概算コスト |
|---|---|---|
| 小さな整数(Smi 範囲内) | PACKED_SMI_ELEMENTS |
4 バイト |
| 小数または非常に大きな整数 | PACKED_DOUBLE_ELEMENTS |
8 バイト |
| 文字列またはオブジェクト | PACKED_ELEMENTS |
4 バイトのポインタと値そのもの |
delete で穴が空いた配列 |
HOLEY_* |
同じ。加えて穴チェック |
| 非常に疎な配列 | DICTIONARY_ELEMENTS |
ハッシュエントリ。通常 20 バイト超 |
覚えておく価値のある二つの規則:
第一に、要素種別は劣化するだけ。 小さな整数で始まる配列は 0.5 を挿入した瞬間に double 配列になり、文字列を挿入すると汎用要素配列になります。自動で回復することはなく、新しい配列を作らなければなりません。
第二に、JSON の数値がどこに落ちるかは値に依存する。 Smi 範囲(およそ ±10.7 億)内の整数は Smi として直接保存され、ポインタスロット一つを占めます。小数や指数を持つ数値、または範囲外の値は約 16 バイトの HeapNumber を割り当て、配列にはそこへのポインタが保存されます。ですから [1, 2, 3] と [0.1, 0.2, 0.3] は値だけでなく、「4 バイトのスロット一つ」と「8 バイトのスロットにヒープオブジェクトを加えたもの」で異なります。
これが大きな整数 ID の精度問題の根でもあります。安全な整数範囲を超えると、JSON の数値は double として扱われ、下位の桁が単に消えます。JSON 配列はパックされ穴なしで届くので、パース後に要素を delete しないでください。配列全体がより高コストな表現に押されます。
5. パースとシリアライズのピーク:プロセスが実際に死ぬ瞬間
定常状態のメモリは一つの台帳、ピークは別の台帳です。JSON.parse(text) が動いている間、プロセスは三つを同時に保持します。
- 入力文字列
text。 - 構築中でまだ完成していないオブジェクトグラフ。
- パーサー自身の一時構造。
パースのピークはおよそ「テキスト+オブジェクトグラフ+一時オーバーヘッド」です。パース後、text が変数、クロージャ、モジュールレベルのキャッシュ、リアクティブストアから参照され続けるなら、そのテキストのメモリは決して返りません。
シリアライズにもピークがあります。JSON.stringify(data) は出力文字列を構築しながらオブジェクトグラフ全体を保持し、その間に連結バッファも持ちます。出力が V8 の文字列長上限(約 5 億文字)に近づくと RangeError を投げます。この失敗は通常、データベース全体を一つの JSON 文書としてエクスポートするスクリプトで現れます。
最も高コストなよくあるパターンは、シリアライズを深いクローンとして使うものです。
// ピークはおよそ:元のオブジェクトグラフ + 一時テキスト + 新しいオブジェクトグラフ
const safeCopy = JSON.parse(JSON.stringify(bigData));
この一行は同じデータを三度保存します。読み取り専用の作業なら参照を渡します。隔離が本当に必要なときは structuredClone を使い、それもオブジェクトグラフ全体を再構築することを受け入れます。
6. 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 パネルでヒープスナップショットを取り、Allocation Timeline の鋸歯状のグラフを見つめるのではなく
Retained Sizeを読みます。
この基準値がなければ、「ストリーミングに変えれば直る」「キャッシュで速くなる」といった主張は単なる感覚です。
7. ストリーミングが買うのはピークメモリで、総時間ではない
ストリーミングパースのトレードは明示的です。ランダムアクセスと引き換えに、ファイルサイズに比例しなくなるメモリ。
| 方法 | ピークメモリ | ランダムアクセス | 向いている用途 |
|---|---|---|---|
全体 JSON.parse |
テキストと完全なオブジェクトグラフ | あり | およそ 100MB 未満で、ほとんどのフィールドが必要 |
stream-json などの SAX パーサー |
チャンク一つと現在の要素 | なし | 巨大ファイル、または一部のフィールドだけが必要 |
| 行単位の JSON Lines | 1 行分のオブジェクト | なし | 自然にレコードの集まりであるデータ |
| 外部インデックスやデータベース | 全体を読み込まない | あり | 同じデータへの繰り返しクエリ |
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 のパース、描画、ストリーミングを参照してください。
8. コピーを減らし、早く解放する
メモリ作業では、完全なコピーを一つ除去する方がパーサーを替えるより価値があることが多いです。
- 読み取り専用の作業には参照を渡す。 「安全のため」深くクローンしない。特に
JSON.parse(JSON.stringify(x))は使わない。 - 生の文書をリアクティブシステムで包まない。 Vue の
reactiveはオブジェクトグラフ全体を再帰的にプロキシし、プロキシ層自体がメモリコストを持ちます。 - 派生データは別に計算してキャッシュする。 リストが id、title、status だけを必要とするなら、レコード全体をストアに入れない。
- 高頻度の文字列を正規化する。
"paid"や"pending"のような値を数値 enum にマッピングし、文字列オブジェクトと比較コストの両方を節約する。 - 長寿命の参照を切る。 終わった大きなオブジェクトを null にし、キャッシュを消し、コールバックを解除して、クロージャが次のページまで持ち運ばないようにする。
- Worker のコピーコストを計上する。
postMessageは既定で structured clone を使い、50MB のオブジェクトグラフをメインスレッドに送り返すと二つ目のコピーを作ります。可能な限り要約を返してください。
サイズ台帳のもう半分はJSON 圧縮を間違った順序でやめるにあります。ディスクのバイトと回線のバイトはヒープ内のオブジェクトグラフとは別の勘定で、単一の割合ですべてを説明できません。エディタと CLI ツールのメモリ挙動はエディタ、コマンドラインツール、自動化を参照してください。
9. チェックリスト
| 症状 | より可能性の高い原因 | 最初の行動 |
|---|---|---|
| 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 を使い、同じ再構築コストを受け入れてください。