Skip to content

GC 方式の選択

JavaScript には、確保したメモリを手動で解放する手段がありません。malloc/freedelete に相当する操作は言語に存在せず、不要になったオブジェクトを回収するのはエンジンの責務です。エンジンは、プログラムがこの先もう触れられなくなったオブジェクトを見つけ出し、その領域を再利用可能にしなければなりません。この「もう触れられない」を判定する基準が到達可能性であり、それをどのアルゴリズムで、いつ、どのようなメモリ配置のもとで実現するかが、GC(ガベージコレクション)方式の選択です。この章では、代表的な GC 方式とそのトレードオフ、割り付け戦略との関係、そして方式を選ぶ際の指針を扱います。到達可能性の起点である「ルート(root)」を具体的にどう集めるか、ネイティブ呼び出しをまたいで一時値をどう守るかは、次章以降で掘り下げます。

なぜ自動メモリ管理か

JavaScript のオブジェクトは、変数・配列要素・他オブジェクトのプロパティなど、さまざまな場所から参照され、参照は実行中に自由に張り替えられます。あるオブジェクトがこの先も使われるかどうかは、プログラムの実行状態全体を見なければ分かりません。これを人手の解放に委ねると、まだ参照されている領域を解放してしまう(use-after-free)か、逆に解放し忘れる(リーク)かのどちらかが避けられません。

そこでエンジンは、生きているオブジェクトを機械的に判定します。判定の基準が到達可能性 (reachability) です。プログラムが今まさにアクセスできる場所 — 実行中のスタック、グローバルオブジェクト、CPU レジスタなど — をルート (roots) と呼び、ルートから参照をたどって到達できるオブジェクトを「生きている」とみなします。ルートからどうやってもたどり着けないオブジェクトは、この先プログラムが触れる方法が原理的に存在しないため、安全に回収できます。

到達可能性はあくまで「回収してよいか」の安全な近似です。到達できるが二度と使われないオブジェクト(いわゆる意味上のリーク)は回収対象になりません。逆に、到達できないものは確実に不要です。

なお、回収が「いつ」起こるかは言語仕様上は観測できません。ECMAScript は GC のタイミングや順序をプログラムから観測する手段を原則として与えず、エンジンは回収の時期を自由に選べます。例外は弱い参照とファイナライザ(WeakRef/FinalizationRegistry)で、これらに限り GC の効果が間接的に観測されます。その実装は第III部の後の章で扱います。

参照カウント

もっとも素朴な自動回収は参照カウント (reference counting) です。各オブジェクトに「自分を指している参照の数」を持たせ、参照を格納するたびにカウントを増やし、参照を上書き・破棄するたびに減らします。カウントが 0 になった瞬間、そのオブジェクトはどこからも指されていないので即座に解放します。

  • 利点: 到達不能になった瞬間に回収できるため、解放が速やかでメモリのピークを抑えやすい。回収処理が参照操作に分散するので、長い停止時間が生じにくい。実装の考え方も局所的で理解しやすい。
  • 欠点: 参照を格納するあらゆる箇所でカウント更新の費用がかかる。変数への代入・配列への追加・関数の引数渡しといった頻出操作のたびに増減が走る。そして根本的な弱点として、循環参照を回収できない。互いを参照し合うオブジェクト群は、外から到達不能になってもカウントが 0 にならず、永久に残る。

循環を回収するには、参照カウントとは別に、循環だけを検出する補助的なコレクタ(サイクルコレクタ)を併設する必要があります。JavaScript ではオブジェクトが相互参照や親子の相互リンクを作ることが日常的なので、循環の扱いは避けて通れません。純粋な参照カウント単独では JavaScript の意味論を満たせず、必ず何らかの循環回収を足すことになります。

マーク&スイープ

