はじめに
JavaScript などのスクリプト言語を扱う場合、すべてのオブジェクト、クラス、ストリング、数値、メソッドには、メモリーを割り当てて、そのメモリーを保持する必要がある、ということを忘れがちです。そうした割り当てと割り当て解除の詳細は、スクリプト言語とランタイムのガーベッジ・コレクターによって見えなくなっています。
メモリー管理をまったく考慮しなくても多くのことを実現できますが、メモリー管理を無視するとプログラムに重大な問題が発生する可能性があります。オブジェクトを適切にクリーンアップしないと、それらのオブジェクトは意図したよりもはるかに長期間、メモリー内に残ります。これらのオブジェクトはイベントに応答し続け、リソースを使い続けます。これらのオブジェクトにより、ブラウザーは必然的に仮想ディスク・ドライブからメモリーをページインすることになり、コンピューターの速度を大幅に低下させます (そして極端な場合にはブラウザーが動作しなくなります)。
メモリー・リークというのは、使用されなくなった後、または不要になった後に存在し続けるオブジェクトです。最近では、多くのブラウザーでページ・ロードとページ・ロードの間に JavaScript からメモリーを回収する機能が改善されています。ただし、すべてのブラウザーが同じように動作するわけではありません。Firefox も古いバージョンの Internet Explorer も、ブラウザーを閉じるまで続くメモリー・リークの問題がありました。
かつてメモリー・リークの原因となった数多くの典型的なパターンは、最近のブラウザーではメモリー・リークにはなりませんが、それらとは異なる傾向のものがメモリー・リークに影響しています。現在、多くの人々が設計する Web アプリケーションは、ページの更新をすることなく 1 つのページのコンテキスト内で実行されるように作られています。こうしたコンテキストでは、ある状態から別の状態へとアプリケーションが遷移したときに、もはや必要のないメモリー (つまり、関係のないメモリー) が保持されたままになりがちです。
この記事では、オブジェクトの基本的なライフサイクルについて、さらにはオブジェクトを解放できるのかどうかをガーベッジ・コレクションが判断する方法と、リークを起こす可能性のある動作を評価する方法について説明します。また、Google Chrome の Heap Profiler を使用してメモリーの問題を診断する方法についても説明します。そのなかで、クロージャー、コンソール・ログ、循環参照によるメモリー・リークに対処する方法を例として紹介します。
この記事で紹介する例のソース・コードは「ダウンロード」セクションからダウンロードすることができます。
オブジェクトのライフサイクル
メモリー・リークを防ぐ方法を理解するには、オブジェクトの基本的なライフサイクルを理解することが重要です。オブジェクトが作成されると、JavaScript はそのオブジェクトに対し、適切な量のメモリーを自動的に割り当てます。その時点以降、そのオブジェクトは常にガーベッジ・コレクターによって評価され、そのオブジェクトが相変わらず有効なオブジェクトであるかどうかが判断されます。
ガーベッジ・コレクターは定期的にオブジェクト・グラフ全体を調べ、各オブジェクトを参照している他のオブジェクトの数をカウントします。あるオブジェクトのカウントがゼロの場合 (そのオブジェクトを参照している他のオブジェクトがない場合)、またはそのオブジェクトに対する唯一の参照が循環参照の場合には、そのオブジェクトのメモリーを回収することができます。図 1 はガーベッジ・コレクターがどのようにメモリーを回収するかの例を示しています。
図 1. ガーベッジ・コレクションによるメモリーの回収
システムが動作している状態を実際に見られるとよいのですが、そうしたことができるツールは限られています。JavaScript アプリケーションがどの程度メモリーを使用しているかを把握するための 1 つの方法は、システム・ツールを使用してブラウザーのメモリー割り当てを観察する方法です。いくつかのツールでは、現在のメモリーの使用レベルを表示したり、あるプロセスのメモリー使用量を時間の経過に対してグラフ化したりすることができます。
例えば Mac OSX に XCode をインストールしてあれば、Instruments アプリケーションを起動し、そのアクティビティー・モニター・ツールをブラウザーに追加してリアルタイムの分析を行うことができます。Windows の場合には「タスク マネージャー」を使用することができます。時間の経過に対するメモリー使用量を示すグラフを表示し、アプリケーションのさまざまな部分を操作するにつれてグラフが階段状に上昇していく場合には、メモリー・リークがあると判断することができます。
ブラウザーのメモリー・フットプリントを観察する方法は、JavaScript アプリケーションが実際に使用するメモリーの量を知るための方法としては非常に大まかです。ブラウザーのデータはどのオブジェクトがリークしているのかを示すわけではなく、そのデータがアプリケーションの真のメモリー・フットプリントと実際に一致していることを保証するわけでもありません。また、一部のブラウザーの実装の問題が原因で、ページ内のある要素が破棄されても、それに対応する DOM 要素 (つまり、そのページ内の要素を支えるアプリケーション・レベルのオブジェクト) が解放されない場合があります。この問題は video タグで特によく発生します。ブラウザーで video タグを実装するには、緻密なインフラストラクチャーが必要です。
これまで、クライアント・サイドの JavaScript ライブラリーからメモリー割り当てを追跡する手段を追加しようとする試みがいくつかありました。残念ながら、これらの試みはどれも、あまり信頼できるものではありませんでした。例えば、よく知られている stats.js パッケージは、この機能が不正確であることから、この機能のサポートを停止しました。一般に、この情報をクライアントで管理したり、判断したりしようとすると、問題が起こりがちです。というのも、アプリケーションにオーバーヘッドが追加され、信頼性のある判断ができなくなるためです。
理想的なソリューションはブラウザー・ベンダーがブラウザーの中にツール・セットを提供することです。そうしたツールがあれば、メモリーの使用状況のモニター、リークしているオブジェクトの特定、ある特定のオブジェクトが相変わらず保持する必要があるとマーキングされている理由などを判断することができます。
現状では、開発者ツールの一部としてメモリー管理ツールを実装している唯一のブラウザーは、Heap Profiler を提供している Google Chrome です。この記事では、JavaScript ランタイムがメモリーをどのように処理するかを Heap Profiler を使用してテストし、説明することにします。
ヒープのスナップショットを分析する
メモリー・リークを作成する前に、メモリーが適切にガーベッジ・コレクションされている場合の簡単なやり取りを見てみましょう。まず、2 つのボタンを持つ単純な HTML ページを作成します (リスト 1)。
リスト 1. index.html
<html> <head> <script src="//ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js" type="text/javascript"></script> </head> <body> <button id="start_button">Start</button> <button id="destroy_button">Destroy</button> <script src="assets/scripts/leaker.js" type="text/javascript" charset="utf-8"></script> <script src="assets/scripts/main.js" type="text/javascript" charset="utf-8"></script> </body> </html>
イベントのバインドがさまざまなブラウザーで適切に動作するとともに、最も一般的な開発プラクティスに極めて近いものとなるようにし、それを単純な構文で扱えるようにするために jQuery を読み込ませています。そして、Leaker
クラスとメインの JavaScript メソッドを読み込むための script タグが追加されています。本番では、複数の JavaScript ファイルを 1 つのファイルにまとめた方が、一般には適切なプラクティスですが、この例の場合には別のファイルにロジックを保持した方が簡単です。
Heap Profiler をフィルタリングすると、特定のクラスのインスタンスのみを表示することができます。この機能を活用するために、リークを起こしているオブジェクトの動作をカプセル化して、プロファイラーの中で見つけやすくした新しいクラスを作成します (リスト 2)。
リスト 2. assets/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(){ } };
Leaker
オブジェクトを初期化して、そのオブジェクトへの参照をグローバル名前空間の変数に代入するメソッドに、「Start (開始)」ボタンをバインドします。また、Leaker
オブジェクトをクリーンアップしてガーベッジ・コレクションの対象にするメソッドに、「Destroy (破棄)」ボタンをバインドします (リスト 3)。
リスト 3. assets/scripts/main.js
$("#start_button").click(function(){ if(leak !== null || leak !== undefined){ return; } leak = new Leaker(); leak.init(); }); $("#destroy_button").click(function(){ leak = null; }); var leak = new Leaker();
この時点で、オブジェクトを作成し、そのオブジェクトのメモリー内での動作を観察し、そのオブジェクトを解放する準備が整いました。
- Chrome に index ページをロードします。
Google から jQuery を直接ロードするため、このサンプルを実行するにはインターネット接続が必要です。
- Chrome ブラウザーのツールバーにある Chrome メニュー表示用のアイコンをクリックし、「Tools (ツール)」 > 「Developer Tools (デベロッパー ツール)」の順に選択することで、「デベロッパー ツール」を開きます。
- 「Profiles (プロファイル)」タブを選択し、ヒープのスナップショットをとります (図 2)。
図 2.「Profiles (プロファイル)」タブ
- Web ページにフォーカスを戻し、「Start (開始)」ボタンをクリックします。
- ヒープのスナップショット (2 度目) をとります。
- 最初のスナップショットをフィルタリングし、
Leaker
クラスのインスタンスを探します。しかしインスタンスは見つからないはずです。2 度目にとったスナップショットに切り換えると、インスタンスが 1 つ見つかるはずです (図 3)。図 3. スナップショットに表示されたインスタンス
- Web ページにフォーカスを戻し、「Destroy (破棄)」ボタンをクリックします。
- ヒープのスナップショット (3 度目) をとります。
- 3 度目のスナップショットをフィルタリングし、
Leaker
クラスのインスタンスを探します。しかしインスタンスは見つからないはずです。あるいは、3 度目のスナップショットがロードされた状態で、分析モードを「Summary (要約)」から 「Comparison (比較)」に切り換え、2 度目のスナップショットと 3 度目のスナップショットとを比較します。すると「-1」という差分が表示されるはずです (2 度目のスナップショットと 3 度目のスナップショットとの間で
Leaker
オブジェクトのインスタンスの 1 つが解放されているということです)。
素晴らしい!ガーベッジ・コレクションが動作しています。では、今度はガーベッジ・コレクションに問題を発生させます。
メモリー・リーク 1: クロージャー
オブジェクトがガーベッジ・コレクションされないようにするための簡単な方法の 1 つは、そのオブジェクトを参照する時間間隔 (インターバル)、つまりタイムアウトをオブジェクトのコールバックの中に設定する方法です。この方法の実際の動作を調べるために、leaker.js クラスをリスト 4 のように変更します。
リスト 4. assets/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(){ this._interval = null; this.start(); }, start: function(){ var self = this; this._interval = setInterval(function(){ self.onInterval(); }, 100); }, destroy: function(){ if(this._interval !== null){ clearInterval(this._interval); } }, onInterval: function(){ console.log("Interval"); } };
ここで、上記のセクションの 1 から 9 までのステップを繰り返すと、スナップショットの中に 3 つの Leaker
オブジェクトが残っており、インターバルでの参照が永遠に実行されていることがわかります。何が起きたのでしょう?クロージャーの中で参照されているすべてのローカル変数は、そのクロージャーが存在する限り、そのクロージャーによって保持されます。Leaker
インスタンスのスコープにアクセスできる状態で setInterval
メソッドに対するコールバックが実行されるように、this
変数が self
ローカル変数に代入されており、この self
を使用してクロージャー内から onInterval
をトリガーしています。onInterval
がトリガーされると、self
を含め、Leaker
オブジェクト内にある任意のインスタンス変数にアクセスすることができます。しかし Leaker
オブジェクトはイベント・リスナーが存在する限りガーベッジ・コレクションされません。
この問題を解決するには、「Destroy (破棄)」ボタンのクリック・ハンドラーに変更を加えます。具体的には、Leaker
オブジェクトに追加された destroy
メソッドをトリガーし、その後でこのオブジェクトに対する参照 (保存されている参照) を無効 (ヌル) にします (リスト 5)。
リスト 5. assets/scripts/main.js
$("#destroy_button").click(function(){ leak.destroy(); leak = null; });
オブジェクトとオブジェクトの所有権を破棄する
オブジェクトをガーベッジ・コレクションの対象にする役割を持つ標準的な手段を用意することは、適切なプラクティスです。destroy 関数の主な目的は、オブジェクトが実行したことのすべてをクリーンアップする役割を一手に担うことです。例えば、以下のような問題がクリーンアップの対象となります。
- オブジェクトの参照カウントが 0 にならない (この問題に対して destroy 関数は、例えば、問題のあるイベント・リスナーやコールバックを削除し、すべてのサービスからオブジェクトを登録解除します)。
- インターバルごとの処理やアニメーションなど、不必要に CPU サイクルを使用してしまう。
destroy
メソッドはオブジェクトをクリーンアップする上で必要なステップであることが多いのですが、destroy メソッドで十分な場合は稀です。破棄されたオブジェクトに対する参照を保持する他のオブジェクトは、(理論的には) その破棄されたオブジェクトのインスタンスが破棄された後で、その破棄されたオブジェクトのメソッドを呼び出します。この状況はまったく予想できない結果につながる可能性があるため、そのオブジェクトが実際に削除されようとしているときにのみ destroy メソッドが呼び出されることが重要です。
一般に、destroy メソッドが最適な場合というのは、1 つのオブジェクトに対して明確な所有者が 1 人いて、その所有者がそのオブジェクトのライフサイクルに責任を持つ場合です。こうした状況は階層構造のシステムでは頻繁に発生します (MVC フレームワークのビューやコントローラー、キャンバスを描画するシステムのシーン・グラフなど)。
メモリー・リーク 2: コンソール・ログ
あるオブジェクトがメモリー内に保持される場合で特にわかりにくいのが、そのオブジェクトをコンソールにログ出力する場合です。リスト 6 は、この例を示すために Leaker
クラスに変更を加えたものです。
リスト 6. assets/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(){ console.log("Leaking an object: %o", this); }, destroy: function(){ } };
以下のステップを実行すると、コンソールの影響を示すことができます。
- index ページをロードします。
- 「Start (開始)」ボタンをクリックします。
- コンソールを開き、リークしているオブジェクトがトレースされていることを確認します。
- 「Destroy (破棄)」ボタンをクリックします。
- コンソールに戻って「
leak
」と入力し、グローバル変数の現在の内容をログに出力します。この時点では値はヌルのはずです。 - 再度ヒープのスナップショットをとり、フィルタリングして
Leaker
オブジェクトを探します。Leaker
オブジェクトが 1 つ残っているはずです。 - コンソールに戻り、コンソールをクリアします。
- 再度ヒープのプロファイルをとります。
残っていた Leaker オブジェクトは、コンソールがクリアされた後では、クリーンアップされているはずです。
コンソール・ログの取得がメモリーのプロファイル全体に与える影響は、非常に大きな問題になる可能性がありますが、多くの開発者はそれについて検討すらしません。不適切なオブジェクトをログに出力すると、大量のデータがメモリーに残る可能性があります。このことが以下のオブジェクトにも当てはまることは、留意すべき重要な点です。
- ユーザーが JavaScript を入力するコンソールで、対話型セッションを行う間にログ出力されるオブジェクト
console.log
メソッドとconsole.dir
メソッドによってログ出力されるオブジェクト
メモリー・リーク 3: 循環参照
2 つのオブジェクトが互いに相手を保持する形で互いを参照している場合、循環参照が発生します (図 4)。
図 4. 互いの参照によって循環が作り出されます
リスト 7 に単純なコードの例を示します。
リスト 7. assets/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(name, parent){ this._name = name; this._parent = parent; this._child = null; this.createChildren(); }, createChildren:function(){ if(this._parent !== null){ // Only create a child if this is the root return; } this._child = new Leaker(); this._child.init("leaker 2", this); }, destroy: function(){ } };
ルート・オブジェクトのインスタンス化をリスト 8 のように変更します。
リスト 8. assets/scripts/main.js
leak = new Leaker(); leak.init("leaker 1", null);
オブジェクトを作成して破棄した後にヒープ分析をすると、「Destroy (破棄)」ボタンが選択されたときにガーベッジ検出プログラムが循環参照を検出してメモリーを解放したことがわかります。
しかし、子を保持する 3 番目のオブジェクトが追加されると、この循環参照によってメモリー・リークが発生します。例えば、リスト 9 のような registry
オブジェクトを作成してみます。
リスト 9. assets/scripts/registry.js
var Registry = function(){}; Registry.prototype = { init:function(){ this._subscribers = []; }, add:function(subscriber){ if(this._subscribers.indexOf(subscriber) >= 0){ // Already registered so bail out return; } this._subscribers.push(subscriber); }, remove:function(subscriber){ if(this._subscribers.indexOf(subscriber) < 0){ // Not currently registered so bail out return; } this._subscribers.splice( this._subscribers.indexOf(subscriber), 1 ); } };
registry
クラスはオブジェクトの単純な例です。このオブジェクトは、他のクラスがこのオブジェクトに登録できるようにし、また自ら登録を解除できるようにします。この登録されたオブジェクトに関して registry クラスが何かをするわけではありませんが、このパターンはイベント・ディスパッチャーやイベント通知システムでは一般的です。
leaker.js を読み込む前に、このクラスを index.html ページにインポートします (リスト 10)。
リスト 10. index.html
<script src="assets/scripts/registry.js" type="text/javascript" charset="utf-8"></script>
Leaker
オブジェクトに変更を加え、(例えば、実装されていない何らかのイベントに関して通知するために) この Leaker オブジェクト自体を registry オブジェクトに登録します。すると子の Leaker オブジェクトを保持するためのルート・ノードからの別のパスが作成され、循環参照により、その親もやはり保持されます (リスト 11)。
リスト 11. assets/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(name, parent, registry){ this._name = name; this._registry = registry; this._parent = parent; this._child = null; this.createChildren(); this.registerCallback(); }, createChildren:function(){ if(this._parent !== null){ // Only create child if this is the root return; } this._child = new Leaker(); this._child.init("leaker 2", this, this._registry); }, registerCallback:function(){ this._registry.add(this); }, destroy: function(){ this._registry.remove(this); } };
最後に main.js に変更を加え、registry オブジェクトをセットアップして、この registry オブジェクトに対する参照を親の Leaker
オブジェクトに渡します (リスト 12)。
リスト 12. assets/scripts/main.js
$("#start_button").click(function(){ var leakExists = !( window["leak"] === null || window["leak"] === undefined ); if(leakExists){ return; } leak = new Leaker(); leak.init("leaker 1", null, registry); }); $("#destroy_button").click(function(){ leak.destroy(); leak = null; }); registry = new Registry(); registry.init();
これでヒープの分析をすると、「Start (開始)」ボタンをクリックするたびに、Leaker
オブジェクトのインスタンスが新たに 2 つ作成されて保持されることがわかるはずです。図 5 はオブジェクト参照のフローを示しています。
図 5. 参照が保持されることでメモリー・リークが発生する
表面的には、この例はわざとらしく見えるかもしれませんが、実際には極めて一般的です。より典型的なオブジェクト指向フレームワークのイベント・リスナーは、多くの場合は図 5 のようなパターンに従っています。この種のパターンがクロージャーやコンソール・ログによる問題と緊密に関係している場合もあります。
この種の問題への対処方法はいくつかありますが、ここでは Leaker
クラスを破棄する時に子オブジェクトも破棄するように Leaker クラスを変更する方法が最も簡単な変更です。この例の場合には、リスト 13 のように destroy
メソッドを更新すれば十分です。
リスト 13. assets/scripts/leaker.js
destroy: function(){ if(this._child !== null){ this._child.destroy(); } this._registry.remove(this); }
場合によると、一方のオブジェクトが他方のオブジェクトのライフサイクルに責任を持つほど強力な関係がない場合にも、2 つのオブジェクトの間に循環参照が存在する場合があります。そうした場合には、その 2 つのオブジェクトの間に関係を確立した方のオブジェクトは、自分が破棄されるときに循環参照を解消する責任があります。
まとめ
JavaScript はガーベッジ・コレクションされますが、それでも不必要なオブジェクトがメモリー内に残ってしまう場合が多々あります。最近のほとんどのブラウザーはメモリーのクリーンアップに関して改善されていますが、アプリケーションのメモリー・ヒープを評価するために利用可能なツールは (Google Chrome を除いて) 相変わらず限られています。単純なテスト・ケースから開始すると、リークを起こす可能性のある動作かどうかの評価や、リークがあるかどうかの判断はかなり容易です。
テストをしない限り、メモリーの使用状況を正確に把握することは不可能です。循環参照を許すことによってオブジェクト・グラフの大部分を保持してしまうことは極めてありがちなことです。Chrome の Heap Profiler はメモリーの問題を診断する上で非常に役立つツールであり、このツールを開発作業の中で日常的に使用するのが賢明です。オブジェクト・グラフ内の特定のリソースがいつ解放されるのかを具体的に想定し、それを検証してください。想定外の結果を見つけた場合には、その問題を調べてください。
オブジェクトを作成する際にオブジェクトのクリーンアップの計画を立てることは、後からアプリケーションにクリーンアップのステップを追加するよりもはるかに容易です。イベント・リスナーやインターバルごとの処理を作成する際には、必ずイベント・リスナーの削除やインターバルごとの処理の停止についての計画を立ててください。アプリケーションがどのようにメモリーを使用しているかを理解すると、より信頼性とパフォーマンスの高いアプリケーションを作成できるようになります。
ダウンロード
内容 | ファイル名 | サイズ |
---|---|---|
Article source code | JavascriptMemoryManagementSource.zip | 4KB |
参考文献
学ぶために
- Chrome Developer Tools: Heap Profiling: このチュートリアルを行い、Heap Profiler を使用してアプリケーションのメモリー・リークを検出する方法を学んでください。
- 「Memory leak patterns in JavaScript」(developerWorks、2007年4月): JavaScript の循環参照について、またなぜ一部のブラウザーで JavaScript の循環参照が問題を起こすのかの基本を学んでください。
- 「Finding Memory Leaks」: この資料を読み、たとえソースに関する知識がなくとも容易にリークを診断できることを学んでください。
- 「JavaScript Memory Leaks」: メモリー・リークの原因と検出について学んでください。
- 「JavaScript and the Document Object Model」(developerWorks、2002年7月): JavaScript によって DOM を処理する方法、またユーザーがメモを追加でき、メモの内容を編集できる Web ページの作成方法を解説した記事を読んでください。
- JavaScript 「再」入門: JavaScript とその機能の詳細について調べてください。
- developerWorks の Web development ゾーン: Web ベースのさまざまなソリューションを解説した記事が豊富に用意されています。Web development 技術文書一覧に用意された、さまざまな技術記事やヒント、チュートリアル、技術標準、IBM Redbooks をご覧ください。
- developerWorks のテクニカル・イベントと Webcast: これらのセッションで最新情報を入手してください。
- developerWorks on-demand demos: 初心者のための製品インストール方法やセットアップのデモから、上級開発者のための高度な機能に至るまで、多様な話題が解説されています。
- developerWorks は Twitter を利用しています: 今すぐ developerWorks のツイートをフォローしてください。
製品や技術を入手するために
- Developer Channel: Google Chrome のリリースと最新バージョンの Developer Tools を入手してください。
- IBM 製品の評価版: IBM 製品の評価版をダウンロードするか、あるいは IBM SOA Sandbox のオンライン試用版で、DB2、Lotus、Rational、Tivoli、WebSphere などが提供するアプリケーション開発ツールやミドルウェア製品を試してみてください。
議論するために
- developerWorks コミュニティー: ここでは他の developerWorks ユーザーとのつながりを持てる他、開発者によるブログ、フォーラム、グループ、Wiki を調べることができます。
コメント
IBM PureSystems
IBM がどのように IT に革命をもたらしているのかをご自身でお確かめください
Knowledge path
developerWorks の Knowledge path シリーズでは、テーマ別の学習資料をご提供しています
ソフトウェア評価版: ダウンロード
developerWorksでIBM製品をお試しください!