Цурење меморије (Memory Leak) — ситуација када апликација задржава референце ка објектима који више нису потребни, не дозвољавајући сакупљачу отпадака (GC) да ослободи occupied memory. Према подацима LeakCanary, чак и у добро написаним апликацијама јавља се 3–5 цурења на 10 000 линија кода. Свако цурење постепено смањује доступну меморију, доводећи до успоравања и OutOfMemoryError.
Најважније
Цурење меморије (Memory Leak) — ситуација у којој објекат остаје достижан кроз ланац јаких референци (Strong Reference), иако логички више није потребан апликацији. Сакупљач отпадака (GC) сматра такав објекат живим и не ослобађа меморију коју заузима. Као резултат, доступна меморија хипа (Heap) се стално смањује, а учесталост GC пауза расте.
За разлику од језика са ручним управљањем меморијом (C, C++), у Java/Kotlin цурење није заборављени free(), већ заборављена референца. Док постоји strong reference од коренског објекта (GC Root) до објекта који цури, GC га сматра потребним. Типични GC Root: статичка поља, активне нити, стек позива, JNI-глобалне референце.
Опасност цурења је њихов кумулативни ефекат. Једно цурење од 100 KB није приметно, али 100 таквих цурења заузима 10 MB и апликација почиње да успорава због честих GC. Критична маса цурења доводи до OutOfMemoryError и пада апликације. Симптоми цурења: стални раст потрошње меморије на графикону Profiler-а, честе GC паузе са STW (Stop The World) и пад перформанси UI-ја.
Пет врста цурења покрива 95% случајева у мобилном развоју. Свака има свој узрок и карактеристичан образац у коду.
Најпознатије цурење на Android-у — чување статичке референце на Activity или Context. Типичан код: статичко поље Activity које се не анулира при onDestroy(). Док је статичко поље живо, читав Activity са својим View стаблом, које може заузимати 1–10 MB, остаје жив. Ово је класично цурење које LeakCanary проналази у првом реду.
Решење: никада не чувајте Activity или Context у статичким пољима. Користите Application Context за синглтоне који надживљавају Activity. Ако вам треба референца на Activity — користите WeakReference<Activity>.
object MySingleton {
private var weakActivity: WeakReference<Activity>? = null
fun attach(activity: Activity) {
weakActivity = WeakReference(activity)
}
}
Анонимне класе и нестатичке угнежђене класе имплицитно чувају референцу на класу која их садржи. Runnable прослеђен Handler-у који се извршава после onDestroy() задржава читав Activity. Retrofit callback који затвара Activity ради исто. Ово је најподмуклији тип цурења — имплицитна референца није видљива у коду.
Kotlin object-изрази и ламбде такође хватају референце на спољашњу класу. Направите угнежђене класе статичким (или top-level у Kotlin-у) и прослеђујте спољашње референце путем WeakReference. За ламбде користите Lifecycle-aware приступ са viewLifecycleOwner.
Претплата на системске сервисе без одјаве — директно цурење. SensorManager, LocationManager, NotificationListener регистровани у onResume() без позива unregister у onPause() задржавају Activity. Слично: RxJava Disposable који није додат у CompositeDisposable и корутина покренута кроз GlobalScope.
Користите Lifecycle-aware компоненте: observe() са LifecycleOwner се аутоматски одјављује при onDestroy(). За RxJava — viewLifecycleOwner.lifecycle.addObserver са DisposableObserver. За корутине — lifecycleScope.launch() је везан за животни циклус.
// аутоматска одјава путем Lifecycle
viewModel.userData.observe(viewLifecycleOwner) { data ->
updateUI(data)
}
// корутине са lifecycleScope
lifecycleScope.launch {
viewModel.loadData().collect { render(it) }
}
Bitmap заузима значајну количину меморије хипа: један FullHD-битмап — 1920 × 1080 × 4 бајта = 8.3 MB. Ако се Bitmap креира за сваки елемент листе и не позива се recycle() при сакривању, меморија се брзо исцрпљује. На старијим верзијама Android-а (пре 3.0), Bitmap је чуван у native меморији, али на модерним — на хипу Dalvik/ART, и GC га може ослободити само ако нема strong reference.
Користите Glide или Coil за учитавање слика — ове библиотеке управљају кеширањем и recycle-ом аутоматски. Ако радите директно са Bitmap-ом, позивајте bitmap.recycle() за велике слике које се више не приказују и користите inSampleSize за учитавање умањених копија.
Fragment има два животна циклуса: самог Fragment-а и његовог View-а. После onDestroyView() View стабло се уништава, али сам Fragment може остати у меморији ако постоји спољашња референца. Типична грешка — чување референце на Fragment у адаптеру ViewPager-а или у навигационом графу која се не чисти при уништењу.
Никада не чувајте референцу на Fragment у пољима дугоживећих објеката. Користите childFragmentManager за угнежђене фрагменте и observe() са LifecycleOwner за пренос података међу њима. ViewPager2 је решио овај проблем на нивоу API-ја: FragmentTransactionAdapter коректно управља животним циклусом.
Откривање цурења захтева проверу две чињенице: меморија се не враћа после expected lifetime и број објеката одређеног типа расте без смањења. Процес дијагностике укључује три етапе.
Прва етапа — визуелна провера кроз Memory Profiler у Android Studio. Отворите картицу Memory, извршите циљану акцију (отворите и затворите екран), притисните GC (Garbage Collection) и гледајте да ли се меморија вратила на почетни ниво. Ако после 3–4 циклуса отварања-затварања меморија стално расте — постоји цурење.
Друга етапа — снимање Heap Dump-а. У Memory Profiler-у притисните Dump Java Heap. Добијени .hprof фајл отворите у Android Studio: видећете све објекте на хипу са величинама и референцама. Тражите класе чији број треба да буде нула после затварања екрана. На пример, MainActivity са бројем 2 после затварања — очигледно цурење.
Трећа етапа — анализа Retained Size и GC Root-а. У Android Studio анализирајте Retained Size: колико меморије ће се ослободити ако избришете овај објекат. Путања од GC Root-а до објекта показује шта га држи: Static field → HashMap → Activity — и видите тачку цурења. Панел Reference widget приказује све држаоце објекта.
Четири алата покривају проналажење цурења од аутоматског откривања до дубинске анализе Heap Dump-а.
| Алат | Метод | Формат резултата |
|---|---|---|
| LeakCanary | Аутоматски мониторинг | Heap Dump + stack trace цурења |
| Android Memory Profiler | Ручни мониторинг | Графикон меморије + Heap Dump |
| MAT (Eclipse) | Дубинска анализа | Извештај Dominator Tree + GC Root путања |
| Perfetto | System-wide трасирање | Временска скала + native меморија |
LeakCanary — must-have за сваки Android пројекат. Аутоматски детектује цурења по завршетку животног циклуса Activity/Fragment и показује тачну локацију цурења са stack trace-ом. Интеграција: један ред у build.gradle. LeakCanary 2.x не захтева ручну иницијализацију — аутоматски региструје Application Watcher.
Превенција цурења се уграђује у процес развоја кроз скуп правила и алата који проверавају код у свакој фази.
Никада не чувајте референцу на Activity, Fragment или View у статичком пољу, синглтону или дугоживећем објекту. Ако се без референце не може — користите WeakReference или чувајте податке путем ViewModel-а који живи тачно колико треба и не држи View директно.
ViewModel и LiveData из Android Architecture Components решавају проблем животног циклуса на нивоу архитектуре. ViewModel преживљава ротацију екрана и не садржи референце ка View-у. LiveData аутоматски одјављује observer при onDestroy(). Користите их уместо ручне претплате на системске сервисе.
На code review обратите пажњу на: статичка поља са типовима Context/View, анонимне класе, ламбде које затварају Activity, ручне претплате, RxJava disposable без composite, чување Fragment-а путем Bundle-а. У Kotlin-у додатно проверавајте корутине на launch без везе са животним циклусом.
LeakCanary може радити као део тест пајплајна: покрените acceptance тестове са LeakCanary и означите build неуспешним ако је пронађено цурење. Ово спречава улазак цурења у продукцију. Допуните проверу Android Lint-ом са правилом StaticFieldLeak — оно проналази потенцијална цурења на нивоу статичке анализе.
// LeakCanary у тестовима
class LeakTest {
@Test
fun activityShouldNotLeak() {
ActivityScenario.launch(MainActivity::class.java)
.close()
LeakAssertions.assertNoLeak() // fail ако постоји цурење
}
}
Често постављана питања
Цурење је узрок, а OutOfMemoryError последица. Једно цурење не доводи до OOM, али нагомилавање десетина цурења исцрпљује Heap. OOM је фатални изузетак, а цурење је образац који временом доводи до њега.
Кроз Android Memory Profiler: отварајте и затварајте екран 5 пута, после сваког затварања позовите GC. Ако се меморија не враћа на основни ниво — постоји цурење. Направите Heap Dump и пронађите у листи класу Activity чији је број већи од 0 после затварања.
Делимично. Kotlin решава проблем null-safety, али не управља strong references. Корутине са lifecycleScope и viewModelScope спречавају цурења од позадинских задатака, а sealed class и data class смањују број стања која воде до цурења. Главна заштита су архитектонски обрасци, а не функције језика.
LeakCanary понекад даје false positive: објекат може бити привремено задржан од стране система (нпр. InputMethodManager задржава последњи View). Проверите ручно: ако је Retained Size < 1 KB и GC Root системски сервис, вероватно је лажна узбуна.
Не. Цурења су могућа на свакој платформи са GC: iOS (Swift/Objective-C), Flutter (Dart), веб-прегледачи (JavaScript). Механизми су исти — strong reference од GC Root-а. На iOS-у ARC аутоматски управља меморијом, али retain cycle између објеката ствара исто цурење.
Закључак
Развићемо мобилну апликацију под кључ
IT Sectr креира iOS и Android апликације за стартапе и предузећа од 2017. године. Саветоваћемо вас и предложити најбоље решење.
Прочитајте такође