メモリリーク — GCがあるのに、なぜ溢れるのか
メモリリークは「使い終わったのに、片付けられないまま残り続けるメモリ」のことです。バケツの水を汲むたびに捨て忘れると、やがて溢れる。プログラムでも同じことが起き、最後はメモリ不足で落ちます。
ここで多くの人がつまずくのが、「GC(ガベージコレクタ)があるのに、なぜリークするのか」という点です。下の図1で、GCを動かしたままリークを起こしてみてください。答えが見えます。
(グローバル変数)
GCが消すのは「ゴミ」ではなく「辿れないもの」
ガベージコレクタは、開発者の気持ちを読んでくれません。GCの判断基準はただ一つ、ルート(グローバル変数や実行中の関数の変数)から参照を辿って到達できるかです。
辿り着けたものは「まだ使う気だ」とみなされ、絶対に消されません。辿り着けなかったものだけが回収されます。つまり「もう使わないオブジェクト」と「GCが消せるオブジェクト」は別物だということです。
図1でリークが止まらないのはこのためです。あなたはもう使うつもりがなくても、cache という配列が掴んだままなので、GCから見れば現役のデータ。だからGCを何度走らせても1マスも空きません。上のタブを「参照を捨てる」に切り替えると、同じ処理なのに使用量が元に戻ります。
だから、リークは「掴みっぱなし」を探す作業になる
現実のメモリリークは、ほとんどが意図せず参照を持ち続けている場所です。よくあるのは次のような形です。
1つ目は、際限なく伸びるグローバルなコレクション。キャッシュのつもりで配列やMapに入れ続け、捨てる条件を書き忘れるパターンです。上限やTTLが無いキャッシュはリークと同じ意味になります。
2つ目は、外し忘れたイベントリスナーやタイマー。要素を画面から消しても、リスナーが登録されたままだと、その要素も一緒に掴まれ続けます。setInterval を止め忘れるのも同じことです。
3つ目は、閉じ込められたクロージャ。小さなコールバックが、たまたま大きなオブジェクトを参照していると、その大きな方まで丸ごと生き残ります。
- ヒープ
- プログラムが動的に確保するメモリ領域。図1のマス目にあたる。
- ルート
- 参照を辿る出発点。グローバル変数や実行中の関数のローカル変数など。
- 到達可能性
- ルートから参照を辿って届くかどうか。GCが生死を決める唯一の基準。
- GC
- 到達できないメモリを自動で回収する仕組み。参照が残っていると何もできない。
- OOM
- Out Of Memory。空きが尽きて確保に失敗し、プログラムが落ちること。
まとめ
メモリリークは「もう使わないのに、参照だけが残っている」状態です。GCがあっても防げないのは、GCが見ているのが使う気があるかではなく辿れるかだから。だから対処も一点に絞られます――掴んだままの参照を、どこで手放すかを決めること。配列から消す、リスナーを外す、タイマーを止める。それだけで、GCは初めて仕事ができるようになります。