マーク&スイープ (mark-sweep) は、ルートから参照をたどって到達可能なオブジェクトに印(マーク)を付け(マークフェーズ)、印の付かなかったオブジェクトをまとめて回収する(スイープフェーズ)方式です。参照をたどる過程そのものが到達可能性の計算になっているため、循環参照も自然に扱えます。相互参照するオブジェクト群でも、ルートからたどれなければ一つもマークされず、丸ごと回収されます。

  • 利点: 参照の格納時に何の費用もかからない。ポインタの書き込みは単なる代入で済み、参照カウントのような増減処理が要らない。循環参照を追加の仕組みなしで回収できる。
  • 欠点: 回収はまとめて行われるため、コレクションが走るまでゴミが居座る(即座には解放されない)。素朴な実装では、マークとスイープの間プログラムの実行を止める(stop-the-world)ため、その間の停止時間が問題になりうる。到達可能性の計算のたびに、生きているオブジェクトを全走査する費用がかかる。

停止時間は、マークやスイープを実行と並行して少しずつ進める(インクリメンタル/並行 GC)ことで緩和できますが、実装は複雑になります。素朴な stop-the-world マーク&スイープは、実装が単純で参照操作にコストを乗せない点が魅力で、軽量なエンジンでは有力な出発点になります。

世代別 GC

多くのプログラムでは、大半のオブジェクトは生成後すぐに不要になるという経験則が成り立ちます(弱い世代仮説)。一時的な計算結果、ループ内で作られる小さなオブジェクト、短命なクロージャなどがその典型です。一方で、長く生き残るオブジェクトは繰り返しの回収を生き延び続ける傾向があります。

世代別 GC (generational GC) は、この偏りを利用します。ヒープを「若い世代 (young)」と「古い世代 (old)」に分け、新しいオブジェクトはまず若い世代に割り付けます。若い世代は小さく、頻繁に回収します。若い世代の回収では大半がゴミなので、少数の生存者だけを古い世代に昇格させれば済み、一回あたりの回収が速く終わります。古い世代は生存率が高いので、回収の頻度を落とします。全ヒープを走査する重い回収の回数を減らせるのが利点です。

ただし世代を分けると、古い世代のオブジェクトが若い世代のオブジェクトを指す参照が問題になります。若い世代だけを回収する際、ルートに加えて「古い世代からの参照」も生存の起点として扱わないと、まだ生きているオブジェクトを誤って回収してしまいます。しかし古い世代を全走査してこうした参照を探すのでは、若い世代を小さく速く回収する意味が失われます。

そこでライトバリア (write barrier) を使います。これは、オブジェクトのフィールドへポインタを書き込む操作に差し込む小さな処理で、「古い世代から若い世代への参照」が作られたときにその箇所を記録しておきます。若い世代の回収時には、ルートとこの記録された箇所だけを起点にすればよく、古い世代の全走査を避けられます。ライトバリアはすべてのポインタ書き込みに乗る費用ですが、参照カウントの増減より軽く作れます。世代別 GC は、この追加の仕組みと引き換えに、短命オブジェクトが多いワークロードで平均的な回収コストを大きく下げます。

移動 vs 非移動

GC 方式とは別の軸として、回収時にオブジェクトを動かすか動かさないかという選択があります。

移動型(コンパクト型, moving/compacting) は、生きているオブジェクトをメモリの一方の端へ詰め直し、隙間をなくします。利点は二つあります。第一に、断片化が起きない。第二に、空き領域が常に連続するため、割り付けがポインタを進めるだけ(バンプ割り付け、後述)になり極めて速い。世代別 GC の若い世代は、この移動とバンプ割り付けの相性が良く、よく組み合わされます。

ただし移動には代償があります。オブジェクトのアドレスが変わるため、そのオブジェクトを指すすべての参照を新しいアドレスへ書き換えなければなりません。ヒープ内の参照だけでなく、ルートに含まれる参照もすべて更新対象です。これは、あるスロットに入っているのが本当にポインタなのかを GC が正確に識別できること(正確な GC)を前提とします。さらに、オブジェクトの内部を指すポインタ(interior pointer)や、エンジン外のネイティブコードが握っている生ポインタがあると、移動のたびにそれらも整合させねばならず、実装が難しくなります。ネイティブコードは通常、GC が動かせない前提で生ポインタを扱うため、移動型はネイティブ境界の設計を複雑にします。

