JavaScript上級 #5 メモリモデルとGC

#4 イベントループ でJavaScriptが時間を扱う方法を見たなら、今回はメモリの番です。JavaScriptはメモリを自動で管理してくれますが、だからこそより頻繁に事故が起こる場面でもあります。

JavaScriptは自動メモリ管理 #

Cのような言語ではメモリを直接割り当て・解放します。JavaScriptはガベージコレクタ (GC) が自動で掃除してくれます。

GCが掃除するか否かを決める基準は単純です。

到達可能 (reachable) なオブジェクトは生かし、到達不可能なオブジェクトは回収する。

到達可能性が核心です。

到達可能性 (Reachability) #

次から始まって辿り着けるすべてのオブジェクトが到達可能です。

  • グローバルオブジェクト (ブラウザ: window、Node: global/globalThis)
  • 現在のコールスタックの関数の仮引数とローカル変数
  • 上記二つから参照されるすべてのオブジェクト
到達可能 vs 不可能
let user = { name: 'カーティス' };
// user がオブジェクトを参照 → 到達可能

user = null;
// もう参照なし → 到達不可能 → GC 対象

user = null で参照を切ると、そのオブジェクトに辿り着く道が消えます。GCは次のラウンドで回収します。

二つのオブジェクトが互いに参照しても、切れれば回収される #

古いGCアルゴリズム (reference counting) は循環参照でメモリを解放できませんでした。JavaScriptのモダンGC (mark-and-sweep) はそうした制限がありません。

循環参照もOK
function makeCycle() {
  const a = {};
  const b = {};
  a.ref = b;
  b.ref = a;   // 互いに参照
}

makeCycle();
// 関数終了時に a, b はどちらも到達不可能 → 両方 GC

外からaやbのどちらも参照していないので、互いを指していても到達できず回収されます。

よくあるリークパターン #

GCが自動でも、参照を意図せず生かしておくとメモリが溜まります。よく出会うパターンを見ていきます。

1) グローバル変数に溜まる #

グローバルリーク
window.cache = window.cache || {};

function fetchUser(id) {
  // 結果をグローバルキャッシュに蓄積 — 永遠に生き残る
  window.cache[id] = result;
}

window.cache がグローバルなので到達可能 — その中のすべてのオブジェクトが永遠にGCされません。キャッシュは適時に空けるかサイズ制限を設けるべきです。

サイズ制限キャッシュ
const cache = new Map();
const MAX = 100;

function setCached(key, value) {
  if (cache.size >= MAX) {
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey);
  }
  cache.set(key, value);
}

本当のLRUキャッシュはライブラリ (lru-cache) を使うのが安全です。

2) 切り離されたDOMノードの参照 #

DOMリーク
const buttons = [];

function setup() {
  const btn = document.createElement('button');
  document.body.appendChild(btn);
  buttons.push(btn);   // JavaScript の配列が参照
}

function cleanup() {
  document.body.innerHTML = '';   // DOM からは消える
  // しかし buttons 配列が依然として参照 → GC されない
}

DOMツリーから切り離してもJavaScriptがまだ参照しているとメモリに残ります。デタッチされたノード (detached node) リーク — 非常によくあるパターンです。

解決策としては、もう不要ならJavaScript側の参照も一緒に切ることです。

解決
function cleanup() {
  document.body.innerHTML = '';
  buttons.length = 0;   // 配列を空にする
}

3) イベントハンドラの登録解除をしない #

ハンドラリーク
function attach() {
  const onClick = () => { /* ... */ };
  button.addEventListener('click', onClick);
  // removeEventListener を呼ばない
}

アンマウント時、またはもう不要になったときに removeEventListener で外さないと、ハンドラとその中のクロージャが生きています。Reactの useEffect cleanup がまさにこの場面を処理するパターンです。

React cleanup パターン
useEffect(() => {
  const onClick = () => { /* ... */ };
  button.addEventListener('click', onClick);

  return () => {
    button.removeEventListener('click', onClick);
  };
}, []);

4) タイマーを片付けない #

タイマーリーク
const id = setInterval(() => {
  fetchData();
}, 1000);
// clearInterval を呼ばないと永遠に動作

同じパターン — 片付けないと生き残ります。コンポーネントのアンマウント時に必ず片付けてください。

5) クロージャが大きなオブジェクトをキャプチャ #

#1 クロージャ で少し触れた場面です。

クロージャが重いオブジェクトを掴んでいる
function setup() {
  const huge = new Array(1_000_000);   // 大きな配列
  process(huge);

  return function() {
    console.log('hi');   // huge を使わなくてもキャプチャされうる
  };
}

