弱い参照とファイナライザ
一般的に、JavaScriptではオブジェクトへの参照は_強く保持されており_、オブジェクトへの参照がある限り、ガベージコレクションされることはありません。
const ref = { x: 42, y: 51 };
// `ref`(または同じオブジェクトへの他の参照)にアクセスできる限り、
// オブジェクトはガベージコレクションされません。
現在のところ、WeakMap
とWeakSet
はJavaScriptでオブジェクトを弱参照する唯一の方法です:WeakMap
やWeakSet
にキーとしてオブジェクトを追加しても、ガベージコレクションを妨げることはありません。
const wm = new WeakMap();
{
const ref = {};
const metaData = 'foo';
wm.set(ref, metaData);
wm.get(ref);
// → metaData
}
// このブロックスコープ内で`ref`への参照が無くなったため、
// オブジェクトはガベージコレクションされます。
// ただし、それが`wm`のキーである場合でも、`wm`へのアクセスは可能です。
<!--truncate-->
const ws = new WeakSet();
{
const ref = {};
ws.add(ref);
ws.has(ref);
// → true
}
// このブロックスコープ内で`ref`への参照が無くなったため、
// オブジェクトはガベージコレクションされます。
// ただし、それが`ws`のキーである場合でも、`ws`へのアクセスは可能です。
注意: WeakMap.prototype.set(ref, metaData)
は、オブジェクトref
に値metaData
のプロパティを追加するかのように動作すると考えることができます:オブジェクトへの参照がある限り、メタデータを取得できます。オブジェクトへの参照がなくなると、それが追加されたWeakMap
への参照がまだ存在していても、オブジェクトはガベージコレクションされる可能性があります。同様に、WeakSet
はすべての値が真偽値である特殊なWeakMap
と考えることができます。
JavaScriptのWeakMap
は実際には_弱参照ではなく_、キーが生きている間はその内容を強く参照します。キーがガベージコレクションされると、その内容を弱参照するようになります。このような関係をより正確にはエフェメロンと呼ぶことができます。
WeakRef
はより高度なAPIであり、_真の_弱参照を提供し、オブジェクトのライフタイムを垣間見ることができます。一緒に例を見ていきましょう。
例として、サーバーと通信するためにWebソケットを使用するチャットWebアプリケーションを作成していると仮定しましょう。MovingAvg
クラスは、パフォーマンス診断目的のためにWebソケットからのイベントセットを保持し、遅延の単純移動平均を計算するために使用されます。
class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}
compute(n) {
// 最後のnイベントに対して単純移動平均を計算します。
// …
}
}
MovingAvgComponent
クラスに使用され、そのクラスでは遅延の単純移動平均の監視を開始および停止する操作を制御できます。
class MovingAvgComponent {
constructor(socket) {
this.socket = socket;
}
start() {
this.movingAvg = new MovingAvg(this.socket);
}
stop() {
// ガベージコレクタにメモリーを解放させる。
this.movingAvg = null;
}
render() {
// レンダリングを行う。
// …
}
}
MovingAvg
インスタンス内にすべてのサーバーメッセージを保持することが大量のメモリを使用するとわかっています。そのため、監視が停止された際にはthis.movingAvg
をnullにすることでガベージコレクタにメモリを解放させます。
しかし、DevToolsのメモリパネルを確認したところ、メモリが全く解放されていないことが判明しました!経験豊富なWeb開発者ならバグをすでに見つけたかもしれませんが、イベントリスナーは強い参照となるため、明示的に削除する必要があります。
start()
を呼び出した後、オブジェクトグラフは以下のようになります。ここで、実線の矢印は強い参照を意味します。MovingAvgComponent
インスタンスから実線の矢印を通じて到達可能なすべてのものはガベージコレクションされません。
stop()
を呼び出した後、MovingAvgComponent
インスタンスからMovingAvg
インスタンスへの強参照を削除しましたが、ソケットのリスナー上では削除していません。
したがって、MovingAvg
インスタンスのリスナーがthis
を参照することで、イベントリスナーが削除されない限りインスタンス全体が生き続けます。
これまでのソリューションは、dispose
メソッドを使用して手動でイベントリスナーを登録解除することでした。
class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}
dispose() {
this.socket.removeEventListener('message', this.listener);
}
// …
}
このアプローチの欠点は、手動によるメモリ管理が必要である点です。MovingAvgComponent
や他のMovingAvg
クラスの利用者は、dispose
を呼び出さないとメモリリークを引き起こしてしまう可能性があります。さらに悪いことに、手動メモリ管理は連鎖的で、MovingAvgComponent
の利用者もstop
を呼び出す必要があるなど、影響が拡大します。この診断クラスのイベントリスナーの有無はアプリケーションの動作に依存せず、リスナー自体は計算速度には影響しないもののメモリ使用量が高いです。理想的には、このリスナーのライフタイムをMovingAvg
インスタンスのライフタイムに論理的に結びつけるべきです。これにより、MovingAvg
はガベージコレクタによる自動メモリ回収が可能な他のJavaScriptオブジェクトと同様に動作するようになります。
WeakRef
を使用することで、実際のイベントリスナーに対する_弱い参照_を作成し、そのWeakRef
を外部イベントリスナー内でラップすることで、このジレンマを解決することが可能になります。これにより、ガベージコレクタはMovingAvg
インスタンスやevents
配列など、実際のイベントリスナーが活性化しているメモリをクリーンアップすることができます。
function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener);
const wrapper = (ev) => { weakRef.deref()?.(ev); };
socket.addEventListener('message', wrapper);
}
class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); };
addWeakListener(socket, this.listener);
}
}
注意: 関数へのWeakRef
の使用は慎重に行う必要があります。JavaScriptの関数はクロージャであり、これにより関数内で参照される自由変数の値を含む外部環境を強く参照します。これらの外部環境は、他の クロージャが参照している変数を含む場合があります。つまり、クロージャとそのメモリは他のクロージャによって微妙に強く参照されていることがよくあります。このため、addWeakListener
は個別の関数として実装されており、wrapper
はMovingAvg
のコンストラクタ内にローカルとして存在しないようにしています。V8では、もしwrapper
がMovingAvg
のコンストラクタ内にローカルで配置され、WeakRef
でラップされたリスナーと語彙スコープを共有していた場合、MovingAvg
インスタンスとそのすべてのプロパティが共有環境を通してwrapper
リスナーから到達可能となり、インスタンスがガベージコレクトされなくなります。コードを書く際にはこれを頭に入れておいてください。
最初にイベントリスナーを作成し、それをthis.listener
に割り当てます。このようにして、MovingAvg
インスタンスが存在する限り、イベントリスナーも存続します。
その後、addWeakListener
で、実際のイベントリスナーをターゲットとするWeakRef
を作成します。その内部のwrapper
で、これをderef
します。WeakRef
は他に強い参照がない場合にターゲットのガベージコレクションを妨げないため、deref
を使用してターゲットを手動で参照する必要があります。その間にターゲットがガベージコレクトされている場合、deref
はundefined
を返します。それ以外の場合、元のターゲット(つまりリスナー関数)が返され、オプショナルチェイニングを使用してその関数を呼び出します。
イベントリスナーがWeakRef
でラップされているため、そのイベントリスナーへの唯一の強い参照はMovingAvg
インスタンス上のlistener
プロパティになります。つまり、イベントリスナーのライフタイムをMovingAvg
インスタンスのライフタイムに成功裏に結びつけたことになります。
到達可能性のダイアグラムに戻ると、WeakRef
実装でstart()
を呼び出した後のオブジェクトグラフは次のようになります(点線は弱い参照を意味します)。
stop()
を呼び出した後は、リスナーへの唯一の強い参照を削除した状況が次のようになります。
最終的にガベージコレクションが発生した後、MovingAvg
インスタンスとリスナーは回収されます。
しかし、ここにはまだ問題があります。それは、WeakRef
でリスナーをラップすることでリスナーに間接層を追加しましたが、addWeakListener
内のラッパーは元々リスナーがリークしていた理由と同じ理由で依然としてリークしています。このリークはMovingAvg
インスタンス全体がリークしていた場合に比べると軽微ですが、それでもリークです。これを解決する方法が、WeakRef
の補完機能であるFinalizationRegistry
です。新しいFinalizationRegistry
APIを使用すると、ガベージコレクタが登録されたオブジェクトを削除した際に実行されるコールバックを登録することができます。このようなコールバックは_ファイナライザ_と呼ばれます。
注意: ファイナライゼーションコールバックはイベントリスナーのガベージコレクション後に即座に実行されるわけではありません。そのため、重要なロジックやメトリクスのために使用しないでください。ガベージコレクションおよびファイナライゼーションコールバックのタイミングは指定されていません。事実上、ガベージコレクションを全く行わないエンジンでも完全に準拠します。しかし、エンジンがガベージコレクションを行い、ファイナライゼーションコールバックが後から実行されることを仮定するのは安全です。ただし、環境が破棄される(タブが閉じる、ワーカーが終了するなど)場合を除きます。この不確定性を心に留めてコードを書くようにしてください。
ガベージコレクションされたイベントリスナーを FinalizationRegistry
を使用して wrapper
をソケットから削除するためにコールバックを登録することができます。最終的な実装は以下のようになります:
const gListenersRegistry = new FinalizationRegistry(({ socket, wrapper }) => {
socket.removeEventListener('message', wrapper); // 6
});
function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener); // 2
const wrapper = (ev) => { weakRef.deref()?.(ev); }; // 3
gListenersRegistry.register(listener, { socket, wrapper }); // 4
socket.addEventListener('message', wrapper); // 5
}
class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); }; // 1
addWeakListener(socket, this.listener);
}
}
注意: gListenersRegistry
はファイナライザが実行されるようにグローバル変数として保持されます。FinalizationRegistry
は登録されたオブジェクトによって存続するわけではありません。レジストリ自体がガベージコレクションされると、ファイナライザが実行されない場合があります。
イベントリスナーを作成して this.listener
に代入することで、MovingAvg
インスタンスによって強く参照されるようにします(1)。次に、WeakRef
を使用して作業を行うイベントリスナーをラップし、ガベージコレクション可能にして、this
を介して MovingAvg
インスタンスへの参照が漏れないようにします(2)。WeakRef
がまだ有効かどうかをチェックし、その場合に呼び出すラッパーを作成します(3)。FinalizationRegistry
に内側のリスナーを登録し、登録時に { socket, wrapper }
という保持値を渡します(4)。その後、返されたラッパーをイベントリスナーとして socket
に追加します(5)。MovingAvg
インスタンスと内側のリスナーがガベージコレクションされた後に、保持値が渡された状態でファイナライザが実行される可能性があります。ファイナライザ内では、ラッパーも削除し、MovingAvg
インスタンスの使用に関連したすべてのメモリをガベージコレクション可能にします(6)。
これにより、MovingAvgComponent
の元の実装はメモリリークすることなく、手動での破棄も必要ありません。
やりすぎないように
これらの新しい機能について知った後、WeakRef
をすべてに使用したくなるかもしれません。しかし、それはおそらく良い考えではありません。いくつかのものは、WeakRef
やファイナライザの利用に明確に適していません。
一般的に、ガベージコレクタが WeakRef
を掃除する、またはファイナライザを予測可能なタイミングで呼び出すことに依存するコードを書くのは避けてください — それは不可能です。さらに、オブジェクトがガベージコレクション可能かどうかは、クロージャの表現などの実装の詳細に依存する場合があり、それは微妙でJavaScriptエンジンの間や同じエンジンの異なるバージョン間でも異なる場合があります。具体的には、ファイナライザコールバック:
- ガベージコレクションの直後に発生するとは限りません。
- 実際のガベージコレクションと同じ順序で発生するとは限りません。
- ブラウザウィンドウが閉じられた場合など、まったく発生しない可能性があります。
そのため、重要なロジックをファイナライザのコードパスに配置しないでください。それらはガベージコレクションに応じてクリーンアップを実行するのに便利ですが、メモリ使用量に関する意味のあるメトリックを記録するために信頼して使用することはできません。その目的のためには、performance.measureUserAgentSpecificMemory
を参照してください。
WeakRef
とファイナライザはメモリを節約するのに役立ち、進化的な拡張手段として控えめに使用すると最も効果的です。これらは上級ユーザー向けの機能であり、大部分の使用はフレームワークやライブラリ内で行われると予想されます。