非移動型 (non-moving) は、オブジェクトを一度割り付けた場所から動かしません。マーク&スイープは典型的な非移動型です。

  • 利点: オブジェクトのアドレスが不変なので、参照の書き換えが要らない。ネイティブコードが生ポインタを握ったまま GC を挟んでもアドレスが変わらず、ネイティブ相互運用が単純になる。interior pointer も素直に扱える。
  • 欠点: 解放と割り付けを繰り返すうちにヒープに穴が空き、断片化する。大きな連続領域が必要なときに、総空きバイト数は足りているのに確保できない、という事態が起きうる。割り付けは空き領域の管理構造(フリーリスト、後述)を介するため、バンプ割り付けほど速くない。

割り付け戦略との関係

GC 方式は、オブジェクトを新規に確保する割り付け (allocation) の速さと表裏一体です。割り付けは実行中に極めて頻繁に起こるため、その一回あたりのコストがエンジン全体の性能に効きます。

  • バンプ割り付け (bump allocation): 連続した空き領域の先頭を指すポインタを一つ持ち、割り付けのたびにそのポインタを必要なバイト数だけ進める。境界を越えたら GC を起動する。ポインタ加算と境界チェックだけなので最速に近い。ただし空き領域が連続していることが前提で、移動型 GC(コンパクションで連続空きを作る)と組み合わせて成立する。
  • フリーリスト (free-list): 空き領域(スイープで生じた穴)をサイズ別のリストなどで管理し、割り付け要求に合う空きを探して切り出す。非移動型 GC と組み合わさる。探索や分割の費用があり、断片化への対処(サイズ分割、合体)も要る。

GC がいつ起動するかも割り付けと結びついています。典型的には、割り付け量がしきい値(前回の回収後に確保した総量や、ヒープ使用量の上限)に達したときに回収を起動します。しきい値を低くすると回収が頻繁になり停止が増え、高くするとメモリのピークが上がる、というトレードオフがあります。世代別 GC では、若い世代が一杯になったら若い世代だけを回収し、古い世代のしきい値超過で全体を回収する、という二段構えを取ります。

実務的な知見: 短命オブジェクトの大量生成

一般的な観察として、大量の短命オブジェクトを作っては捨てるワークロードでは、トレース型(マーク&スイープや世代別)の GC が参照カウントより有利になりやすいという傾向があります。大きな一時データ構造を組み立て、使い終わったら丸ごと捨てる、という処理がその典型です。

理由は二つあります。第一に、参照カウントは参照を格納するたびに増減の費用を払う。巨大な構造を組み立てる過程では、要素の追加・リンクの張り替えのたびにカウント更新が走り、その総量が無視できなくなります。第二に、構造を捨てるとき、参照カウントは要素を一つずつたどってカウントを減らし、0 になったものを順に解放します。捨てる作業自体が構造の大きさに比例した仕事になります。

対してトレース型では、参照の格納そのものにコストが乗らず(ライトバリアがあってもごく軽い)、割り付けはバンプまたは追記に近い安価な操作にできます。そして捨てられた構造は、ルートからたどれなくなった時点で「マークされない」だけであり、参照カウントのように要素を一つずつたどって後始末する必要がありません。マーク処理は生存者だけをたどれば済み、その費用は生存オブジェクトの量に比例します(非移動型ではスイープが死んだ領域も走査しますが、遅延・並行スイープで薄められます)。捨てる作業が構造の大きさに比例しないのが、この場面での本質的な強みです。

これは、割り付けが重い処理(たとえば大きな中間構造を作るネイティブ組み込み)が、インタプリタ本体の実行速度に関わらずエンジン間で互角に戦えることがある一因でもあります。処理の律速がインタプリタのディスパッチではなく割り付けと回収にあるとき、安価なバンプ割り付けと一括回収を持つ軽量なトレース GC は、より高機能なエンジンと遜色ない性能を出しうる、という一般的な観察です。

