JavaRush /Курси /Kotlin SELF /Памʼять JVM для Kotlin‑розробника: heap/stack/GC

Памʼять JVM для Kotlin‑розробника: heap/stack/GC

Kotlin SELF
Рівень 61 , Лекція 4
Відкрита

1. Вступ

Якщо ви пишете навчальний проєкт і він обробляє 10 рядків, то розмови про памʼять звучать як занудство рівня «не дихай поруч із процесором — перегріється». Але щойно даних стає більше (логів, файлів, записів у колекціях, звітів), раптом виявляється, що найдорожчі проблеми — не «складні алгоритми», а купа дрібних алокацій і постійне прибирання сміття збирачем. На JVM це проявляється як непередбачувані паузи, стрибки споживання памʼяті й відчуття, ніби програма то летить, то задумливо дивиться у вікно.

Щоб говорити з JVM однією мовою, нам потрібна робоча модель із трьох слів: stack, heap, GC. Ця модель не пояснює абсолютно всього. Сьогодні ми свідомо не заглиблюємося в байткод, JIT і тонкі налаштування GC. Зате вона чудово пояснює 80 % ситуацій «чому стало повільніше» і «куди поділася памʼять».

2. Мінімальна модель памʼяті JVM: stack і heap

Коли ви викликаєте функцію в Kotlin, ви не просто «переходите на інший рядок». JVM потрібно десь зберігати інформацію про цей виклик: які там локальні змінні й куди повертатися. Це місце — stack (стек). А коли ви створюєте обʼєкти (класи, рядки, списки, винятки), вони зазвичай живуть у heap (купі). Коли ж ви перестаєте на них посилатися, зʼявляється GC (garbage collector), який періодично приходить і питає: «Так, хто тут зайвий?».

Одразу важлива думка: більшість проблем із продуктивністю в «звичайному прикладному Kotlin» — це не стек, а купа. Стек зазвичай «ламається» драматично (наприклад, StackOverflowError), а купа — повільно й сумно: «щось воно їсть дедалі більше памʼяті, та й паузи якісь дивні».

Stack: памʼять під виклики функцій і чому він «не гумовий»

Уявіть стопку таць у їдальні. Ви кладете зверху нову тацю, коли викликаєте функцію, і прибираєте її, коли функція завершується. Поки функція виконується, на її «таці» лежать локальні змінні (умовно). Це і є стек: структура LIFO (останнім прийшов — першим пішов).

Практичний сенс простий: якщо ви випадково робите нескінченну або надто глибоку рекурсію, стек росте, росте, росте… і зрештою закінчується. У JVM це зазвичай призводить до StackOverflowError (у Kotlin він теж належить до серйозних помилок рівня Error, а не до «звичайних помилок, які треба ловити»). У документації Kotlin про винятки прямо підкреслено, що такі речі належать до проблем, після яких застосунок часто не може «нормально відновитися».

Нам не потрібно зараз оптимізувати стек. Важливіше інше: стек — це про глибину викликів і локальні дані конкретного виклику, а не про «всі ваші списки й рядки».

Heap: памʼять під обʼєкти — усе цікаве й усе дороге

Heap — це «спільний склад обʼєктів». Туди потрапляє майже все, що в Kotlin виглядає як обʼєкт: екземпляри класів, String, колекції, Pair, лямбди (у деяких випадках), винятки тощо.

Ключовий момент: heap «живе довше, ніж один виклик функції». Ви створили MutableList, повернули його з функції — і він продовжує існувати. Поки є посилання, він вважається потрібним. Щойно посилань немає — обʼєкт стає кандидатом на збирання сміття.

І тут зʼявляється тонка, але дуже практична думка: на продуктивність сильно впливає не лише те, «скільки обʼєктів живе», а й те, «як часто ви створюєте нові». Якщо ви щосекунди створюєте мільйон дрібних обʼєктів, GC буде дуже зайнятий.

Посилання: чому val не «кладе обʼєкт у змінну»

Багато новачків підсвідомо думають, що val x = ... «кладе обʼєкт у змінну». На JVM частіше корисніше мислити інакше: змінна зберігає посилання на обʼєкт у heap (адресу/вказівник у побутовому сенсі), а не сам обʼєкт.

Тому фраза «колекція в val незмінювана» — неправильна (якщо це MutableList). val захищає посилання (не можна переприсвоїти іншу колекцію), але не забороняє змінювати сам обʼєкт. Документація Kotlin окремо пояснює: змінювану колекцію можна тримати в val, щоб не втрачати контроль над посиланням і випадково не підмінити його іншою колекцією.

