Auf der Suche nach JavaScript-Speicherlecks: Erreichbarkeit, Retainer und Aufräumarbeiten
Erfahren Sie, warum garbage-collected JavaScript dennoch Speicherlecks verursacht, welche alltäglichen Muster Speicher beibehalten und wie man mit Heap-Snapsshots sowie Retainer-Ketten den Schuldigen findet.
Eine Webanwendung, die um 9 Uhr morgens sofort reagiert und gegen 17 Uhr träge wird, ist eines der häufigsten Anzeichen für einen Speicherdurchlass: Klicks reagieren etwas verzögert, das Scrollen verliert an Geschmeidigkeit, Animationen stocken, der RAM-Verbrauch steigt über einen Gigabyte an, und ein Neuladen der Seite bringt alles wieder in Ordnung. Solche Durchlässe werfen keine Ausnahmen aus, brechen keine Tests und schaffen es problemlos durch den CI-Prozess, denn sie zeigen sich erst, wenn jemand die Anwendung stundenlang geöffnet lässt. Dieser Leitfaden erklärt, warum auch Sprachen mit Garbage Collection zu Durchlässen führen können, geht auf die Muster ein, die die meisten realen Durchlässe verursachen, und bietet Ihnen einen wiederholbaren Workflow in den Chrome DevTools, um die genaue Referenz zu finden, die den Speicher am Leben hält.
Garbage Collection befreit das Unzugängliche, nicht das Unbenutzte
Weil JavaScript Sie niemals auffordert, malloc() oder free() aufzurufen, ist es verlockend zu glauben, der Laufzeitumgebung obliege die vollständige Verwaltung des Speichers. Diese Annahme ist nur teilweise richtig. Der Sammler holt ein Objekt nicht zurück, sobald Ihr Code damit fertig ist; er holt es erst dann zurück, wenn nichts mehr darauf zugreifen kann. Wenn noch eine vergessene Referenz auf ein Objekt zeigt, hat der Engine keine Möglichkeit zu erkennen, dass dieses Objekt überflüssig ist. Aus Sicht der Laufzeitumgebung könnte alles, was noch erreichbar ist, weiterhin benötigt werden.
Die Lücke zwischen „nicht mehr verwendet“ und „nicht mehr erreichbar“ ist der Ort, an dem einige der schwierigsten Leistungsfehler in der modernen Webentwicklung entstehen.
Ein Szenario: Das Dashboard, das jeden Nachmittag langsamer wurde
Stellen Sie sich ein Operations-Dashboard vor, das das Personal während der gesamten Schicht geöffnet lässt. In der Qualitätskontrolle ist es schnell: schnelle Erstladung, effiziente API-Aufrufe sowie gute Lighthouse-Werte. Dann kommen die Rückmeldungen aus der Produktion. Nach fünf oder sechs Stunden wird das Wechseln zwischen Tabellen langsam, Diagramme werden nur träge neu gezeichnet, und selbst ein einfaches Modal-Fenster braucht eine beträchtliche Zeit, um zu öffnen.
Die typische erste Reaktion ist es, dem Backend die Schuld zu geben. Das Team optimiert die Datenbankabfragen, stellt sicher, dass die API-Antworten unter 100 Millisekunden liegen, und stellt fest, dass die CPU-Nutzung auf den Servern gering ist. Nichts davon erklärt eine Verlangsamung, die im Laufe des Tages zunimmt.
Die Durchbruchslösung stammt aus Chromes Task Manager. Die Tabellenanzeige lag am Morgen bei etwa 150 MB und erreichte gegen Ende des Nachmittags fast 1,4 GB. Jede Navigation, jedes Dialogfeld und jede Aktualisierung eines Widgets hinterließ etwas Speicher. Jede Zuweisung war zwar gering, doch Tausende davon zusammen führten zu Problemen. Die Darstellung, die Netzwerkleistung sowie die Ausführungsgeschwindigkeit waren alle in Ordnung. Die Anwendung gab einfach niemals Objekte frei, die sie nicht mehr benötigte.
Wie Engines entscheiden, was weiterhin genutzt wird
Um ein Objekt in JavaScript zu erstellen, ist überhaupt keine Formalität nötig:
const user = {
id: 101,
name: "Emma"
};
Sobald nichts mehr auf user verweist, kann der Engine diesen wieder freigeben. Interessant ist dabei die Art und Weise, wie sie dies entscheidet. V8 (Chrome und Node.js), SpiderMonkey (Firefox) sowie JavaScriptCore (Safari) verfolgen alle ein Graphen von Objekten, die durch Referenzen miteinander verbunden sind. Wurzeln wie das globale Objekt befinden sich an der Spitze, und alles, was Ihre Anwendung erstellt, hängt von ihnen ab: die Anwendungsinstanz, der Router, der Store, Komponentenbäume sowie alle globalen Variablen.
Ein Sammelzyklus beginnt an diesen Wurzeln und folgt jeder Referenz, die er finden kann. Alles, was er erreicht, bleibt bestehen. Alles, was er nicht erreichen kann, wird zur Entsorgung in Frage kommen. Das Schlüsselwort hierfür ist eligible, und die entscheidende Eigenschaft ist unreachable. Ein Objekt wird nicht gesammelt, weil es alt ist, ungenutzt oder vergessen wurde. Es wird nur dann gesammelt, weil kein Referenzpfad zu ihm führt.
Nehmen wir an, Sie laden eine große Liste von Datensätzen:
const employees = fetchEmployees();
Eine Weile später zeigt die Benutzeroberfläche diese Daten nicht mehr an, doch ein anderes Objekt verweist weiterhin darauf:
cache.employees = employees;
Auch wenn niemals wieder cache.employees gelesen wird, kann der Sammler das nicht annehmen. Da die Referenz weiterhin existiert, bleibt das gesamte Array im Speicher. Der Engine verhält sich genau wie vorgesehen; das Leck stammt vom Anwendungscode.
Daran denken, in Referenzen statt in Objekten
Lecks werden viel leichter zu verstehen, sobald man aufhört, sich auf Objekte zu konzentrieren, und stattdessen an die Referenzen denkt, die auf sie verweisen. Nehmen wir eine Funktion, die ein Objekt erstellt und es zurückgibt:
function createUser() {
const user = {
name: "Alice"
};
return user;
}
const employee = createUser();
Nach ihrer Ausführung verbindet eine einzige Referenz die Variable mit dem Objekt:
employee
│
▼
{ name: "Alice" }
Falls man diese Referenz anschließend löscht, hat das Objekt keine eingehenden Verbindungen mehr und kann bei der nächsten Sammlung entfernt werden:
employee = null;
Eine Korrektur, falls Sie es selbst ausprobieren: In dem vorherigen Beispiel wurde employee mit const deklariert, wodurch eine Neuzuweisung einen TypeError auslöst. Deklarieren Sie es mit let, wenn Sie später die Referenz aufheben möchten. Unabhängig davon gilt: Nur durch das Entfernen der letzten Referenz wird das Objekt für die Sammlung bereitgestellt.
Ändern Sie nun das Beispiel leicht, sodass die Funktion das Erstellte in einem array auf Modulebene speichert:
const users = [];
function createUser() {
const user = {
name: "Alice"
}; users.push(user);
}
Sobald createUser() zurückgegeben hat, wird jedes Benutzerobjekt weiterhin durch das users-Array referenziert, und das Array selbst ist vom obersten Ebene aus erreichbar:
Window
│
▼
users
│
├── User 1
├── User 2
├── User 3
└── User 4
Solange users erreichbar ist, sind auch alle Elemente darin erreichbar. Deshalb wachsen Lecks in der Regel schrittweise: Kein einzelnes Objekt ist groß, aber Tausende kleiner Objekte häufen sich über Stunden oder Tage an.
Lecks entstehen nach und nach durch jede Interaktion
Der Begriff „Speicherleck“ lässt oft das Bild eines riesigen Objekts entstehen, das Hunderte von Megabyte verbraucht. In der Praxis ist das Muster fast immer klein und wiederkehrend.
Stellen Sie sich vor, das Schließen eines Einstellungsdialogs hinterlässt etwa 20 KB. Das ist für sich genommen unbedeutend. Ein Power-User, der diesen Dialog an einem Arbeitstag 500 Mal öffnet und schließt, hat bereits etwa 10 MB verloren. Fügen Sie fünf Komponenten mit ähnlich kleinen Datenverlusten pro Interaktion, eine achtstündige Sitzung, mehrere gleichzeitig geöffnete Tabellen sowie Live-Updates hinzu, die alle paar Sekunden eintreffen, dann verwandeln sich diese unbedeutenden Mengen in Hunderte von Megabyte.
Diese Rechnung erklärt auch, warum Entwickler es selten bemerken. Während der Entwicklung lädt man die Seite alle paar Minuten neu, wodurch alles wieder zurückgesetzt wird. Nutzer hingegen laden die Seite nicht neu; sie arbeiten weiter.
Warum Single-Page-Anwendungen am stärksten betroffen sind
Klassische Mehrseiten-Webseiten hatten eine unbeabsichtigte Sicherheitsmaßnahme: Jede Navigation lud ein neues Dokument und verworf den gesamten JavaScript-Heap, einschließlich der leckenden Objekte.
Einfachseiten-Apps, die mit React, Angular, Vue, Svelte oder ähnlichen Frameworks erstellt werden, können stundenlang ohne vollständige Neu laden ausgeführt werden. Das ist hervorragend für die Benutzererfahrung – und genau deshalb ist Disziplin bei der Speichernutzung wichtig. Jeder Route-Wechsel, jedes Modalfenster, jede Benachrichtigung, jede WebSocket-Nachricht sowie jeder Aktualisierungsvorgang von Diagrammen erzeugt Objekte. Wenn diese nicht ordnungsgemäß freigegeben werden, bleiben sie so lange bestehen wie die Seite selbst.
Darin liegt eine Ironie: Je besser die Benutzererfahrung, desto länger bleiben die Nutzer auf der Seite – und desto mehr Chancen haben kleine Speicherverluste, sich anzusammeln.
Der Speichermanager ist ausgefeilt – nicht hellseherisch
Moderne Engine verwenden schrittweises und generationsbasiertes Sammeln, gleichzeitiges Markieren, Komprimierung sowie Sammeln in Inaktivitätszeiten. Diese Techniken sorgen für eine schnelle und unauffällige Speichermanagement-Logik – doch sie können keine logischen Fehler beheben.
Stellen Sie sich vor, Sie leihen jemandem ein Buch und bitten nie darum, es zurückzubekommen. Die Person kann nicht wissen, dass Sie es vergessen haben, daher gehen sie davon aus, dass Sie es eines Tages trotzdem zurückhaben möchten. Referenzen funktionieren genauso. Solange Ihre Anwendung eine Referenz auf ein Objekt besitzt, geht der Engine davon aus, dass dieses Objekt wichtig ist – unabhängig davon, ob Ihr Code es jemals wieder aufrufen wird. Die Engine kann Absichten nicht erkennen; sie folgt lediglich den Verbindungen im Graphen.
Diese Veränderung beeinflusst die Art und Weise, wie Sie Fehler beheben. Anstatt sich zu fragen, warum der Speichermanager den Speicher nicht freigibt, stellen Sie eine produktivere Frage: Was hält immer noch eine Referenz auf dieses Objekt? Fast immer liegt der Fehler dort.
Die Muster hinter den meisten realen Speicherverlusten
Der Sammler ist nicht kaputt; Lecks entstehen, weil der Code weiterhin Referenzen behält, die er eigentlich loswerden sollte. Solche Referenzen wirken selten verdächtig. Sie stammen aus gewöhnlichem, vernünftig aussehendem Code und nicht von exotischen Algorithmen oder Browserfehlern. Die folgenden Muster umfassen die Lecks, auf die man in der Produktion am ehesten stoßen wird.
Event-Listener, die niemals entfernt werden
Listener gehören zu den häufigsten Ursachen für Lecks, insbesondere in SPAs. Die Einrichtung ist in der Regel unbedenklich: Man holt sich ein Element und fügt einen Handler hinzu.
const button = document.getElementById("save");
button.addEventListener("click", saveDocument);
Später navigiert der Benutzer weg und die Schaltfläche verschwindet von der Seite. Das Entfernen eines Elements aus dem DOM beendet nicht automatisch alle damit verbundenen JavaScript-Referenzen. Wenn der Handler weiterhin registriert ist und etwas anderes das Element oder den Handler weiterhin erreichbar hält, bleiben sowohl der Zuhörer als auch alles, worauf er verweist, im Speicher. Der Leck ist am schlimmsten, wenn Zuhörer an lang lebende Zielobjekte wie window oder document gebunden sind, denn diese Zielobjekte verschwinden niemals. Die Lösung besteht darin, den Handler explizit abzumelden:
button.removeEventListener("click", saveDocument);
In React, Angular oder Vue sollte man dies in der Unmount- oder Destroy-Phase des Komponenten-Lebenszyklus durchführen. Eine nützliche Gewohnheit ist es, jeden Aufruf von addEventListener() als Verpflichtung zu betrachten: Wenn man einen solchen Aufruf vornimmt, muss man wissen, wann und wo er wieder entfernt wird. Das Übergeben eines AbortController-Signals an mehrere Zuhörer und dessen Abbrechen beim Teardown ist eine praktische Methode, um diese Verpflichtungen in großem Umfang einzuhalten.
Timer, die länger bestehen als der Bildschirm
Auch Timer verursachen leise „Lecks“. Ein Dashboard, das alle fünf Sekunden nach neuen Daten fragt, könnte so aussehen:
const timer = setInterval(() => {
loadLatestData();
}, 5000);
Falls der Benutzer die Seite verlässt und der Zeitintervall nicht gelöscht wird, tritt die Callback-Funktion weiterhin im Hintergrund aus. Sie hält ihre Schließfunktion am Leben, und damit auch alle Funktionen, Variablen oder sogar ganze Komponenteninstanzen, auf die diese Schließfunktion verweist. Einige vergessene Zeitintervalle können viel mehr Speicher belegen, als man erwarten würde. Löschen Sie sie, wenn ihr Besitzer weggeht:
clearInterval(timer);
Dieselbe Disziplin gilt für setTimeout() (über clearTimeout()) und requestAnimationFrame() (über cancelAnimationFrame()).
Trennte DOM-Node
Ein getrennter Node ist ein Element, das nicht mehr Teil des Dokuments ist, aber weiterhin aus JavaScript referenziert wird. Das Anheben eines Modals und dessen Entfernen ist eine typische Methode, um einen solchen Node zu erstellen:
const modal = document.getElementById("modal");
modal.remove();
Es scheint verschwunden zu sein, doch wenn noch eine Variable, ein Array, eine Schließfunktion oder ein Zustandsobjekt auf dieses Element verweist, kann es nicht freigegeben werden – genauso wenig wie seine Unterelemente. Apps, die dynamisch Modale, Tooltips, Dropdowns oder Benachrichtigungsleisten erstellen, sind besonders anfällig dafür. Jeder Knoten ist klein, doch nach Hunderten von Interaktionen können die abgetrennten Unterbäume erstaunlich viel Speicher belegen.
Schließfunktionen, die mehr erfassen, als nötig
Schließfunktionen gehören zu den mächtigsten Funktionen von JavaScript – sie erschweren aber auch versehentlich das Freigeben von Speicher. Betrachten wir eine „Fabrik“, die vor dem Zurückgeben einer Funktion ein großes Array zuweist:
function createLogger() {
const largeData = new Array(100000).fill("data");
return function () {
console.log("Logging...");
};
}
Die zurückgegebene Funktion berührt largeData niemals. Ob dieses Array weiterhin vorhanden bleibt, hängt davon ab, wie der Engine den umgebenden Scope darstellt. In der Praxis behält V8 nur solche Variablen bei, auf die eine Schließung in diesem Scope tatsächlich verweist; daher behält dieser spezifische Codeausschnitt in der Regel das Array nicht bei. Das Risiko tritt auf, wenn eine zweite Schließung im selben Scope largeData verwendet: Die Schließungen teilen sich ein Kontextobjekt, wodurch auch der langlebige Logger das große Array am Leben erhält. Die Verwendung von eval innerhalb des Scope zwingt außerdem den Engine, alles beizubehalten.
Nichts davon macht Schließfunktionen schlecht; moderne JavaScript-Implementierungen sind von ihnen abhängig. Die Lektion besteht darin, bewusst zu entscheiden, was eine lang lebende Funktion sehen darf. Wenn sie nur einen Wert benötigt, sollte dieser übergeben oder kopiert werden anstelle eines gesamten Objekts oder Datensatzes zu verwenden. Kleine Anpassungen am Gültigkeitsbereich können den Speicherverbrauch erheblich verringern.
Caches ohne Entfernungsrichtlinie
Caching spart wiederholte Arbeiten, doch ein Cache, der ständig wächst, ist lediglich ein langsamer Speicherleck mit guten Absichten. Hier ist eine minimale Implementierung zur Merkhilfe:
const cache = {};
function getUser(id) {
if (!cache[id]) {
cache[id] = fetchUser(id);
} return cache[id];
}
Zu Beginn funktioniert sie gut. Nach sechs Monaten im Einsatz kann sie jedoch Hunderttausende von Einträgen enthalten, nach denen niemand mehr fragen wird. Anstatt zuzulassen, dass ein Cache unbegrenzt wächst, sollten folgende Aspekte berücksichtigt werden:
- Eine maximale Größe
- Einen zeitbasierten Ablauf für veraltete Einträge
- Eine LRU-(am seltensten genutzte)-Entfernungsstrategie
WeakMap, wenn der Cache nach Objekten sortiert ist, deren Lebensdauer die Lebensdauer des Eintrags bestimmen sollBemerken Sie, dass WeakMap nur Objekte (oder nicht registrierte Symbole) als Schlüssel akzeptiert, weshalb sie eine LRU-Strategie für Caches, die nach numerischen IDs sortiert sind wie oben beschrieben, nicht ersetzen kann. Wenn Sie sich an die Funktionsweise schwacher Referenzen erinnern möchten, lesen Sie unseren Überblick zu Symbols, WeakMaps, Proxies und Generatoren. Ein Cache ohne Löschstrategie ist eigentlich kein Cache – es handelt sich dabei um eine dauerhafte Speicherung.
Globale Variablen, die ewig bestehen
Alles, was im globalen Scope vorhanden ist, existiert so lange wie die Anwendung. Das ist gleichermaßen praktisch und riskant. Eine Sammlung auf Modulebene wie diese:
let allUsers = [];
wächst jedes Mal weiter, wenn neue Daten hinzugefügt werden:
allUsers.push(...newUsers);
Sofern kein Code es explizit kürzt, wird der Array nur immer größer. Im Laufe mehrerer Monate Entwicklung neigen große globale Objekte dazu, zu Sammelstellen zu werden, an denen Daten anhäufen. Wenn man ein Leistungsproblem untersucht, ist der globale Zustand einer der ersten Orte, die man überprüfen sollte.
WebSockets und andere langanhaltende Verbindungen
Echtzeitfunktionen verlassen sich oft auf WebSockets, und deren Eröffnung erfordert nur eine einzige Zeile:
const socket = new WebSocket(url);
Der Fehler besteht darin, sie nicht zu schließen. Ein offener Socket erhält weiterhin Nachrichten, ruft Callbacks aus und behält den Anwendungsstatus bei, nachdem sich der Benutzer an einen anderen Ort begeben hat. Schließen Sie Verbindungen, sobald die Funktion, die sie benötigt, nicht mehr vorhanden ist:
socket.close();
Dieselbe Regel gilt für Observables, Streams, benutzerdefinierte Ereignisemitter sowie alle anderen Abonnements, die länger bestehen können als ihr Verbraucher.
Der gemeinsame Nenner
Obwohl diese Beispiele auf den ersten Blick unterschiedlich erscheinen, haben sie alle denselben Ursprung: Etwas behält weiterhin eine Referenz auf ein Objekt, das eigentlich unerreichbar sein sollte. In der Regel handelt es sich dabei um eines von folgendem:
- Einen registrierten Zuhörer
- Eine ausstehende Zeitintervall- oder Timeout-Einstellung
- Einen Schließungsbereich
- Einen stetig wachsenden Cache
- Eine modulweite oder globale Variable
- Einen offenen Socket oder eine andere Abonnementsverbindung
Sobald man anstelle von Objekten an Referenzen denkt, wird das Aufspüren von Lecks viel intuitiver. Anstatt zu fragen, warum der Speicherbedarf weiter steigt, sollte man sich fragen, was noch an dem Objekt festhält. Diese Frage führt in der Regel direkt zum Leck.
Das Leck vor Ihren Nutzern finden
Es ist nützlich, die Ursachen zu kennen, aber in einer echten Codebasis stellt sich die eigentliche Frage einfach dar: Wo liegt das Leck? Eine große Anwendung kann Tausende von Komponenten und Hunderte von Zuhörern haben, wobei jedes Sekunde neue Objekte erstellt werden. Raten funktioniert selten. Die Tools der Browser sind hervorragend; entscheidend ist es, sie in einer konsistenten und disziplinierten Reihenfolge zu verwenden, anstatt alle Funktionen der DevTools zu lernen.
Überprüfen, ob tatsächlich ein Leck vorliegt
Ein Anstieg des Arbeitsspeichers bedeutet nicht automatisch ein Leck. Die Engine verteilt Speicher während der Nutzung der Anwendung und holt ihn bei der Sammlung wieder zurück, sodass eine gesunde Anwendung ein Zahnradmuster aufweist:
Memory
^
| /\ /\ /\
| / \ / \ / \
|______/____\__/____\___/____\____ Time
Der Speicherverbrauch steigt während der Aktivität an und fällt nach jeder Sammlung wieder ab. Eine Anwendung mit einem Leck zeigt ein anderes Muster:
Memory
^
| /\ /\
| / \ / \
| / \ / \
|_______/______\__/______\________
| /
| /
| /
|___________/________________ Time
Die kleinen Tiefpunkte zeigen an, dass die Sammlung läuft, doch jeder Tiefpunkt liegt höher als der vorherige. Der Speicher kehrt niemals zu seinem ursprünglichen Niveau zurück – das ist das erste echte Anzeichen dafür, dass Objekte behalten werden. Bevor Sie Schlüsse ziehen, aktivieren Sie die Sammlung manuell (das Mülleimer-Symbol in den Panels „Memory“ und „Performance“), denn ein Niveau, das nur deshalb hoch erscheint, weil die Sammlung noch nicht stattgefunden hat, deutet nicht auf einen Speicherdurchbruch hin.
Schritt 1: Das Memory-Panel öffnen
Chrome bietet mehrere Tools zum Überwachen des Speichers, doch Sie benötigen nicht alle auf einmal. Öffnen Sie die DevTools und wechseln Sie zum Memory-Panel. Je nach Chrome-Version sehen Sie Profilierungstypen wie einen Heap-Snapshot, Instrumentierungen zur Überwachung von Speicherzuweisungen in einer Zeitlinie sowie Auswertungen durch Sampling; die genauen Namen und Optionen ändern sich je nach Version, daher prüfen Sie bei abweichenden Funktionen die aktuelle DevTools-Dokumentation.
Für die meisten Untersuchungen ist ein Heap-Snapshot der richtige Ausgangspunkt, denn er zeigt an, was derzeit im Speicher vorhanden ist.
Schritt 2: Eine Baseline aufnehmen
Vor dem Ausprobieren der verdächtigen Funktion sollten Sie einen Snapshot des ursprünglichen Zustands der Anwendung erstellen, also ein Bild des Heaps. Führen Sie anschließend die von Ihnen vermutete Interaktion wiederholt durch. Beispiele:
- Einen Modal-Fenster zehn Mal öffnen und schließen
- Zwischen Seiten hin- und herwechseln
- Einen Dateiupload durchführen
- Filter auf eine große Datentabelle anwenden
- Immer wieder zwischen den Dashboard-Taben wechseln
Sobald Sie fertig sind, nehmen Sie einen weiteren Snapshot auf. Nun haben Sie zwei Zustände zum Vergleichen.
Schritt 3: Die Snapshots vergleichen
Hier beginnt die eigentliche Untersuchung. Wenn die Aufräumarbeiten funktionieren, sollten die während der Interaktion erstellten temporären Objekte nach ihrer Sammlung verschwinden. Fällt das nicht auf, nehmen bestimmte Objekttypen weiter zu. Typische Verdächtige sind:
- Trennte DOM-Elemente
- Große Arrays
- Event-Listener
- Ihre eigenen Anwendungsklassen
- Framework-Komponenten, die eigentlich zerstört worden wären
Es ist nicht notwendig, jeden Eintrag im Heap zu verstehen. Verwenden Sie den Vergleichsbereich und suchen Sie nach Objekttypen, deren Anzahl bei jeder Wiederholung derselben Aktion um einen konstanten Betrag zunimmt. Konsistenz ist in der Regel der stärkste Hinweis.
Trennte DOM-Node sind das leichteste Indiz zu erkennen
Abschnittene Knoten gehören zu den einfachsten Lecks, die man erkennen kann. Öffnen und schließen Sie ein Modalfenster zwanzig Mal; nach jedem Schließen sollte dieses Fenster verschwinden. Wenn der Snapshot immer noch zwanzig Modalelemente enthält, hält etwas sie zurück. Sie können „Detached“ in den Klassensieb des Snapshots eingeben, um sie schnell aufzulisten.
Das DOM selbst ist selten das eigentliche Problem. Die tatsächliche Ursache liegt in der Regel woanders:
- Ein Handler, der immer noch im Element oder in einem lang lebenden Ziel registriert ist
- Ein Intervall oder Timeout, dessen Callback auf den Knoten verweist
- Eine Schließfunktion, die das Element erfasst hat
- Ein Speicher, ein Komponentenfeld oder ein Array, das einen Zeiger darauf gespeichert hat
Betrachten Sie den abgetrennten Knoten als Symptom – als Beweis dafür, dass eine andere Referenz die Bereinigung verhindert hat.
Folgen Sie der Retainer-Kette
Sobald Sie ein Objekt finden, das offensichtlich nicht mehr existieren sollte, stellt sich die nächste Frage: Wer hält es am Leben? Der Abschnitt Retainers einer Heap-Snapshot-Datei beantwortet genau diese Frage. Wählen Sie das Objekt aus, und Chrome zeigt die Referenzkette an, die es wieder zu einer GC-Root verbindet. Konzeptionell könnte das wie folgt aussehen:
Window
│
Application
│
UserService
│
cachedUsers
│
User Object
Die Untersuchung wird nun einfacher. Anstatt sich darüber zu wundern, warum ein Benutzerobjekt weiterhin vorhanden ist, können Sie erkennen, dass es durch eine cachedUsers-Sammlung innerhalb von UserService referenziert wird. Das Finden des behaltenen Objekts ist hilfreich; doch erst die Identifizierung dessen, was es am Leben hält, behebt tatsächlich den Fehler.
Live-Zähler mit dem Performance Monitor überwachen
Snapshots eignen sich ideal für detaillierte Analysen, sind aber nicht das einzige Werkzeug. Chromes Performance Monitor zeigt Live-Metriken, zu denen gehören:
- JS-Heap-Größe
- Anzahl der DOM-Node
- Anzahl der JS-Ereignislistener
- Dokumente und Frames
Falls die Anzahl der DOM-Node oder Listener weiter steigt, während Sie dieselbe Aktion wiederholen, findet keine Aufräumarbeit statt. Der Vorteil liegt in der Geschwindigkeit: Sie müssen nicht warten, bis die Anwendung langsam wird, und verdächtige Trends werden oft bereits innerhalb weniger Minuten sichtbar.
Isolieren Sie ein kleines, wiederholbares Szenario
Ein häufiger Fehler ist es, versuchen zu wollen, die gesamte Anwendung auf einmal zu untersuchen. Konzentrieren Sie sich stattdessen auf eine einzige Interaktion:
- Ziehen Sie ein einzelnes Modalfenster auf, schließen Sie es wieder und wiederholen Sie das zwanzig Mal in Folge
- Oder wechseln Sie fünfzig Mal zwischen denselben zwei Routen hin und her
Situationen wie diese, in denen Ressourcen knapp sind, lassen sich viel leichter quantifizieren. Wenn eine wiederholte Aktion bei jeder Ausführung zu einem Wachstum der Speichernutzung führt, haben Sie den Suchbereich bereits erheblich eingegrenzt, und der fehlerhafte Code ist in der Regel leicht daraufhin zu finden.
Testen Sie längere Sitzungen absichtlich
Entwickler neigen dazu, eine App zehn oder fünfzehn Minuten lang zu nutzen und dann weiterzumachen. Reale Nutzer eines internen Dashboards, einer Handelsplattform, eines Überwachungstools oder eines Support-Portals lassen sie möglicherweise den ganzen Tag über geöffnet. Beinhaltet in Ihren Speichertest auch längere Sitzungen: Lassen Sie die App geöffnet, interagieren Sie periodisch mit ihr und beobachten Sie, wie sich der Speicherverbrauch entwickelt. Viele Speicherlecks werden erst nach Hunderten oder Tausenden von Interaktionen sichtbar.
Ein Debugging-Workflow, der Mutmaßungen vermeidet
Dazwischenwechseln zwischen Profilern verschwendet Zeit. Eine feste Vorgehensweise funktioniert in der Regel besser:
- Stellen Sie sicher, dass der Speicherverbrauch über die verschiedenen Datenkollektionen hinweg weiter ansteigt.
Dadurch entfällt das Raten im Prozess. Anstatt zu vermuten, welcher bestimmte Bestandteil schuld ist, lassen Sie die Beweise darauf hinweisen.
Vermeidung von Lecks, bevor sie in die Produktion gelangen
Die Diagnose ist nur die halbe Miete; der günstigere Weg besteht darin, von vornherein keine Lecks entstehen zu lassen. Die meisten Lecks sind nicht das Ergebnis eines Fehlverständnisses von JavaScript seitens der Entwickler. Sie entstehen, weil moderne Anwendungen lang lebensfähig, hochinteraktiv und ständig Ressourcen allozieren – in dieser Umgebung ist es leicht zu vergessen, dass alles, was man erstellt, letztendlich wieder beseitigt werden muss. Teams, die selten mit Speicherproblemen zu kämpfen haben, schreiben nicht unbedingt intelligenteren Code; sie verfügen einfach über Gewohnheiten, die das Entstehen von Lecks unwahrscheinlich machen.
Geben Sie jeder Ressource einen expliziten Lebensende
Jedes Mal, wenn Code etwas Langlebiges erstellt, stellen Sie sich eine Frage: Wann wird das zerstört? Das gilt für weitaus mehr als nur den Rohspeicher:
- Listener auf Elementen,
windowoderdocument - Intervalle, Timeout-Funktionen und Animationsschritte
- Sockets sowie andere Netzwerkverbindungen
Die Einrichtung dieser Elemente ist in der Regel einfach; beim Aufräumen versagen Anwendungen oft. Eine einfache Regel fasst das zusammen: Wenn Ihr Code einen Startpunkt hat, braucht er auch ein Ende. Schon diese Denkweise verhindert einen erheblichen Teil der Leckagen.
Sorgen Sie dafür, dass Komponenten sich selbst aufräumen
Frameworke auf Komponentenbasis fördern selbstständige Einheiten, und Selbstständigkeit sollte auch das Aufräumen beinhalten. Eine Komponente, die einen Timer startet, stoppt ihn, wenn sie deinstalliert wird. Eine Komponente, die Zuhörer registriert, entfernt sie wieder. Nichts, was von einer Komponente gestartet wurde, sollte weiterlaufen, sobald sie außer Sichtweite ist.
Stellen Sie sich vor, Sie verlassen ein Hotelzimmer: Sie gehen nicht mit eingeschaltetem Licht, laufendem Fernseher und fließendem Wasser. Ein ordentlich funktionierendes Komponenten-Element hinterlässt alles in dem Zustand, in dem es es vorgefunden hat. In React bedeutet das, dass von jeder useEffect-Funktion, die sich anmeldet, Termine festlegt oder Verbindungen herstellt, eine Aufräumfunktion zurückgegeben werden muss.
Entwerfen Sie Caches mit Blick auf das Entfernen, nicht nur auf das Hinzufügen
Caches entstehen aus guten Absichten, um wiederholte API-Aufrufe oder aufwändige Berechnungen zu vermeiden. Monate später können sie Tausende von Objekten enthalten, die seit Wochen niemand mehr angefordert hat. Wenn Sie einen Cache entwerfen, sollten Sie genauso sorgfältig darüber nachdenken, wie Einträge entfernt werden, wie sie hinzugefügt werden:
- Wie lange sollten Einträge im Speicher bleiben?
- Welche ist die maximale Größe?
- Sollten Einträge automatisch ablaufen?
- Können selten genutzte Einträge entfernt werden?
Falls diese Fragen unbeantwortet bleiben, wird der Cache mit großer Wahrscheinlichkeit im Laufe der Zeit wachsen.
Bewahren Sie nur die Daten auf, die Sie tatsächlich benötigen
Ein weiteres häufiges Problem ist das Speichern ganzer Objekte, obwohl nur ein kleiner Teil verwendet wird. Wenn Sie ein großes Profil abrufen, nur um einen Benutzernamen anzuzeigen, gibt es keinen Grund, die gesamte Antwort dauerhaft zu speichern; lagern Sie nur die Felder ab, die die Benutzeroberfläche benötigt. Kleinere Objekte verbrauchen weniger Speicher, sind leichter zu verstehen und werden seltener versehentlich aufbewahrt. Manchmal liegt die Lösung nicht in mehr Code, sondern in weniger gespeicherten Daten.
Nehmen Sie kleine Datendurchsickern ernst
Es ist verlockend, ein paar Kilobyte an Datenverlust zu ignorieren. Das Problem ist jedoch, dass Nutzer selten etwas nur einmal tun. Ein Dashboard, das den ganzen Tag über genutzt wird, ein internes Admin-Tool, das von Hunderten Mitarbeitern geteilt wird, oder eine Überwachungsanzeige, die niemals neu geladen wird – all das führt immer wieder zu denselben Codepfaden. Ein heute kaum messbarer Datendurchsickerr kann nach Wochen normativer Nutzung zu einem echten Problem werden.
Fügen Sie Speicherkontrollen zur täglichen Entwicklung hinzu
Leistungsoptimierungen konzentrieren sich in der Regel auf Ladezeiten und API-Latenz, doch auch der Speicher verdient Aufmerksamkeit. Verbringen Sie beim Entwickeln einer neuen Funktion ein paar zusätzliche Minuten damit, Folgendes zu überprüfen:
- Kommt der Speicherverbrauch nach Nutzung der Funktion wieder auf seinen Ausgangswert zurück?
- Steigt die Anzahl der Event-Listener unerwartet an?
- Verlieren DOM-Node nach Entfernung der Komponenten ihre Existenz?
- Führt die wiederholte Ausführung derselben Aktion zu einem stetigen Anstieg des Speicherverbrauchs?
Diese Kontrollen sind unkompliziert und können später Stunden an Debugging ersparen.
Checkliste für Code-Reviews
Vor dem Merge gehen Sie kurz eine interne Checkliste durch:
- Wurden alle hinzugefügten Event-Listener auch wieder entfernt?
- Werden Timer bereinigt, sobald sie nicht mehr benötigt werden?
- Wurden Abonnements ordnungsgemäß beendet?
- Könnte dieser Cache unbegrenzt wachsen?
Ihre Anwendung muss das nicht auf jede Zeile angewendet werden, doch wenn Sie dies zum Bestandteil der Überprüfung machen, verringert sich das Risiko erheblich, einen Fehler in die Produktion zu schicken.
Kernpunkte
- Fehler führen selten schon am ersten Tag zu Ausfällen; sie bauen sich schleichend auf und betreffen zunächst die am aktivsten nutzenden Benutzer, was sie gefährlich macht.
- Sie sind außerdem vorhersehbar: Ein Objekt bleibt erhalten, solange es noch von etwas referenziert wird – daher lässt sich jeder Fehler auf eine Referenz zurückverfolgen, die eigentlich entfernt worden wäre.
- Wenn eine App im Laufe mehrerer Stunden langsamer wird, sollten Sie nicht den Browser oder die Engine dafür verantwortlich machen. Machen Sie ein Heap-Snapshot, finden Sie die weiterhin vorhandenen Objekte und verfolgen Sie die Referenzkette.
- Die übliche Antwort ist banal: ein vergessener Zuhörer, ein nicht bereinigter Timer, ein unbegrenztes Cache oder eine Komponente, die nie mit der Bereinigung fertig geworden ist.
- Schnelles JavaScript geht nicht nur um die Ausführungsgeschwindigkeit; es geht darum, den Lebenszyklus dessen zu verwalten, was man allokiert, damit die App weiterhin reaktiv bleibt – egal ob jemand sie fünf Minuten oder einen ganzen Arbeitstag lang verwendet.
Verwandte Artikel
- React Native Memory Leaks: Tracing JS Heap and Native Memory Owners – Erfahren Sie, warum die Garbage Collection eine React Native-App nicht vor nativen Lecks schützen kann, und wie man herausfindet, was Callbacks, JSI-Objekte sowie decodierte Bilder am Leben hält.
- Node's Quellkarten-Cache ist ein stiller Speicherverlust im Dev-Modus — Erfahren Sie, warum das Aktivieren von --enable-source-maps oder NODE_V8_COVERAGE aufgrund wiederholter eval-Aufrufe zu einem unbegrenzten Wachstum des Heap-Speichers führen kann, und wie Sie dies heute diagnostizieren und verringern können.