ガベージコレクションのブラウザ内動作(世代別GCとメモリリーク)
GCがあるのに増え続けるメモリの正体がわかる。V8の世代別GC・インクリメンタル/並行回収の仕組みと、デタッチDOMやクロージャ由来のリークの見つけ方を原理から解説します。
- 1.V8は世代別GC。短命オブジェクトは新生領域をScavenge(コピー)で高速回収し、生き残ったものだけ旧世代へ昇格してMark-Sweep-Compactで回収する。
- 2.停止時間を抑えるためインクリメンタル(マーキング分割)・並行(別スレッド)・並列(複数スレッド)を併用し、ループ中のメインスレッド停止を短く保つ。
- 3.GCがあってもリークは起きる。生きた参照(到達可能)が残るのが原因で、デタッチDOM・閉じ込めたクロージャ・解除し忘れたリスナーやMap/Setが典型。
なぜGCの内部を知るのか
JavaScriptはメモリの確保・解放を明示しない言語で、不要になったオブジェクトはガベージコレクタ(GC)が自動回収します。便利な反面、「GCがあるのにメモリが増え続ける」「ときどき処理がカクつく」といった現象は、GCの判断基準と回収タイミングを知らないと原因にたどり着けません。鍵となるのは、GCが回収するのは「使われていないオブジェクト」ではなくどこからも到達できなくなったオブジェクトだという一点です。本稿はChromeやNode.jsが使うV8を題材に、世代別GCの仕組みとリークの原理を内部動作から説明します。前提として JavaScript の値とオブジェクトの扱い、エンジン全体像は JavaScriptエンジンの内部 を押さえておくと理解が速いです。
到達可能性(Reachability):GCの唯一の基準
V8のGCは参照カウントではなく到達可能性ベースで動きます。GCルート(グローバルオブジェクト、現在の実行スタック上の変数、組み込みのハンドルなど)を起点に参照をたどり、たどり着けるオブジェクトを「生存」、たどり着けないものを「ゴミ」と判定します。これがマーク&スイープの「マーク(印付け)」フェーズです。
参照カウント方式では循環参照(AがBを、BがAを参照)を回収できませんが、到達可能性ベースならルートから切り離された循環は丸ごと回収できます。逆に言えば、ルートからたどれる限り、たとえ二度と使わないオブジェクトでも回収されません。これが後述するリークの本質です。
プログラマの意図する「もう使わない」と、GCが見る「到達できない」は一致しないことがあります。GCはコードの意図を読めません。配列やMapに入れたまま、あるいはクロージャに閉じ込めたまま参照が1本でも残っていれば、それは到達可能=生存扱いになり、永遠に回収されません。
世代別仮説と二つの領域
V8のヒープ設計は世代別仮説に立っています。「ほとんどのオブジェクトは生成後すぐ死ぬ。長く生きたオブジェクトはさらに長生きしやすい」という経験則です。関数内の一時オブジェクトやループ中の中間値は典型的な短命オブジェクトで、その大半はすぐ不要になります。
そこでヒープを二つに分けます。
| 領域 | 対象 | 回収アルゴリズム | 頻度 |
|---|---|---|---|
| 新生領域 (Young Generation) | 生成直後の短命オブジェクト | Scavenge(コピーGC) | 高頻度・短時間 |
| 旧世代 (Old Generation) | 生き残り昇格したオブジェクト | Mark-Sweep-Compact | 低頻度・長時間 |
短命なものを安く高速に回収し、生き残った少数だけを重い回収にかける——この分担で、回収コストを生存オブジェクトの量に見合わせます。新生領域のGCをマイナーGC、旧世代を含む全体のGCをメジャーGCと呼びます。
Scavenge:新生領域のコピーGC
新生領域はさらに From空間 と To空間 という同サイズの半分に分かれます(セミスペース方式)。新しいオブジェクトはFrom空間に詰めて確保され、空間が埋まるとScavengeが走ります。
- マーク&コピー:ルートからたどって生きているオブジェクトだけを、From空間からTo空間へコピーする。
- スワップ:コピー後、FromとToの役割を入れ替える。生存物が詰めて配置された新しいFrom空間が残る。
- 解放:旧From空間はまるごと「空」とみなす。死んだオブジェクトを個別に消す処理は不要。
ポイントは、死んだオブジェクトには一切触れないことです。生存物だけをコピーするので、コストは「生きている量」に比例します。世代別仮説どおり生存物がごく少なければ、新生領域はほぼ生存ゼロのコピーで一掃でき、極めて高速です。確保もポインタを進めるだけ(バンプアロケーション)で安価です。
代償として新生領域は実効容量が半分になりますが、新生領域自体が小さい(数MB規模)ので問題になりません。Scavengeを2回生き延びたオブジェクトは「短命ではない」と判断され、旧世代へ**昇格(promotion)**します。
[From] A B C D E <- E,F が死亡、A,B,C,D が生存
| 生存物だけコピー
v
[To] A B C D <- 詰めて配置。From/To を入れ替えて完了
Mark-Sweep-Compact:旧世代の回収
旧世代は容量が大きく、半分をコピー用に空けておくのは非効率です。そこでコピーではなく**Mark-Sweep(マーク&スイープ)**を使います。
- Mark:GCルートから到達可能なオブジェクトすべてに印を付ける。
- Sweep:印の付いていない領域を「空き」としてフリーリストに戻す。生存物は動かさない。
Sweepはオブジェクトを動かさないため高速ですが、空きが歯抜けに散らばる**フラグメンテーション(断片化)**が進みます。連続した大きな領域を確保できなくなると、**Compact(コンパクション)**で生存オブジェクトを片側に寄せ、空きを連続させます。コンパクションはオブジェクトを移動しポインタを張り替えるため重く、断片化が問題になったときだけ部分的に行われます。これが Mark-Sweep-Compact の名の由来です。
停止時間との戦い:インクリメンタル/並行/並列
GCの素朴な実装は、回収中にJavaScriptの実行を完全に止める Stop-The-World(STW) です。旧世代が大きいとMarkに時間がかかり、メインスレッドが長く止まればフレーム落ちやカクつき(jank)になります。V8はこれを次の三本柱で緩和します。
| 技法 | やること | 狙い |
|---|---|---|
| インクリメンタル | Markを小さな単位に分割し、JS実行の合間に少しずつ進める | 1回の停止時間を短く保つ |
| 並行 (Concurrent) | 別スレッドで、メインスレッドを止めずにMark/Sweepを進める | メインスレッドの停止自体を減らす |
| 並列 (Parallel) | 複数のヘルパースレッドで作業を手分けする | GC全体の所要時間を短縮する |
インクリメンタルマーキングには課題があります。少しずつ印を付けている最中にJSが参照を書き換えると、印付け済みの結果が古くなり、生存オブジェクトを誤って未マークのまま回収する恐れがあります。これを防ぐのがライトバリアです。マーキング中にオブジェクト参照が書き換わるたび小さなフックが走り、新たに参照されたオブジェクトを記録して取りこぼしを防ぎます(三色マーキングの不変条件を保つ仕組み)。V8の世代並行GCは「Orinoco」という名で知られ、Scavengeも並列・並行化されています。
本番コードでGCを明示起動する標準APIはありません(window.gc()はテスト用フラグ起動時のみ)。やるべきは「回収しやすい状況を作る」こと——不要参照を切り、短命オブジェクトを長寿命コンテナに溜め込まない、です。GCのタイミングはエンジンに委ね、こちらは到達可能性を制御します。
メモリリークの原理:GCがあっても漏れる
GC言語のリークは、C言語の「解放し忘れ」とは違い、意図せず到達可能なまま残ることで起きます。コードの意図では捨てたつもりでも、参照が1本残っていればGCは回収できません。代表的な発生源を見ます。
デタッチDOM(Detached DOM)
DOM要素をJS変数に持ったまま、その要素をDOMツリーからremoveChild等で外すと、画面上は消えてもJS側の参照が生きている限りメモリには残ります。さらにその要素が子孫を持っていれば、サブツリー全体が解放されません。DOMの構造は DOM を参照。
let cache = [];
function show() {
const el = document.createElement("div");
document.body.appendChild(el);
cache.push(el); // 配列が参照を握り続ける
}
// 後で DOM から外しても、cache[i] が生きている限り回収されない
cache[0].remove(); // DOMツリーからは消えるが、配列の参照は残る
対策は、外すときに保持側の参照も切る(cache = []、該当要素を配列から除去)こと。あるいは寿命の都合で参照を持ちたい場合は WeakRef / WeakMap を使い、GCの回収を妨げない弱い参照にします。
クロージャによる閉じ込め
クロージャは外側スコープの変数を捕捉し続けます。長生きする関数(イベントハンドラ、setIntervalのコールバック、Promiseチェーン)が大きなオブジェクトを捕捉すると、そのオブジェクトはハンドラが生きている間ずっと到達可能です。
function setup(bigData) {
el.addEventListener("click", () => {
// bigData を使わなくても、同スコープにある限り捕捉され得る
doSomething();
});
}
要素やハンドラを破棄するときは removeEventListener で必ず解除します。解除しないとハンドラ→クロージャ→捕捉データの鎖が残り、要素を消してもデータが漏れます。
解除し忘れる長寿命コンテナ
setInterval、グローバルなMap/Set/配列、購読(subscribe)系は、明示的に止める/取り除くまで参照を保持し続けます。要素をキーにしたキャッシュは、要素が不要になってもMapが握り続けるので、WeakMapを使えばキー要素が他から到達不能になった時点で自動的にエントリごと回収されます。
DevToolsのMemoryパネルが要です。(1) Performanceでヒープ使用量の折れ線が山と谷を繰り返さず右肩上がりなら漏れの疑い。(2) 操作の前後でHeap snapshotを2枚撮り比較し、増え続けるオブジェクトを特定。(3) snapshotでDetachedを検索するとデタッチDOMが見つかります。3回の同一操作でメモリが戻らない(3-snapshot法)かを見るのが定石です。
回収を助けるコードの考え方
リーク対策は「使い終わったら参照を切る」に集約されます。実務での勘所をまとめます。
| やりがちなパターン | なぜ漏れるか | 対策 |
|---|---|---|
| 外したDOMを変数/配列に保持 | デタッチDOMが到達可能なまま | 保持側の参照も切る/WeakRef |
| addEventListenerの解除漏れ | ハンドラ→クロージャの鎖が残る | 破棄時にremoveEventListener |
| 要素をキーにMapでキャッシュ | Mapが要素を強参照し続ける | WeakMap/WeakSetを使う |
| setInterval/購読の止め忘れ | コールバックが捕捉物を保持 | clearInterval/unsubscribe |
| グローバルにデータを溜める | ルート直下で常に到達可能 | スコープを絞り寿命を短く |
なお、短命オブジェクトを大量生成してもScavengeが安く回収するので、過度な「オブジェクト使い回し」最適化は不要なことが多いです。むしろ問題は長寿命コンテナへの溜め込みです。新生領域で死ぬはずだったものが配列やMap経由で旧世代へ昇格すると、重いメジャーGCの対象になり、断片化やSTW時間の増大につながります。性能計測の全体観は Webパフォーマンス も参照してください。
まとめ
V8のGCは到達可能性だけを基準に動きます。新生領域は Scavenge(生存物だけTo空間へコピーし、死んだ側を一掃)で高速に回収し、2回生き延びたものを旧世代へ昇格。旧世代は Mark-Sweep-Compact で、印付け→空き回収→必要時に断片化解消を行います。停止時間はインクリメンタル・並行・並列とライトバリアで抑えます。GCがあってもリークは起きる——デタッチDOM・クロージャの閉じ込め・解除し忘れたリスナーやMapが、捨てたつもりのオブジェクトを到達可能に保つからです。直すコツは一貫して「使い終わったら参照を切る」、必要ならWeakMap/WeakRefで弱く持つこと。あとはDevToolsのHeap snapshotで、戻らないメモリを実測で突き止めれば原因にたどり着けます。
Web/フロントエンド Article
ガベージコレクションのブラウザ内動作(世代別GCとメモリリーク)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
停止時間を抑えるためインクリメンタル(マーキング分割)・並行(別スレッド)・並列(複数スレッド)を併用し、ループ中のメインスレッド停止を短く保つ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / V8」に近いか確認する。
- 強みである「V8は世代別GC。短命オブジェクトは新生領域をScavenge(コピー)で高速回収し、生き残ったものだけ旧世代へ昇格してMark-Sweep-Compactで回収する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。