Міні‑приклад, щоб «намацати» ідею посилань:

fun main() {
    val xs = mutableListOf(1, 2)
    val ys = xs

    ys.add(3)

    println(xs) // [1, 2, 3]
    println(ys) // [1, 2, 3]
}

ys не «скопіював список», а отримав друге посилання на той самий обʼєкт у heap.

3. GC: хто виносить сміття і чому він іноді заважає

Збирач сміття в JVM — це механізм, який звільняє памʼять у heap, коли обʼєкти стають недоступними (на них більше ніхто не посилається). Він робить те, що в мовах без GC довелося б робити вручну. І так: вручну це часто закінчується або витоками, або «ой, ми звільнили памʼять двічі», або й тим і тим одночасно.

Але за зручність доводиться платити: прибирання сміття — це робота, яка споживає ресурси й іноді призводить до пауз. У хорошому випадку ви взагалі не помічаєте GC. У гіршому — програма починає «підгальмовувати», і ви відкриваєте моніторинг та бачите: CPU не зайнятий корисною роботою, але застосунок раз у раз робить паузи.

Корисна побутова модель: GC тим щасливіший, чим менше сміття ви створюєте. І тут ми плавно підходимо до алокацій.

Намалюємо спрощену схему «життєвого шляху обʼєкта»:

flowchart TD
    A[Створили обʼєкт у heap] --> B[На нього є посилання]
    B --> C{Посилання зникли?}
    C -- ні --> B
    C -- так --> D[Обʼєкт стає кандидатом на GC]
    D --> E[GC звільняє памʼять]

Цього достатньо, щоб далі розуміти, чому «зайві тимчасові обʼєкти» — не невинна дрібниця.

4. Алокації: де в Kotlin народжуються зайві обʼєкти

Алокація — це момент, коли JVM виділяє місце в heap під новий обʼєкт. Сама по собі одна алокація зазвичай не катастрофа. Катастрофа — це тисяча алокацій у циклі, який крутиться мільйон разів. Тому наша мета — навчитися помічати типові місця, де Kotlin‑код «випадково» створює багато тимчасових обʼєктів.

Найчесніший спосіб мислити такий: усе, що виглядає як «створив нове значення», потенційно створює новий обʼєкт. Іноді компілятор оптимізує, іноді — ні. Тому варто тримати в голові прості патерни, які майже завжди дорого обходяться.

Рядки та конкатенація в циклах: класична фабрика сміття

Рядки в JVM — обʼєкти. Ба більше, вони незмінювані (immutable): якщо ви зробили "a" + "b", JVM не «дописала в старий рядок», а створила новий.

У Kotlin оператор + для рядків зручний. Але якщо ви починаєте робити result += ... у циклі, то часто створюєте багато тимчасових рядків. Типовий анти‑приклад:

fun buildReportBad(lines: List<String>): String {
    var s = ""
    for (line in lines) {
        s += line + "\n"
    }
    return s
}

Так, це читається. Так, це працює. Так, GC потім плаче.

Найкращий побутовий інструмент — StringBuilder:

fun buildReportGood(lines: List<String>): String {
    val sb = StringBuilder()
    for (line in lines) {
        sb.append(line).append('\n')
    }
    return sb.toString()
}

StringBuilder тримає всередині буфер і розширює його за потреби, тож ви не створюєте новий рядок на кожному кроці.

Колекції та ланцюжки map/filter: проміжні списки — це теж алокації

Багато операцій із колекціями в Kotlin повертають нову колекцію: map, filter, sorted тощо. Це чудово для читабельності й функціонального стилю, але важливо памʼятати: кожна така операція може створити проміжний список.

Якщо у вас ланцюжок із пʼяти перетворень над великою колекцією, ви потенційно створюєте кілька великих тимчасових списків. Іноді це нормально (просто читабельність важливіша). Іноді — дорого. Тут ми згадуємо тему Sequence: вона дає змогу зробити перетворення лінивими й зменшити кількість проміжних колекцій (але теж не безплатно).

До речі, у документації Kotlin підкреслено різницю між колекціями та масивами: масив має фіксований розмір, а колекції зручніші для додавання/видалення елементів і загалом для повсякденних задач. Це не прямо про памʼять, але опосередковано важливо: спроби «оптимізувати все масивами» часто перетворюються на складний і крихкий код.

Pair, Triple і дрібні тимчасові обʼєкти: наче дрібниця, а вже відро

Pair і Triple зручні, але це теж обʼєкти. Якщо ви використовуєте їх як тимчасові контейнери в гарячому місці (наприклад, повертаєте Pair із функції в циклі), ви створюєте багато короткоживучих обʼєктів.

