Витік пам’яті (Memory Leak) — ситуація, коли застосунок утримує посилання на об’єкти, які більше не потрібні, не дозволяючи збирачеві сміття звільнити зайняту пам’ять. За даними 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 КБ не помітний, але 100 таких витоків займають 10 МБ, і застосунок починає гальмувати через часті GC. Критична маса витоків призводить до OutOfMemoryError і крашу застосунку. Симптоми витоку: постійне зростання споживання пам’яті на графіку Profiler, часті GC-паузи з STW (Stop The World) і падіння продуктивності UI.
П’ять видів витоків покривають 95% випадків у мобільній розробці. Кожен має свою причину і характерний патерн у коді.
Найвідоміший витік в Android — зберігання статичного посилання на Activity або Context. Типовий код: статичне поле Activity, яке не обнуляється при onDestroy(). Поки живе статичне поле — живе весь Activity зі своїм View-деревом, яке може займати 1–10 МБ. Це класичний витік, який LeakCanary знаходить у першу чергу.
Рішення: ніколи не зберігайте Activity або Context у статичних полях. Використовуйте Application Context для синглтонів, які переживають Activity. Якщо потрібно послатися на Activity — використовуйте WeakReference<Activity>.
object MySingleton {
private var weakActivity: WeakReference<Activity>? = null
fun attach(activity: Activity) {
weakActivity = WeakReference(activity)
}
}
Анонімні класи та нестатичні вкладені класи неявно зберігають посилання на containing class. Runnable, який передається в Handler, що виконується після onDestroy(), утримує весь Activity. Callback Retrofit, який замикає 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 МБ. Якщо 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 або в navigation graph, яка не очищується при знищенні.
Ніколи не зберігайте посилання на 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 віджет показує всіх утримувачів об’єкта.
Чотири інструменти покривають пошук витоків від автоматичного виявлення до глибинного аналізу Heap Dump.
| Інструмент | Метод | Формат результатів |
|---|---|---|
| LeakCanary | Автоматичний моніторинг | Heap Dump + stack trace витоку |
| Android Memory Profiler | Ручний моніторинг | Графік пам’яті + Heap Dump |
| MAT (Eclipse) | Глибинний аналіз | Звіт Dominator Tree + GC Root path |
| Perfetto | System-wide трасування | Часова шкала + native memory |
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 і fail збірку, якщо знайдено витік. Це запобігає потраплянню витоків у продакшен. Доповніть перевірку 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). Перевірте manually: якщо Retained Size < 1 КБ і GC Root — системний сервіс, ймовірно, це хибне спрацьовування.
Ні. Витоки можливі на будь-якій платформі з GC: iOS (Swift/Objective-C), Flutter (Dart), веб-браузери (JavaScript). Механізми однакові — strong reference від GC Root. На iOS ARC автоматично керує пам’яттю, але retain cycle між об’єктами створює той самий витік.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також