DanLevy.net

WeakMapでメモリリークを止めよう

弱参照で弱いコードを直す!

コードを1行変えてメモリ使用量が50%下がる瞬間を知っているだろうか。ChromeのDevToolsパフォーマンスモニターを見ながら、ダッシュボードアプリが1時間100MBを浪費する状態から、丸1日下午クリーンに動くのを目の当たりにした。

変えた1行:new Map()new WeakMap() にしただけ。

それだけだ。同じAPI、同じ使い方、でも内部的な動きは全く違う。でも、これがなぜ効くのかを理解するには、ほとんどのJavaScript開発者が考えたこともないこと――誰も見ていないデータに何が起きるのか――を理解する必要がある。

参照が錨になるとき

JavaScriptの普通のMapは、キーを大事な荷物のように扱う。一度そこに置いたものは、鉄の握力で離さない。ガベージコレクターはこの関係を見て、「まだ必要なんだろうな、触らないでおこう」と判断する。

この保護本能は、一時的なものについてメタデータを保存しているときに問題になる。削除されるDOMノード。期限切れのユーザーセッション。アンマウントされるコンポーネントインスタンス。Mapはこれらのオブジェクトがもう役目を終えたことを知らない。参照を持っているから、生かしておく、それだけだ。

const cache = new Map();
function trackClick(element) {
cache.set(element, { clicks: 0 });
}
document.body.removeChild(element);
// 要素はDOMから消えたが、cacheがメモリに保持し続けている

ガベージコレクターはelementを片付けられない。cacheがまだ指しているからだ。これは「強参照」と呼ばれ、長時間動くSPAでは、最終的にブラウザをクラッシュさせるリークになる。

WeakMapがルールを変える

WeakMapは動きが違う。キーを永住者ではなく一時的な住人として扱う。WeakMapに何かを保存するとき、本質的にはこう言っているのと同じだ:「このデータをこのオブジェクトに関連付けたい。でも、このオブジェクトが生き残る理由にはしたくない。」

オブジェクトをメモリに繋ぎ止めているのがWeakMapだけなら、ガベージコレクターはそれを回収してよい。オブジェクトが消えれば、WeakMapのエントリも一緒に消える。手動のクリーンアップは不要だ。

const cache = new WeakMap();
function trackClick(element) {
cache.set(element, { clicks: 0 });
}
document.body.removeChild(element);
// 要素はガベージコレクションされる
// キャッシュエントリも自動で消える

10万個のDOMノードを作り、それぞれにメタデータを保存し、全部削除するベンチマークを走らせてみた。Mapの場合、ブラウザは150〜200MBを保持した。WeakMapの場合、70〜80MBに下がった。同じコード、同じ機能、メモリフットプリントは半分だ。

何を諦めるか

WeakMapには制約がある。でも、それが魔法を可能にしていることに気づけば、制限ではなく設計だとわかる。

WeakMapは反復できない。 forEachkeys()values()もない。考えてみれば当然だ:ループの途中でガベージコレクターがエントリを削除するかもしれない。そんなものを扱いたいか?

サイズも確認できない。.sizeプロパティも.lengthもない。これも動くターゲットだからだ。聞いた時と答えが返ってくる時の間で数字が変わる可能性がある。

キーはオブジェクトでなければならない。 文字列も数値もプリミティブもダメ。これは弱参照の仕組みに根本的な関係がある:プリミティブ値には、値とは別の同一性がないからだ。

これらはバグではない。設計だ。WeakMapは一つの特定の仕事のために作られている:オブジェクトにメタデータを付けること、ただしそのオブジェクトがクリーンアップされるのを邪魔しないように。反復やプリミティブのキーやエントリ数が必要なら、たぶん別の問題を解いていて、普通のMapを使うべきだ。

実際に役立つ場面

「プライベートデータ」パターンは、JavaScriptに#privateフィールドができる前の、WeakMapの元々のユースケースだった。ライブラリはクラスの外にWeakMapを作り、インスタンスからアクセスすべきでないデータをそこに保存していた。

const privateData = new WeakMap();
class User {
constructor(name) {
privateData.set(this, { name });
}
getName() {
return privateData.get(this).name;
}
}

Userインスタンスがガベージコレクションされれば、プライベートデータも一緒に消える。クリーンアップコードは不要だ。

メモ化も自然な適用先だ。特に、プリミティブ値ではなくオブジェクト入力に基づいて結果をキャッシュする場合。コストの高い計算が設定オブジェクトを入力にとるなら、WeakMapを使えばキャッシュが設定オブジェクトより長生きする心配がない。

const cache = new WeakMap();
function expensiveCalc(obj) {
if (cache.has(obj)) return cache.get(obj);
const result = heavyMath(obj);
cache.set(obj, result);
return result;
}

キャッシュはキャッシュ対象のオブジェクトが生きている間だけ存在する。objがどこからも参照されなくなれば、キャッシュされた結果もキャッシュエントリも一緒に消える。

いつ使うべきか

モダンなウェブアプリのメモリリークは、大抵、片付けるべきだった古い参照から生じる。長時間動くもの、一日中開きっぱなしのダッシュボード、何時間も動くチャットアプリ、リフレッシュしない管理パネルを作っているなら、古いデータに何が起きるかを考える必要がある。

WeakMapは、DOMノード、コンポーネントインスタンス、あるいはライフタイムを自分で制御できない任意のオブジェクトにデータを関連付けるときに特に便利。参照に基づいて何かを保存していて、その参照が消える可能性があるなら、WeakMapがクリーンアップをずっとシンプルにする。

本当のキャッシュを退去ポリシー付きで作るとき、エントリを反復する必要があるとき、プリミティブのキーを使うとき、オブジェクトとの関連ではなくデータ自体が重要なときは、普通のMapが正しい選択だ。

WeakMapの良いところは、必要なときが普通は明らかだということだ。オブジェクトが破棄されたときにMapのエントリを削除するクリーンアップコードを書いているなら、それが信号だ。何をいつ削除すればいいかわからなくてメモリが際限なく増えるのを心配しているなら、それも信号だ。

最高の機能というのは、考えなくてもただ動くものだ。

リソース