Іноді це нормально. Іноді краще повернути два значення інакше (наприклад, через спеціалізовану модель, або через оновлення акумулятора, або через fold). Але важливо не перетворювати код на «оптимізацію заради оптимізації»: спершу ви маєте розуміти, що саме у вас є гарячим місцем.

5. Boxing: чому List<Int> може бути важчою, ніж здається

Boxing (упаковка) — це ситуація, коли «просте число» (Int) перетворюється на обʼєкт‑обгортку, бо його потрібно покласти туди, де очікуються обʼєкти (наприклад, в узагальнену колекцію).

У Kotlin/JVM узагальнення (generics) здебільшого працюють із посилальними типами. Тому List<Int> на рівні JVM часто зберігає не «голі int», а обʼєкти‑обгортки. Це не означає, що List<Int> завжди зло. Це означає лише те, що для величезних масивів чисел іноді вигідніше використовувати спеціалізовані структури.

Документація Kotlin про масиви прямо каже: якщо використовувати Array із примітивами, вони будуть boxed. Вона також пропонує натомість масиви примітивів (IntArray, DoubleArray тощо), щоб уникнути накладних витрат упаковки.

Array<Int> vs IntArray: маленький приклад «на пальцях»

fun main() {
    val a: Array<Int> = arrayOf(1, 2, 3)
    val b: IntArray = intArrayOf(1, 2, 3)

    println(a.joinToString()) // 1, 2, 3
    println(b.joinToString()) // 1, 2, 3
}

Зовні майже однаково. Але ідея така: IntArray зберігає примітиви щільніше й без упаковки.

Коли це важливо в нашому «практичному CLI‑проєкті»

У нашому навчальному застосунку (умовний CLI‑трекер/аналізатор, де ми зберігаємо записи й будуємо звіти) найчастіше дані — це обʼєкти: записи витрат/подій/рядків. Тут boxing не головний ворог.

Але якщо у вас зʼявляється підсистема, яка зберігає великі числові ряди (наприклад, щоденні суми за днями, метрики, часові ряди), перехід із List<Int> на IntArray іноді різко зменшує тиск на памʼять. А це вже впливає на GC: менше обʼєктів — менше сміття — менше пауз.

6. Накладні витрати: винятки та рефлексія

У попередні дні курсу ми вже обговорювали винятки як механізм керування помилками. Тут ми дивимося на них із боку памʼяті та продуктивності — і поруч ставимо рефлексію, бо в неї схожий профіль: «потужно, але не безплатно».

Винятки та stack trace: чому try/catch для парсингу — це дорого

Виняток — це обʼєкт. І він несе в собі контекст: повідомлення, причину (cause) і найдорожче — stack trace, тобто список кадрів стека, який показує, як виконання дійшло до точки помилки. Документація Kotlin окремо пояснює, що stack trace — це послідовність викликів функцій, і наводить приклад виводу на JVM.

Це корисно для налагодження, але означає просту річ: створити виняток — не те саме, що «повернути null». Виняток зазвичай дорожчий, бо потрібно зібрати діагностичну інформацію.

Ба більше, документація підкреслює, що винятки — stateful‑обʼєкти. Тому їх не варто робити object‑одинаками, адже стан (зокрема й stack trace) має відображати місце виникнення. Це ще одна підказка: виняток — не «дешевий прапорець», а «товста папка з документами».

Якщо помилка очікувана (наприклад, користувач увів не число), то частіше краще використовувати toIntOrNull()/toDoubleOrNull() і валідацію. У нашому курсі це вже було: «прочитав → підготував → розпарсив», а якщо є проблема — повернути null і попросити повторити введення.

Тут також доречно згадати fail‑fast стиль: require і check — це швидкий спосіб викинути виняток, якщо продовжувати не можна. Kotlin офіційно описує ці передумови як механізм, який автоматично кидає винятки в разі порушення умов.

Рефлексія та памʼять: чому динамічний доступ «важкий»

Ми щойно вивчали рефлексію, тож тепер можна чесно сказати: так, це потужно, але безплатно не буває.

Рефлексія часто призводить до:

  • додаткових метаданих (іноді ще й окремої залежності),
  • пошуку членів у списках,
  • створення допоміжних обʼєктів для виклику,
  • ускладнення трасування й налагодження.

З погляду памʼяті головна практична порада така: не робіть рефлексію в гарячому циклі. Якщо вам потрібно за імʼям викликати метод або прочитати властивість, зазвичай варто один раз знайти «опис» (умовний KFunction/KProperty) і перевикористовувати його. Так ви не будете щоразу заново сканувати memberFunctions.

