Skip to content

弱い参照とファイナライザ

ガベージコレクタは「到達可能なオブジェクトは生かし、到達不能になったものは回収する」という原則で動きます。この原則を素直に適用すると、あるオブジェクトを指す参照が一つでも残っている限り、そのオブジェクトは決して回収されません。ところが実際のプログラムには、「対象が生きている間だけ関連づけておきたいが、その対象を自分のために生き延びさせたくはない」という関係が存在します。キャッシュ、オブジェクトへの付随メタデータ、リソースの後始末などです。これを表現するのが弱い参照であり、JavaScript では WeakRefWeakMapWeakSetFinalizationRegistry として言語機能になっています。この章では、弱い参照がなぜ必要か、トレース型 GC でそれをどう実装するか、そして弱い構造の実装で陥りやすい罠を扱います。GC のマーキングやルート集合の考え方は前章までを前提とします。

強い参照と弱い参照

まず二種類の参照を区別します。

  • 強い参照: 対象を生かし続ける参照。GC から見れば「たどるべき辺(トレース対象のエッジ)」であり、ルートから強い参照だけをたどって到達できるオブジェクトはすべて生存扱いになる。通常の変数・プロパティ・配列要素はすべて強い参照である。
  • 弱い参照: 対象を指してはいるが、対象を生かし続けない参照。ある対象が弱い参照からしか指されていない(強い参照が他にない)とき、その対象は回収可能とみなされ、回収後に弱い参照は自動的に切られる。

言い換えると、あるオブジェクトが生存するかどうかは「強い参照だけをたどった到達可能性」で決まり、弱い参照はその判定に関与しません。

なぜ言語に弱い参照が必要か

強い参照しかない世界では、次のような場面で意図しないメモリリークが生じます。

  • キャッシュ: 計算結果をキーとなるオブジェクトに対応づけて覚えておきたい。しかしキャッシュが強くキーを保持すると、プログラム本体がそのオブジェクトを捨てても、キャッシュが生かし続けてしまう。キャッシュは本来「対象が生きている間だけ」有効であればよい。
  • 付随メタデータ: ライブラリが、利用者から渡されたオブジェクトに内部的な補助情報を紐づけたい。オブジェクトを変更せずに外側から関連づけたいが、その関連づけのためにオブジェクトを生存させてはならない。
  • リソースの後始末: あるオブジェクトが外部リソース(ファイルハンドル、ネイティブメモリなど)を代表しているとき、そのオブジェクトが回収されたら対応するリソースも解放したい。

弱い参照は、これらを「対象の寿命に追従する」形で実現します。対象が生きている間は関連づけが有効で、対象が捨てられれば関連づけも自動的に消える、というわけです。

弱い意味を必要とする機能

JavaScript には、弱い参照を土台とする四つの機能があります。

  • WeakRef: 対象への弱いハンドル。deref() で対象を取り出せるが、対象がすでに回収されていれば undefined を返す。生きている対象を「必要なら使うが、そのためだけには生かさない」用途に使う。
  • WeakMap / WeakSet: キー(WeakSet では要素)を弱く保持するコレクション。キーが他から到達不能になると、そのエントリは自動的に消える。オブジェクトをキーにした付随情報の格納に向く。値は強く保持されるが、その生存はキーの生存に従属する(後述のエフェメロン)。
  • FinalizationRegistry: 対象の回収後に呼ばれるクリーンアップコールバックを登録する仕組み。対象が回収されると、登録時に渡した「保持値(held value)」を引数にコールバックがキューへ積まれ、後で実行される。

これらの「何が弱いか」「いつ切られる/呼ばれるか」を整理すると次のようになります。

機能 弱く保持されるもの 取り出し 対象回収時の挙動
WeakRef 参照先の対象 deref() で対象または undefined deref()undefined を返すようになる
WeakMap / WeakSet キー(要素) キー指定で値を取得/存在判定 エントリが消える(以後キーで引けない)
FinalizationRegistry 登録した対象 (取り出し不可) 保持値を引数にコールバックがジョブとして積まれる

いずれも共通するのは、「弱く保持される対象の生存判定に、その弱い構造自身は影響しない」という点です。WeakMap のキーは、WeakMap に入っているという理由では生き延びません。

トレース型 GC での実装

弱い参照の意味は、トレース型コレクタ(マーク&スイープなど)のマーキングを少し拡張することで実現できます。鍵は、強いグラフを先に全部マークしてから、弱い構造をまとめて後処理するという順序です。

大まかな流れは次の三段階です。

  1. 強いマーク: ルート集合から出発し、強い参照の辺だけをたどって到達可能なオブジェクトをすべてマークする。このとき、弱い辺(WeakRef の参照先、WeakMap/WeakSet のキー)はたどらない。弱い構造自身(WeakMap オブジェクトなど)は通常どおりマークされるが、そのエントリのキーを強い辺として扱ってはならない。
  2. 弱い後処理: 強いマークが完了した時点で、「マークされたか否か=強く到達可能か否か」が確定する。ここで弱い構造を走査し、次を行う。
  3. WeakRef のうち、参照先がマークされていないものは参照を切る(deref()undefined を返すようにする)。
  4. WeakMap/WeakSet のエントリのうち、キーがマークされていないものを削除する。
  5. FinalizationRegistry に登録された対象のうち、マークされていないものについて、対応するコールバックを実行キューへ積む。
  6. スイープ: マークされなかったオブジェクトを回収する。

