Przejdź do treści

Garbage Collection w JavaScripcie - jak silnik sprząta po twoim kodzie

Profile photo of Adrian Zawadzki

Adrian Zawadzki

14 min czytania

Każdy obiekt, który tworzysz w JS, zajmuje miejsce w pamięci komputera. JavaScript usuwa nieużywane obiekty automatycznie, robiąc miejsce na nowe. Silnik czasem jednak nie potrafi rozpoznać, że dany obiekt nie jest już potrzebny - i wtedy obiekt zostaje w pamięci. Tak powstają memory leaki. Post o mechanizmie tego sprzątania (mark-and-sweep), o najczęstszych pułapkach i o tym, kiedy faktycznie sięgać po WeakMap czy WeakRef.

Każdy obiekt, każda tablica, każdy string w JavaScripcie zajmuje miejsce w pamięci komputera. Kiedy piszesz const arr = [], w RAM-ie powstaje fragment reprezentujący ten obiekt. Pamięć jest skończona - jeśli kod tylko dodaje obiekty, a nigdy nie usuwa starych, w końcu zabraknie miejsca.

JavaScript wykonuje to sprzątanie automatycznie. Mechanizm w silniku zwany garbage collectorem (dalej: GC) co jakiś czas analizuje pamięć i usuwa obiekty, które nie są już potrzebne - robiąc miejsce na nowe. W większości przypadków odbywa się to bez ingerencji programisty: funkcja kończy się, lokalne zmienne znikają, obiekty zostają posprzątane.

Aż któregoś dnia tab w przeglądarce zajmuje 2GB pamięci i pytanie „gdzie ta pamięć leci” przestaje być akademickie. Automatyczne sprzątanie ma bowiem jeden warunek: GC może usunąć obiekt tylko wtedy, gdy nikt go już nie używa. Silnik decyduje kiedy sprzątać, ale to ty decydujesz, czy w ogóle ma do tego podstawę. Jeśli kod gdzieś trzyma obiekt (nawet niechcący), GC nie ma sposobu, by stwierdzić, że obiekt nie jest już potrzebny - i pozostawia go w pamięci. Tak powstaje memory leak: obiekt, który powinien zostać usunięty, ale dalej zajmuje miejsce.

Ten post jest o tym, jak silnik podejmuje decyzję „jeszcze potrzebne / już można usunąć”, gdzie programiści najczęściej blokują mu drogę i kiedy faktycznie sięgać po WeakMap zamiast zwykłego Map.

#Reachability - kiedy obiekt jest „jeszcze potrzebny”

Pytanie kluczowe: po czym silnik rozpoznaje, że dany obiekt można już usunąć z pamięci?

Pierwsza próba algorytmu, którą wymyślono historycznie, to reference counting (zliczanie referencji). Każdy obiekt ma licznik - liczbę miejsc w kodzie, które aktualnie na niego wskazują (czyli mają do niego dostęp). Powstaje nowa zmienna trzymająca ten obiekt - licznik rośnie. Stara zmienna znika - licznik maleje. Gdy spada do zera, oznacza to, że nikt już nie ma do tego obiektu dostępu, więc można go usunąć z pamięci.

Mechanizm wygląda na poprawny. Ma jednak jeden poważny problem:

function f() {
  const a = {};
  const b = {};
  a.partner = b;
  b.partner = a;
}

f();

Po wyjściu z funkcji nikt z zewnątrz nie ma dostępu do a ani b. Ale licznik referencji każdego z nich pokazuje 1 - bo wskazują na siebie nawzajem. W algorytmie opartym na zliczaniu referencji oba zostają w pamięci na zawsze. Cykl, który nie ma wejścia z zewnątrz, robi memory leak. Żaden współczesny silnik JS nie używa już reference counting, właśnie z tego powodu.

Algorytm, który wszystkie używają, opiera się na innej intuicji: reachability.

Wyobraź sobie wszystkie obiekty w pamięci jako kropki na rysunku, połączone strzałkami pokazującymi „kto na kogo wskazuje” (jeśli zmienna a trzyma obiekt b, idzie strzałka z a do b). Taki obraz nazywa się graf referencji. Niektóre punkty na nim są specjalne - to tzw. roots: globalny obiekt (window w przeglądarce, globalThis ogólnie), aktualny call stack, wszystkie aktywne closure’y.

