Wyciek pamięci (Memory Leak) — sytuacja, gdy aplikacja przechowuje referencje do obiektów, które nie są już potrzebne, uniemożliwiając garbage collectora zwolnienie occupied memory. Według danych LeakCanary, nawet w dobrze napisanych aplikacjach występują 3–5 wycieków na 10 000 linii kodu. Każdy wyciek stopniowo zmniejsza dostępną pamięć, prowadząc do spowolnień i OutOfMemoryError.
Najważniejsze
Wyciek pamięci (Memory Leak) — to sytuacja, w której obiekt pozostaje osiągalny poprzez łańcuch silnych referencji (Strong Reference), choć logicznie nie jest już potrzebny aplikacji. Garbage collector (GC) uznaje taki obiekt za żywy i nie zwalnia zajmowanej przez niego pamięci. W rezultacie dostępna pamięć sterty (Heap) stale się zmniejsza, a częstotliwość pauz GC rośnie.
W przeciwieństwie do języków z ręcznym zarządzaniem pamięcią (C, C++), w Java/Kotlin wyciek to nie zapomniany free(), a zapomniana referencja. Dopóki istnieje strong reference od obiektu głównego (GC Root) do wyciekającego obiektu, GC uznaje go za potrzebny. Typowe GC Root: pola statyczne, aktywne wątki, stos wywołań, referencje JNI-globalne.
Zagrożenie wycieków — ich efekt kumulacyjny. Jeden wyciek 100 KB jest niezauważalny, ale 100 takich wycieków zajmuje 10 MB, a aplikacja zaczyna zwalniać z powodu częstych GC. Krytyczna masa wycieków prowadzi do OutOfMemoryError i crasha aplikacji. Objawy wycieku: stały wzrost zużycia pamięci na wykresie Profilera, częste pauzy GC z STW (Stop The World) i spadek wydajności UI.
Pięć rodzajów wycieków pokrywa 95% przypadków w programowaniu mobilnym. Każdy ma swoją przyczynę i charakterystyczny wzorzec w kodzie.
Najbardziej znany wyciek w Androidzie — przechowywanie statycznej referencji do Activity lub Context. Typowy kod: statyczne pole Activity, które nie jest zerowane przy onDestroy(). Dopóki żyje pole statyczne — żyje całe Activity ze swoim drzewem View, które może zajmować 1–10 MB. To klasyczny wyciek, który LeakCanary znajduje w pierwszej kolejności.
Rozwiązanie: nigdy nie przechowuj Activity lub Context w polach statycznych. Używaj Application Context dla singletonów, które żyją dłużej niż Activity. Jeśli potrzebujesz referencji do Activity — użyj WeakReference<Activity>.
object MySingleton {
private var weakActivity: WeakReference<Activity>? = null
fun attach(activity: Activity) {
weakActivity = WeakReference(activity)
}
}
Anonimowe klasy i niestatyczne klasy zagnieżdżone niejawnie przechowują referencję do klasy zawierającej. Runnable przekazywany do Handlera, który wykonuje się po onDestroy(), przechowuje całe Activity. Callback Retrofit zamykający Activity robi to samo. To najbardziej podstępny typ wycieku — niejawna referencja nie jest widoczna w kodzie.
Kotlin object-wyrażenia i lambdy również przechwytują referencje do klasy zewnętrznej. Twórz klasy zagnieżdżone jako statyczne (lub top-level w Kotlin) i przekazuj referencje zewnętrzne przez WeakReference. Dla lambd używaj podejścia Lifecycle-aware z viewLifecycleOwner.
Subskrypcja usług systemowych bez wypisywania się — bezpośredni wyciek. SensorManager, LocationManager, NotificationListener zarejestrowane w onResume() bez wywołania unregister w onPause() przechowują Activity. Analogicznie: RxJava Disposable niedodany do CompositeDisposable i coroutine uruchomiona przez GlobalScope.
Używaj Lifecycle-aware komponentów: observe() z LifecycleOwner automatycznie wypisuje się przy onDestroy(). Dla RxJava — viewLifecycleOwner.lifecycle.addObserver z DisposableObserver. Dla coroutines — lifecycleScope.launch() jest powiązany z cyklem życia.
// automatyczne wypisywanie się przez Lifecycle
viewModel.userData.observe(viewLifecycleOwner) { data ->
updateUI(data)
}
// coroutines z lifecycleScope
lifecycleScope.launch {
viewModel.loadData().collect { render(it) }
}
Bitmap zajmuje znaczną ilość pamięci sterty: jeden FullHD-bitmap — 1920 × 1080 × 4 bajty = 8.3 MB. Jeśli Bitmap jest tworzony dla każdego elementu listy i nie wywołuje się recycle() przy ukrywaniu, pamięć szybko się wyczerpuje. Na starych wersjach Androida (przed 3.0) Bitmap był przechowywany w pamięci native, ale na współczesnych — na stercie Dalvik/ART, a GC może go zwolnić tylko jeśli nie ma strong reference.
Używaj Glide lub Coil do ładowania obrazów — te biblioteki zarządzają buforowaniem i recycle automatycznie. Jeśli pracujesz bezpośrednio z Bitmap, wywołuj bitmap.recycle() dla dużych obrazów, które nie są już wyświetlane, i używaj inSampleSize do ładowania zmniejszonych kopii.
Fragment ma dwa cykle życia: samego Fragment i jego View. Po onDestroyView() drzewo View jest niszczone, ale sam Fragment może pozostać w pamięci, jeśli istnieje zewnętrzna referencja. Typowy błąd — przechowywanie referencji do Fragment w adapterze ViewPager lub w grafie nawigacji, która nie jest czyszczona przy zniszczeniu.
Nigdy nie przechowuj referencji do Fragment w polach długożyjących obiektów. Używaj childFragmentManager dla zagnieżdżonych fragmentów i observe() z LifecycleOwner do przesyłania danych między nimi. ViewPager2 rozwiązał ten problem na poziomie API: FragmentTransactionAdapter poprawnie zarządza cyklem życia.
Wykrycie wycieku wymaga sprawdzenia dwóch faktów: pamięć nie wraca po expected lifetime i liczba obiektów określonego typu rośnie bez zmniejszania. Proces diagnostyki obejmuje trzy etapy.
Pierwszy etap — wizualne sprawdzenie przez Memory Profiler w Android Studio. Otwórz zakładkę Memory, wykonaj docelową akcję (otwórz i zamknij ekran), naciśnij GC (Garbage Collection) i sprawdź, czy pamięć wróciła do początkowego poziomu. Jeśli po 3–4 cyklach otwierania-zamykania pamięć stale rośnie — jest wyciek.
Drugi etap — wykonanie Heap Dump. W Memory Profiler naciśnij Dump Java Heap. Uzyskany plik .hprof otwórz w Android Studio: zobaczysz wszystkie obiekty na stercie z rozmiarami i referencjami. Szukaj klas, których liczba powinna być zerowa po zamknięciu ekranu. Na przykład MainActivity z liczbą 2 po zamknięciu — wyraźny wyciek.
Trzeci etap — analiza Retained Size i GC Root. W Android Studio analizuj Retained Size: ile pamięci zostanie zwolnione, jeśli usuniesz ten obiekt. Ścieżka od GC Root do obiektu pokazuje, co go utrzymuje: Static field → HashMap → Activity — i widzisz punkt wycieku. Panel Reference widget pokazuje wszystkich posiadaczy obiektu.
Cztery narzędzia pokrywają wykrywanie wycieków od automatycznego wykrywania do głębokiej analizy Heap Dump.
| Narzędzie | Metoda | Format wyników |
|---|---|---|
| LeakCanary | Automatyczny monitoring | Heap Dump + stack trace wycieku |
| Android Memory Profiler | Ręczny monitoring | Wykres pamięci + Heap Dump |
| MAT (Eclipse) | Głęboka analiza | Raport Dominator Tree + GC Root path |
| Perfetto | System-wide trace | Oś czasu + native memory |
LeakCanary — must-have dla każdego projektu Android. Automatycznie wykrywa wycieki po zakończeniu cyklu życia Activity/Fragment i pokazuje dokładne miejsce wycieku z stack trace. Integracja: jedna linia w build.gradle. LeakCanary 2.x nie wymaga ręcznej inicjalizacji — automatycznie rejestruje Application Watcher.
Profilaktyka wycieków jest wbudowywana w proces tworzenia poprzez zestaw reguł i narzędzi sprawdzających kod na każdym etapie.
Nigdy nie przechowuj referencji do Activity, Fragment lub View w polu statycznym, singletonie lub długożyjącym obiekcie. Jeśli bez referencji nie można się obejść — użyj WeakReference lub przechowuj dane przez ViewModel, który żyje dokładnie tak długo, jak potrzeba, i nie przechowuje View bezpośrednio.
ViewModel i LiveData z Android Architecture Components rozwiązują problem cyklu życia na poziomie architektury. ViewModel przeżywa obrót ekranu i nie zawiera referencji do View. LiveData automatycznie wypisuje observer przy onDestroy(). Używaj ich zamiast ręcznej subskrypcji usług systemowych.
Na code review zwracaj uwagę na: pola statyczne z typami Context/View, klasy anonimowe, lambdy zamykające Activity, ręczne subskrypcje, RxJava disposable bez composite, przechowywanie Fragment przez Bundle. W Kotlin dodatkowo sprawdzaj coroutines na launch bez powiązania z cyklem życia.
LeakCanary może działać jako część potoku testowego: uruchamiaj testy akceptacyjne z LeakCanary i oznaczaj build jako nieudany, jeśli znaleziono wyciek. Zapobiega to przedostawaniu się wycieków do produkcji. Uzupełnij kontrolę Android Lint z regułą StaticFieldLeak — znajduje ona potencjalne wycieki na poziomie analizy statycznej.
// LeakCanary w testach
class LeakTest {
@Test
fun activityShouldNotLeak() {
ActivityScenario.launch(MainActivity::class.java)
.close()
LeakAssertions.assertNoLeak() // fail jeśli jest wyciek
}
}
Często zadawane pytania
Wyciek to przyczyna, a OutOfMemoryError to skutek. Jeden wyciek nie prowadzi do OOM, ale nagromadzenie dziesiątek wycieków wyczerpuje Heap. OOM to fatalny wyjątek, a wyciek to wzorzec, który do niego prowadzi z upływem czasu.
Przez Android Memory Profiler: otwieraj i zamykaj ekran 5 razy, po każdym zamknięciu wywołuj GC. Jeśli pamięć nie wraca do poziomu bazowego — jest wyciek. Wykonaj Heap Dump i znajdź na liście klasę Activity, której liczba jest większa niż 0 po zamknięciu.
częściowo. Kotlin rozwiązuje problem null-safety, ale nie zarządza strong references. Coroutines z lifecycleScope i viewModelScope zapobiegają wyciekom z zadań w tle, a sealed class i data class zmniejszają liczbę stanów prowadzących do wycieków. Główna ochrona to wzorce architektoniczne, a nie funkcje języka.
LeakCanary czasami daje false positive: obiekt może być tymczasowo przechowywany przez system (np. InputMethodManager przechowuje ostatni View). Sprawdź ręcznie: jeśli Retained Size < 1 KB i GC Root to usługa systemowa, prawdopodobnie to fałszywy alarm.
Nie. Wycieki są możliwe na każdej platformie z GC: iOS (Swift/Objective-C), Flutter (Dart), przeglądarki (JavaScript). Mechanizmy są takie same — strong reference od GC Root. W iOS ARC automatycznie zarządza pamięcią, ale retain cycle między obiektami tworzy ten sam wyciek.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również