const fn = setup();   // huge が fn によって生き残る可能性

モダンなエンジンは使われていない変数を賢く解放しようと努めますが、保証はありません。意識的に解放したい場合は次のようにします。

明示的に解放
function setup() {
  let huge = new Array(1_000_000);
  process(huge);
  huge = null;   // もう使わないなら解放

  return function() {
    console.log('hi');
  };
}

WeakRef, WeakMap, WeakSet — 弱い参照 #

GCの到達可能性検査をスキップしたいときに使うツールです。弱い参照はオブジェクトを生かしません。

WeakMap — キーを弱く #

WeakMap
const cache = new WeakMap();

function attach(user, data) {
  cache.set(user, data);
}

let u = { id: 'u1' };
attach(u, 'データ');

u = null;   // u オブジェクトが到達不可能 → WeakMap のエントリも自動で消える

Map はキーオブジェクトを生かしますが、WeakMap はそうではありません。オブジェクトにメタデータを付けたいが、そのオブジェクトが消えたら一緒に片付いてほしいときに向いています。

主な違いは次のとおりです。

  • Map — キーがどんな値でもOK、キーオブジェクトを生かす、iterable
  • WeakMap — キーはオブジェクトのみ、キーオブジェクトを生かさない、iterableではない

WeakSet — 値を弱く #

WeakSet
const visited = new WeakSet();

function process(node) {
  if (visited.has(node)) return;
  visited.add(node);
  // ...
}

Set のweakバージョン。オブジェクト訪問の追跡のような場面に向いています。JSON stringifyの循環参照追跡 (中級 #7) で見たパターン。

WeakRef — ES2021 #

オブジェクトに対する弱い参照そのものです。

WeakRef
let user = { name: 'カーティス' };
const ref = new WeakRef(user);

ref.deref();   // user オブジェクト (まだ生きていれば)

user = null;
// ... GC 時点以降
ref.deref();   // undefined (回収済み)

WeakRef は珍しいです。キャッシュ実装や非主流のデータ構造を書く場面で出会います。一般のコードではほとんど使うことがありません。

FinalizationRegistry — 回収時点を知る #

オブジェクトがGCされるときにコールバックを受け取るツールです。

FinalizationRegistry
const registry = new FinalizationRegistry((token) => {
  console.log(`片付け済み: ${token}`);
});

let user = { name: 'カーティス' };
registry.register(user, 'user-token');

user = null;
// GC 時点以降 — '片付け済み: user-token'

いつ呼ばれるかは保証されません (GCの時点はエンジン次第)。外部リソース (ネイティブハンドルなど) の片付けのような非常に特殊な場面でのみ意味があり、一般のアプリコードではほとんど使いません。

メモリデバッグ — ブラウザツール #

Chrome DevToolsの Memory タブが最も強力なツールです。

Heap snapshot #

現在メモリにあるすべてのオブジェクトをスナップショットとして撮って見ます。「どのクラスが何個」が見え、「なぜGCされなかったか」の参照経路を辿れます。

Allocation timeline #

時間に沿ってメモリがどう増えていくかをグラフで見ます。メモリリーク追跡の標準ツールです。

詳細な使い方は別途ガイドが必要なほど深いですが、「メモリリークが疑わしい」ときに最初に覗くところがここだということだけ覚えておいてください。

JavaScriptのメモリモデル — 値型と参照型を再び #

基礎 #2 で見たプリミティブ vs 参照の違いがメモリモデルでも再び意味を持ちます。

  • プリミティブ値 — 変数自体に値が入っています。小さく軽い。
  • オブジェクト — 変数にはオブジェクトの位置だけが入る。本当のオブジェクトはヒープ (heap) に。

関数のローカルプリミティブ変数たちはコールスタックに一緒に住んで、関数が終わると一緒に消えます。オブジェクトはヒープに別途あり、誰かが参照している間は生き残ります。

まとめ #

今回の記事で整理した内容。

  • JavaScriptは自動メモリ管理 (GC)
  • GCの基準は到達可能性 — グローバル/スタックから辿れるか
  • モダンGCはmark-and-sweep — 循環参照も回収可能
  • よくあるリーク — グローバルキャッシュ、切り離されたDOM、未片付けのハンドラ/タイマー、重いクロージャ
  • WeakMap/WeakSet はキー/値を生かさない
  • WeakRef / FinalizationRegistry は非常に特殊な場面
  • メモリデバッグはDevToolsのMemoryタブ

次回の記事 (#6 SymbolとProxy) では、JavaScriptのもっと変わったツールたち — 衝突しないキーを作るSymbol、オブジェクトの動作を傍受するProxyを扱います。

X