Wstęp
Wycieki pamięci. Boi się ich każdy developer Androida. Widzisz ostrzeżenia w profilerze, może LeakCanary wyskakuje z niepokojącym powiadomieniem, a twoja aplikacja działa coraz wolniej, aż w końcu system ją zabija. Ale co tak naprawdę się dzieje? Dlaczego wycieki pamięci zdarzają się w języku z automatycznym garbage collection? Czy nie mieliśmy być wolni od ręcznego zarządzania pamięcią? Odpowiedź tkwi w tym, jak obiekty odwołują się do siebie nawzajem — w grafie obiektów. Dziś zobaczymy, jak garbage collector naprawdę decyduje, co zachować, a co wyrzucić, dlaczego aplikacje na Androida są wyjątkowo podatne na wycieki i co właściwie się dzieje, gdy LeakCanary sygnalizuje, że coś jest nie tak. Gdy raz zobaczysz graf obiektów, wycieki pamięci przestają być zagadką i stają się oczywiste.
Obiekty wskazują na inne obiekty
Zbudujmy model myślowy od zera. Każdy obiekt w pamięci twojej aplikacji zawiera dwa rodzaje rzeczy: dane — liczby, wartości logiczne, bajty — oraz referencje do innych obiektów. Twoja Activity trzyma referencję do swojego ViewModelu. ViewModel trzyma referencję do Repository. Repository trzyma referencję do serwisu Retrofit. Serwis Retrofit trzyma referencję do OkHttpClient. I tak dalej. Te referencje tworzą sieć — graf skierowany. Każdy obiekt jest węzłem, a każda referencja to strzałka z jednego węzła do drugiego. To jest graf obiektów. I to najważniejsza struktura z punktu widzenia garbage collectora. Nie hierarchia klas ani drzewo dziedziczenia. GC obchodzi tylko to, kto wskazuje na kogo. To decyduje, czy obiekt żyje, czy umiera.
GC roots: tu zaczyna się śledzenie
Garbage collector nie sprawdza każdego obiektu w pamięci, pytając: „czy jesteś jeszcze potrzebny?”. To byłoby niewyobrażalnie wolne. Zamiast tego startuje z określonego zbioru punktów początkowych, nazywanych GC roots. Czym są GC roots? To referencje, które runtime z definicji uznaje za żywe. Zmienne lokalne na stosach aktywnych wątków. Pola statyczne — w Kotlinie to właściwości companion object i właściwości top-level. Same aktywne wątki. Referencje JNI z kodu natywnego. Wyobraź sobie GC roots jako główną rozdzielnię elektryczną w budynku. Jeśli przewód jest podłączony do rozdzielni, prąd płynie nim do wszystkiego, co jest na drugim końcu. GC startuje z każdego roota i podąża za referencjami na zewnątrz, oznaczając jako „żywy” każdy obiekt, do którego dotrze. Gdy śledzenie się kończy, wszystko, co nie zostało oznaczone, jest nieosiągalne — żaden kod w twojej aplikacji nie ma do tego dostępu — więc zostaje zebrane. Zwolnione. Jego pamięć wraca do puli.
Zasada: jeśli dociera do ciebie JAKAKOLWIEK ścieżka, żyjesz
Oto kluczowa zasada i nie ma od niej wyjątków. Jeśli istnieje jakakolwiek ścieżka referencji z dowolnego GC roota do twojego obiektu, ten obiekt pozostaje żywy. Nie ma znaczenia, że ścieżka składa się z pięciu referencji. Nie ma znaczenia, że prowadzi przez obiekty, o których zapomniałeś. Nie ma znaczenia, że twoim zdaniem obiekt powinien być już martwy. Jeśli istnieje ścieżka, obiekt żyje. Weźmy przykład. Masz statyczną HashMap — w Kotlinie mapę w companion object. Wstawiasz do niej wpis, którego wartością jest Activity. Ta Activity trzyma referencję do całej swojej hierarchii widoków — każdego przycisku, każdego TextView, każdego obrazka. Nawet po zniszczeniu Activity, nawet po wywołaniu onDestroy, jeśli ten wpis w HashMap wciąż istnieje, GC przechodzi od roota, czyli pola statycznego, przez HashMap, do Activity i do każdego widoku. Nic z tego nie może zostać zebrane. Wszystko wycieka. Jedna zapomniana referencja w jednej mapie i megabajty pamięci są zablokowane na zawsze.
Typowe wycieki w Androidzie
Przejdźmy przez najczęstsze wycieki pamięci w Androidzie, bo wszystkie działają według tego samego schematu. Pierwszy: referencja do Activity przechwycona w callbacku lub listenerze. Przekazujesz Activity jako click listener albo rejestrujesz ją jako callback w jakimś managerze, a ten manager żyje dłużej niż Activity. Manager trzyma referencję do Activity, Activity trzyma swoje widoki i nic z tego nie może zostać zebrane. Drugi: ViewModel, który trzyma bezpośrednią referencję do View albo Activity. ViewModele przetrwają zmiany konfiguracji — właśnie po to istnieją. Ale jeśli ViewModel trzyma referencję do View ze starej Activity, ta stara Activity wycieka razem ze wszystkimi swoimi widokami. Dlatego ViewModele nigdy nie powinny bezpośrednio odwoływać się do View, Fragmentów ani Activity. Trzeci: statyczne referencje do Context. Właściwość companion object, która trzyma kontekst Activity. Takiej Activity garbage collector nie zbierze przez cały czas życia aplikacji. Gdy potrzebujesz długo żyjącego Context, użyj applicationContext. Czwarty: klasy wewnętrzne i obiekty anonimowe. Niestatyczna klasa wewnętrzna w Javie — albo lambda, która w Kotlinie przechwytuje this — trzyma niejawną referencję do klasy zewnętrznej. Jeśli taka klasa wewnętrzna przeżyje zewnętrzną Activity, masz wyciek.
Prawdziwa zasada: niedopasowany czas życia
Wszystkie te wycieki mają jedną wspólną przyczynę i chcę, żebyś zapamiętał tę zasadę na zawsze. Wycieki pamięci powstają wtedy, gdy na obiekt o krótszym czasie życia wskazuje obiekt o dłuższym czasie życia. Activity żyje przez jedną wizytę na ekranie. ViewModel przetrwa zmiany konfiguracji. Application żyje przez cały czas trwania procesu. Pole statyczne żyje wiecznie. Gdy coś długo żyjącego trzyma referencję do czegoś krótko żyjącego, ten krótko żyjący obiekt nie może umrzeć wtedy, kiedy powinien. Jest sztucznie utrzymywany przy życiu. Prawdziwa zasada unikania wycieków brzmi więc: zawsze myśl o czasie życia. Zanim zapiszesz referencję, zadaj sobie pytanie: czy obiekt, w którym ją zapisuję, przeżyje obiekt, na który ona wskazuje? Jeśli tak, masz potencjalny wyciek. Użyj WeakReference, użyj komponentu lifecycle-aware albo przebuduj kod tak, żeby referencja szła w drugą stronę — od obiektu krótko żyjącego do długo żyjącego, co zawsze jest bezpieczne.
LeakCanary: co naprawdę robi
LeakCanary to jedna z najpopularniejszych bibliotek do wykrywania wycieków pamięci w Androidzie. Ale co właściwie robi pod spodem? Oto cały proces. Gdy Activity albo Fragment zostaje zniszczony, LeakCanary opakowuje referencję do niego w WeakReference i rejestruje ją w ReferenceQueue. Jeśli garbage collector zbierze obiekt, WeakReference pojawi się w kolejce. LeakCanary czeka kilka sekund, wywołuje GC i sprawdza: czy referencja pojawiła się w kolejce? Jeśli tak — świetnie, obiekt został zebrany, wycieku nie ma. Jeśli nie — obiekt nie został zebrany, czyli coś wciąż trzyma do niego silną referencję. Wtedy LeakCanary robi zrzut sterty (heap dump) — migawkę każdego obiektu w pamięci i każdej referencji między obiektami. Następnie analizuje ten zrzut, żeby znaleźć najkrótszą ścieżkę od GC roota do obiektu, który wyciekł. Właśnie tę ścieżkę pokazuje ci w leak trace. Dosłownie pokazuje łańcuch referencji, który utrzymuje martwy obiekt przy życiu. Gdy zobaczysz ten łańcuch, rozwiązanie jest zwykle oczywiste: przerwij jedno z ogniw.
Jak czytać leak trace
Przeczytajmy prawdziwy leak trace, żebyś wiedział, na co zwracać uwagę. Typowy trace z LeakCanary wygląda mniej więcej tak. Na górze widzisz GC root — na przykład pole statyczne albo wątek. Potem strzałki prowadzą w dół przez łańcuch obiektów. Może to być singleton z serwisem, który ma pole listener, a to pole jest instancją klasy anonimowej z referencją do Activity. Na dole znajduje się obiekt, który wyciekł — twoja Activity albo Fragment. Każda strzałka w trace to referencja. Każdy obiekt to pośrednie ogniwo, które ją trzyma. Twoim zadaniem jest znaleźć ogniwo, które nie powinno istnieć, i je przerwać. Zwykle chodzi o rejestrację listenera — zapomniałeś go wyrejestrować w onStop albo onDestroy. Czasem to długo działająca korutyna, która przechwyciła referencję do scope Activity. Czasem globalny cache, który gromadził wpisy i nigdy ich nie usuwał. Trace mówi ci dokładnie, gdzie szukać.
Słabe referencje: wyjście awaryjne
Gdy potrzebujesz referencji do obiektu, ale nie chcesz blokować jego zebrania przez garbage collector, używasz WeakReference. WeakReference mówi GC: interesuje mnie ten obiekt, ale nie musi on żyć ze względu na mnie. Jeśli nie zostały żadne silne referencje, zbierz go. Poradzę sobie z tym, że zniknie. W kodzie opakowujesz obiekt: WeakReference(myActivity). Gdy później chcesz się do niego dostać, wywołujesz get(). Jeśli zwraca null, obiekt został zebrany. Jeśli zwraca obiekt, ten wciąż żyje. WeakReference to kluczowe narzędzie do przerywania schematów wycieków. Gdyby wspomniany manager callbacków trzymał WeakReference do Activity zamiast silnej referencji, Activity mogłaby zostać normalnie zebrana. Przy próbie wywołania callbacku manager dostałby null i mógłby elegancko posprzątać. WeakReference omówimy znacznie dokładniej w jednym z kolejnych filmów. Na razie wystarczy wiedzieć, że istnieje i pozwala obserwować obiekt bez przedłużania jego życia.
Wizualizacja grafu obiektów
Chcę, żebyś zaczął wyobrażać sobie graf obiektów swojej aplikacji. Pomyśl o nim tak: na górze Application, od której silne referencje płyną w dół do singletonów — instancji Retrofit, bazy Room, repozytoriów. Pod nimi ViewModele, z których każdy trzyma referencje do repozytoriów. Jeszcze niżej Activity i Fragmenty, które trzymają referencje do widoków. Czy strzałki powinny płynąć głównie od obiektów długo żyjących do krótko żyjących? Nie — to jest właśnie schemat wycieku. Bezpieczny kierunek jest odwrotny: obiekty krótko żyjące wskazują na obiekty długo żyjące. Activity wskazuje na kontekst Application — to bezpieczne, bo Application żyje dłużej niż Activity. ViewModel wskazuje na singleton Repository — to bezpieczne, bo singleton żyje dłużej niż ViewModel. Problemy pojawiają się, gdy strzałki idą w górę — gdy singleton wskazuje na Activity albo gdy Repository wskazuje na ViewModel. Zawsze pytaj: w którą stronę płyną moje referencje?
Zapobieganie w praktyce
Oto konkretne sposoby zapobiegania wyciekom. Po pierwsze: wyrejestrowuj wszystko. Jeśli rejestrujesz listener w onStart, wyrejestruj go w onStop. Jeśli rejestrujesz w onCreate, wyrejestruj w onDestroy. Zachowuj symetrię. Po drugie: nigdy nie przechowuj referencji do Activity ani View w ViewModelach. Przekazuj dane, nie referencje. Do komunikacji z ViewModelu do UI używaj LiveData albo StateFlow. Po trzecie: unikaj statycznych referencji do Context. Jeśli singleton potrzebuje Context, weź applicationContext, nigdy Activity. Po czwarte: uważaj na korutyny. Korutyna uruchomiona w viewModelScope jest w porządku — zostaje anulowana, gdy ViewModel jest czyszczony. Korutyna uruchomiona w GlobalScope, która przechwytuje referencję do Activity, to wyciek. Po piąte: używaj komponentów lifecycle-aware. LifecycleObserver, lifecycleScope, repeatOnLifecycle — zaprojektowano je tak, żeby same sprzątały po zakończeniu cyklu życia.
Podsumowanie i co dalej
Oto, co omówiliśmy. Obiekty odwołują się do innych obiektów i tworzą graf. GC śledzi referencje od rootów i oznacza wszystko, co osiągalne, jako żywe. Jeśli jakakolwiek ścieżka od roota dociera do obiektu, nie może on zostać zebrany. Wycieki pamięci powstają, gdy obiekty długo żyjące trzymają referencje do obiektów krótko żyjących i w ten sposób blokują ich zebranie. Typowe wycieki w Androidzie to referencje do Activity w callbackach, ViewModele trzymające widoki i statyczne referencje do Context. LeakCanary wykrywa wycieki, obserwując obiekty, które żyją dłużej, niż powinny, a potem robi zrzut sterty, żeby znaleźć łańcuch referencji. WeakReference pozwala trzymać referencję bez blokowania zebrania obiektu. Podstawowa zasada: myśl o czasie życia i kierunku referencji. W następnym filmie zajmiemy się cache'owaniem — wymianą pamięci na szybkość — i tym, dlaczego źle zrobiony cache może wszystko pogorszyć. Do zobaczenia.