2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

array[i++] = null; は本当に GC を促進するのか — Node.js で実測して確かめる

2
Posted at

はじめに

きっかけは React の内部ソースを読んでいて、こんなコードに出会ったことでした。

packages/react-reconciler/src/ReactFiberConcurrentUpdates.js
// 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 から到達可能です。実用的には「プログラム終了まで到達可能」と考えて差し支えありません。

myModule.js
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 を使っています。理由は次のような流れになっているからです。

  1. global.gc() を呼ぶと GC は走る
  2. でも、Buffer のメモリ解放は GC が完了した「直後」ではなく、finalizer(GC 後の後処理)が非同期に呼ばれてから反映される
  3. つまり global.gc() を呼んでメモリを測ると、まだ反映されていないことがある
  4. なので global.gc() の後に少し待ってからもう一度 GC を呼ぶことで、確実に反映させる

用語:finalizer
オブジェクトが GC で回収される時に呼ばれる「後片付け処理」。Buffer の場合、JS ヒープ側のラッパーが GC されたあと、C++ 側の実メモリを解放するために finalizer が呼ばれる。これが非同期に走るので、global.gc() 直後の計測には反映されない。

「GC を 1 回呼んで、少し待って、もう一度呼ぶ」を 2〜3 回繰り返すのはこのためです。async / await は、この「少し待つ」を await sleep(100) で実装するために必要です。

leak-test.mjs
// メモリ使用量を取得するための 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

array-init-elements-kind.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 状態を公平に比較できます。コードはこうなります。

sparse-test-v3.mjs
// 実行: 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-eachlength = 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-eachlength-zeroretained: 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 が無視する参照(参照先を生かす力がない)。

MapWeakMap の違い

通常の 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 のコードはこうなっています。

packages/react-reconciler/src/ReactFiberConcurrentUpdates.js
// 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: イベントのバッチキュー

例えば「複数のイベントをまとめて処理する」用途で、モジュールスコープにキューを置くケース。

危ない例:

event-queue.js
// (モジュールスコープに置く)
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 圧力が上がります。

修正例:

event-queue.js
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 で動かして自分の手で数値として確かめてもらえれば、と思います。

参考

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?