Витік пам'яті (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 references. Будь-який об'єкт, досяжний за посиланнями від цих коренів, вважається живим — навіть якщо розробник знає, що він більше не потрібен.
// Приклад: статична колекція як 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. Якщо синглтон, статичне поле або довгоживучий сервіс зберігає посилання на 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 chain для кожного підозрілого об'єкта.
Xcode Memory Graph Debugger — для iOS. Призупиняє застосунок та візуалізує граф об'єктів. Retain cycles підсвічуються червоним, можна клікнути на будь-який об'єкт і побачити його retain count та посилання.
| Інструмент | Можливості | Складність |
|---|---|---|
| Memory Profiler | Real-time графік, heap dump, Object Allocation Tracking | Низька |
| Eclipse MAT | Dominator tree, Leak Suspects, OQL запити | Середня |
| LeakCanary | Автоматичне виявлення, trace витоку в сповіщенні | Мінімальна |
| Xcode Memory Graph | Візуальний граф retain cycles, список живих об'єктів | Низька |
За даними Uber Engineering Blog, впровадження автоматичного профілювання пам'яті (LeakCanary + heap dump analysis) у CI/CD пайплайн скорочує кількість memory-related інцидентів у production на 60% протягом 3 місяців.
Заміна Context — якщо об'єкт живе довше за Activity, використовуйте applicationContext. Усі довгоживучі об'єкти (синглтони, репозиторії, database helpers) повинні отримувати Application Context, а не Activity Context. Виняток: UI-компоненти, яким потрібен доступ до теми або ресурсів, специфічних для Activity.
Lifecycle-aware компоненти — використання LifecycleObserver, DefaultLifecycleObserver або реактивних розширень автоматично скасовує підписки при 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], але обережно — при зверненні до звільненого об'єкта буде crash.
Поширені запитання
У 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. Якщо Bitmap не викликати recycle() або не обнулити посилання, 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 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також