Obiekt jest reachable (osiągalny), jeśli istnieje jakakolwiek ścieżka po strzałkach z roota do niego. Jeśli takiej ścieżki nie ma - obiekt jest unreachable (nieosiągalny) i nikt już nigdy się do niego nie dostanie. GC odpalany od czasu do czasu znajduje wszystkie osiągalne obiekty (idąc po strzałkach od rootów), a resztę kasuje z pamięci.

Ten algorytm nazywa się mark-and-sweep i ma dwie fazy:

  1. Mark: zaczyna od rootów i rekurencyjnie odwiedza wszystko, co da się osiągnąć po referencjach. Każdy odwiedzony obiekt dostaje znacznik „żywy”.
  2. Sweep: przechodzi przez całą pamięć i kasuje wszystkie obiekty bez znacznika („żywy”) z fazy mark.

Wracając do przykładu z a.partner = b; b.partner = a - mark-and-sweep radzi sobie z nim bez problemu. Skoro do tej pary obiektów nie da się dojść z żadnego roota (funkcja, w której powstały, się skończyła), nikt ich nie zaznaczy w fazie mark. W fazie sweep idą do śmietnika razem, mimo że wzajemnie się trzymają. To wystarczy żeby zrozumieć wszystkie memory leaki w JS: obiekt zostaje w pamięci, dopóki cokolwiek osiągalne z roota go trzyma.

Widget poniżej pozwala uruchomić mark-and-sweep na czterech scenariuszach grafu obiektów:

  1. Wszystko żywe - wszystkie obiekty są osiągalne z roota.
  2. Cykl bez roota - dwa obiekty trzymające siebie nawzajem, ale do nikogo z nich nie da się dojść z zewnątrz (klasyczny argument przeciw reference counting, do którego wracaliśmy wyżej).
  3. Oderwany podgraf - klaster kilku obiektów odcięty od roota.
  4. Listener trzymający DOM - realny pattern memory leaka, w którym listener wciąż trzyma referencję do już niepotrzebnego elementu DOM.

Przycisk „Run GC” uruchamia fazę mark - silnik startuje od roota i krok po kroku odwiedza wszystko, co da się od niego osiągnąć (kolory w widgecie pokazują, na jakim etapie marking jest który obiekt). Potem sweep usuwa wszystko, co nie zostało odwiedzone. Wniosek: po zakończeniu fazy mark obiekty nieodwiedzone są definitywnie nieosiągalne - kolejność, w jakiej silnik chodzi po strzałkach, nie ma znaczenia, liczy się tylko czy ścieżka istnieje.

Każdy obiekt ma ścieżkę z roota. Po przejściu mark-and-sweep nic nie zostaje uwolnione - wszystko jest osiągalne.

globalThisROOTABCDE
Idle - kliknij Run GC, by uruchomić mark-and-sweep
Żywe: 6Zebrane: 0
  • white (jeszcze nie odwiedzony)
  • gray (w kolejce do mark)
  • black (osiągalny z roota)
  • swept (zebrany przez GC)

#Co dzieje się w V8 pod spodem

W praktyce silniki nie analizują całej pamięci (zwanej heap - to obszar pamięci, w którym przechowywane są wszystkie obiekty JS) przy każdym uruchomieniu mark-and-sweep. Algorytm jest ten sam, ale wokół niego znajdują się optymalizacje, które warto poznać - wpływają one na to, kiedy i jak długo aplikacja zatrzymuje się, by silnik mógł wykonać sprzątanie.

Założenie leżące u podstaw V8 to tzw. generational hypothesis (hipoteza pokoleniowa): większość obiektów ma krótki czas życia. Tymczasowe tablice w pętli, lokalne struktury w funkcjach, obiekty zdarzeń - znaczna ich część jest niedostępna już po kilku iteracjach event loopa. Dlatego V8 dzieli heap na young generation i old generation:

  • Young generation (mała przestrzeń, sprzątana często) - tutaj trafiają wszystkie nowe obiekty. Algorytm to Scavenger w wariancie semi-space copying. Działa tak: V8 dzieli young generation na dwie równe połowy. W każdym momencie tylko jedna jest używana, druga stoi pusta. Gdy używana się zapełni, silnik kopiuje osiągalne obiekty do tej pustej, a starą zeruje jednym ruchem (nie analizując, co tam zostało). Role się odwracają. Operacja jest tania, bo skoro większość obiektów ma krótki czas życia, do skopiowania jest niewiele.
  • Old generation (duża przestrzeń, sprzątana rzadziej) - tutaj trafiają obiekty, które przetrwały kilka cykli w young generation. Algorytm to Mark-Compact - klasyczny mark-and-sweep plus dodatkowa faza zwana kompakcją. Po sweepie pamięć wygląda jak parking z dziurami między autami (tam, gdzie wcześniej były skasowane obiekty). Te dziury utrudniają wstawianie nowych obiektów, jeśli trzeba im zwartej przestrzeni. Kompakcja przesuwa wszystkie ocalałe obiekty obok siebie, zostawiając jedną dużą, zwartą wolną przestrzeń na nowe.