この順序が本質的です。もし弱い後処理を強いマークの途中で行うと、「まだマークが終わっていないだけで、実は後から強く到達可能になる」オブジェクトを誤って回収対象と判断してしまいます。全強参照をたどり切ってから弱い判定を下すからこそ、判定が正しくなります。

擬似コード風に書くと、弱いマークと通常のマークの違いは辺の扱いだけです。

マーク時:
  通常の参照 → たどって相手をマークする(強い辺)
  WeakRef の参照先 → たどらない
  WeakMap/WeakSet のキー → たどらない(値の扱いは後述)

強いマーク完了後:
  各 WeakRef について: 参照先が未マークなら参照を切る
  各 WeakMap/WeakSet について: キーが未マークのエントリを削除
  各 FinalizationRegistry について: 登録対象が未マークならコールバックを enqueue

ファイナライザのコールバックは、この後処理の中で即座に呼ぶのではなく、GC の外(ジョブ/ターンの境界)で実行するようキューへ積むだけにします。理由は後述します。

実装上の罠

弱い構造は「マークをどう拡張するか」という一点に正しさが集中しており、間違えても即座にクラッシュせず、静かにメモリリークや仕様違反を起こすため発見が難しい領域です。

WeakMap/WeakSet が強参照になっている

もっとも起こしやすく、そして厄介なのが、弱いコレクションのエントリを通常の強い参照としてマークしてしまうバグです。

WeakMap は内部的にはキーと値の対応表であり、実装上は通常の Map と似たストレージを流用したくなります。ところが、通常の Map のマークルーチンは「格納された全キー・全値を強くたどる」ように書かれています。これを WeakMap にそのまま適用すると、キーが強くマークされてしまい、キーは WeakMap に入っている限り永遠に回収されなくなります。これは「弱い」という定義そのものへの違反です。

この誤りの症状は次のとおりです。

  • キーにしたオブジェクトが、他から到達不能になっても回収されない(メモリリーク)。エントリが自動的に消えるはずなのに残り続ける。
  • そのキーを対象に FinalizationRegistry を仕掛けていても、対象がいつまでも「生存」扱いなので、コールバックが決して発火しない
  • クラッシュはしないため、テストをすり抜けやすい。長時間稼働で徐々にメモリが増える形で顕在化する。

これは仕様から観測可能なリーク(spec-observable leak)であり、深刻度は「クラッシュではないがメモリリーク」に分類されます。WeakMap が通常の Map のストレージやマーク経路を再利用しているときに、うっかり混入しやすいのが特徴です。

対策は、弱いコレクションを強いマークの対象から外し、必ず弱い後処理で扱うことです。強いマークではキーも(単純な)値もたどらず、後処理でキーの生死を見てエントリを残すか削除するかを決めます。「WeakMap を通常の Map と同じマーク経路に通さない」ことを設計として明示するのが安全です。

エフェメロン問題

WeakMap の値の扱いには、単純な後処理では正しく解けない微妙な問題があります。値がキーを(直接または間接に)参照している場合です。

WeakMap の意味は「値はキーが生きている間だけ生かす」です。したがって、キーが生存なら値も生かし、キーが回収されるなら値も(それだけが理由なら)道連れにできます。ところが、値がキーを参照していると循環が生じます。

  • 値を生かすべきかはキーの生死で決まる。
  • しかし値をたどるとキーに到達し、キーが生きているように見えてしまう。

素朴に「生きているキーのエントリの値をマークする」を一回だけ行うと、この依存関係を取りこぼします。たとえば、あるエントリ(第 1)の値が別のエントリ(第 2)のキーを指している場合、第 2 のキーはこの値の連鎖を通じてしか生存が確定しません。走査の順序によっては、第 2 エントリを見た時点でそのキーがまだ未マークのため値をマークせず素通りし、あとから第 1 エントリがキーをマークしても手遅れで、第 2 エントリの値を誤って回収してしまいます。具体的には、wm.set(a, b); wm.set(b, c) のように第 1 エントリの値 b が第 2 エントリのキーであるとき、ルートから a だけが到達可能だと、a を起点に b はマークできても、b をキーとする第 2 エントリの値 c を取りこぼします。

flowchart LR
    root["ルート"] --> a["a"]
    subgraph wm["WeakMap: wm.set(a, b) と wm.set(b, c)"]
      e1["エントリ1<br/>キー=a 値=b"]
      e2["エントリ2<br/>キー=b 値=c"]
    end
    a -. "キーaが生存" .-> e1
    e1 -. "値bをマーク" .-> b["b"]
    b -. "キーbが生存" .-> e2
    e2 -. "値cをマーク" .-> c["c"]
