ガベージ コレクションにより、(メモリ管理ではなく) アプリケーション ロジックに集中できるようになります。ただし、ガベージ コレクションは魔法ではありません。その仕組みと、ずっと前に解放されているはずだったメモリを保持する方法を理解することで、アプリケーションの高速化と信頼性の向上につながる可能性があります。この記事では、JavaScript アプリケーションのメモリ リークを特定する体系的なアプローチ、いくつかの一般的なリーク パターン、およびこれらのリークを解決するための適切な方法を学びます。
1. はじめに
JavaScript のようなスクリプト言語を扱う場合、すべてのオブジェクト、クラス、文字列、数値、メソッドでメモリを割り当てて予約する必要があることを忘れがちです。メモリの割り当てと割り当て解除の具体的な詳細は、言語およびランタイム ガベージ コレクターからは隠されています。
多くの機能はメモリ管理を考慮せずに実装できますが、メモリ管理を無視するとプログラムに重大な問題が発生する可能性があります。不適切にクリーニングされたオブジェクトは、予想よりもはるかに長く存続する可能性があります。これらのオブジェクトは引き続きイベントに応答し、リソースを消費します。これらはブラウザに仮想ディスク ドライブからメモリ ページを強制的に割り当て、コンピュータの速度を大幅に低下させます (極端な場合にはブラウザのクラッシュを引き起こします)。
メモリ リークとは、所有または必要がなくなった後も存続するオブジェクトです。近年、多くのブラウザでは、ページの読み込み中に JavaScript からメモリを再利用する機能が向上しています。ただし、すべてのブラウザが同じように動作するわけではありません。 Firefox と Internet Explorer の古いバージョンの両方で、ブラウザを閉じるまで続くメモリ リークが発生しました。
過去にメモリ リークを引き起こした多くの古典的なパターンは、最新のブラウザではメモリ リークを引き起こさなくなりました。ただし、現在、メモリ リークに影響を与える別の傾向があります。多くの企業は、ハード ページを更新せずに単一ページで実行される Web アプリケーションを設計しています。このような単一ページでは、アプリケーションのある状態から別の状態に移行するときに、不要になったメモリや関連性のなくなったメモリが保持されやすくなります。
この記事では、オブジェクトの基本的なライフサイクル、ガベージ コレクションによってオブジェクトが解放されたかどうかがどのように判断されるか、および潜在的なリーク動作を評価する方法について学びます。さらに、Google Chrome のヒープ プロファイラーを使用してメモリの問題を診断する方法を学びます。いくつかの例では、クロージャ、コンソール ログ、ループからのメモリ リークを解決する方法を示します。
2. オブジェクトのライフサイクル
メモリリークを防ぐ方法を理解するには、オブジェクトの基本的なライフサイクルを理解する必要があります。オブジェクトが作成されると、JavaScript はそのオブジェクトに適切なメモリを自動的に割り当てます。この瞬間から、ガベージ コレクターは継続的にオブジェクトを評価して、それがまだ有効なオブジェクトであるかどうかを確認します。
ガベージ コレクターはオブジェクトを定期的にスキャンし、各オブジェクトへの参照を持つ他のオブジェクトの数をカウントします。オブジェクトの参照が 0 である (他のオブジェクトがそれを参照していない) 場合、またはオブジェクトへの参照が循環のみである場合、オブジェクトのメモリを再利用できます。図 1 は、ガベージ コレクターがメモリを再利用する例を示しています。
図 1. ガベージ コレクションによるメモリの再利用
このシステムが実際に動作しているのを見るのは役に立ちますが、この機能を提供するツールは限られています。 JavaScript アプリケーションがどれだけのメモリを使用しているかを知る方法の 1 つは、システム ツールを使用してブラウザのメモリ割り当てを確認することです。現在の使用量と、プロセスのメモリ使用量の経時的な傾向グラフを提供できるツールがいくつかあります。
たとえば、Mac OSX に XCode がインストールされている場合は、Instruments アプリを起動し、そのアクティビティ モニター ツールをブラウザに接続してリアルタイム分析を行うことができます。 Windows® では、タスク マネージャーを使用できます。アプリケーションの使用中に時間の経過とともにメモリ使用量が着実に増加していることに気付いた場合は、メモリ リークが発生していることがわかります。
ブラウザのメモリ使用量を観察しても、JavaScript アプリケーションの実際のメモリ使用量を非常に大まかに示すだけです。ブラウザーのデータからは、どのオブジェクトが漏洩したかがわかりません。また、データが実際にアプリケーションの実際のメモリ フットプリントと一致するという保証もありません。また、一部のブラウザの実装上の問題により、対応する要素がページ内で破棄された場合、DOM 要素 (または代替のアプリケーション レベルのオブジェクト) が解放されない場合があります。これは、ブラウザがより複雑なインフラストラクチャを実装する必要があるビデオ タグに特に当てはまります。
クライアント側の JavaScript ライブラリにメモリ割り当ての追跡を追加する試みが数多く行われてきました。残念ながら、どの試みも特に信頼できるものではありませんでした。たとえば、人気のある stats.js パッケージは不正確なためサポートされていません。一般に、クライアントからこの情報を維持または決定しようとすると、アプリケーションにオーバーヘッドが発生し、確実に終了できないため、問題が発生します。
理想的な解決策は、ブラウザ ベンダーが、メモリ使用量を監視し、リークしたオブジェクトを特定し、特定のオブジェクトがまだ予約済みとしてマークされている理由を判断するのに役立つ一連のツールをブラウザ内に提供することです。
現在、開発者ツールとしてメモリ管理ツールを実装しているのは、Google Chrome (ヒープ プロファイルを提供する) だけです。この記事では、ヒープ プロファイラーを使用して、JavaScript ランタイムがメモリをどのように処理するかをテストし、デモンストレーションします。
3. ヒープスナップショットの分析
メモリ リークを作成する前に、メモリを適切に収集する単純な対話を確認してください。まず、リスト 1 に示すように、2 つのボタンを持つ単純な HTML ページを作成します。
リスト 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 は、ブラウザー間で機能し、最も一般的な開発慣行に厳密に従ってイベント バインディングを管理するためのシンプルな構文を保証するために組み込まれています。スクリプト タグをリーカー クラスとメインの JavaScript メソッドに追加します。開発環境では、多くの場合、JavaScript ファイルを 1 つのファイルに結合することをお勧めします。この例では、ロジックを別のファイルに配置する方が簡単です。
ヒープ プロファイラをフィルタリングして、特定のクラスのインスタンスのみを表示できます。この機能を利用するには、リークするオブジェクトの動作をカプセル化する新しいクラスを作成します。リスト 2 に示すように、このクラスはヒープ プロファイラーで簡単に見つかります。
リスト 2.assets/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(){ } };
Start ボタンをバインドして Leaker オブジェクトを初期化し、それをグローバル名前空間の変数に割り当てます。また、リスト 3 に示すように、Destroy ボタンを Leaker オブジェクトをクリーンアップしてガベージ コレクションの準備をするメソッドにバインドする必要もあります。
リスト 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();
これで、オブジェクトを作成し、メモリ内で表示して、解放する準備が整いました。
1) Chrome にインデックス ページを読み込みます。 Google から直接 jQuery を読み込んでいるため、このサンプルを実行するにはインターネット接続が必要です。
2) 「表示」メニューを開き、「開発」サブメニューを選択して開発者ツールを開きます。 [開発者ツール] コマンドを選択します。
3) [プロファイル] タブに移動し、図 2 に示すようにヒープ スナップショットを取得します。
図 2. [プロファイル] タブ
4) Web に注意を戻し、[開始] を選択します。
5) 別のヒープ スナップショットを取得します。
6) 最初のスナップショットをフィルタリングして Leaker クラスのインスタンスを探しますが、インスタンスは見つかりません。 2 番目のスナップショットに切り替えると、図 3 に示すようなインスタンスが見つかるはずです。
図 3. スナップショットの例
7) Web に注意を戻し、[破棄] を選択します。
8) 3 番目のヒープ スナップショットを取得します。
9) 3 番目のスナップショットをフィルターして Leaker クラスのインスタンスを探しますが、インスタンスは見つかりません。 3 番目のスナップショットをロードするときに、分析モードを概要から比較に切り替えて、3 番目と 2 番目のスナップショットを比較することもできます。オフセット値 -1 が表示されます (Leaker オブジェクトのインスタンスはスナップショット間で解放されました)。
長生きしてください!ガベージコレクションは効果的です。今こそそれを破壊する時です。
4. メモリリーク 1: 閉鎖
オブジェクトがガベージ コレクションされるのを防ぐ簡単な方法は、コールバックでオブジェクトを参照する間隔またはタイムアウトを設定することです。実際の動作を確認するには、リスト 4 に示すように、leaker.js クラスを更新します。
リスト 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 メソッドへのコールバックが確実に実行されるようにするには、この変数をローカル変数 self に割り当てる必要があります。この変数は、クロージャ内から onInterval をトリガーするために使用されます。 onInterval が起動すると、Leaker オブジェクト (それ自体を含む) 内の任意のインスタンス変数にアクセスできます。ただし、イベント リスナーが存在する限り、Leaker オブジェクトはガベージ コレクションされません。
この問題を解決するには、リスト 5 に示すように、Destroy ボタンのクリック ハンドラーを更新して、リーカー オブジェクトへの保存された参照をクリアする前に、リーカー オブジェクトに追加された destroy メソッドをトリガーします。
リスト 5.assets/scripts/main.js
$("#destroy_button").click(function(){ leak.destroy(); leak = null; });
五、销毁对象和对象所有权
一种不错的做法是,创建一个标准方法来负责让一个对象有资格被垃圾回收。destroy 功能的主要用途是,集中清理该对象完成的具有以下后果的操作的职责:
1、阻止它的引用计数下降到 0(例如,删除存在问题的事件侦听器和回调,并从任何服务取消注册)。
2、使用不必要的 CPU 周期,比如间隔或动画。
destroy 方法常常是清理一个对象的必要步骤,但在大多数情况下它还不够。在理论上,在销毁相关实例后,保留对已销毁对象的引用的其他对象可调用自身之上的方法。因为这种情形可能会产生不可预测的结果,所以仅在对象即将无用时调用 destroy 方法,这至关重要。
一般而言,destroy 方法最佳使用是在一个对象有一个明确的所有者来负责它的生命周期时。此情形常常存在于分层系统中,比如 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(){ } };
可采取以下步骤来演示控制台的影响。
控制台日志记录对总体内存配置文件的影响可能是许多开发人员都未想到的极其重大的问题。记录错误的对象可以将大量数据保留在内存中。注意,这也适用于:
1)、在用户键入 JavaScript 时,在控制台中的一个交互式会话期间记录的对象。
2)、由 console.log 和 console.dir 方法记录的对象。
七、内存泄漏 3:循环
在两个对象彼此引用且彼此保留时,就会产生一个循环,如图 4 所示。
图 4. 创建一个循环的引用
该图中的一个蓝色 root 节点连接到两个绿色框,显示了它们之间的一个连接
清单 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(){ } };
Root 对象的实例化可以修改,如清单 8 所示。
清单 8. assets/scripts/main.js
leak = new Leaker(); leak.init("leaker 1", null);
如果在创建和销毁对象后执行一次堆分析,您应该会看到垃圾收集器检测到了这个循环引用,并在您选择 Destroy 按钮时释放了内存。
但是,如果引入了第三个保留该子对象的对象,该循环会导致内存泄漏。例如,创建一个 registry 对象,如清单 9 所示。
清单 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 类是让其他对象向它注册,然后从注册表中删除自身的对象的简单示例。尽管这个特殊的类与注册表毫无关联,但这是事件调度程序和通知系统中的一种常见模式。
将该类导入 index.html 页面中,放在 leaker.js 之前,如清单 10 所示。
清单 10. index.html
charset="utf-8">
更新 Leaker 对象,以向注册表对象注册该对象本身(可能用于有关一些未实现事件的通知)。这创建了一个来自要保留的 leaker 子对象的 root 节点备用路径,但由于该循环,父对象也将保留,如清单 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 以设置注册表,并将对注册表的一个引用传递给 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 对象的两个新实例。图 5 显示了对象引用的流程。
图 5. 由于保留引用导致的内存泄漏
从表面上看,它像一个不自然的示例,但它实际上非常常见。更加经典的面向对象框架中的事件侦听器常常遵循类似图 5 的模式。这种类型的模式也可能与闭包和控制台日志导致的问题相关联。
尽管有多种方式来解决此类问题,但在此情况下,最简单的方式是更新 Leaker 类,以在销毁它时销毁它的子对象。对于本示例,更新destroy 方法(如清单 13 所示)就足够了。
清单 13. assets/scripts/leaker.js
destroy: function(){ if(this._child !== null){ this._child.destroy(); } this._registry.remove(this); }
有时,两个没有足够紧密关系的对象之间也会存在循环,其中一个对象管理另一个对象的生命周期。在这样的情况下,在这两个对象之间建立关系的对象应负责在自己被销毁时中断循环。
结束语
即使 JavaScript 已被垃圾回收,仍然会有许多方式会将不需要的对象保留在内存中。目前大部分浏览器都已改进了内存清理功能,但评估您应用程序内存堆的工具仍然有限(除了使用 Google Chrome)。通过从简单的测试案例开始,很容易评估潜在的泄漏行为并确定是否存在泄漏。
不经过测试,就不可能准确度量内存使用。很容易使循环引用占据对象曲线图中的大部分区域。Chrome 的 Heap Profiler 是一个诊断内存问题的宝贵工具,在开发时定期使用它也是一个不错的选择。在预测对象曲线图中要释放的具体资源时请设定具体的预期,然后进行验证。任何时候当您看到不想要的结果时,请仔细调查。
在创建对象时要计划该对象的清理工作,这比在以后将一个清理阶段移植到应用程序中要容易得多。常常要计划删除事件侦听器,并停止您创建的间隔。如果认识到了您应用程序中的内存使用,您将得到更可靠且性能更高的应用程序。