Die Garbage Collection gibt uns die Möglichkeit, uns auf die Anwendungslogik zu konzentrieren (statt auf die Speicherverwaltung). Allerdings ist die Speicherbereinigung keine Zauberei. Wenn Sie verstehen, wie es funktioniert und wie Sie dafür sorgen können, dass der Speicher erhalten bleibt, der schon vor langer Zeit hätte freigegeben werden sollen, kann dies zu schnelleren und zuverlässigeren Anwendungen führen. In diesem Artikel lernen Sie einen systematischen Ansatz zum Auffinden von Speicherlecks in JavaScript-Anwendungen, mehrere gängige Leckmuster und die geeigneten Methoden zur Behebung dieser Lecks kennen.
1. Einleitung
Beim Umgang mit einer Skriptsprache wie JavaScript vergisst man leicht, dass jedes Objekt, jede Klasse, jeder String, jede Zahl und jede Methode Speicher zuweisen und reservieren muss. Die spezifischen Details der Speicherzuweisung und -freigabe sind den Sprach- und Laufzeit-Garbage Collectors verborgen.
Viele Funktionen können implementiert werden, ohne die Speicherverwaltung zu berücksichtigen, aber das Ignorieren kann zu erheblichen Problemen im Programm führen. Unsachgemäß gereinigte Gegenstände können viel länger als erwartet bestehen bleiben. Diese Objekte reagieren weiterhin auf Ereignisse und verbrauchen Ressourcen. Sie zwingen den Browser, Speicherseiten von einem virtuellen Laufwerk zu reservieren, was den Computer erheblich verlangsamt (und in extremen Fällen zum Absturz des Browsers führt).
Ein Speicherverlust ist jedes Objekt, das bestehen bleibt, nachdem Sie es nicht mehr besitzen oder benötigen. In den letzten Jahren haben viele Browser ihre Fähigkeit verbessert, beim Laden von Seiten Speicher von JavaScript zurückzugewinnen. Allerdings funktionieren nicht alle Browser gleich. Sowohl bei Firefox als auch bei älteren Versionen von Internet Explorer sind Speicherlecks aufgetreten, die bestehen bleiben, bis der Browser geschlossen wird.
Viele klassische Muster, die in der Vergangenheit zu Speicherverlusten geführt haben, verursachen in modernen Browsern keine Speicherverluste mehr. Allerdings gibt es heute einen anderen Trend, der sich auf Speicherlecks auswirkt. Viele entwerfen Webanwendungen so, dass sie auf einer einzigen Seite ohne harte Seitenaktualisierungen ausgeführt werden. Auf einer einzelnen Seite wie dieser lässt sich leicht Speicher behalten, der nicht mehr benötigt oder relevant ist, wenn man von einem Status der Anwendung in einen anderen wechselt.
In diesem Artikel erfahren Sie mehr über den grundlegenden Lebenszyklus von Objekten, wie die Speicherbereinigung feststellt, ob ein Objekt freigegeben wurde, und wie Sie potenzielles Leckverhalten bewerten können. Erfahren Sie außerdem, wie Sie Heap Profiler in Google Chrome verwenden, um Speicherprobleme zu diagnostizieren. Einige Beispiele zeigen, wie Speicherlecks durch Schließungen, Konsolenprotokolle und Schleifen behoben werden.
2. Objektlebenszyklus
Um zu verstehen, wie Speicherlecks verhindert werden können, müssen Sie den grundlegenden Lebenszyklus von Objekten verstehen. Wenn ein Objekt erstellt wird, weist JavaScript dem Objekt automatisch den entsprechenden Speicher zu. Von diesem Moment an wertet der Garbage Collector das Objekt kontinuierlich aus, um festzustellen, ob es noch ein gültiges Objekt ist.
Der Garbage Collector scannt Objekte regelmäßig und zählt die Anzahl anderer Objekte, die Verweise auf jedes Objekt haben. Wenn ein Objekt 0 Referenzen hat (keine anderen Objekte verweisen darauf) oder die einzige Referenz auf das Objekt zirkulär ist, kann der Speicher des Objekts zurückgefordert werden. Abbildung 1 zeigt ein Beispiel für die Speicherrückgewinnung durch den Garbage Collector.
Abbildung 1. Speicherrückgewinnung durch Garbage Collection
Es wäre hilfreich, dieses System in Aktion zu sehen, aber die Tools zur Bereitstellung dieser Funktionalität sind begrenzt. Eine Möglichkeit herauszufinden, wie viel Speicher Ihre JavaScript-Anwendung beansprucht, besteht darin, mithilfe von Systemtools die Speicherzuweisungen Ihres Browsers zu überprüfen. Es gibt mehrere Tools, die Ihnen aktuelle Nutzungs- und Trenddiagramme der Speichernutzung eines Prozesses im Zeitverlauf liefern können.
Wenn Sie beispielsweise XCode unter Mac OSX installiert haben, können Sie die Instruments-App starten und das Aktivitätsmonitor-Tool zur Echtzeitanalyse an Ihren Browser anschließen. Unter Windows® können Sie den Task-Manager verwenden. Sie wissen, dass ein Speicherverlust vorliegt, wenn Sie bei der Verwendung Ihrer Anwendung im Laufe der Zeit einen stetigen Anstieg der Speichernutzung feststellen.
Die Beobachtung des Speicherbedarfs des Browsers gibt nur einen sehr groben Hinweis auf die tatsächliche Speichernutzung einer JavaScript-Anwendung. Durch Browserdaten erfahren Sie nicht, welche Objekte durchgesickert sind, und es gibt keine Garantie dafür, dass die Daten tatsächlich mit dem tatsächlichen Speicherbedarf Ihrer Anwendung übereinstimmen. Aufgrund von Implementierungsproblemen in einigen Browsern werden außerdem DOM-Elemente (oder alternative Objekte auf Anwendungsebene) möglicherweise nicht freigegeben, wenn das entsprechende Element auf der Seite zerstört wird. Dies gilt insbesondere für Video-Tags, bei denen Browser eine aufwändigere Infrastruktur implementieren müssen.
Es gab viele Versuche, die Verfolgung von Speicherzuweisungen in clientseitigen JavaScript-Bibliotheken hinzuzufügen. Leider war keiner der Versuche besonders zuverlässig. Beispielsweise wird das beliebte Paket stats.js aufgrund von Ungenauigkeiten nicht unterstützt. Im Allgemeinen ist der Versuch, diese Informationen vom Client zu verwalten oder zu ermitteln, problematisch, da dadurch ein Mehraufwand für die Anwendung entsteht und diese nicht zuverlässig beendet werden kann.
Die ideale Lösung wäre, dass der Browser-Anbieter eine Reihe von Tools im Browser bereitstellt, mit denen Sie die Speichernutzung überwachen, durchgesickerte Objekte identifizieren und feststellen können, warum ein bestimmtes Objekt immer noch als reserviert markiert ist.
Derzeit implementiert nur Google Chrome (das Heap Profile bereitstellt) ein Speicherverwaltungstool als Entwicklertools. In diesem Artikel verwende ich Heap Profiler, um zu testen und zu demonstrieren, wie die JavaScript-Laufzeit mit Speicher umgeht.
3. Heap-Snapshots analysieren
Bevor Sie ein Speicherleck erzeugen, schauen Sie sich eine einfache Interaktion an, die den Speicher entsprechend sammelt. Erstellen Sie zunächst eine einfache HTML-Seite mit zwei Schaltflächen, wie in Listing 1 gezeigt.
Liste 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 ist enthalten, um eine einfache Syntax für die Verwaltung von Ereignisbindungen zu gewährleisten, die browserübergreifend funktioniert und sich strikt an die gängigsten Entwicklungspraktiken hält. Fügen Sie der Leaker-Klasse und den wichtigsten JavaScript-Methoden Skript-Tags hinzu. In einer Entwicklungsumgebung ist es oft besser, JavaScript-Dateien in einer einzigen Datei zusammenzufassen. Für dieses Beispiel ist es einfacher, die Logik in einer separaten Datei abzulegen.
Sie können den Heap-Profiler filtern, um nur Instanzen einer bestimmten Klasse anzuzeigen. Um diese Funktion zu nutzen, erstellen Sie eine neue Klasse, die das Verhalten von Leckobjekten kapselt. Diese Klasse ist leicht im Heap Profiler zu finden, wie in Listing 2 gezeigt.
Liste 2. asset/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(){ } };
Binden Sie die Schaltfläche „Start“, um das Leaker-Objekt zu initialisieren und es einer Variablen im globalen Namespace zuzuweisen. Sie müssen die Schaltfläche „Zerstören“ außerdem an eine Methode binden, die das Leaker-Objekt bereinigen und für die Garbage Collection vorbereiten soll, wie in Listing 3 gezeigt.
Liste 3. asset/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();
Jetzt können Sie ein Objekt erstellen, es im Speicher anzeigen und dann freigeben.
1) Laden Sie die Indexseite in Chrome. Da Sie jQuery direkt von Google laden, benötigen Sie eine Internetverbindung, um dieses Beispiel auszuführen.
2) Öffnen Sie die Entwicklertools, indem Sie das Menü „Ansicht“ öffnen und das Untermenü „Entwickeln“ auswählen. Wählen Sie den Befehl Entwicklertools.
3) Gehen Sie zur Registerkarte „Profile“ und erstellen Sie einen Heap-Snapshot, wie in Abbildung 2 dargestellt.
Abbildung 2. Registerkarte „Profile“
4) Wenden Sie Ihre Aufmerksamkeit wieder dem Web zu und wählen Sie „Starten“.
5). Holen Sie sich einen weiteren Heap-Snapshot.
6) Filtern Sie den ersten Snapshot und suchen Sie nach Instanzen der Leaker-Klasse, es können jedoch keine Instanzen gefunden werden. Wechseln Sie zum zweiten Snapshot und Sie sollten eine Instanz finden, wie in Abbildung 3 dargestellt.
Abbildung 3. Snapshot-Beispiel
7) Richten Sie Ihre Aufmerksamkeit wieder auf das Web und wählen Sie „Zerstören“.
8). Erhalten Sie den dritten Heap-Snapshot.
9) Filtern Sie den dritten Snapshot und suchen Sie nach Instanzen der Leaker-Klasse, aber es können keine Instanzen gefunden werden. Beim Laden des dritten Snapshots können Sie auch den Analysemodus von Zusammenfassung auf Vergleich umstellen und den dritten und zweiten Snapshot vergleichen. Sie sehen einen Offsetwert von -1 (eine Instanz des Leaker-Objekts wurde zwischen Snapshots freigegeben).
Es lebe! Die Müllabfuhr ist effektiv. Jetzt ist es an der Zeit, es zu zerstören.
4. Speicherleck 1: Schließung
Eine einfache Möglichkeit, zu verhindern, dass ein Objekt durch Garbage Collection erfasst wird, besteht darin, ein Intervall oder eine Zeitüberschreitung für die Referenzierung des Objekts in Rückrufen festzulegen. Um es in Aktion zu sehen, aktualisieren Sie die Klasse „leaker.js“, wie in Listing 4 gezeigt.
Liste 4. asset/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"); } };
Wenn Sie nun die Schritte 1–9 aus dem vorherigen Abschnitt wiederholen, sollten Sie im dritten Snapshot sehen, dass das Leaker-Objekt bestehen bleibt und das Intervall für immer weiterläuft. Was ist also passiert? Alle lokalen Variablen, auf die innerhalb eines Abschlusses verwiesen wird, werden vom Abschluss beibehalten, solange der Abschluss existiert. Um sicherzustellen, dass der Rückruf an die setInterval-Methode ausgeführt wird, wenn auf den Bereich der Leaker-Instanz zugegriffen wird, muss die Variable this der lokalen Variablen self zugewiesen werden, die zum Auslösen von onInterval innerhalb des Abschlusses verwendet wird. Wenn onInterval ausgelöst wird, kann es auf jede Instanzvariable im Leaker-Objekt zugreifen (einschließlich sich selbst). Solange der Ereignis-Listener jedoch vorhanden ist, wird das Leaker-Objekt nicht durch Garbage Collection erfasst.
Um dieses Problem zu beheben, lösen Sie die dem Leaker-Objekt hinzugefügte Destroy-Methode aus, bevor Sie den gespeicherten Verweis darauf löschen, indem Sie den Click-Handler der Destroy-Schaltfläche aktualisieren, wie in Listing 5 gezeigt.
Liste 5. asset/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 是一个诊断内存问题的宝贵工具,在开发时定期使用它也是一个不错的选择。在预测对象曲线图中要释放的具体资源时请设定具体的预期,然后进行验证。任何时候当您看到不想要的结果时,请仔细调查。
在创建对象时要计划该对象的清理工作,这比在以后将一个清理阶段移植到应用程序中要容易得多。常常要计划删除事件侦听器,并停止您创建的间隔。如果认识到了您应用程序中的内存使用,您将得到更可靠且性能更高的应用程序。