Витік пам'яті: що це, типові сценарії та діагностика

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

Витік пам'яті (memory leak) — ситуація, коли застосунок не звільняє пам'ять, зайняту об'єктами, які більше не потрібні. У мобільній розробці це особливо критично: обмежений heap та відсутність swap призводять до OutOfMemoryError та падіння застосунку. За даними Purdue University (2022), 35% Android-застосунків у Google Play містять хоча б один витік пам'яті. Розглянемо типові сценарії, інструменти діагностики та методи усунення.

Головне

  • GC Root — точка входу, через яку збирач сміття визначає живі об'єкти
  • Context витік — передача Activity Context у синглтон призводить до утримання всієї View hierarchy
  • Handler з postDelayed — якщо Activity знищено, Handler не дає їй піти на GC
  • Heap dump — основний метод аналізу витоків через MAT або Android Profiler
  • SoftReference — альтернатива WeakReference для кешів з автоматичним очищенням при нестачі пам'яті

Що таке витік пам'яті в мобільних застосунках?

Витік пам'яті — це ситуація, коли виділена пам'ять не повертається системі після того, як об'єкт перестав бути потрібним програмі. Збирач сміття вважає такий об'єкт живим, тому що на нього веде активний ланцюжок посилань від 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. Будь-який об'єкт, досяжний за посиланнями від цих коренів, вважається живим — навіть якщо розробник знає, що він більше не потрібен.

kotlin
// Приклад: статична колекція як 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.

Типові сценарії витоків в Android та iOS

Activity Context — наймасовіший сценарій витоків в Android. Якщо синглтон, статичне поле або довгоживучий сервіс зберігає посилання на Activity Context, то вся Activity з усіма View не може бути зібрана GC. Рішення: використовуйте Application Context для довгоживучих об'єктів.

Handler та надіслані повідомлення — Handler.postDelayed(runnable, delay) ставить повідомлення в чергу Main Looper. Якщо Activity знищено до закінчення затримки, повідомлення все ще в черзі та тримає посилання через Runnable → анонімний клас → зовнішній клас (Activity).

kotlin
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 не може бути зібрана.

  • TimerTask та ScheduledExecutorService — завдання, заплановані до знищення Activity
  • BroadcastReceiver — незареєстрований в onPause/onDestroy продовжує тримати Context
  • ViewModel з посиланням на View — ViewModel переживає Activity, посилання на View веде до витоку
  • Retrofit Call — якщо Call не скасовано, відповідь приходить у знищений Fragment

Інструменти діагностики витоків пам'яті

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 ProfilerReal-time графік, heap dump, Object Allocation TrackingНизька
Eclipse MATDominator 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 для явного посилання.

kotlin
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 для візуальної перевірки.

Чи може Kotlin-корутина викликати витік?

Так, якщо CoroutineScope не скасовано при знищенні компонента. Корутина, запущена в GlobalScope, продовжує виконуватися навіть після finish() Activity. Рішення: використовуйте viewModelScope (скасовується в onCleared) або lifecycleScope (скасовується в onDestroy). Для власних Scope створюйте lifecycle-aware scopes через LifecycleOwner.

Як Bitmap впливає на витоки?

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.

Як уникнути витоків в iOS з ARC?

ARC автоматично звільняє об'єкти, коли лічильник сильних посилань падає до нуля. Retain cycle — єдиний спосіб витоку при ARC. Завжди використовуйте weak для посилань parent→child, де child може пережити parent (делегати, data source). Для замикань використовуйте capture list [weak self] та перевіряйте self на nil всередині замикання.

Підсумки

  • Витік пам'яті — об'єкт недоступний коду, але не видалений GC, тому що на нього є активне посилання від GC Root
  • GC Roots включають статичні поля, стекові змінні та JNI references; будь-який об'єкт, досяжний від них, живий
  • Context витік — наймасовіша проблема в Android: передача Activity Context у синглтон або статичне поле
  • Handler та Inner Class — друга за частотою причина: нескасовані повідомлення в черзі Looper тримають посилання на Activity
  • LeakCanary — стандартний інструмент авто виявлення; робить heap dump та показує точну GC root chain
  • lifecycleScope та viewModelScope вирішують проблему витоків через корутини — автоматичне скасування при destroy
  • Профілюйте пам'ять в CI/CD: LeakCanary в debug + heap dump analysis в тестовому прогоні повинні блокувати мерж при нових витоках

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

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

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

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