Навіть якщо ви не міряєте це бенчмарками (а сьогодні ми не заглиблюємося в дисципліну вимірювань), здоровий глузд тут працює чудово: «пошук у списку» + «обгортки» + «виклик через call» повторити 100000 разів — майже завжди погана ідея.

7. Міні‑рефакторинг: зменшуємо алокації без втрати читабельності

Зараз зробимо найкориснішу для життя річ: подивимося на шматок коду з «практичного CLI‑проєкту» й поліпшимо його так, щоб він створював менше сміття, але залишався зрозумілим. Важливо: наша мета не «вичавити максимум», а «не стріляти собі в ногу за замовчуванням».

Уявімо, що в нас є доменна модель (спрощено):

data class Expense(
    val title: String,
    val amountCents: Int
)

І ми будуємо текстовий звіт за списком витрат.

Погане збирання звіту через +=

fun renderExpensesBad(items: List<Expense>): String {
    var s = "Витрати:\n"
    for (e in items) {
        s += "${e.title}: ${e.amountCents} центів\n"
    }
    return s
}

Працює? Так. Створює багато тимчасових рядків? Також так.

Хороше збирання звіту через StringBuilder

fun renderExpensesGood(items: List<Expense>): String {
    val sb = StringBuilder()
    sb.append("Витрати:\n")
    for (e in items) {
        sb.append(e.title)
            .append(": ")
            .append(e.amountCents)
            .append(" центів\n")
    }
    return sb.toString()
}

Це, як і раніше, читабельно, але створює менше тимчасових обʼєктів. До того ж ми прибрали зайву інтерполяцію в рядку всередині циклу.

Масиви vs колекції у звітах: обираємо структуру усвідомлено

Іноді студенти намагаються «прискорити все», переходячи на масиви. Але масиви мають фіксований розмір. А операції додавання/видалення часто призводять до створення нового масиву й копіювання елементів (це прямо показано в документації Kotlin як неефективний шлях у разі частих змін).

Тому в нашому проєкті базовий контейнер для записів — це колекція (MutableList, List). І це правильно. А якщо зʼявляється спеціалізований числовий буфер (наприклад, IntArray для сум за категоріями), тоді можна точково використати масив примітивів, щоб не наштовхуватися на накладні витрати boxing.

8. Типові помилки

Помилка №1: збирати великі рядки через += у циклі й дивуватися, чому «на великих даних» стало сумно.
Проблема не в тому, що оператор + поганий. Проблема в тому, що рядок незмінюваний. Тож за кожного += ви часто створюєте новий рядок у heap, а старий віддаєте GC. На малих даних це непомітно, а на великих — перетворюється на фабрику сміття.

Помилка №2: використовувати винятки як звичайний механізм розгалуження («якщо не вийшло — спіймаємо»).
Виняток — це обʼєкт зі станом, зокрема зі stack trace, який потрібен для діагностики й налагодження. Документація показує, що stack trace відображає ланцюжок викликів, і це не безплатна інформація. Якщо помилка очікувана (наприклад, парсинг введення), краще повернути null/Result/sealed‑результат і обробити це спокійно, а винятки залишити для справді виняткових ситуацій.

Помилка №3: не розуміти, де відбувається boxing, і зберігати величезні числові масиви в Array<Int> / List<Int>, коли потрібен щільний буфер.
Kotlin прямо попереджає: використання Array із примітивами призводить до упаковки (boxing). Для таких задач є масиви примітивів (IntArray, DoubleArray тощо). Це не означає, що треба переписати весь проєкт на масиви. Це означає, що для «мільйона чисел» варто хоча б замислитися.

Помилка №4: робити рефлексію в гарячому місці й повторювати пошук членів на кожному кроці циклу.
Рефлексія — інструмент, який зменшує гарантії компілятора й додає накладні витрати. Якщо вже ви змушені використовувати її для динаміки, намагайтеся обмежити область застосування й кешувати результати пошуку, щоб не сканувати метадані знову й знову.

Помилка №5: намагатися оптимізувати памʼять наосліп, ламаючи читабельність там, де це не потрібно.
Найприкріша ситуація — коли код став утричі складнішим, а прискорився на 0,5 % у місці, яке й так не було вузьким. Значно корисніше спершу прибрати очевидні фабрики сміття (конкатенація рядків у циклі, зайві проміжні колекції, винятки в масовому сценарії), а вже потім думати про тонші речі.

1
Опитування
Рефлексія, рівень 61, лекція 4
Недоступний
Рефлексія
Рефлексія, KSP, Kotlin 2.x
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