Wyciek pamięci (memory leak) — sytuacja, w której aplikacja nie zwalnia pamięci zajętej przez obiekty, które nie są już potrzebne. W programowaniu mobilnym jest to szczególnie krytyczne: ograniczona sterta i brak swap prowadzą do OutOfMemoryError i awarii aplikacji. Według Purdue University (2022), 35% aplikacji Android w Google Play zawiera co najmniej jeden wyciek pamięci. Omówimy typowe scenariusze, narzędzia diagnostyczne i metody eliminacji.
Najważniejsze
Wyciek pamięci — to sytuacja, w której przydzielona pamięć nie jest zwracana systemowi po tym, jak obiekt przestał być potrzebny programowi. Garbage collector uważa taki obiekt za żywy, ponieważ prowadzi do niego aktywny łańcuch referencji od GC Root.
W Java/Kotlin garbage collector działa automatycznie, ale nie może określić, że obiekt logicznie nie jest potrzebny, jeśli istnieje do niego techniczna referencja. Deweloper musi jawnie zrywać niepotrzebne połączenia. W Swift/Objective-C ARC automatycznie zlicza referencje, ale retain cycles blokują wyzerowanie licznika.
Główne niebezpieczeństwo wycieków — efekt kumulacyjny. Każdy wyciek pochłania niewielką ilość pamięci, ale przy wielokrotnych przejściach między ekranami (obrotach ekranu, otwieraniu/zamykaniu Activity) wycieki kumulują się, aż do wyczerpania limitu sterty.
Wyciek — obiekt jest niedostępny dla kodu, ale nie usunięty przez GC. Rozdęcie — obiekt jest logicznie potrzebny, ale przechowywany w nadmiernej ilości. Przykład rozdęcia: pamięć podręczna obrazów na 100 MB przy roboczym zbiorze 30 MB. Oba problemy prowadzą do OOM, ale przyczyny i metody leczenia są różne.
ART (Android Runtime) używa pokoleniowej garbage collectora z concurrent compaction. Pamięć dzieli się na młode pokolenie (Young), stare (Old) i ogromne obiekty (Large). Obiekty, które przetrwały kilka cykli GC, przenoszone są do Old generation, gdzie zbieranie odbywa się rzadziej — przyspiesza to zwykłe cykle.
GC uruchamia się, gdy sterta osiąga określony próg zajętości (zwykle 75-85%). Podczas GC wszystkie wątki aplikacji są wstrzymywane (STW — Stop The World). Im więcej żywych obiektów, tym dłuższa pauza. Wycieki zwiększają liczbę żywych obiektów, wydłużając pauzy GC.
Garbage collector określa żywe obiekty, przechodząc graf od GC Roots: pól statycznych, zmiennych stosowych aktywnych wątków, referencji JNI. Każdy obiekt osiągalny przez referencje od tych korzeni jest uważany za żywy — nawet jeśli deweloper wie, że nie jest już potrzebny.
// Przykład: statyczna kolekcja jako GC Root — trwały wyciek
object GlobalHolder {
val listeners = mutableListOf<WeakReference<Any>>()
}
class LeakingFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle ?= null) {
super.onCreate(savedInstanceState)
GlobalHolder.listeners.add(WeakReference(this))
// WeakReference nie blokuje GC — prawidłowe zachowanie
}
}
WeakReference rozwiązuje problem: GC ignoruje weak referencje przy określaniu żywych obiektów. Jeśli na obiekt pozostały tylko weak referencje, zostanie zebrany w najbliższym cyklu GC.
Activity Context — najbardziej masowy scenariusz wycieków w Android. Jeśli singleton, pole statyczne lub długożyjący serwis przechowuje referencję do Activity Context, całe Activity ze wszystkimi View nie może zostać zebrane przez GC. Rozwiązanie: używaj Application Context dla długożyjących obiektów.
Handler i wysłane wiadomości — Handler.postDelayed(runnable, delay) umieszcza wiadomość w kolejce Main Looper. Jeśli Activity zostało zniszczone przed upływem delay, wiadomość wciąż jest w kolejce i utrzymuje referencję przez Runnable → anonimową klasę → klasę zewnętrzną (Activity).
class SafeActivity : AppCompatActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private val callback = Runnable { /* update UI */ }
override fun onResume() {
super.onResume()
mainHandler.postDelayed(callback, 5000)
}
override fun onPause() {
mainHandler.removeCallbacks(callback) // obowiązkowo: wyczyść kolejkę
super.onPause()
}
}
Inner Classes — niestatyczna klasa wewnętrzna ma niejawną referencję do instancji klasy zewnętrznej. Jeśli klasą zewnętrzną jest Activity, a klasa wewnętrzna została przekazana gdzieś na zewnątrz (np. do RecyclerView.Adapter), Activity nie może zostać zebrane.
Android Studio Memory Profiler — wbudowane narzędzie do monitorowania sterty w czasie rzeczywistym. Pokazuje wykres zajętej pamięci, liczbę alokacji i obiektów według typów. Pozwala zapisać heap dump i wyeksportować w formacie HPROF do analizy w MAT.
Eclipse MAT (Memory Analyzer Tool) — desktopowy analizator heap dump. Automatycznie buduje Leak Suspects Report, który podświetla obiekty z największym retained size i proponuje przewidywaną GC root chain dla każdego podejrzanego obiektu.
Xcode Memory Graph Debugger — dla iOS. Wstrzymuje aplikację i wizualizuje graf obiektów. Retain cycles są podświetlane na czerwono, można kliknąć na dowolny obiekt i zobaczyć jego retain count i referencje.
| Narzędzie | Możliwości | Złożoność |
|---|---|---|
| Memory Profiler | Wykres w czasie rzeczywistym, heap dump, Object Allocation Tracking | Niska |
| Eclipse MAT | Dominator tree, Leak Suspects, zapytania OQL | Średnia |
| LeakCanary | Automatyczne wykrywanie, ślad wycieku w powiadomieniu | Minimalna |
| Xcode Memory Graph | Wizualny graf retain cycles, lista żywych obiektów | Niska |
Według Uber Engineering Blog, wdrożenie automatycznego profilowania pamięci (LeakCanary + analiza heap dump) w pipeline CI/CD zmniejsza liczbę incydentów związanych z pamięcią w produkcji o 60% w ciągu 3 miesięcy.
Zastąpienie Context — jeśli obiekt żyje dłużej niż Activity, używaj applicationContext. Wszystkie długożyjące obiekty (singletony, repozytoria, database helpers) powinny otrzymywać Application Context, a nie Activity Context. Wyjątek: komponenty UI, które potrzebują dostępu do motywu lub zasobów specyficznych dla Activity.
Komponenty Lifecycle-aware — użycie LifecycleObserver, DefaultLifecycleObserver lub reactivex automatycznie anuluje subskrypcje przy onDestroy. Android Jetpack udostępnia lifecycleScope i viewModelScope, które są czyszczone przez odpowiednie zdarzenie.
Static inner class — jeśli wewnętrzna klasa nie potrzebuje dostępu do pól klasy zewnętrznej, zrób ją static. Statyczna wewnętrzna klasa nie ma niejawnej referencji do klasy zewnętrznej. Jeśli dostęp jest potrzebny, użyj WeakReference dla jawnej referencji.
class MyActivity : AppCompatActivity() {
// ❌ Wewnętrzna klasa niestatyczna — niejawna referencja do MyActivity
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ Wewnętrzna klasa statyczna — brak niejawej referencji
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
W iOS używaj capture lists: [weak self] w domknięciach, które mogą przeżyć swojego twórcę. Dla delegatów używaj słabych referencji (weak var delegate). Dla domknięć, które gwarantowanie są wywoływane tylko za życia self, można użyć [unowned self], ale ostrożnie — przy odwołaniu do zwolnionego obiektu nastąpi crash.
Często zadawane pytania
W Android wykonaj kilka przejść między ekranami (Activity A → B → A → B) i sprawdź adb shell dumpsys meminfo package_name. Jeśli Total PSS stale rośnie i nie wraca do początkowej wartości — jest wyciek. W iOS analogicznie: użyj Debug Memory Graph w Xcode do wizualnego sprawdzenia.
Tak, jeśli CoroutineScope nie został anulowany przy zniszczeniu komponentu. Korutyna uruchomiona w GlobalScope kontynuuje działanie nawet po finish() Activity. Rozwiązanie: używaj viewModelScope (anulowany w onCleared) lub lifecycleScope (anulowany w onDestroy). Dla własnych Scope twórz lifecycle-aware scopes przez LifecycleOwner.
Bitmap przechowuje dane pikseli w natywnej stercie, a nie w Java heap. Oznacza to, że Java GC nie widzi rzeczywistego rozmiaru Bitmap. Jeśli Bitmap nie zostanie wywołany recycle() lub nie wyzeruje się referencji, pamięć natywna nie zostanie zwolniona. Używaj BitmapFactory z inSampleSize do ładowania pomniejszonych kopii i Glide/Coil do automatycznego zarządzania pamięcią podręczną.
Pole statyczne — to GC Root. Żyje tak długo, jak długo klasa jest załadowana (w Android — tak długo, jak żyje Process). Jeśli pole statyczne referencjonuje Activity, Bitmap, View lub inny ciężki obiekt, ten obiekt nigdy nie zostanie zebrany przez GC. Pole statyczne — wieczna referencja. Rozwiązanie: przechowuj tylko WeakReference lub wyzeruj pole statyczne w onDestroy.
ARC automatycznie zwalnia obiekty, gdy licznik silnych referencji spadnie do zera. Retain cycle — jedyny sposób na wyciek przy ARC. Zawsze używaj weak dla referencji parent→child, gdzie child ma przeżyć parent (delegaty, data source). Dla domknięć używaj capture list [weak self] i sprawdzaj self na nil wewnątrz domknięcia.
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ż