ルートから a だけが到達可能なとき、a→(エントリ1)→b→(エントリ2)→c と条件付き辺を段階的にたどる必要があり、一回の走査では b がまだ未マークのうちに c を取りこぼすため、不動点まで反復する。

この構造はエフェメロン(ephemeron)と呼ばれます。エフェメロンは「キーと値の対で、値はキーが生存するときに限り生存する」という条件付きの辺であり、通常の強い辺とも弱い辺とも異なる第三の意味を持ちます。

正しく解くには、不動点に達するまで反復する必要があります。

  1. 強いマークを通常どおり完了する。
  2. 弱いコレクションを走査し、キーがすでにマークされているエントリについて、その値を(強い辺として)マークする。このとき新たにマークされたオブジェクトが、別のエントリのキーであるかもしれない。
  3. 走査で一つでも新たにマークが増えたら、もう一度 2 を繰り返す。増えなくなる(不動点)まで続ける。
  4. 反復が収束したら、なおキーが未マークのエントリを削除する。

こうすると、「キーが生きているエントリの値」→「その値が指す別のキー」→「そのキーのエントリの値」…という連鎖が正しく展開され、条件付きの生存関係が破綻なく解けます。単純な一回走査で済ませると、値の連鎖を通じてのみ生存が確定するエントリの値を取りこぼす、という形の誤回収が起きます。

ファイナライザの安全性

FinalizationRegistry のコールバックは強力ですが、その実行タイミングと再入について慎重な設計が要ります。

  • タイミングは未規定: コールバックは GC の最中ではなく、後の適当なジョブ/ターン境界で実行されます。いつ呼ばれるか、そもそも呼ばれるか(プログラム終了前に対象が回収されるとは限らない)は仕様上保証されません。したがってプログラムの正しさをファイナライザに依存させてはいけません。ファイナライザはあくまで最終手段の後始末であり、明示的な解放(close() など)を代替するものではない、という位置づけを守ります。
  • GC 中に呼ばない: コールバックを弱い後処理の最中に直接実行すると、ユーザコードが GC の途中で走ることになり、ヒープの不変条件が崩れたり再入で壊れたりします。だからこそ、後処理では「キューに積む」だけにとどめ、実行は GC 完了後の安全な境界に回します。
  • 復活(resurrection): ファイナライザの中で、回収されたはずの対象に関連する新しいオブジェクトを作ったり、保持値を生きた構造へ再登録したりすると、実質的にオブジェクトを「復活」させる形になります。これ自体は禁止されませんが、次回以降の GC 判定やレジストリの状態と整合させる必要があり、扱いを誤るとファイナライザが二度呼ばれる/呼ばれ続けるといった不具合につながります。
  • 再入: ファイナライザの中からさらに弱い構造を操作したり、別の対象を登録・解除したりできるため、コールバック実行中のレジストリ変更に耐える実装(実行中のスナップショットを固定するなど)が必要です。

正しさの検証が難しい理由

弱い参照とファイナライザに固有の難しさは、GC のタイミングがこれらの機能を通してのみ観測可能という点にあります。通常、いつ GC が走り何が回収されたかはプログラムから見えません。ところが WeakRef.deref() の結果や FinalizationRegistry の発火は、まさに「何が回収されたか」を露出させます。

このため、弱い構造のバグは「特定の GC タイミングでのみ現れる」形になりがちで、決定的に再現させにくいものです。検証には、GC を強制的かつ高頻度に走らせる仕組み(GC ストレス)を用意し、弱い参照が期待どおり切れること・エフェメロン連鎖が誤回収されないこと・ファイナライザが過不足なく発火することを、意図的に GC を挟みながら確認する方法が有効です。裏を返せば、これらの機能が正しく実装されていれば、GC のタイミングはやはりそれ以外からは観測できないままに保たれます。

まとめ

  • 強い参照は対象を生かし、弱い参照は生かさない。弱い参照は、キャッシュ・付随メタデータ・後始末のように「対象の寿命に追従したい」関係を表現するために言語に必要となる。
  • WeakRef は弱いハンドル、WeakMap/WeakSet はキーを弱く保持するコレクション、FinalizationRegistry は回収後のクリーンアップを担う。いずれも弱く保持する対象の生存判定には関与しない。
  • トレース型 GC では「強いグラフを先に全部マークし、その後に弱い構造をまとめて後処理する」順序で実装する。順序を守ることが判定の正しさの前提となる。
  • 弱いコレクションを通常の強いマーク経路に通すと、キーが永遠に生き残るメモリリークとなり、FinalizationRegistry も発火しなくなる。弱い構造は必ず後処理で扱う。
  • 値がキーを参照するエフェメロン問題は、不動点まで反復するアルゴリズムでしか正しく解けない。一回走査は値経由でのみ生きるキーを取りこぼす。
  • ファイナライザはタイミングが未規定であり、正しさを依存させない。実行は GC 外の安全な境界へ回し、復活・再入に備える。
  • GC タイミングはこれらの機能を通してのみ観測できるため、正しさの検証には GC ストレスなど意図的に GC を挟む手法が要る。

参考文献