Изтичане на памет (memory leak) — ситуация, при която приложението не освобождава паметта, заета от обекти, които вече не са необходими. В мобилното разработване това е особено критично: ограниченият heap и липсата на swap водят до OutOfMemoryError и срив на приложението. Според Purdue University (2022), 35% от Android приложенията в Google Play съдържат поне едно изтичане на памет. Ще разгледаме типични сценарии, диагностични инструменти и методи за отстраняване.
Основни точки
Изтичане на памет — това е ситуация, при която заделената памет не се връща на системата, след като обектът вече не е необходим на програмата. Събирачът на отпадъци счита такъв обект за жив, защото води до него активна верига от референции от GC Root.
В Java/Kotlin събирачът на отпадъци работи автоматично, но не може да определи, че обектът логически не е необходим, ако съществува техническа референция към него. Разработчикът трябва изрично да прекъсва ненужните връзки. В Swift/Objective-C ARC автоматично брои референциите, но retain cycles блокират нулирането на брояча.
Основната опасност от изтичания — кумулативен ефект. Всяко изтичане изяжда малко количество памет, но при многократни преходи между екрани (завъртания на екрана, отваряне/затваряне на Activity) изтичанията се натрупват, докато не изчерпят лимита на heap.
Изтичане — обектът е недостъпен за кода, но не е премахнат от GC. Надуване — обектът е логически необходим, но се съхранява в прекомерно количество. Пример за надуване: кеш от изображения от 100 MB с работен набор от 30 MB. И двата проблема водят до OOM, но причините и методите за лечение са различни.
ART (Android Runtime) използва поколенческо събиране на отпадъци с concurrent compaction. Паметта се дели на младо поколение (Young), старо (Old) и огромни обекти (Large). Обектите, оцелели след няколко GC цикъла, се преместват в Old generation, където събирането се случва по-рядко — това ускорява обикновените цикли.
GC започва, когато heap достигне определен праг на заетост (обикновено 75-85%). По време на GC всички нишки на приложението се спират (STW — Stop The World). Колкото повече живи обекти, толкова по-дълга е паузата. Изтичанията увеличават броя на живите обекти, удължавайки GC паузите.
Събирачът определя живите обекти, обхождайки графа от GC Roots: статични полета, стекови променливи на активни нишки, JNI референции. Всеки обект, достижим чрез референции от тези корени, се счита за жив — дори ако разработчикът знае, че вече не е необходим.
// Пример: статична колекция като GC Root — постоянно изтичане
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 не блокира GC — правилно поведение
}
}
WeakReference решава проблема: GC игнорира слабите референции при определяне на живите обекти. Ако до обекта останат само слаби референции, той ще бъде събран в следващия GC цикъл.
Activity Context — най-масовият сценарий на изтичания в Android. Ако singleton, статично поле или дългоживееща услуга съхранява референция към Activity Context, цялото Activity с всички View не може да бъде събрано от GC. Решение: използвайте Application Context за дългоживеещи обекти.
Handler и изпратени съобщения — Handler.postDelayed(runnable, delay) поставя съобщение в опашката на Main Looper. Ако Activity е унищожено преди изтичането на закъснението, съобщението все още е в опашката и държи референция чрез Runnable → анонимен клас → външен клас (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) // задължително: изчистете опашката
super.onPause()
}
}
Inner Classes — нестатичен вътрешен клас има имплицитна референция към инстанцията на външния клас. Ако външният клас е Activity и вътрешният клас е предаден някъде навън (например в RecyclerView.Adapter), Activity не може да бъде събрано.
Android Studio Memory Profiler — вграденият инструмент за наблюдение на heap в реално време. Показва графика на заетата памет, брой алокации и обекти по типове. Позволява запис на heap dump и експорт в HPROF формат за анализ в MAT.
Eclipse MAT (Memory Analyzer Tool) — десктоп анализатор на heap dump. Автоматично изгражда Leak Suspects Report, който подчертава обекти с най-голям retained size и предлага предполагаема GC root верига за всеки подозрителен обект.
Xcode Memory Graph Debugger — за iOS. Спира приложението и визуализира графа на обектите. Retain cycles се подчертават в червено, може да се кликне на всеки обект и да се види неговият retain count и референции.
| Инструмент | Възможности | Сложност |
|---|---|---|
| Memory Profiler | Графика в реално време, heap dump, проследяване на алокации на обекти | Ниска |
| Eclipse MAT | Dominator tree, Leak Suspects, OQL заявки | Средна |
| LeakCanary | Автоматично откриване, следа на изтичане в известие | Минимална |
| Xcode Memory Graph | Визуален граф на retain cycles, списък на живи обекти | Ниска |
Според Uber Engineering Blog, внедряването на автоматично профилиране на паметта (LeakCanary + анализ на heap dump) в CI/CD тръбопровода намалява броя на инцидентите, свързани с памет, в продукция с 60% в рамките на 3 месеца.
Замяна на Context — ако обектът живее по-дълго от Activity, използвайте applicationContext. Всички дългоживеещи обекти (singleton-и, хранилища, database helpers) трябва да получават Application Context, а не Activity Context. Изключение: UI компоненти, които се нуждаят от достъп до темата или ресурси, специфични за Activity.
Lifecycle-aware компоненти — използването на LifecycleObserver, DefaultLifecycleObserver или reactivex автоматично отменя абонаментите при onDestroy. Android Jetpack предоставя lifecycleScope и viewModelScope, които се почистват от съответното събитие.
Static inner class — ако вътрешният клас не се нуждае от достъп до полетата на външния, го направете static. Статичният вътрешен клас няма имплицитна референция към външния клас. Ако е необходим достъп, използвайте WeakReference за изрична референция.
class MyActivity : AppCompatActivity() {
// ❌ Нестатичен вътрешен клас — имплицитна референция към MyActivity
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ Статичен вътрешен клас — без имплицитна референция
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
В iOS използвайте capture lists: [weak self] в затваряния, които могат да надживеят създателя. За делегати използвайте слаби референции (weak var delegate). За затваряния, които гарантирано се извикват само по време на живота на self, може да се използва [unowned self], но внимателно — достъпът до освободен обект ще доведе до срив.
Често задавани въпроси
В Android изпълнете няколко прехода между екрани (Activity A → B → A → B) и проверете adb shell dumpsys meminfo package_name. Ако Total PSS стабилно расте и не се връща към първоначалната стойност — има изтичане. В iOS аналогично: използвайте Debug Memory Graph в Xcode за визуална проверка.
Да, ако CoroutineScope не бъде отменен при унищожаване на компонента. Корутина, стартирана в GlobalScope, продължава да се изпълнява дори след finish() на Activity. Решение: използвайте viewModelScope (отменя се в onCleared) или lifecycleScope (отменя се в onDestroy). За собствени Scope създавайте lifecycle-aware scopes чрез LifecycleOwner.
Bitmap съхранява пикселните данни в native heap, а не в Java heap. Това означава, че Java GC не вижда реалния размер на Bitmap. Ако не се извика recycle() на Bitmap или референцията не се нулира, native паметта няма да се освободи. Използвайте BitmapFactory с inSampleSize за зареждане на умалени копия и Glide/Coil за автоматично управление на кеша.
Статичното поле — е GC Root. То живее, докато класът е зареден (в Android — докато Process е жив). Ако статично поле реферира Activity, Bitmap, View или друг тежък обект, този обект никога няма да бъде събран от GC. Статичното поле — вечна референция. Решение: съхранявайте само WeakReference или нулирайте статичното поле в onDestroy.
ARC автоматично освобождава обектите, когато броячът на силни референции падне до нула. Retain cycle — единственият начин за изтичане при ARC. Винаги използвайте weak за parent→child референции, където child трябва да надживее parent (делегати, data source). За затваряния използвайте capture list [weak self] и проверявайте self за nil вътре в затварянето.
Обобщение
Ще разработим мобилно приложение под ключ
IT Sectr създава iOS и Android приложения за стартъпи и бизнеси от 2017 г. Ще ви консултираме и ще предложим най-доброто решение.
Прочетете също