Wyciek pamięci w JavaScript, określany jako memory leak, występuje wtedy, gdy aplikacja zachowuje w pamięci obiekty, których w praktyce już nie potrzebuje. JavaScript posiada automatyczny mechanizm Garbage Collector, ale nie potrafi rozpoznać intencji programisty - jeżeli do obiektu nadal prowadzi osiągalna referencja, może on pozostać w pamięci mimo tego, że nie jest już wykorzystywany przez interfejs lub logikę aplikacji. Skutkiem stopniowego wycieku może być rosnące użycie RAM, wolniejsze działanie strony, dłuższe przerwy związane z odśmiecaniem pamięci, a w skrajnych przypadkach zawieszenie karty przeglądarki. Znalezienie memory leaka wymaga więc nie tyle obserwacji samego zużycia pamięci, ile ustalenia, jakie obiekty pozostają osiągalne i dlaczego Garbage Collector nie może ich usunąć.
Co to jest memory leak w JavaScript?
Memory leak to sytuacja, w której pamięć zajęta przez obiekty nie zostaje zwolniona, mimo że z punktu widzenia działania aplikacji obiekty te przestały być potrzebne. Problem nie musi oznaczać błędu samego silnika JavaScript. Znacznie częściej przyczyną jest sposób, w jaki program przechowuje referencje do danych.
Dobrym przykładem może być aplikacja typu SPA. Użytkownik otwiera widok produktu, przechodzi do listy, otwiera następny produkt i powtarza operację kilkadziesiąt razy. Jeśli po zamknięciu każdego widoku jego dane, listenery lub elementy DOM pozostają dostępne przez inną referencję, zużycie pamięci może stopniowo rosnąć.
Niebezpieczne są przede wszystkim wycieki powtarzalne. Jednorazowe pozostawienie niewielkiego obiektu może nie mieć zauważalnego wpływu na aplikację. Jeżeli jednak każde otwarcie modala pozostawia kolejne 2 MB danych, po stu takich operacjach problem staje się już istotny.
Jak działa Garbage Collector i dlaczego nie zapobiega wszystkim wyciekom?
JavaScript zarządza pamięcią automatycznie. Programista tworzy obiekty, tablice czy funkcje, natomiast silnik JavaScript zajmuje się przydzielaniem pamięci i jej późniejszym odzyskiwaniem. Za usuwanie niepotrzebnych obiektów odpowiada mechanizm nazywany Garbage Collectorem.
W dużym uproszczeniu Garbage Collector analizuje, które obiekty są nadal osiągalne z aktywnych części programu. Jeżeli do danego obiektu nie prowadzi już żadna potrzebna referencja, może on zostać uznany za możliwy do usunięcia.
Kluczowe jest jednak słowo "osiągalny". Garbage Collector nie rozumie, że programista nie zamierza już używać konkretnego obiektu. Widzi jedynie istniejące relacje pomiędzy obiektami.
Jeżeli duża tablica jest nadal przechowywana w globalnym cache, handler zdarzenia zachowuje dane starego komponentu albo aktywny timer korzysta z funkcji posiadającej referencję do niepotrzebnego obiektu, dla mechanizmu odśmiecania dane nadal pozostają osiągalne.
obiekt niepotrzebny aplikacji ≠ obiekt nieosiągalny dla Garbage Collectora
To właśnie ta różnica jest podstawą większości wycieków pamięci w aplikacjach JavaScript.
Jak rozpoznać wyciek pamięci?
Memory leak rzadko objawia się komunikatem jednoznacznie wskazującym problem. Częściej aplikacja po prostu zaczyna działać gorzej po dłuższym czasie użytkowania.
Stopniowo rosnące użycie pamięci
Po wykonaniu tej samej operacji wielokrotnie zajęta pamięć zwiększa się i nie wraca w okolice wcześniejszego poziomu nawet po zakończeniu zadania oraz wykonaniu Garbage Collection.
Aplikacja zwalnia po dłuższej pracy
Pierwsze minuty korzystania ze strony nie powodują problemów, ale po dłuższej sesji interfejs reaguje wolniej, animacje zaczynają się zacinać, a operacje wykonywane wcześniej natychmiast wymagają więcej czasu.
Problem narasta po powtarzaniu jednej czynności
Charakterystyczny przypadek to otwieranie i zamykanie modala, zmiana tras w SPA, przechodzenie między ekranami albo wielokrotne tworzenie i usuwanie tego samego komponentu.
W pamięci pozostają stare obiekty
Profilowanie pokazuje instancje komponentów, elementy DOM lub struktury danych, które powinny już zniknąć po zakończeniu określonej operacji.
Czy rosnące użycie pamięci zawsze oznacza memory leak?
Nie. Sam wzrost wykorzystania pamięci nie wystarcza do stwierdzenia wycieku. Aplikacja może chwilowo tworzyć wiele obiektów podczas przetwarzania danych, renderowania widoku lub wykonywania złożonej operacji.
Garbage Collector nie musi również usuwać każdego niepotrzebnego obiektu natychmiast po utracie ostatniej referencji. Odśmiecanie jest wykonywane zgodnie ze strategią silnika JavaScript, dlatego wykres pamięci często przypomina serię wzrostów zakończonych gwałtownymi spadkami.
Typowy zdrowy przebieg może wyglądać w uproszczeniu tak:
40 MB → 65 MB → GC → 43 MB → 70 MB → GC → 45 MB
Bardziej podejrzany jest wzorzec:
40 MB → 65 MB → GC → 55 MB → 80 MB → GC → 70 MB → 95 MB → GC → 85 MB
Istotna jest więc nie sama wartość maksymalna, ale trend poziomu pamięci pozostającej po kolejnych cyklach Garbage Collection. Jeżeli po wykonaniu identycznego scenariusza punkt bazowy systematycznie rośnie, warto rozpocząć dokładniejszą diagnostykę.
Globalne referencje i nieograniczone kolekcje
Jednym z najprostszych sposobów utworzenia wycieku jest kolekcja, która przez cały czas życia aplikacji przechowuje kolejne dane i nigdy ich nie usuwa.
const cache = [];
function saveResult(result) {
cache.push(result);
}
Jeżeli `saveResult()` zostanie wywołana dziesięć razy, problem może być niezauważalny. Jeśli aplikacja wykonuje funkcję przez kilka godzin i dodaje tysiące dużych obiektów, tablica może stopniowo zajmować coraz więcej pamięci.
Sam cache nie jest błędem. Problemem jest brak określonego cyklu życia danych. Jeżeli kolekcja ma działać jako pamięć podręczna, trzeba ustalić, kiedy elementy są usuwane - na przykład po przekroczeniu liczby rekordów, po określonym czasie albo po zakończeniu sesji.
Dobrym testem projektowym jest pytanie: "co powoduje usunięcie tego obiektu?". Jeżeli dla stale rosnącej kolekcji odpowiedź brzmi "nic", warto sprawdzić, czy nie powstaje potencjalny wyciek.
Event listenery jako źródło wycieku pamięci
Nasłuchiwanie zdarzeń jest częstym elementem aplikacji webowych, ale szczególnej uwagi wymagają listenery dodawane do długo żyjących obiektów, takich jak `window` lub `document`.
Załóżmy, że komponent podczas inicjalizacji wykonuje:
function createPanel(data) {
function handleResize() {
console.log(data.length);
}
window.addEventListener("resize", handleResize);
}
Funkcja `handleResize` korzysta z `data`, a listener został zarejestrowany na `window`. Jeżeli panel przestanie być potrzebny, ale listener pozostanie aktywny, może nadal utrzymywać cały kontekst potrzebny funkcji. Wielokrotne tworzenie takich paneli może dodatkowo powodować rejestrowanie kolejnych handlerów.
Rozwiązaniem jest kontrolowanie cyklu życia listenera:
function createPanel(data) {
function handleResize() {
console.log(data.length);
}
window.addEventListener("resize", handleResize);
return function destroy() {
window.removeEventListener("resize", handleResize);
};
}
Warto przy tym unikać uproszczenia, że każdy listener przypięty do elementu DOM automatycznie tworzy wyciek. Jeśli element wraz z listenerem staje się całkowicie nieosiągalny, pamięć może zostać odzyskana. Problem powstaje wtedy, gdy dłużej żyjąca część aplikacji nadal zachowuje referencję do handlera, elementu lub danych przez niego przechwytywanych.
setInterval i timery, które pozostają aktywne
Podobny problem może dotyczyć `setInterval()`. Aktywny timer regularnie wywołuje przekazaną funkcję, dlatego nie powinien pozostawać uruchomiony po zakończeniu życia modułu, który go utworzył.
function startStatistics(largeDataset) {
const timer = setInterval(() => {
console.log(largeDataset.length);
}, 5000);
return timer;
}
Dopóki interval jest aktywny, callback nadal korzysta z `largeDataset`. Jeśli dane są duże, pozostawienie timera może niepotrzebnie utrzymywać je w pamięci.
Kod powinien mieć jednoznaczny etap sprzątania:
const timer = startStatistics(data);
// gdy moduł kończy pracę
clearInterval(timer);
Ta sama zasada dotyczy innych zasobów, które mają cykl życia dłuższy niż pojedyncze wykonanie funkcji - subskrypcji, obserwatorów, połączeń oraz callbacków rejestrowanych poza lokalnym komponentem.
Closures i niepotrzebnie zachowane dane
Closure pozwala funkcji korzystać ze zmiennych dostępnych w otaczającym ją zakresie także po zakończeniu wykonania funkcji zewnętrznej. Jest to jedna z podstawowych i bardzo użytecznych właściwości JavaScript, ale może mieć znaczenie dla czasu życia danych.
function createHandler() {
const hugeData = new Array(100000).fill("data");
return function handleClick() {
console.log(hugeData.length);
};
}
const handler = createHandler();
Zwrócona funkcja korzysta z `hugeData`, dlatego dane muszą pozostać dostępne tak długo, jak długo osiągalny jest `handler`. Nie jest to jeszcze memory leak - program może celowo potrzebować tych informacji. Wyciek pojawi się dopiero wtedy, gdy handler pozostanie osiągalny niepotrzebnie.
W diagnostyce closures najważniejsze jest więc ustalenie, dlaczego funkcja nadal żyje i jakie dane zachowuje poprzez swój zakres leksykalny. Sama obecność closure nie jest błędem.
Detached DOM - element usunięty ze strony, ale nie z pamięci
Szczególnie charakterystycznym problemem aplikacji działających w przeglądarce są odłączone elementy DOM. Element może zostać usunięty z dokumentu, ale nadal pozostawać osiągalny z JavaScript.
let oldPanel;
function removePanel() {
const panel = document.querySelector(".panel");
oldPanel = panel;
panel.remove();
}
Po wykonaniu `panel.remove()` element znika z interfejsu, ale globalna zmienna `oldPanel` nadal przechowuje do niego referencję. Jeżeli panel zawiera rozbudowane poddrzewo DOM, w pamięci może pozostać znacznie więcej niż pojedynczy element.
W prawdziwych aplikacjach referencja rzadko jest tak oczywista. Może znajdować się w tablicy, obiekcie cache, callbacku, starym stanie komponentu lub strukturze biblioteki. Dlatego odłączone drzewa DOM są dobrym przykładem problemu, którego często nie da się znaleźć jedynie poprzez czytanie kodu.
Jak znaleźć memory leak w Chrome DevTools?
Najbardziej wiarygodna diagnostyka polega na przygotowaniu powtarzalnego scenariusza. Zamiast przypadkowo klikać aplikację i obserwować zużycie RAM, warto wybrać konkretną operację, która prawdopodobnie powoduje wyciek.
Przykładowy test może wyglądać następująco:
1. Ustal stan początkowy
Otwórz aplikację i doprowadź ją do stabilnego punktu, zanim rozpoczniesz pomiar.
2. Wykonaj badaną czynność wielokrotnie
Otwórz i zamknij modal 20 razy, przejdź kilkanaście razy pomiędzy dwoma trasami albo utwórz i usuń ten sam komponent.
3. Pozwól na Garbage Collection
Celem nie jest zmierzenie pamięci chwilowo potrzebnej do wykonania operacji, lecz znalezienie danych, które pozostają po jej zakończeniu.
4. Powtórz scenariusz
Jeżeli po kolejnych cyklach zostaje coraz więcej obiektów tego samego rodzaju, otrzymujesz znacznie mocniejszy sygnał niż pojedynczy wysoki odczyt pamięci.
W Chrome DevTools szczególnie przydatny jest panel Memory, w którym można analizować stertę JavaScript oraz sprawdzać, jakie obiekty pozostają w pamięci.
Heap Snapshot - jak porównać stan pamięci?
Heap Snapshot jest migawką obiektów znajdujących się na stercie w określonym momencie. Pojedynczy snapshot dostarcza wielu informacji, ale przy szukaniu memory leaka szczególnie użyteczne jest porównanie stanu przed i po wykonaniu konkretnej operacji.
Praktyczna procedura może wyglądać tak:
| Etap | Działanie |
|---|---|
| 1 | Doprowadź aplikację do ustalonego stanu początkowego |
| 2 | Wykonaj pierwszy Heap Snapshot |
| 3 | Powtórz badaną czynność, np. 20 razy otwórz i zamknij komponent |
| 4 | Wymuś odśmiecenie podczas testu diagnostycznego |
| 5 | Wykonaj kolejny Heap Snapshot |
| 6 | Porównaj liczbę instancji i sprawdź obiekty, które nie zniknęły |
Jeżeli po zamknięciu 20 instancji komponentu w pamięci nadal pozostaje 20 odpowiadających im obiektów, jest to dobry punkt wyjścia do dalszej analizy. Następne pytanie brzmi już nie "czy pamięć rośnie?", lecz "co utrzymuje te obiekty przy życiu?".
Retainers i Retained Size - jak znaleźć przyczynę?
Samo znalezienie dużego obiektu nie oznacza jeszcze znalezienia wycieku. Duży cache może być potrzebny aplikacji, a niewielki obiekt może pośrednio utrzymywać ogromną strukturę danych.
Dlatego podczas analizy stosuje się między innymi pojęcia Shallow Size i Retained Size. Shallow Size dotyczy pamięci zajmowanej bezpośrednio przez sam obiekt. Retained Size pozwala ocenić pamięć, która mogłaby potencjalnie zostać odzyskana, gdyby dany obiekt i zależne od niego obiekty przestały być osiągalne.
Szczególnie przydatna jest analiza retainers, czyli obiektów i referencji utrzymujących badaną wartość przy życiu. Ścieżka może wyglądać na przykład tak:
window → appCache → component → handler → largeDataset
Wtedy `largeDataset` nie jest prawdziwą przyczyną problemu. Trzeba zdecydować, dlaczego komponent nadal znajduje się w `appCache` albo dlaczego cache nie usuwa zakończonych instancji.
To jedna z najważniejszych różnic między zgadywaniem a rzeczywistą diagnostyką memory leaka: szukamy nie tylko obiektu pozostającego w pamięci, ale przede wszystkim referencji, która nie pozwala go usunąć.
WeakMap i WeakSet - kiedy pomagają?
`WeakMap` i `WeakSet` mogą być przydatne wtedy, gdy chcemy powiązać dodatkowe dane z obiektem, ale nie chcemy, aby sama obecność tego obiektu w kolekcji uniemożliwiała Garbage Collectorowi jego usunięcie.
Przykładowo zwykły `Map` utrzymuje referencję do swojego klucza:
const metadata = new Map();
function register(element, data) {
metadata.set(element, data);
}
Jeżeli obiekt `element` pozostaje kluczem w `Map`, kolekcja nadal go przechowuje. Gdy charakter danych pozwala na użycie słabej referencji, można rozważyć `WeakMap`:
const metadata = new WeakMap();
function register(element, data) {
metadata.set(element, data);
}
Obecność obiektu jako klucza w `WeakMap` nie powinna sama w sobie blokować jego odśmiecenia, gdy znikną pozostałe silne referencje.
Nie oznacza to jednak, że zamiana wszystkich `Map` na `WeakMap` jest sposobem na naprawianie wycieków. `WeakMap` rozwiązuje konkretny problem związany z czasem życia kluczy i ma inny model użycia. Jeżeli wyciek powoduje aktywny listener, timer albo globalna tablica, zastosowanie `WeakMap` w przypadkowym miejscu niczego nie naprawi.
Jak zapobiegać wyciekom pamięci w JavaScript?
Najlepszym sposobem ograniczania memory leaków jest projektowanie kodu z jasno określonym cyklem życia zasobów. Jeśli moduł coś rejestruje, warto od razu ustalić, kiedy zostanie to wyrejestrowane. Jeśli tworzy cache, trzeba określić warunki jego czyszczenia. Jeśli uruchamia timer, powinno istnieć miejsce odpowiedzialne za jego zatrzymanie.
Łącz inicjalizację ze sprzątaniem
Kod wykonujący `addEventListener()` powinien mieć odpowiadający mu etap `removeEventListener()`, a `setInterval()` - `clearInterval()`, jeżeli zasób ma ograniczony czas życia.
Ograniczaj cache i kolekcje
Kolekcja przechowująca dane przez całą sesję powinna mieć uzasadnienie. Przy cache warto określić maksymalny rozmiar, czas ważności albo inną politykę usuwania.
Nie przechowuj elementów DOM bez potrzeby
Po usunięciu rozbudowanego fragmentu interfejsu należy upewnić się, że nie pozostają do niego niepotrzebne referencje w strukturach aplikacji.
Testuj powtarzalne scenariusze
Wyciek często staje się oczywisty dopiero po wykonaniu tej samej operacji kilkanaście lub kilkadziesiąt razy. Test "otwórz → zamknij → powtórz" może być znacznie skuteczniejszy niż jednorazowe sprawdzenie użycia RAM.
Profiluj przed optymalizacją
Nie warto przepisywać przypadkowych fragmentów aplikacji tylko dlatego, że zużycie pamięci wygląda wysoko. Najpierw trzeba znaleźć obiekt, który nie powinien już istnieć, a następnie prześledzić referencje utrzymujące go w pamięci.
Najważniejsza zasada diagnostyki memory leaków jest prosta: nie szukamy miejsca, które zużywa najwięcej pamięci, lecz danych, które pozostają w niej dłużej, niż powinny. Dopiero połączenie powtarzalnego testu, Heap Snapshotów i analizy ścieżki referencji pozwala ustalić rzeczywistą przyczynę problemu zamiast jedynie obserwować jego skutki.

Komentarze