Витік пам’яті в мобільних застосунках — що це таке, причини та методи пошуку

Автор: IT Sectr Опубліковано: 2026-03-29 Час читання: 9 хв

Витік пам’яті (Memory Leak) — ситуація, коли застосунок утримує посилання на об’єкти, які більше не потрібні, не дозволяючи збирачеві сміття звільнити зайняту пам’ять. За даними LeakCanary, навіть у добре написаних застосунках трапляється 3–5 витоків на 10 000 рядків коду. Кожен витік поступово зменшує доступну пам’ять, призводячи до гальмувань і OutOfMemoryError.

Головне

  • Memory Leak — об’єкт залишається в пам’яті, хоча на нього немає активних посилань з логіки застосунку
  • Статичні посилання на Activity або Context — найчастіша причина витоків в Android
  • LeakCanary — стандартний інструмент для автоматичного виявлення витоків в Android
  • WeakReference і Application Context — базові техніки запобігання витокам
  • Lifecycle-aware компоненти усувають цілий клас витоків, пов’язаних з підписками

Що таке витік пам’яті

Витік пам’яті (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% випадків у мобільній розробці. Кожен має свою причину і характерний патерн у коді.

Статичне посилання на Activity або Context

Найвідоміший витік в Android — зберігання статичного посилання на Activity або Context. Типовий код: статичне поле Activity, яке не обнуляється при onDestroy(). Поки живе статичне поле — живе весь Activity зі своїм View-деревом, яке може займати 1–10 МБ. Це класичний витік, який LeakCanary знаходить у першу чергу.

Рішення: ніколи не зберігайте Activity або Context у статичних полях. Використовуйте Application Context для синглтонів, які переживають Activity. Якщо потрібно послатися на Activity — використовуйте WeakReference<Activity>.

kotlin
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() прив’язаний до життєвого циклу.

kotlin
// автоматична відписка через Lifecycle
viewModel.userData.observe(viewLifecycleOwner) { data ->
    updateUI(data)
}

// корутини з lifecycleScope
lifecycleScope.launch {
    viewModel.loadData().collect { render(it) }
}

Bitmap без recycle

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 Reference після onDestroyView

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
PerfettoSystem-wide трасуванняЧасова шкала + native memory

LeakCanary — must-have для будь-якого Android-проекту. Він автоматично детектує витоки по закінченні життєвого циклу Activity/Fragment і показує точне місце витоку з stack trace. Інтеграція: один рядок в build.gradle. LeakCanary 2.x не потребує ручної ініціалізації — він автоматично реєструє Application Watcher.

Як запобігти витокам пам’яті

Профілактика витоків вбудовується в процес розробки через набір правил та інструментів, що перевіряють код на кожному етапі.

Правило сильних посилань

Ніколи не зберігайте посилання на Activity, Fragment або View у статичному полі, синглтоні або довгоживучому об’єкті. Якщо без посилання не обійтися — використовуйте WeakReference або зберігайте дані через ViewModel, яка живе рівно стільки, скільки потрібно, і не тримає View безпосередньо.

Lifecycle-aware архітектура

ViewModel і LiveData з Android Architecture Components вирішують проблему життєвого циклу на рівні архітектури. ViewModel переживає поворот екрана і не містить посилань на View. LiveData автоматично відписує observer при onDestroy(). Використовуйте їх замість ручної підписки на системні сервіси.

Code Review з фокусом на GC Root

На code review звертайте увагу на: статичні поля з типами Context/View, анонімні класи, лямбди, що замикають Activity, ручні підписки, RxJava disposable без composite, зберігання Fragment через Bundle. В Kotlin додатково перевіряйте корутини на launch без прив’язки до життєвого циклу.

Автоматична перевірка в CI

LeakCanary може працювати як частина тестового пайплайну: запускайте acceptance-тести з LeakCanary і fail збірку, якщо знайдено витік. Це запобігає потраплянню витоків у продакшен. Доповніть перевірку Android Lint з правилом StaticFieldLeak — воно знаходить потенційні витоки на рівні статичного аналізу.

kotlin
// LeakCanary в тестах
class LeakTest {
    @Test
    fun activityShouldNotLeak() {
        ActivityScenario.launch(MainActivity::class.java)
            .close()
        LeakAssertions.assertNoLeak() // fail якщо є витік
    }
}

Часті запитання

Чим витік пам’яті відрізняється від OutOfMemoryError?

Витік — це причина, а OutOfMemoryError — наслідок. Один витік не призводить до OOM, але накопичення десятків витоків виснажує Heap. OOM — фатальний виняток, а витік — це патерн, який до нього призводить з часом.

Як знайти витік без LeakCanary?

Через Android Memory Profiler: відкривайте і закривайте екран 5 разів, після кожного закриття викликайте GC. Якщо пам’ять не повертається до базового рівня — є витік. Зніміть Heap Dump і знайдіть у списку клас Activity, кількість якого більше 0 після закриття.

Чи може Kotlin запобігти витокам на рівні мови?

Частково. Kotlin вирішує проблему null-safety, але не керує strong references. Корутини з lifecycleScope і viewModelScope запобігають витокам від фонових завдань, а sealed class і data class зменшують кількість станів, що ведуть до витоків. Основний захист — архітектурні патерни, а не функції мови.

Чому LeakCanary знаходить витік, якого немає?

LeakCanary іноді дає false positive: об’єкт може бути тимчасово утриманий системою (наприклад, InputMethodManager утримує останню View). Перевірте manually: якщо Retained Size < 1 КБ і GC Root — системний сервіс, ймовірно, це хибне спрацьовування.

Чи бувають витоки пам’яті тільки на Android?

Ні. Витоки можливі на будь-якій платформі з GC: iOS (Swift/Objective-C), Flutter (Dart), веб-браузери (JavaScript). Механізми однакові — strong reference від GC Root. На iOS ARC автоматично керує пам’яттю, але retain cycle між об’єктами створює той самий витік.

Підсумки

  • Memory Leak — об’єкт, який GC не може звільнити через забуте strong reference
  • Статичні посилання на Activity і Context — найпоширеніша причина витоків
  • Неявні посилання через анонімні класи, лямбди та RxJava підписки підступніші за явні
  • LeakCanary автоматично знаходить витоки і показує точний stack trace
  • Lifecycle-aware компоненти (ViewModel, LiveData, lifecycleScope) усувають клас витоків
  • Heap Dump і аналіз Retained Size — основний метод ручної діагностики
  • Профілактика включає code review на strong reference і CI-перевірку LeakCanary

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також