弱い参照とファイナライザ
一般的に、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 とファイナライザはメモリを節約するのに役立ち、進化的な拡張手段として控えめに使用すると最も効果的です。これらは上級ユーザー向けの機能であり、大部分の使用はフレームワークやライブラリ内で行われると予想されます。