方式の比較

方式 参照格納の費用 停止の挙動 循環の回収 アドレスの安定性 実装の難しさ
参照カウント 毎回増減 分散(即時解放) 不可(補助が必要) 安定(非移動) 易しい(循環対策を除く)
マーク&スイープ(非移動) なし 一括(stop-the-world) 安定
世代別(移動を伴うことが多い) ライトバリア 若い世代は短く頻繁 移動時に変わる 難しい
  • 参照格納の費用: 参照カウントのみが毎回の増減を要する。トレース型は無料〜軽いライトバリアのみ。
  • 停止の挙動: 参照カウントは費用が実行に分散し即時解放できるが、大きな構造の一括破棄では連鎖的な解放でまとまった停止が起きうる。マーク&スイープは回収時にまとめて止まる。世代別は若い世代の回収を短く保てる。
  • 循環の回収: トレース型は原理的に可能。参照カウントは補助のサイクルコレクタが要る。
  • アドレスの安定性: 非移動型は不変でネイティブ相互運用が容易。移動型は回収のたびに参照更新が要る。
  • 実装の難しさ: 素朴なマーク&スイープが実装コストと機能のバランスが良い出発点。世代別・移動型・並行化は段階的に複雑さが増す。

選択の指針

方式選びは単独では決まらず、値の表現・オブジェクトモデル・ネイティブ境界の設計と一体で考える必要があります。実務的な指針は次のとおりです。

  • 軽量さと実装単純性を優先するなら、非移動のマーク&スイープが堅実な出発点です。参照操作にコストを乗せず、循環を追加機構なしで回収でき、アドレスが安定するためネイティブ組み込みとの相互運用が単純になります。断片化と停止時間は、必要になってから対処すればよい設計余地として残せます。
  • 停止時間の短さと割り付けの速さを重視するなら、世代別・移動型が有力です。短命オブジェクトが多い JavaScript のワークロードによく合い、若い世代のバンプ割り付けと短い回収で平均コストを下げられます。代償として、ライトバリア・参照更新・正確なポインタ識別・ネイティブ境界の保護という実装負担を引き受けることになります。
  • 参照カウントを主軸に据えるのは避けるのが無難です。JavaScript は循環を日常的に作るため必ずサイクルコレクタが要り、結局トレースの仕組みを別に持つことになります。毎回の増減費用と、大きな構造の一括破棄コストも、割り付けの多いワークロードでは重荷になります。

いずれの方式でも、GC が値の中からポインタを正しく見分けられることが前提になります。これは前章「値の表現」のタグ設計と直結します。そして、ルートをどこからどう集めるか、ネイティブ呼び出しをまたいで一時値をどう GC から守るかは、方式の正しさを左右する実装の核心です。これらは次章「ルート集合と到達可能性」と、その次の「ネイティブコードとの相互作用と GC 安全性」で扱います。

まとめ

  • JavaScript には手動解放がなく、エンジンが到達不能なオブジェクトを回収する。生存の基準はルートからの到達可能性である。
  • 参照カウントは即時解放できるが、参照格納ごとの費用がかかり、循環を単独では回収できない。
  • マーク&スイープは参照格納が無料で循環も回収できるが、回収は一括で、素朴な実装では stop-the-world の停止が生じる。
  • 世代別 GC は「大半のオブジェクトは早く死ぬ」を利用し、若い世代を頻繁に、古い世代を稀に回収する。古い世代から若い世代への参照はライトバリアで追跡する。
  • 移動型は断片化を防ぎ割り付けをバンプ化できるが全参照の更新が要り、非移動型はアドレスが安定しネイティブ相互運用が単純な代わりに断片化する。
  • 大量の短命オブジェクトを生成・破棄するワークロードでは、安価な割り付けと一括回収を持つトレース型が参照カウントより有利になりやすい。

参考文献

  • ECMA-262 (ECMAScript 言語仕様) — GC のタイミングが原則として観測できないこと、および WeakRef/FinalizationRegistry による例外的な観測の一次資料。