Wycieki pamięci występują w językach zbieranych w ramach zbiorczego systemu

Czy wycieki pamięci mogą występować w językach zapisywanych w systemie śmieciowym?

Tak. A żeby zrozumieć dlaczego, trzeba odrzucić jeden z najbardziej uporczywych mitów w rozwoju oprogramowania.

Mit: zbieranie śmieci zapobiega wyciekom pamięci. Rzeczywistość: zbieranie śmieci zapobiega jednemu konkretnemu rodzajowi problemu z pamięcią, czyli problemowi zwisającego odwołania, w którym program traci wszystkie wskaźniki do bloku pamięci i nie ma możliwości jego zwolnienia. Zbieranie śmieci nie zapobiega i nie może zapobiec problemowi odwrotnemu: programowi, który przechowuje odwołanie do obiektu, którego już nie potrzebuje. Zbieracz śmieci dostrzega aktywne odwołanie i prawidłowo pozostawia obiekt w spokoju. Programista zamierzał zaprzestać używania tego obiektu. Pamięć rośnie. Efekt jest identyczny jak w przypadku tradycyjnego wycieku: nieograniczony wzrost pamięci, ewentualne błędy OOM, restarty usług, ale mechanizm jest inny.

To rozróżnienie ma znaczenie, ponieważ zmienia miejsce, w którym szukasz problemu i sposób jego rozwiązania. W językach C i C++ wyciek pamięci oznacza brakujący element. free() or deleteW Javie, Pythonie, C#, Go i JavaScript oznacza to odwołanie, które powinno zostać usunięte, ale tak się nie stało. Zbieracz śmieci działa prawidłowo. Prawidłowo respektuje odwołanie, którego program nie powinien był zachować.

Wykrywanie wycieków w każdym języku

SMART TS XL mapuje długotrwałe łańcuchy odniesień i nieograniczone struktury danych w całej bazie kodu.

Więcej informacji

Co właściwie robią śmieciarze

Zadaniem zbieracza śmieci jest identyfikacja i odzyskiwanie pamięci, która nie jest już dostępna z poziomu korzeni programu, zmiennych globalnych, zmiennych stosu i stanu lokalnego wątku. Każdy obiekt osiągalny z poziomu korzenia jest uważany za aktywny. Każdy obiekt nieosiągalny kwalifikuje się do zebrania.

Każda z trzech dominujących strategii GC inaczej definiuje osiągalność:

Zaznacz i zamiataj (używany przez języki Java HotSpot, Go, C#/.NET) rozpoczyna od zbioru głównego i przechodzi przez wszystkie dostępne referencje do obiektów. Obiekty, do których nie udało się dotrzeć podczas przechodzenia, są usuwane. To poprawnie obsługuje referencje cykliczne – dwa obiekty wskazujące na siebie, ale osiągalne z dowolnego innego miejsca, są zbierane. Czego nie obsługuje: kontenera osiągalnego do katalogu głównego (statycznego). HashMap, na poziomie klasy List) która rośnie bez ograniczeń, ponieważ wpisy są dodawane i nigdy nie są usuwane.

Liczenie odniesień (Główny mechanizm CPythona) śledzi liczbę odwołań wskazujących na każdy obiekt. Gdy liczba ta osiągnie zero, obiekt jest natychmiast zwalniany. Klasyczny tryb awarii: odwołania cykliczne, gdzie A odwołuje się do B, a B do A. Oba mają liczbę odwołań równą co najmniej 1 i nigdy nie są zwalniane. CPython rozwiązuje ten problem za pomocą dodatkowego kolektora cykli, ale kolektor cykli ma przypadki brzegowe obejmujące obiekty z… __del__ Metody.

Generacyjne GC (używane przez większość współczesnych środowisk wykonawczych w połączeniu z powyższym) dzieli obiekty na młode i stare generacje w zależności od czasu ich przetrwania. Obiekty krótkowieczne są gromadzone często i przy niskich kosztach. Obiekty długowieczne są gromadzone rzadko. Konsekwencją wycieków pamięci jest to, że obiekty, które powinny być krótkotrwałe, ale przypadkowo zatrzymane, przenoszą się do starszej generacji i są gromadzone coraz rzadziej, co utrudnia obserwację i diagnozę wycieku.

Żaden z tych mechanizmów nie sprawdza, czy program powinien nadal używać obiektu, a jedynie czy jest w stanie do niego dotrzeć.

Pięć mechanizmów powodujących wycieki pamięci w językach GC

1. Kolekcje statyczne i na poziomie klasy

Pole statyczne w Javie, zmienna klasowa w Pythonie, właściwość statyczna w C# – wszystkie te elementy istnieją przez cały czas trwania procesu aplikacji. Każda kolekcja przechowywana w polu statycznym gromadzi obiekty przez cały czas istnienia programu, chyba że zostanie jawnie wyczyszczona.

Jawa

// Java: static cache that grows without bound
public class SessionManager {
    // Lives for the lifetime of the JVM process
    private static final Map<String, UserSession> sessions = new HashMap<>();

    public void createSession(String userId) {
        sessions.put(userId, new UserSession(userId));
        // Session is added but never removed when it expires
    }
    // Missing: cleanup when session expires or user logs out
}

sessions Do mapy można dotrzeć z poziomu modułu ładującego klasy, do którego można dotrzeć z poziomu katalogu głównego. Każdy UserSession Wszystkie kiedykolwiek utworzone zasoby kumulują się. W przypadku długotrwałej usługi, wyczerpuje to stertę danych w ciągu godzin lub dni. Naprawa wymaga aktywnego usunięcia (wywołaj sessions.remove(userId) (w przypadku wylogowania lub wygaśnięcia) lub przełączenie się na strukturę z automatycznym usuwaniem, np. pamięć podręczna ograniczona czasowo.

2. Słuchacze zdarzeń i wywołania zwrotne

Zarejestrowanie obserwatora, nasłuchiwacza lub wywołania zwrotnego tworzy odwołanie ze źródła zdarzeń do nasłuchiwacza. Jeśli nasłuchiwacz nigdy nie zostanie wyrejestrowany, źródło zdarzeń utrzymuje go przy życiu tak długo, jak długo istnieje źródło zdarzeń, co może trwać znacznie dłużej niż zamierzono.

pyton

# Python: listener retained by long-lived event source
class DataSource:
    def __init__(self):
        self._listeners = []

    def add_listener(self, listener):
        self._listeners.append(listener)

    # Missing: remove_listener method

class DataProcessor:
    def __init__(self, source: DataSource):
        self.source = source
        source.add_listener(self._on_data)   # strong reference held in source

    def _on_data(self, data):
        pass

# Each DataProcessor created is never collected because
# DataSource._listeners holds a reference to its bound method
source = DataSource()
for _ in range(10000):
    processor = DataProcessor(source)
    # processor goes out of scope here
    # but source._listeners still holds 10,000 bound method references

3. Zmienne lokalne wątku w pulach wątków

Pamięć lokalna wątku jest ograniczona do wątku, a nie do konkretnego zadania. W aplikacjach korzystających z pul wątków, gdzie wątki są ponownie wykorzystywane w wielu żądaniach, zmienne lokalne wątku ustawione podczas jednego żądania pozostają ustawione dla każdego kolejnego żądania obsługiwanego przez ten sam wątek.

Jawa

// Java: ThreadLocal leak in a thread pool
public class RequestContextHolder {
    private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();

    public static void setContext(RequestContext ctx) {
        context.set(ctx);
    }

    public static RequestContext getContext() {
        return context.get();
    }

    // Missing: clearContext() method called after each request
}

// In a servlet or request handler:
public void handleRequest(HttpRequest req) {
    RequestContextHolder.setContext(new RequestContext(req));
    // ... process request
    // Missing: RequestContextHolder.clearContext();
    // The RequestContext stays associated with this thread indefinitely
}

W puli wątków składającej się z 50 wątków obsługujących miliony żądań każdy wątek ostatecznie przechowuje RequestContext Odwołując się do danych z ostatniego żądania, szczegółów połączenia, danych uwierzytelniających użytkownika i parametrów żądania. Dane te nigdy nie są gromadzone, ponieważ same wątki nigdy nie są gromadzone.

4. Zamknięcia przechwytujące duże grafy obiektów

Domknięcia przechwytują odwołania do zmiennych w ich otaczającym zakresie. W JavaScript, a zwłaszcza w Pythonie, małe wywołanie zwrotne lub handler może nieumyślnie przechwycić duży obiekt, całe drzewo DOM, obiekt żądania lub duży zestaw danych, uniemożliwiając ich zebranie długo po tym, jak powinny.

javascript

// JavaScript: closure capturing large object in event listener
function setupHandler() {
    const largeDataset = fetchLargeDataset(); // 50MB of data

    document.getElementById('btn').addEventListener('click', function() {
        // Only uses one field, but captures entire largeDataset in closure
        console.log(largeDataset.summary);
    });

    // largeDataset cannot be collected as long as the button exists
    // because the event listener closure holds a reference to it
}

// Fix: capture only what you need
function setupHandlerFixed() {
    const largeDataset = fetchLargeDataset();
    const summary = largeDataset.summary; // extract only needed data

    document.getElementById('btn').addEventListener('click', function() {
        console.log(summary); // closure captures only the string
    });
    // largeDataset is now eligible for collection
}

5. Nieograniczone pamięci podręczne bez możliwości eksmisji

Pamięć podręczna jest jednym z najczęstszych źródeł wycieków pamięci w usługach produkcyjnych. Pamięć podręczna, która rośnie bez polityki usuwania, gromadzi obiekty, których nie można zebrać, ponieważ sama pamięć podręczna jest zawsze dostępna.

csharp

// C#: unbounded dictionary used as cache
public class PriceService {
    // Growing forever -- no eviction, no size limit
    private static Dictionary<string, decimal> _priceCache = new();

    public decimal GetPrice(string productId) {
        if (!_priceCache.TryGetValue(productId, out var price)) {
            price = FetchPriceFromDatabase(productId);
            _priceCache[productId] = price;
        }
        return price;
    }
    // After processing 1 million unique product IDs, the cache
    // holds 1 million entries and grows with every new product
}

// Fix: use a cache with eviction policy
using Microsoft.Extensions.Caching.Memory;

public class PriceService {
    private readonly IMemoryCache _cache;

    public PriceService(IMemoryCache cache) {
        _cache = cache;
    }

    public decimal GetPrice(string productId) {
        return _cache.GetOrCreate(productId, entry => {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.SizeLimit = 1;  // bounded by cache size configuration
            return FetchPriceFromDatabase(productId);
        });
    }
}

Wycieki pamięci według języka

Java

Najczęstsze wzorce wycieków w Javie, poza zbiorami statycznymi, obejmują: finalize() Metoda i klasy wewnętrzne. Niestatyczne klasy wewnętrzne zawierają niejawne odwołanie do otaczającej instancji klasy zewnętrznej. Anonimowa klasa zdefiniowana wewnątrz Aktywności lub Fragmentu (w systemie Android) zawiera odwołanie do tej Aktywności, uniemożliwiając jej zebranie po obrocie lub nawigacji.

Jawa WeakHashMap Rozwiązuje problem statycznej kolekcji w przypadkach, gdy klucze są obiektami znaczącymi: gdy klucz staje się niedostępny z innego miejsca, wpis jest automatycznie usuwany. W przypadku wartości, które powinny zostać zwolnione, gdy nie ma już do nich odwołań, WeakReference oraz SoftReference owijaj obiekty, nie tworząc silnych odniesień, które uniemożliwiają ich zbieranie.

Narzędzia do wykrywania: Java VisualVM, Eclipse Memory Analyzer (MAT), JProfiler, YourKit i HeapHero do analizy zrzutów pamięci. Szukaj byte[], char[], Object[] wzrost histogramów sterty, które zazwyczaj odzwierciedlają struktury danych w rozszerzających się zbiorach.

Python

GC w Pythonie składa się z dwóch warstw: zliczania referencji (podstawowej) i kolektora cykli (uzupełniającej). Warstwa zliczania referencji zwalnia obiekty natychmiast po osiągnięciu zera, co jest szybkie i przewidywalne. Kolektor cykli obsługuje referencje cykliczne, ale działa rzadziej i generuje wyjątki.

pyton

# Python circular reference with __del__ -- historically problematic
class Node:
    def __init__(self):
        self.child = None

    def __del__(self):
        print("Node deleted")

a = Node()
b = Node()
a.child = b
b.child = a   # circular reference
del a, b      # neither freed in Python 2; freed by cycle collector in Python 3.4+

Python 3.4+ poprawnie obsługuje większość odwołań cyklicznych. Pozostałe wzorce wycieków to: obiekty przechowywane w zmiennych globalnych na poziomie modułu, rosnące defaultdict or dict struktury używane jako rejestry oraz pamięć w modułach rozszerzeń C, która nie jest dostępna dla Python GC.

Wykrycie: tracemalloc (biblioteka standardowa), memory_profiler, objgraph, objgraph.show_most_common_types() oraz objgraph.show_growth() funkcje identyfikują, które typy się kumulują.

C# i .NET

Wycieki w języku C# najczęściej występują, gdy: procedury obsługi zdarzeń nie są wypisane, statyczne źródła zdarzeń przechowują odwołania do instancji subskrybentów i obiekty IDisposable nie są usunięte.

csharp

// C#: event handler leak
public class Publisher {
    public static event EventHandler DataChanged;  // static event
}

public class Subscriber {
    public Subscriber() {
        Publisher.DataChanged += OnDataChanged;  // strong reference
    }

    private void OnDataChanged(object sender, EventArgs e) { }

    // Missing: Dispose() calling Publisher.DataChanged -= OnDataChanged
}

Subscriber nigdy nie jest zbierany tak długo, jak Publisher istnieje (co, będąc statycznym, jest wieczne), ponieważ zdarzenie statyczne zawiera delegata odwołującego się do instancji subskrybenta. Poprawka jest wdrażana IDisposable i wypisanie się z subskrypcji Dispose()lub za pomocą WeakEventManager.

Wykrywanie: .NET Memory Profiler, dotMemory, Visual Studio Diagnostic Tools i WinDbg z rozszerzeniem SOS do zrzutów pamięci produkcyjnej.

Go

Go używa algorytmu GC typu „zaznacz i zamiataj” z bardzo krótkim czasem pauzy „zatrzymaj świat”. Mimo to w programach Go występują wycieki pamięci z powodu wycieków goroutine – goroutine, które są uruchamiane i nigdy nie kończą się.

go

// Go: goroutine leak -- channel never closed, goroutine blocked forever
func processItems(items []Item) {
    results := make(chan Result)

    go func() {
        for _, item := range items {
            results <- process(item)
        }
        // Missing: close(results)
        // This goroutine blocks on the send if no one reads after items run out
    }()

    for result := range results {
        handleResult(result)
    }
    // If the goroutine blocks and results is never closed,
    // the loop here also blocks -- both goroutines leak
}

Każda wyciekła goroutina zawiera swój stos (początkowo 2 KB, rosnący w miarę potrzeb) oraz wszelkie obiekty sterty, do których się odwołuje. Usługa, która wycieka jedną goroutinę na żądanie, z czasem kumuluje ich tysiące.

Wykrycie: runtime.NumGoroutine() metryczny, pprof punkt końcowy profilu goroutine (/debug/pprof/goroutine) i goleak do testowania. Profil pprof pokazuje ślad stosu każdej goroutine, ułatwiając identyfikację funkcji blokowanych na czas nieokreślony.

JavaScript i Node.js

W Node.js najczęstszymi wzorcami wycieków są zmienne globalne, nasłuchiwacze emiterów zdarzeń gromadzące się bez usuwania oraz bufory/strumienie, które nie są prawidłowo zamykane.

javascript

// Node.js: EventEmitter listener leak
const EventEmitter = require('events');
const emitter = new EventEmitter();

function createHandler() {
    emitter.on('data', (data) => {
        // This listener is never removed
        // Each call to createHandler adds another listener
        console.log(data);
    });
}

// Called once per request in a server:
// After 11 calls, Node prints MaxListenersExceededWarning
// After thousands of calls, thousands of listeners accumulate

// Fix: use emitter.once() for one-time listeners
// or explicitly remove with emitter.off()

Wykrywanie: migawki sterty Chrome DevTools (dla JavaScript przeglądarki), node --inspect z Chrome DevTools (dla Node.js), clinic.js do profilowania produkcyjnego Node.js i heapdump Pakiet npm służący do przechwytywania i porównywania migawek sterty w czasie.

Wzory zapobiegania w różnych językach GC

Zamiast debugowania wycieków po ich ujawnieniu się w środowisku produkcyjnym, poniższe wzorce strukturalne zapobiegają najczęstszym przyczynom:

Słabe referencje Zezwalaj na odwoływanie się do obiektu bez blokowania jego kolekcji. Gdy GC potrzebuje pamięci, obiekty słabo odwoływane kwalifikują się do kolekcji, nawet jeśli istnieją do nich odwołania. Użyj WeakReference<T> w Javie i C#, weakref.ref() w Pythonie i WeakRef w JavaScript dla pamięci podręcznych i rejestracji obserwatorów, gdzie obiekt odwołujący się nie powinien wydłużać czasu życia obiektu odwołującego się.

Pamięci podręczne o ograniczonym rozmiarze z możliwością eksmisji zastąpić surowe HashMap or dict z implementacją pamięci podręcznej specjalnie zbudowaną: Guava CacheBuilder w Javie, functools.lru_cache or cachetools w Pythonie, IMemoryCache z ograniczeniami rozmiaru w C# i node-lru-cache w Node.js.

Jawne zarządzanie cyklem życia zapewnia, że ​​obiekty z zarejestrowanymi słuchaczami, otwartymi zasobami lub stanem lokalnym wątku implementują close(), dispose()lub równoważną metodę, która wykonuje czyszczenie i którą osoby wywołujące zawsze wywołują, try-with-resources w Javie, with instrukcje w Pythonie, using deklaracje w C# i defer w Go.

Anulowanie gorutyny w Go używa context.Context z anulowaniem, aby zapewnić, że każda gorutyna ma ścieżkę do zakończenia. Gorutyny, które wybierają ctx.Done() może otrzymać sygnał wyjścia, zapobiegający akumulacji.

Jak analiza statyczna wykrywa potencjalne nieszczelności przed rozpoczęciem produkcji

Profilowanie pamięci wykrywa wycieki po ich wystąpieniu. Analiza statyczna wykrywa wzorce strukturalne, które je powodują, jeszcze przed uruchomieniem kodu.

SMART TS XL'S statyczna analiza kodu analizuje strukturalne cechy baz kodu w językach Java, Python, C#, JavaScript i innych, identyfikując wzorce związane z niezamierzonym przechowywaniem: statyczne kolekcje bez udokumentowanego usunięcia, rejestracje nasłuchiwaczy zdarzeń bez odpowiadającego im wyrejestrowania, wykorzystanie ThreadLocal bez czyszczenia oraz alokacje zasobów bez odpowiadającego im usuwania.

Możliwość mapowania zależności aplikacji ujawnia długie łańcuchy odwołań, które zwykle występują w przypadku wycieków pamięci języka GC, czyli obiekty, do których można dotrzeć od korzeni programu poprzez kilka poziomów pośredniości, gdzie ścieżka do korzenia przebiega przez długotrwały kontener przeznaczony do tymczasowego przechowywania obiektów.

W przypadku organizacji zarządzających dużymi, starszymi bazami kodu, w których wzorce zarządzania pamięcią zostały ustalone lata lub dekady temu, SMART TS XL'S analiza wpływu możliwości sprawiają, że naprawa ma określony zakres i jest systematyczna: zidentyfikuj każdą lokalizację, w której występuje określony antywzorzec (nieograniczona statyczna pamięć podręczna, nieusunięty obiekt nasłuchujący), wymień dotknięte komponenty i zaplanuj naprawę według stopnia ryzyka, zamiast odkrywać wystąpienia po jednym incydencie produkcyjnym na raz.