Dodatkowo V8 prowadzi marking tri-color: każdy obiekt jest biały (jeszcze nie odwiedzony), szary (odwiedzony, ale jego dzieci jeszcze nie) lub czarny (przetworzony). Marking kończy się, gdy nie ma już szarych obiektów - białe są wtedy nieosiągalne. Ten kolorowy zapis stanu pozwala prowadzić marking współbieżnie: część pracy wykonuje worker thread, podczas gdy główny wątek wykonuje JS. W Chrome 64 i Node.js 10 V8 włączył concurrent marking, redukując czas, w którym główny wątek czeka na GC, o około 70%.

V8 wykonuje również garbage collection podczas idle time przeglądarki - między klatkami animacji, gdy Blink scheduler ma wolne 16ms zaplanowane na rendering, a aktualna klatka renderowała się 8ms. Efekt: około 43% pracy GC odbywa się bez wstrzymywania animacji.

Praktyczny wniosek z tego mechanizmu jest następujący: nie da się skutecznie „pomóc” GC z poziomu kodu aplikacji. Można mu jedynie przeszkodzić, trzymając referencje, które nie są już potrzebne.

#Cztery klasyczne pułapki pamięci

GC potrafi znaleźć obiekty, których nikt już nie używa. Problem pojawia się, gdy programista przypadkowo trzyma obiekt, którego nie potrzebuje, nie zdając sobie z tego sprawy. Z punktu widzenia GC obiekt jest wciąż osiągalny z roota, więc zostaje w pamięci. Cztery wzorce, które stoją za większością memory leaków w JS:

#1. Detached DOM nodes

Trzymasz referencję na element DOM w zmiennej JS, potem usuwasz ten element ze strony przez element.remove(). Element znika z ekranu, ale referencja w JS dalej go trzyma - razem z całym jego poddrzewem:

let cachedNode = document.querySelector(".big-list");
cachedNode.remove(); // znika z DOM
// Ale `cachedNode` dalej trzyma węzeł i wszystko, co on trzyma:
// dzieci, eventy, dane. Trzeba ręcznie zerwać:
cachedNode = null;

W aplikacjach z dużymi listami i cache’em węzłów (np. dla animacji, undo/redo) łatwo o tysiące detached nodes wiszących w pamięci.

#2. Listenery, intervaly, timeouty

addEventListener, setInterval, setTimeout rejestrują funkcję w zewnętrznym mechanizmie (DOM, scheduler). Mechanizm utrzymuje referencję na funkcję, a funkcja jako closure utrzymuje cały swój scope:

function setupChart() {
  const data = new Array(1_000_000).fill(0);

  window.addEventListener("resize", () => {
    redraw(data); // closure trzyma `data`
  });
}

setupChart();
// `data` żyje, dopóki listener jest podpięty - czyli wiecznie
// (chyba że ktoś zrobi removeEventListener z tą samą referencją funkcji).

To samo dotyczy setInterval po unmount komponentu, setTimeout na 5 minut, czy subskrypcji do EventEmittera. Każdy add... ma odpowiadający remove... i wskazany właściciel, który go wywoła. We frameworkach tym właścicielem jest cleanup w useEffect / onUnmounted.

#3. Closures ze współdzielonym scope

Najbardziej podstępny rodzaj leaka, bo nie jest oczywisty z odczytu kodu. Wszystkie funkcje zadeklarowane w tym samym scope dzielą ten sam Environment Record. Jeśli choć jedna z nich zostaje przy życiu, scope zostaje cały - razem ze zmiennymi, do których jakakolwiek funkcja w tym scope się odwołuje:

let theThing = null;

