メモリリークを見つける
メモリリークとは、使用されたメモリが解放されないまま残り、メモリの空き容量がどんどん減っていく症状のことを指します。ページ内で使用されたメモリは通常、他のページを読み込んだり、ブラウザを終了すると自動的に解放されます。Ajaxを利用すると、ページ遷移を行わずに部分的にコンテンツを読み込んで表示できますが、1つのページを長時間開き続けることが多くなります。ゲームのようなコンテンツも同じページを長時間開き続けることが多いでしょう。このようにユーザーが長時間操作を続けてもページ遷移がないケースでは、正しくメモリ解放を行わずに新しいメモリを使い続けると、メモリが足りなくなりブラウザがだんだん重くなっていくことがあります。この現象がメモリリークです。
ブラウザがどのくらいメモリを使用しているかは、Windowsであればタスクマネージャ、Macであればアクティビティモニタで確認できます。また、Chromeに付属しているタスクマネージャを使うと、ブラウザのタブごとにメモリ使用量を確認できます(図8)。
キャッシュのためなど意図してメモリを確保している場合はよいのですが、意図せずに大量のメモリが消費されていく状況は対処する必要があります。とくに、ユーザーの操作に比例してメモリの消費量が大きくなる場合、1回や2回操作しただけでは何ともなくても、数十回、数百回操作を繰り返した場合に問題が起きることがあります。ユーザーが長時間滞在することを意図したページでは、不要なメモリリークが起きていないかどうか注意してチェックしましょう。
より詳しくメモリリークを見つけるための方法を紹介します。
タイムラインでメモリリークを確認する
調査対象のページをChromeで開き、デベロッパーツールを起動してTimelineパネルを開きます。Memoryタブをクリックし、下のバーにある丸いアイコンのRecordボタンをクリックするとボタンが赤に変わって記録が始まり、メモリ使用量のグラフが描画されていきます。この状態のままページに戻り、ボタンを押すなどの操作をすると、デベロッパーツールのグラフが変化してメモリ使用量の推移がわかります。
図9のようにところどころ崖のように落ちている箇所は、ガベージコレクタが起動してメモリが解放されたことを表しています。操作を繰り返してもメモリ使用量が一定の範囲を保っていれば、メモリリークは起きていません。
一方、図10のようにメモリの使用量が増え続けている場合、メモリリークが発生しています。ガベージコレクタが起動してもメモリが解放できないため、崖ができずに上がり続けています。
再びRecordボタンを押すと記録が停止します。調査を繰り返し、どの操作がメモリリークに直結しているのかを推測します。メモリリークに結びつく操作が推測できたら、今度はメモリ内部の状況を詳しく見ていきます。
調査対象のHTML
今回はリスト4のHTMLをプロファイラで調査してみます。action()
関数の中で大量のdiv要素を作成し、それらをグローバル変数divsに追加しています。
JavaScriptでは、どこからの参照も持たなくなったオブジェクトはガベージコレクタによって回収され、空きメモリとして他の用途に使えるようになります。参照が1つでも残っているオブジェクトはガベージコレクタに回収されずにメモリ上に残ります。
リスト4ではaction()
の実行が終わってもdivsからdivへの参照が残るため、作成されたdivはすべてメモリ上に残ります。
<!doctype html> <html> <head> <meta charset="utf-8"> <title>Leak</title> <script> var divs = []; function action() { for (var i = 0; i < 10000; i++) { var div = document.createElement('div'); // 100バイトの文字列を付加 div.myString = new Array(101).join('1'); divs.push(div); } } </script> </head> <body> <button id="actionButton" onclick="action()">実行</button> </body> </html>
操作の前後の状況を比較する
Chromeでリスト4のHTMLを開き、デベロッパーツールを起動してProfilesパネルを開きます。Take Heap Snapshotを選択してStartボタンを押します。スナップショットの作成が始まり、しばらく待つと左サイドバーにSnapshot 1が現れます。これで操作前のスナップショットが取れました。次に、ページに戻って「実行」ボタンを押します。再びデベロッパーツールに戻り、下の黒丸ボタンを押してスナップショットを取ります。しばらく待つとSnapshot 2が現れます。これが操作後のスナップショットです。
Snapshot 2をクリックし、下のバーのSummaryと表示されているボタンをクリックしてComparisonを選択し、しばらく待つとデータが表示されます(図11)。このComparisonビューは2つのスナップショット間の差分を表しています。それぞれの列の意味は、「# New」は新しく作成された数、「# Deleted」は削除された数、その右のデルタ列は# Newと# Deletedの差分です。Alloc. Sizeは確保されたメモリサイズ、Freed Sizeは解放されたメモリサイズ、そして一番右のデルタ列はAlloc. SizeとFreed Sizeの差分を表しています。
一番右のデルタ列をクリックして降順にソートすると、(string)、つまり文字列が1.08MB増加したことがわかります。(string)に付いている右三角のマークをクリックして展開すると個々の文字列が現れ、そのうちの一つをクリックすると、選択した文字列がどのオブジェクトによって参照されているのかが下の「Object's retaining tree」に表示されます(図12)。
ここでは、文字列がHTMLDivElementから参照されていることがわかります。赤の背景色は、documentに属していないDOM要素であることを表します。HTMLDivElementの階層を展開すると、このHTMLDivElementはArray: @24711という配列から参照されており、Array: @24711はDOMWindow、つまりwindowオブジェクトから参照されていることがわかります。
このArray: @24711の正体を突き止めるため、下のComparisonボタンをクリックしてContainmentビューに移動します(図13)。
Containmentビューではオブジェクト構造の全景を見ることができます。DOMWindowの階層を展開してArray: @24711を探すと、divsという変数名が表示されています(図14)。このdivsの中身をクリアして参照を切ればメモリリークを防げることがわかります。
クロージャによるメモリ消費
クロージャを使う場合、意図せずサイズの大きな変数がメモリを占拠することがないよう注意してください。
例えば、以下のコードではaction()
関数内のローカル変数としてbigString
を作成していますが、そのbigString
はクロージャ内で使用されています。このクロージャはグローバル変数であるglobalObj
に保持されるため、action()
関数の実行後もbigString
が保持されます。クロージャは便利な反面、どのメモリが保持されているかわかりづらくなるため気をつけてください。
<!doctype html> <html lang="ja"> <head> <meta charset="utf-8"> <title>Leak demo</title> <script> var globalObj; function action() { // 10MBの文字列を作成 var bigString = new Array(10485761).join('1'); globalObj = function() { return bigString }; } </script> </head> <body> <button id="actionButton" onclick="action()">実行</button> </body> </html>