はじめに
きっかけは React の内部ソースを読んでいて、こんなコードに出会ったことでした。
// L65-L72, 一部抜粋 / 日本語コメントは筆者
while (i < endIndex) {
const fiber: Fiber = concurrentQueues[i];
concurrentQueues[i++] = null; // ← これは何のため?
const queue: ConcurrentQueue = concurrentQueues[i];
concurrentQueues[i++] = null;
// ...
}
値を取り出した直後、わざわざ null を代入しています。コメントには「参照を切らないと GC(ガベージコレクション)に回収されず、メモリリークになる」とあります。
ここで疑問が湧きました。React に限らず、自分が「使い回し配列」をモジュールスコープに置くようなコードを書くときにも、同じ罠を踏むのでは?
調べてみると、配列をクリアする方法は他にもあるみたいです。
-
arr[i] = nullで各スロットを上書き(React の選択) -
delete arr[i]で各スロットを削除 -
arr.length = 0で一気にクリア - そもそも
index = 0で書き込み位置だけ戻す
どれが正解なのか? そもそも本当に差はあるのか?
この記事では、JS の使い回し配列で起きるメモリリークパターンを Node.js で実測し、4 つのクリア方法の挙動を数値で比較します。手元環境(Node.js v24.11.1 / Windows 11)の実走結果をそのまま貼っているので、読者も同じコードを動かして追試できます。
扱うのは「実測で挙動を比較する」「--expose-gc + v8.getHeapStatistics() で GC を観測する」「WeakMap という設計レベルの解決策」の 3 点。前提知識は「JS の基本文法」と「GC というものがある」ことを知っている、くらいで読めるようにしてあります。
前提: 「参照を切る」とはどういうことか
まず、JavaScript の GC がどう動くかを押さえます。
用語:GC(Garbage Collection)
プログラムが使わなくなったメモリを自動で回収する仕組み。Java にも同じ機能があります。
GC Root と「到達可能性」
GC が「生きているオブジェクト」を判定するには、起点(=ここから辿れるものは生きている、と決める基準点)が必要です。これを GC Root と呼びます。
具体的には次のものが GC Root です。
-
グローバルオブジェクト — Node.js の
global、ブラウザのwindow - 現在実行中の関数のローカル変数 — スタック上にある変数
- アクティブなクロージャがキャプチャしている変数
例えば次のコードを考えてみてください。
// グローバルに代入 → global オブジェクトのプロパティになる = GC Root から到達可能
global.bigData = new Array(1000000);
function run() {
const localObj = { value: 42 }; // 関数実行中はスタック経由で GC Root から到達可能
// ...
}
run();
// run() を抜けた瞬間、localObj はもうスタックに無い = GC Root から到達不可 = GC 対象
V8(Chrome / Node.js の JS エンジン)の GC は、この到達可能性(reachability)でオブジェクトの生存を判定します。GC Root から参照を辿って到達できるものが「生きている」、辿り着けなくなったものが回収対象、それだけです。
モジュールスコープ変数は GC Root から到達可能
ファイルのトップレベル(モジュールスコープ)に宣言した変数は、モジュールがロードされている間ずっと GC Root から到達可能です。実用的には「プログラム終了まで到達可能」と考えて差し支えありません。
const queue = []; // ← モジュールスコープ変数。プログラム終了まで生き続ける
このとき、メモリ上の参照関係はこうなります。
[GC Root] [メモリ上のオブジェクト]
global オブジェクト
└→ (モジュール内部参照)
└→ queue (Array) ← ずっと生きている
├─ [0]: 何かの大きいオブジェクト ← 配列経由で到達可能 = GC されない
├─ [1]: 同上
└─ ...
配列のスロットに参照を入れている限り、その先のオブジェクトも「生きている」と判定されます。
「参照を切る」とは
「参照を切る」とは、オブジェクトを指していた変数やスロットに別の値(null など)を入れて、矢印の向き先を変えることです。
[切る前] queue[0] ──→ Buffer (1MB)
↑
[切った後] queue[0] ──→ null
(Buffer への矢印が消える)
(Buffer に他から矢印が向いていなければ「到達不可」になり GC 対象)
逆に、スロットに null を代入すると参照チェーンが切れ、他に参照がなければオブジェクトは GC の回収対象になります。
ここで素朴な疑問が浮かびます。
- 配列を「クリアした」と思ったとき、その「クリア」は本当に参照を切っているのか?
- 「インデックスを 0 に戻すだけ」では何が起きていて、何が起きていないのか?
これを次のセクションからコードで実際に確かめます。
実験 — 4 パターン比較
「使い回し配列」を書いたコードで、クリア方法を 4 パターン比較します。
検証環境
- Node.js v24.11.1
- V8 の公式 API
v8.getHeapStatistics()でメモリ使用量を観測 -
--expose-gcオプションで GC を強制発火
ここで使う技術用語を先に整理します。
用語:
--expose-gc
Node.js の起動オプション。これを付けると JavaScript からglobal.gc()を呼んで GC を明示的に発火できます。通常のプログラムでは使いません(デバッグ用)。
用語:
Buffer.alloc(n)
Node.js が提供する API で、nバイトのバイナリデータ用メモリ領域を確保します。例えばBuffer.alloc(1024 * 1024)は 1MB のメモリを確保します。今回は「大きなオブジェクトを大量に作る」テスト用途で使います。
参考: Node.js docs: Buffer.alloc
用語:
v8.getHeapStatistics()
V8 が提供する API で、現在のメモリ使用状況を取得します。返り値はused_heap_size(使用中の JS ヒープ)、external_memory(外部メモリ)などのプロパティを持つオブジェクト。今回は GC 前後の値を比較してメモリが解放されたかを判定します。
参考: Node.js docs: v8.getHeapStatistics()
なぜ external_memory を見るか
Buffer.alloc(n) が確保するメモリは、V8 の JS ヒープ(used_heap_size)ではなく Node.js の C++ レイヤが管理する external_memory に乗ります。これは Buffer の実体が C++ 側で管理されているため。JS ヒープ側だけを見ていても差が出ないので、対応する external_memory を観測します。
検証コード
このコードでは async / await を使っています。理由は次のような流れになっているからです。
-
global.gc()を呼ぶと GC は走る - でも、Buffer のメモリ解放は GC が完了した「直後」ではなく、finalizer(GC 後の後処理)が非同期に呼ばれてから反映される
- つまり
global.gc()を呼んでメモリを測ると、まだ反映されていないことがある - なので
global.gc()の後に少し待ってからもう一度 GC を呼ぶことで、確実に反映させる
用語:finalizer
オブジェクトが GC で回収される時に呼ばれる「後片付け処理」。Buffer の場合、JS ヒープ側のラッパーが GC されたあと、C++ 側の実メモリを解放するために finalizer が呼ばれる。これが非同期に走るので、global.gc()直後の計測には反映されない。
「GC を 1 回呼んで、少し待って、もう一度呼ぶ」を 2〜3 回繰り返すのはこのためです。async / await は、この「少し待つ」を await sleep(100) で実装するために必要です。
// メモリ使用量を取得するための Node.js 標準モジュール
import v8 from "node:v8";
// =========================================================
// モジュールスコープに置く「使い回し配列」
// プログラムが終わるまで GC されない場所に配列を置くのがポイント
// =========================================================
const queue = [];
let index = 0;
// 配列の末尾にオブジェクトを追加する関数
// queue[index] にセットしてから index を進める
function push(obj) {
queue[index++] = obj;
}
// コマンドライン引数の第1引数で「クリア方法」を切り替える
// 例: node --expose-gc leak-test.mjs null-each
// argv[0]='node', argv[1]='leak-test.mjs', argv[2]='null-each'
const mode = process.argv[2] ?? "index-only";
// =========================================================
// 配列の中身を「処理し終えた」状態に戻す関数
// 4つのモードで挙動を変える
// =========================================================
function finish() {
const endIndex = index; // 現在の書き込み位置を保存
index = 0; // ①インデックスを先頭に戻す(全モード共通)
if (mode === "null-each") {
// モードB: 各スロットを null で上書きする
// → 配列から各オブジェクトへの参照が切れる
for (let i = 0; i < endIndex; i++) {
queue[i] = null;
}
} else if (mode === "delete-each") {
// モードC: delete 演算子でスロット自体を削除する
// → 配列が「穴あき(HOLEY)」状態になる
for (let i = 0; i < endIndex; i++) {
delete queue[i];
}
} else if (mode === "length-zero") {
// モードD: 配列の length を 0 にする
// → 配列の見かけのサイズが 0 になり、内部バッファも切り詰められる可能性
queue.length = 0;
}
// モードA(index-only): 何もしない
// → スロットには古いオブジェクトへの参照が残ったまま
}
// バイト数を MB 表示にする補助関数
function mb(bytes) {
return (bytes / 1024 / 1024).toFixed(2);
}
// 指定ミリ秒だけ待つ補助関数(Promise を返す)
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// =========================================================
// メイン処理
// =========================================================
async function main() {
// ① 開始時点の external_memory(Buffer 用メモリ)を記録
const before = v8.getHeapStatistics().external_memory;
// ② 1MB の Buffer を 100 個作って配列に push(合計 100MB 分)
for (let i = 0; i < 100; i++) {
push(Buffer.alloc(1024 * 1024));
}
// ③ クリア処理を実行(モードによって挙動が違う)
finish();
// ④ GC を発火して Buffer のメモリ解放を促す
// Buffer の finalizer は非同期で呼ばれるため、
// GC → 待機 → GC → 待機 → GC を繰り返して確実に反映させる
global.gc();
await sleep(100);
global.gc();
await sleep(100);
global.gc();
// ⑤ GC 後の external_memory を取得して、開始時との差分を表示
const after = v8.getHeapStatistics().external_memory;
console.log(`mode=${mode}`);
console.log(` before: ${mb(before)} MB`); // 100 個 push する前
console.log(` after : ${mb(after)} MB`); // 100 個 push + GC 後
// GC しても解放されなかったメモリ量(=リーク量)
console.log(` retained: ${mb(after - before)} MB (GC後も残った量)`);
}
main();
実行方法
# パターンA: 何もしない(インデックスを戻すだけ)
node --expose-gc leak-test.mjs index-only
# パターンB: 各スロットに null を代入
node --expose-gc leak-test.mjs null-each
# パターンC: delete 演算子で各スロットを削除
node --expose-gc leak-test.mjs delete-each
# パターンD: length = 0 で一気にクリア
node --expose-gc leak-test.mjs length-zero
何を見るかというと、
-
finish()の後にglobal.gc()を呼ぶ - もしスロットに残った参照が GC を妨げていれば、
retainedは約 100MB のまま - 参照が切れていれば、100MB 分の Buffer が解放されて
retainedはほぼ 0
実測結果 — 4 パターンの比較
Node.js v24.11.1 / Windows 11 で実行した結果です。
mode=index-only
retained: 100.00 MB ← GCしても100MB分がそのまま残った
mode=null-each
retained: 0.00 MB ← 100MB分すべて解放
mode=delete-each
retained: 0.00 MB ← 100MB分すべて解放
mode=length-zero
retained: 0.00 MB ← 100MB分すべて解放
| モード | 配列スロットの参照 | GC 後に残った量 |
|---|---|---|
index-only |
残ったまま | 約 100 MB |
null-each |
null で上書き | 約 0 MB |
delete-each |
スロットごと削除 | 約 0 MB |
length-zero |
length 0 で配列短縮 | 約 0 MB |
「インデックスをリセットするだけ」は事実上のリーク、それ以外の 3 つはいずれも GC 観点では問題なく解放される、という結果になりました。
なぜ index-only だけリークするのか
参照の矢印で考えると、4 モードの違いがクリアになります。
[index-only モード: 100MB 残る]
GC Root ──→ queue ──→ [0]: Buffer (1MB) ← 矢印が残ったまま = 生きている
──→ [1]: Buffer (1MB) ← 同じく
──→ ...
──→ [99]: Buffer (1MB)
index = 0 にしても、配列のスロット 0〜99 には Buffer への参照(矢印)がそのまま残る
[null-each モード: 0MB 残る]
GC Root ──→ queue ──→ [0]: null ← 矢印が null に書き換わった
──→ [1]: null
──→ ...
Buffer (1MB) ← どこからも矢印が向いていない = 到達不可 = GC対象
「配列から Buffer への参照が切れる」とは、上の図のように、配列のスロットが指していた矢印を null で上書きして Buffer に向く矢印を 0 本にする、ということです。
-
index-only: スロット 0〜99 には Buffer への参照がそのまま残っている → 配列経由で到達可能 → GC されない -
null-each/delete-each/length-zero: いずれも配列から Buffer への矢印が消える → どこからも到達できない → GC される
GC は「到達可能なオブジェクトは回収しない」のがルールなので、矢印が残っているとメモリは解放されません。
「全部 0 なら好きな方法でいい」のか? — 性能の落とし穴
retained: 0.00 MB だけ見ると 3 つのクリア方法は同点に見えますが、性能観点では差があります。特に delete には注意が必要です。
delete arr[i] がもたらす sparse array 化
V8 の配列は内部的にいくつかの表現(内部での持ち方)を持っています。
「穴」とは何か
PACKED / HOLEY の話の前に、まず「穴」の正体を押さえます。
const a = [1, 2, 3, 4, 5]; // 全スロットに値が入っている → PACKED(密)
const b = [1, 2, , 4, 5]; // インデックス 2 が「空っぽ」 → HOLEY(疎)
delete a[2]; // 同じく a もインデックス 2 が「空っぽ」 → HOLEY 化
delete で消したスロットは、undefined を入れたのとは違い、「そもそも値が存在しない」という状態になります。これが「穴(hole)」です。
const x = [1, 2, 3];
delete x[1];
console.log(x); // [ 1, <1 empty item>, 3 ] ← 穴あき
console.log(x[1]); // undefined ← アクセスすると undefined が返る
console.log(1 in x); // false ← でも「インデックス 1 は存在しない」扱い
用語:PACKED と HOLEY
V8 内部での配列の分類。PACKED(密)は全スロットに値が入っている状態で、最も高速。HOLEY(疎)はどこかに「穴」があると判定された状態で、各アクセスで「穴ではないか」を確認する分だけ遅くなります。
参考: V8 blog: Elements kinds in V8
なぜ HOLEY だと遅いのか(原理)
PACKED 配列なら arr[i] を読むときは「メモリから値を取ってくるだけ」で済みます。一方、HOLEY 配列では各アクセスで「穴かもしれないので、もし穴ならプロトタイプチェーン(Array.prototype)も探さないといけない」というチェックが必要になります。
ここで地味に効いてくるのが、delete arr[i] はたとえその後再度値を入れても配列を HOLEY 表現に降格させたまま、ということです。一度「この配列は穴あきになりうる」と判定されると、V8 は元の PACKED に戻してくれません。
V8 公式ブログにも次の記述があります。
"Once the array is marked as holey, it remains holey forever — even if all its elements are present later!"
(配列が「穴あき」とマークされると、たとえ後でその要素がすべて存在することになっても、その配列は永久に「穴あき」のままとなります。)
— V8 blog: Elements kinds in V8
つまり delete arr[i] は、GC 観点では参照を切れるけれど、配列の内部表現を HOLEY に降格させてしまう副作用がある。使い回す前提の配列でこれを選ぶ理由は特に見当たりません。
落とし穴: new Array(n) は生成した瞬間から HOLEY
HOLEY 化の話には、もう一つ知っておくと得をする罠があります。new Array(n) で長さだけ確保した配列は、その時点ですでに HOLEY 扱いです。直感的には「これから順番に全部埋めるつもりだから PACKED で良いのでは?」と思いますが、V8 はそうは判断してくれません。
「長さを先に指定した = どこかが穴になる可能性がある」と V8 は解釈し、最初から HOLEY_SMI_ELEMENTS で配列を作ります。そしてその後で全スロットを埋めても、kind は HOLEY のまま戻りません。これは前項で述べた「一度 HOLEY になったら戻らない」ルールの直接の帰結です。
これを確かめるには、配列の内部表現を直接覗ける V8 のデバッグ機能を使います。
用語:
%DebugPrint(パーセント DebugPrint)
V8 が内部に持っているデバッグ用のヘルパ関数の 1 つ。引数に渡したオブジェクトの内部構造(Map/ elements kind / backing store の中身など、通常の JS API では見えない情報)を stderr にダンプする。Map(Hidden Class)や elements 配列の実体まで出るので、「この配列を V8 はどう見ているか」を直接観察できる。
用語:
--allow-natives-syntax(フラグ)
Node.js(V8)はデフォルトでは%で始まる識別子を許可しておらず、書くとSyntaxErrorになる。このフラグを付けて起動すると初めて%DebugPrintなどの内部関数を呼べる。逆に言うと、このフラグが必要 = 通常用途ではない研究/デバッグ用途というシグナルでもある。実行例:node --allow-natives-syntax script.mjs。
// 実行: node --allow-natives-syntax array-init-elements-kind.mjs
const N = 10;
// ① new Array(N) で長さだけ確保した配列
const a1 = new Array(N);
%DebugPrint(a1);
// ② new Array(N) で長さ確保 → 全スロットを埋めた配列
const a2 = new Array(N);
for (let i = 0; i < N; i++) a2[i] = i;
%DebugPrint(a2);
// ③ [] から push で作った配列
const a3 = [];
for (let i = 0; i < N; i++) a3.push(i);
%DebugPrint(a3);
%DebugPrint の出力(関連部分のみ抜粋):
--- new Array(N)(長さだけ確保) ---
- map: <Map[32](HOLEY_SMI_ELEMENTS)> ← 最初から HOLEY
- elements: <FixedArray[10]> {
0-9: <the_hole_value> ← 中身は「穴」
}
--- new Array(N) を全部埋めた配列 ---
- map: <Map[32](HOLEY_SMI_ELEMENTS)> ← 全部埋めても HOLEY のまま
- elements: <FixedArray[10]> {
0: 0, 1: 1, ..., 9: 9 ← 値は入っているが kind は変わらない
}
--- [] + push で作った配列 ---
- map: <Map[32](PACKED_SMI_ELEMENTS)> ← こちらは PACKED
注目したいのは a2 の出力です。全スロットに値が入っているのに kind は HOLEY_SMI_ELEMENTS のまま。a1 の elements に並んでいる <the_hole_value> が「穴」の正体で、これは undefined とは別物の V8 内部シンボルです。一度この the_hole_value が入る前提で配列が作られると、後から値で埋めても elements kind は降格したまま戻りません。長さを先に確保したいという気持ちで new Array(n) を使うと、それだけで HOLEY 配列が出来上がってしまう、という話です。配列を高速に作りたいなら [] から push するか、配列リテラル [a, b, c, ...] を使うのが原則になります。
実測: 3 つの elements kind を走査速度で比較する
new Array(n) を避けて PACKED を作れることがわかったので、ようやく PACKED / HOLEY / null 上書き後の PACKED という 3 状態を公平に比較できます。コードはこうなります。
// 実行: node sparse-test-v3.mjs
function buildPacked() {
const arr = []; // [] からスタートして
for (let i = 0; i < SIZE; i++) arr.push(i); // push で埋める → PACKED_SMI_ELEMENTS
return arr;
}
function buildHoley() {
const arr = [];
for (let i = 0; i < SIZE; i++) arr.push(i);
delete arr[(SIZE / 2) | 0]; // 1箇所 delete → HOLEY_SMI_ELEMENTS に降格
arr[(SIZE / 2) | 0] = 999; // 再代入しても elements kind は HOLEY のまま戻らない
return arr;
}
function buildNulled() {
const arr = [];
for (let i = 0; i < SIZE; i++) arr.push(i);
arr[(SIZE / 2) | 0] = null; // null 上書き → PACKED_ELEMENTS(数値とnull混在のまま PACKED)
arr[(SIZE / 2) | 0] = 999;
return arr;
}
%DebugPrint で elements kind を確認するとそれぞれ別の kind になっていました:
PACKED ([]+push) : <Map[32](PACKED_SMI_ELEMENTS)>
HOLEY (delete経由) : <Map[32](HOLEY_SMI_ELEMENTS)>
null 上書き : <Map[32](PACKED_ELEMENTS)>
この状態で 100 万要素を 300 回走査した結果(Node.js v24.11.1 / Windows 11):
[trial 1]
PACKED 173.0 ms
null 上書き 214.0 ms
HOLEY (delete経由) 240.9 ms
[trial 2]
PACKED 242.1 ms
null 上書き 256.6 ms
HOLEY (delete経由) 246.8 ms
[trial 3]
PACKED 261.7 ms
null 上書き 248.9 ms
HOLEY (delete経由) 250.4 ms
trial 1 を見ると PACKED → null 上書き → HOLEY の順に階層的に遅くなっており、HOLEY は PACKED に対して 30〜40% 遅いです。trial 2 以降で差が縮むのは、V8 が裏で JIT と Inline Cache という 2 つの最適化をやっているからです。先に用語を 3 つ整理します。
用語:JIT(Just-In-Time コンパイル)
プログラムの実行中に、コードを機械語にコンパイルして高速化する仕組み。V8 は最初はインタプリタ(Ignition)でコードを実行しつつ、何度も呼ばれる関数だけをその場で機械語に変換していく。「ホットなコード」ほど最適化される、というのが基本ルール。
用語:Hidden Class(V8 内部では Map)
V8 が JS オブジェクトの形状(どのプロパティをどの順番で持っているか)に対して内部的に割り振っている分類タグ。同じ Hidden Class のオブジェクト同士は「同じ形」とみなされ、プロパティアクセスが高速になる。配列も例外ではなく、PACKED_SMI_ELEMENTS/HOLEY_SMI_ELEMENTSなどの elements kind は Hidden Class の一部。%DebugPrintの出力で見ていた<Map[32](PACKED_SMI_ELEMENTS)>のMapがまさにこれ。
参考: V8 blog: Fast properties in V8
用語:Inline Cache(IC、インラインキャッシュ)
JIT が生成した機械語コード上の各アクセス箇所(コード上のarr[i]という位置)ごとに、「ここで過去にどんな Hidden Class のオブジェクトが渡されたか」を覚えておくキャッシュ。最初は 1 パターン専用に最適化して速く動くが、別のパターンが来ると分岐コードに切り替わり、さらに種類が増えると専用化を諦めて汎用処理に落ちる。専門用語ではこの 3 段階を monomorphic → polymorphic → megamorphic と呼ぶ。例えば
arr[i]という 1 ヶ所のコードに、最初にPACKED_SMI_ELEMENTSの配列が渡されると、IC は「次もきっとPACKED_SMIだろう」とアタリをつけ、ガード("本当にPACKED_SMIか?" という 1 命令の確認)と高速ロードのコードを生成する。これが monomorphic な状態で最速。後から別の Hidden Class(例:PACKED_ELEMENTS)も流れてくると IC は polymorphic に切り替わり、複数のガードを試す分岐コードになる。「IC が温まる」とは、繰り返しアクセスを通じてこのガードとハンドラがその関数に最適な形で固まり切った状態を指す(V8 公式ブログ Elements kinds in V8 では、配列アクセスが monomorphic である前提で最適化される旨が説明されている)。
JIT と IC が「温まる」とは、最初は Ignition インタプリタで動いていた走査ループが、繰り返し呼ばれるうちに TurboFan で機械語化され、IC も arr[i] の形状を学習して高速なアクセスコードに置き換わっていく、ということです。これで PACKED と null 上書き(PACKED_ELEMENTS)の差は吸収されます。
それでも HOLEY だけは縮みません。HOLEY 配列のアクセスでは「その値が穴ではないか」を毎回チェックする命令が機械語に組み込まれるため、これは IC や JIT が温まっても消えない構造的なコストだからです。つまり、条件を整えれば HOLEY ペナルティは実際に観測できる。
一方で、現実のアプリでは JIT / IC に隠れて差が見えにくいことも事実です。それでも delete を避けたい理由は、「一度 HOLEY になったら戻らない」という原理的なリスクのほうにあります。観測できないことと、起きていないことは別物。使い回し配列のクリアという用途では、原理的に安全な null 代入を選んでおくのが無難です。
arr.length = 0 の使いどころ
length = 0 も全スロットを切り離す効果がありますが、こちらは配列そのものを短くする操作です。
用語:backing store(内部バッファ)
JS の配列は、内部的には「実際の値を入れる連続したメモリ領域」を持っています。これを backing store と呼びます。例えば長さ 100 の配列なら、V8 は「最低 100 個ぶんのメモリ領域」を確保しています。配列をどんどん追加していくとこの領域は拡張され、length = 0にすると縮小される(ことがある)。
用語:GC 圧力(GC pressure)
短時間に大量のオブジェクトが「生成 → 不要になる」を繰り返すと、GC が頻繁に走ることになります。GC が走っている間はプログラムの実行が止まる(または遅くなる)ので、これがパフォーマンス低下の原因になる。この「GC を頻繁に走らせる状態」を「GC 圧力が高い」と表現します。
null-each と length = 0 の挙動の違いを図にするとこうです。
[null-each: 容量を保ったまま参照だけ切る]
クリア前: [Buffer, Buffer, ..., Buffer] 容量100
クリア後: [null, null, ..., null] 容量100 (内部バッファはそのまま)
再 push: [Buffer, Buffer, ..., Buffer] 容量100 (同じバッファに上書き、再確保なし)
[length = 0: 容量自体をリセット]
クリア前: [Buffer, Buffer, ..., Buffer] 容量100
クリア後: [] 容量0 (内部バッファが切り詰められる可能性)
再 push: [Buffer, ...] 容量再拡張 (バッファの再確保が発生しうる)
「再確保」とは具体的に何が起きるのか
ここで一度、length = 0 のあとに再 push したときに V8 内部で何が起きているかを覗いてみます。100 個 push し終わるまでに起きる箱の作り直しを書き出すとこうなります。
push 1個目 : 容量 0 → 4 (新しい箱を確保 / 0 個コピー / 古い箱を捨てる)
push 5個目 : 容量 4 → 8 (新しい箱を確保 / 4 個コピー / 古い箱を捨てる)
push 9個目 : 容量 8 → 16 (新しい箱を確保 / 8 個コピー / 古い箱を捨てる)
push 17個目 : 容量 16 → 32 (新しい箱を確保 / 16 個コピー / 古い箱を捨てる)
push 33個目 : 容量 32 → 64 (新しい箱を確保 / 32 個コピー / 古い箱を捨てる)
push 65個目 : 容量 64 → 128(新しい箱を確保 / 64 個コピー / 古い箱を捨てる)
100 個 push し終わるまでに 6 回の再確保 が発生し、合計で 124 個ぶんの値をコピーしています。さらに古い箱(4 + 8 + 16 + 32 + 64 = 124 スロットぶん)はすべてゴミになって GC が回収します。これがループごとに繰り返されるのが length = 0 方式です。
null-each 方式なら、これは 最初のループでだけ 起きます。容量 128 の箱が一度確保されれば、それ以降のループでは「同じ箱に上書きするだけ」なので、再確保もコピーも GC ゴミも発生しません。
引っ越しに例えるなら
少し例え話で整理すると、
-
length = 0方式 = 毎週末、家具を全部捨てて翌週同じ家具を買い直す- 配送と組み立てに時間がかかる(再確保のコスト)
- 捨てた家具の処分業者が忙しくなる(GC 圧力)
-
null-each方式 = 家具はそのままで、中に置くものだけ入れ替える- 家具を動かさないので時間がかからない
- 捨てるものが出ないので処分業者も呼ばなくていい
「使い回す前提」なら、家具は壊さないのが当然、ということです。React の concurrentQueues[i++] = null がこの方式を選んでいるのも、レンダリングのたびにキューを使い回すために再確保コストを払いたくない、という設計判断だと考えられます。
中身の Buffer は両方とも GC で解放される
ここで紛らわしいのが、「中身(Buffer)の解放」と「配列の箱の解放」は別の話だということです。
[共通: 中身の Buffer はどちらも GC で解放される]
null-each → Buffer × 100 が GC される
length = 0 → Buffer × 100 が GC される
[違うのは「配列の箱」の扱い]
null-each → 箱はそのまま(容量 128 のまま)
length = 0 → 箱が縮む可能性あり
実測結果(null-each も length-zero も retained: 0.00 MB)が示しているのは前者だけ、つまり「中身は両方とも解放される」という事実です。違いは「配列の箱そのものがどうなるか」のほうにあります。
「null-each だと箱は残る」と聞くとリークを心配したくなりますが、箱の大きさは「null × 100 個ぶんの参照」程度なので、せいぜい数百バイト〜数 KB です。中に入っていた Buffer(合計 100 MB)と比べれば桁違いに小さく、実用上は無視できます。
「使い回さない」場合はどう選ぶか
ここまでは「配列を使い回す」前提で話してきましたが、「もう二度とこの配列は使わない」ケースもあります。状況別の最適解を整理しておきます。
| 状況 | おすすめ | 理由 |
|---|---|---|
| 同じ配列を何度も使い回す | arr[i] = null |
箱の再確保コストが発生しない |
| もう使わない、配列自体も捨てたい |
arr = null または変数のスコープを抜ける |
箱ごと GC される(最も素直) |
| もう使わないが、配列の参照は他から保持されていて触れない | arr.length = 0 |
中身も箱も縮められる |
つまり「使い回さないなら length = 0 のほうがいい」というのは概ね正しいのですが、もっと正確には 「配列変数を捨てられるなら arr = null のほうがクリーンで、length = 0 は『変数は触れないが中身だけ捨てたい』という限定的な場面の選択肢」 ということになります。例えば次のように、モジュール間で共有される配列を相手側にも反映させたい場合です。
// 自分は配列の参照を持っているが、他のモジュールも同じ配列を見ている
// 自分の都合で arr = null にしても、相手側の参照は切れない
import { sharedArray } from "./shared.js";
function cleanup() {
sharedArray.length = 0;
// ↑ こうすれば、共有している全員にとって中身が空になる
// sharedArray = null は import した側のローカル変数を変えるだけ
}
まとめ表
| クリア方法 | 中身を GC で解放? | 配列の箱を維持? | HOLEY 化リスク | 主な用途 |
|---|---|---|---|---|
index = 0 だけ |
× | ○ | なし | (リーク。使わない) |
arr[i] = null |
○ | ○ (再利用) | なし | 何度も使い回す配列 |
delete arr[i] |
○ | ○ だが HOLEY 化 | あり | 避けるべき |
arr.length = 0 |
○ | × (縮む可能性) | なし | 共有配列の中身だけ捨てたい |
arr = null |
○ | × (箱ごと GC) | — | もう使わない配列 |
要約すると、使い回し配列のクリアは null 代入が最も無難で、性能的にも安全、ということになります。
設計レベルでの解決策 — WeakMap という選択肢
ここまでは「使い回し配列に参照が残る」問題を、参照を手動で切る方向で解いてきました。発想を変えると、最初から GC を妨げない参照を使う設計もあります。そのキーになるのが「弱参照」という考え方です。
用語整理: 強参照と弱参照
ここまで本記事で「参照」と呼んできたものは、すべて強参照(strong reference)です。前提セクションで見た「GC Root から到達可能 = 生きている」というルールを思い出してください。GC が辿るのはこの強参照だけです。
GC Root ──強参照──→ A ──強参照──→ B
(A も B も生きていると判定される)
これに対して弱参照(weak reference)は、GC の到達可能性判定に使われない参照です。GC は弱参照を「ないもの」として扱います。
GC Root ──強参照──→ A B
╲ (弱参照)
╲╴╴╴╴→
(A は生きているが、B は弱参照しか向いていないので「到達不可」 = GC される)
ひと言でまとめると、強参照は GC が辿る参照(参照先を生かす)、弱参照は GC が無視する参照(参照先を生かす力がない)。
Map と WeakMap の違い
通常の Map はキーを強参照で保持します。一度 Map に入れたキーは、Map 自身が生きている限り GC の対象になりません。
WeakMap はキーを弱参照で保持します。WeakMap がキーを持っていても、それは GC の到達可能性判定に影響しないので、キーとなるオブジェクトに他から強参照が向いていなければ、そのまま GC の対象になります(エントリごと自動で消える)。
// 強参照キャッシュ: cache がキーを強参照しているので、
// user オブジェクトは他から参照されなくなっても GC されない
const cacheStrong = new Map();
function computeStrong(user) {
if (!cacheStrong.has(user)) cacheStrong.set(user, expensive(user));
return cacheStrong.get(user);
}
// 弱参照キャッシュ: cache はキーを弱参照しているので、
// user が他から強参照されなくなれば自動で消える
const cacheWeak = new WeakMap();
function computeWeak(user) {
if (!cacheWeak.has(user)) cacheWeak.set(user, expensive(user));
return cacheWeak.get(user);
}
図にするとこうです。
[Map の場合] 外の世界 ──強参照──→ user
↑
cache ──強参照──┘
→ 外の世界が user を手放しても、cache が強参照しているので GC されない
[WeakMap の場合] 外の世界 ──強参照──→ user
↑
cache ╴╴弱参照╴╴┘
→ 外の世界が user を手放すと、強参照が無くなるので GC される
(cache のエントリも一緒に消える)
WeakMap の制約
便利な反面、WeakMap には制約があります。
- キーはオブジェクトのみ(プリミティブ不可)
- イテレーション不可(中身を列挙できない)
- 順序の保証なし
「キャッシュのキーが寿命の決まったオブジェクト」のときは強力ですが、「順次処理する配列」のような今回のケースには使えません(順序保証がないので「先頭から順に取り出す」ができない)。設計時に「ここは本当に強参照が必要か?」と問い直してみると、配列より Map / Set / WeakMap の方が向いている文脈は意外に多い、というのが個人的な学びでした。
補足: React コードに戻る
冒頭で触れた React の concurrentQueues[i++] = null; の話に簡単に戻ります。実際の React のコードはこうなっています。
// L53-L88, 一部抜粋
const concurrentQueues: Array<any> = [];
let concurrentQueuesIndex = 0;
export function finishQueueingConcurrentUpdates(): void {
const endIndex = concurrentQueuesIndex;
concurrentQueuesIndex = 0; // ①インデックスをリセット
let i = 0;
while (i < endIndex) {
const fiber: Fiber = concurrentQueues[i];
concurrentQueues[i++] = null; // ②スロットを null 化
// ...
}
}
ポイントは 2 つの独立した操作が組み合わされていることです。
-
concurrentQueuesIndex = 0;は次回の書き込み位置を先頭に戻す(容量は維持) -
concurrentQueues[i++] = null;はスロットの参照を切る(GC 対策)
本記事の検証で見たとおり、前者だけだと index-only モードに相当して 100MB がリークする状態になります。後者を組み合わせて初めて参照が切れる、という設計です。
FinalizationRegistry を使わなかった理由
GC を観測する別の手段として FinalizationRegistry(オブジェクトが GC されたら通知される仕組み)もあります。しかし MDN が明確に警告しています。
"If an application or library depends on GC cleaning up a WeakRef or calling a finalizer in a timely, predictable manner, it's likely to be disappointed."
(アプリケーションやライブラリが、WeakRef のクリーンアップや finalizer の呼び出しが時間どおり・予測可能な形で行われることに依存している場合、おそらく失望することになるでしょう)
— MDN: WeakRef
タイミングが非決定的で「呼ばれないことすらある」ため、決定的な検証には使えません。今回は external_memory の数値差を直接観測する方法を採用しました。
実務への応用 — 自分のコードで同じ罠を見抜く
モジュールスコープやクロージャ内の配列・Map に「一時置き場」として参照を入れるパターンは現場でよく出てきます。それぞれの「危ない書き方」と「修正後」を具体コードで見ていきます。
パターン 1: イベントのバッチキュー
例えば「複数のイベントをまとめて処理する」用途で、モジュールスコープにキューを置くケース。
危ない例:
// (モジュールスコープに置く)
const pendingEvents = [];
export function pushEvent(event) {
pendingEvents.push(event);
}
export function flushEvents() {
// 全イベントを順次処理
for (const event of pendingEvents) {
processEvent(event);
}
pendingEvents.length = 0; // ← length = 0 でクリアしている "つもり"
}
length = 0 でも参照は切れますが、処理頻度が高い場合は backing store の再確保で GC 圧力が上がります。
修正例:
const pendingEvents = [];
let endIndex = 0;
export function pushEvent(event) {
pendingEvents[endIndex++] = event; // 配列を「使い回す」スタイル
}
export function flushEvents() {
for (let i = 0; i < endIndex; i++) {
processEvent(pendingEvents[i]);
pendingEvents[i] = null; // ★ 処理直後にスロットの参照を切る
}
endIndex = 0; // 次回の書き込み位置をリセット
}
これは React の concurrentQueues と全く同じパターンです。
パターン 2: 簡易キャッシュ
ユーザーごとに重い計算結果をキャッシュするケース。
危ない例:
// 強参照キャッシュ: user オブジェクトが永遠に GC されない
const cache = new Map();
export function compute(user) {
if (cache.has(user)) return cache.get(user);
const result = expensiveCalculation(user);
cache.set(user, result); // ← user を強参照、削除する仕組みがないと無限に積もる
return result;
}
このコードは「ユーザーが増えれば増えるほどキャッシュが膨らみ、ユーザーがログアウトしても消えない」状態を作ります。
修正例: WeakMap で自動 GC
// 弱参照キャッシュ: user が他から参照されなくなれば自動で消える
const cache = new WeakMap();
export function compute(user) {
if (cache.has(user)) return cache.get(user);
const result = expensiveCalculation(user);
cache.set(user, result); // user は弱参照なので、外で手放されれば一緒に消える
return result;
}
ただし WeakMap はキーがオブジェクトのときしか使えないことに注意。
パターン 3: デバウンス/スロットル用の保留バッファ
入力イベントを一定時間まとめてから処理するケース。
危ない例:
let pendingArgs = null;
let timer = null;
export function debounce(...args) {
pendingArgs = args; // ← 最後の引数だけ保持しているつもり
if (timer) return;
timer = setTimeout(() => {
handle(...pendingArgs);
timer = null;
// pendingArgs を null に戻していない! → ハンドル後も参照が残る
}, 300);
}
pendingArgs は呼び出しのたびに上書きされますが、最後に呼ばれた引数(大きなオブジェクトかもしれない)はずっと保持されたままになります。
修正例:
let pendingArgs = null;
let timer = null;
export function debounce(...args) {
pendingArgs = args;
if (timer) return;
timer = setTimeout(() => {
const args = pendingArgs;
pendingArgs = null; // ★ 取り出した直後に参照を切る
timer = null;
handle(...args);
}, 300);
}
チェックポイント(まとめ)
- 読み出し後のスロットを明示的に
nullで上書きしているか - 「インデックスを戻すだけ」「
length = 0するだけ」で処理した気になっていないか - 配列を使い回す前提なら、
deleteではなくnull代入を選んでいるか - キャッシュなら
WeakMapが使える文脈ではないか(キーがオブジェクトの場合)
まとめ
ここまでの内容を最後に確認しておくと、こんな感じです。
- モジュールスコープの配列は GC Root から到達可能なので、スロットに残した参照は GC を妨げる
-
index = 0とスロット = nullは別の操作。インデックスを戻すだけでは参照は切れない - 解放だけ見れば
null-each/delete-each/length-zeroはどれも有効だが、null-eachがいちばん無難(deleteは HOLEY 化のリスク、length = 0は backing store 再確保のリスクがあり、使い回し配列では避けたい) - 設計レベルでは
WeakMapで「強参照を持たない」選択肢もある(キーがオブジェクトのとき限定) - Node.js の
--expose-gc+v8.getHeapStatistics()で、GC の挙動を数値として観測できる(Buffer を使う場合はexternal_memoryを見る)
冒頭の React のコードコメントが「GC に回収されない」と主張していたのは真実でした。そしてそれは React に限った話ではなく、JS で「使い回し配列」を扱うコード全般に当てはまります。読者のみなさんも、同じコードを Node.js で動かして自分の手で数値として確かめてもらえれば、と思います。
参考
- V8 blog: Trash talk (Garbage collection) — V8 の GC(Mark & Sweep、世代別 GC、Orinoco)の公式解説
- V8 blog: Elements kinds in V8 — PACKED / HOLEY などの配列内部表現の解説
- V8 blog: Fast properties in V8 — Hidden Class(Map)と shape の解説。Inline Cache が前提とする「形状の同一性」もここで扱われる
- MDN: Memory Management — GC Root・到達可能性・Mark-and-Sweep の基本
- MDN: WeakMap — 弱参照キャッシュの公式リファレンス
-
MDN: WeakRef —
FinalizationRegistryが非決定的であることの警告 - Chrome DevTools: Heap Snapshots — ブラウザでのヒープスナップショットの取り方
-
Node.js diagnostics: Using GC traces —
--trace-gcとv8.getHeapStatistics()の公式ガイド - javascript.info: Garbage collection — GC Roots と Mark-and-Sweep の読みやすい解説
-
React ソース:
ReactFiberConcurrentUpdates.js(main) — 本記事のきっかけとなった React のコード -
PR #25309: Fix memory leak after repeated setState bailouts —
null代入が追加された実際の PR