function replaceThing() {
  const previousThing = theThing; // duży obiekt z poprzedniego wywołania
  const unused = () => {
    // nigdy nie wywoływana, ale jej obecność trzyma `previousThing`
    if (previousThing) console.log("hi");
  };
  theThing = {
    longText: new Array(1_000_000).join("*"),
    someMethod: () => {}, // żyje na zewnątrz, więc trzyma scope
  };
}

setInterval(replaceThing, 1000);

Klucz: unused nigdy nie zostaje wywołana, ale fakt, że odwołuje się do previousThing, zatrzymuje previousThing w scope. A skoro someMethod żyje na zewnątrz i trzyma ten sam scope, łańcuch przeszłych wywołań rośnie z każdym kolejnym wywołaniem replaceThing. Mechanizm Environment Records opisałem szerzej w poście o closure.

#4. Niekończący się cache

Cache jako Map albo zwykły obiekt, który tylko rośnie i nigdy nie jest czyszczony:

const cache = new Map();

function expensiveLookup(user) {
  if (cache.has(user.id)) return cache.get(user.id);
  const result = compute(user);
  cache.set(user.id, result);
  return result;
}

Map trzyma silne referencje na klucze. Dopóki cache żyje, każdy user.id (i powiązany result) zostaje. Jeśli user to obiekt, który powinien zniknąć (np. zalogowany user, który się wylogował) - cache go zatrzymuje. Rozwiązanie: następna sekcja.

#Słabe referencje - kiedy faktycznie sięgać

Reguła z początku posta: obiekt zostaje w pamięci, dopóki cokolwiek go trzyma. Czasem chcesz świadomie wyjąć się z tego mechanizmu - mieć dostęp do obiektu, ale nie blokować GC. JS oferuje na to słabe referencje (weak references) - referencje, które GC ignoruje przy decyzji „obiekt jest jeszcze potrzebny”. Praktycznie liczą się dwie konstrukcje:

  • WeakMap / WeakSet - działają jak Map / Set, ale klucze są trzymane słabo. Typowe zastosowanie: metadane przypięte do elementów DOM. Gdy element znika ze strony i nikt go już nie trzyma, jego wpis w WeakMap znika razem z nim - bez ręcznego cache.delete(el).
  • WeakRef i FinalizationRegistry - słaba referencja na pojedynczy obiekt + opcjonalny callback po jego skasowaniu. MDN ostrzega bezpośrednio: „best avoided if possible”. Specyfikacja celowo nie gwarantuje, kiedy ani czy callback zostanie wywołany - nie buduj wokół tego logiki aplikacji.

Reguła praktyczna: w 99% przypadków słabej referencji potrzebujesz WeakMap. WeakRef widujesz może raz w życiu (np. cache obiektów z czyszczeniem zewnętrznych zasobów). W code review powinien być sygnałem „naprawdę tego potrzebujemy?”.

#Co z tego zapamiętać

Trzy rzeczy, na których stoi wszystko powyżej:

  1. Obiekt zostaje w pamięci, dopóki cokolwiek osiągalne z roota go trzyma. To jedyny mental model, którego potrzebujesz przy debugowaniu memory leaków. „Dlaczego ta rzecz nadal żyje?” - odpowiedź zawsze ma kształt łańcucha referencji od roota.
  2. Każdy add... ma swój remove.... Listenery, intervaly, subskrypcje - jeśli coś rejestrujesz, zaplanuj kto i kiedy to wyrejestruje. We frameworkach to cleanup w hookach lifecycle, w vanilla JS pilnujesz tego sam.
  3. Closure’y dzielą scope z sąsiednimi funkcjami z tego samego scope’u. Jeśli funkcja A zwraca długożyjącą funkcję, a obok niej w tym samym scope znajduje się nieużywana funkcja B, która odwołuje się do dużych zmiennych - te zmienne pozostają w pamięci tak długo, jak A. To nie jest bug, lecz konsekwencja mechanizmu Environment Records.

W praktyce: jeśli w Chrome DevTools zobaczysz wykres pamięci, który tylko rośnie - wracasz do punktu 1. Heap snapshot pokaże ci ścieżkę referencji od roota do problematycznego obiektu. Wystarczy usunąć jedną referencję na tej ścieżce, a obiekt przestanie być osiągalny i GC go skasuje.

Komentarze

Wczytywanie komentarzy…

Dodaj komentarz