一般的に、JavaScript におけるオブジェクトへの参照は強く保持されます。つまり、オブジェクトへの参照を持っている限り、そのオブジェクトはガベージコレクションされません。
const ref = { x: 42, y: 51 };
// As long as you have access to `ref` (or any other reference to the
// same object), the object won’t be garbage-collected.
現在、WeakMap
と WeakSet
は、JavaScript でオブジェクトを弱く参照する唯一の方法です。オブジェクトを WeakMap
または WeakSet
のキーとして追加しても、そのオブジェクトがガベージコレクションされるのを妨げません。
const wm = new WeakMap();
{
const ref = {};
const metaData = 'foo';
wm.set(ref, metaData);
wm.get(ref);
// → metaData
}
// We no longer have a reference to `ref` in this block scope, so it
// can be garbage-collected now, even though it’s a key in `wm` to
// which we still have access.
const ws = new WeakSet();
{
const ref = {};
ws.add(ref);
ws.has(ref);
// → true
}
// We no longer have a reference to `ref` in this block scope, so it
// can be garbage-collected now, even though it’s a key in `ws` to
// which we still have access.
注: WeakMap.prototype.set(ref, metaData)
は、オブジェクト ref
に値 metaData
を持つプロパティを追加すると考えることができます。オブジェクトへの参照を持っている限り、メタデータを取得できます。オブジェクトへの参照がなくなると、そのオブジェクトは、追加された WeakMap
への参照が残っていても、ガベージコレクションの対象になります。同様に、WeakSet
はすべての値がブール値である WeakMap
の特殊なケースと考えることができます。
JavaScript の WeakMap
は実際には弱いわけではありません。キーが有効である限り、その内容は強く参照されます。WeakMap
がその内容を弱く参照するのは、キーがガベージコレクションされた後のみです。この種の関連性をより正確に表す名前は、エフェメロンです。
WeakRef
は、オブジェクトの生存期間を垣間見ることができる、より高度な API です。一緒に例を見ていきましょう。
たとえば、サーバーとの通信に Web ソケットを使用するチャット Web アプリケーションに取り組んでいるとします。パフォーマンス診断の目的で、WebSocket からのイベントのセットを保持して、レイテンシーの単純移動平均を計算する MovingAvg
クラスがあると想像してください。
class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}
compute(n) {
// Compute the simple moving average for the last n events.
// …
}
}
これは、レイテンシーの単純移動平均の監視を開始および停止するタイミングを制御できる MovingAvgComponent
クラスで使用されます。
class MovingAvgComponent {
constructor(socket) {
this.socket = socket;
}
start() {
this.movingAvg = new MovingAvg(this.socket);
}
stop() {
// Allow the garbage collector to reclaim memory.
this.movingAvg = null;
}
render() {
// Do rendering.
// …
}
}
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
インスタンスとそのすべてのプロパティは、ラッパーリスナーから共有環境経由で到達可能になり、インスタンスが収集不可能になります。コードを作成する際は、このことを念頭に置いてください。
最初にイベントリスナーを作成し、this.listener
に割り当てて、MovingAvg
インスタンスによって強く参照されるようにします。言い換えれば、MovingAvg
インスタンスが有効である限り、イベントリスナーも有効です。
次に、addWeakListener
で、ターゲットが実際のイベントリスナーである WeakRef
を作成します。wrapper
内で、それを deref
します。WeakRef
は、ターゲットに他の強い参照がない場合、ターゲットのガベージコレクションを防がないため、手動で逆参照してターゲットを取得する必要があります。ターゲットがその間にガベージコレクションされている場合、deref
は undefined
を返します。それ以外の場合は、元のターゲットが返され、それが オプションチェイニングを使用して呼び出す listener
関数です。
イベントリスナーは WeakRef
でラップされているため、それへの唯一の強い参照は MovingAvg
インスタンスの listener
プロパティです。つまり、イベントリスナーの有効期間を MovingAvg
インスタンスの有効期間に正常に結び付けました。
到達可能性図に戻ると、WeakRef
実装で start()
を呼び出した後のオブジェクトグラフは次のようになります。ここで、点線矢印は弱い参照を意味します。
stop()
を呼び出した後、リスナーへの唯一の強い参照を削除しました
最終的に、ガベージコレクションが発生した後、MovingAvg
インスタンスとリスナーが収集されます
しかし、ここにはまだ問題があります。listener
を WeakRef
でラップすることで間接参照のレベルを追加しましたが、addWeakListener
のラッパーは、listener
が元々リークしていたのと同じ理由で依然としてリークしています。確かに、これは、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
は、登録されているオブジェクトによって存続させられません。レジストリ自体がガベージコレクションされた場合、そのファイナライザーは実行されない可能性があります。
イベントリスナーを作成し、MovingAvg
インスタンスによって強く参照されるように this.listener
に割り当てます (1)。次に、内部で動作するイベントリスナーを WeakRef
でラップして、ガベージコレクションできるようにし、this
経由で MovingAvg
インスタンスへの参照をリークしないようにします (2)。WeakRef
を deref
して、まだ有効かどうかを確認するラッパーを作成し、有効な場合はそれを呼び出します (3)。内部リスナーを FinalizationRegistry
に登録し、登録に保持値 { socket, wrapper }
を渡します (4)。次に、返されたラッパーを socket
のイベントリスナーとして追加します (5)。MovingAvg
インスタンスと内部リスナーがガベージコレクションされた後、しばらくして、ファイナライザーが実行され、保持値が渡されます。ファイナライザー内部で、ラッパーも削除し、MovingAvg
インスタンスの使用に関連付けられたすべてのメモリをガベージコレクションできるようにします (6)。
これにより、MovingAvgComponent
の元の実装はメモリをリークせず、手動による破棄も必要ありません。
やりすぎないでください #
これらの新しい機能について聞くと、WeakRef
をすべてに適用したくなるかもしれません。ただし、それはおそらく良い考えではありません。WeakRef
とファイナライザーのユースケースとして明示的に適していないものがあります。
一般に、ガベージコレクターが WeakRef
をクリーンアップしたり、予測可能なタイミングでファイナライザーを呼び出したりすることに依存するコードの記述は避けてください。 - それを行うことはできません! さらに、オブジェクトがガベージコレクション可能かどうかは、クロージャの表現など、JavaScript エンジン間で、さらには同じエンジンの異なるバージョン間でも微妙で異なる可能性のある実装の詳細に依存する場合があります。具体的には、ファイナライザーコールバック
- ガベージコレクションの直後に発生しない可能性があります。
- 実際のガベージコレクションと同じ順序で発生しない可能性があります。
- たとえば、ブラウザーウィンドウが閉じられた場合など、まったく発生しない可能性があります。
したがって、重要なロジックをファイナライザーのコードパスに配置しないでください。それらはガベージコレクションに応答してクリーンアップを実行するのに役立ちますが、メモリ使用量に関する意味のあるメトリクスを記録するために確実に使用することはできません。そのユースケースについては、performance.measureUserAgentSpecificMemory
を参照してください。
WeakRef
とファイナライザーは、メモリを節約するのに役立ち、プログレッシブエンハンスメントの手段として控えめに使用するのが最適です。これらはパワーユーザー向けの機能であるため、ほとんどの使用はフレームワークまたはライブラリ内で行われると予想されます。
WeakRef
のサポート #
- Chrome: バージョン 74 以降でサポート
- Firefox: バージョン 79 以降でサポート
- Safari: サポートなし
- Node.js: バージョン 14.6.0 以降でサポート
- Babel: サポートなし