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 = "Expenses:\n"
for (e in items) {
s += "${e.title}: ${e.amountCents} cents\n"
}
return s
}
Работает? Да. Создаёт много временных строк? Тоже да.
Хорошая сборка отчёта через StringBuilder
fun renderExpensesGood(items: List<Expense>): String {
val sb = StringBuilder()
sb.append("Expenses:\n")
for (e in items) {
sb.append(e.title)
.append(": ")
.append(e.amountCents)
.append(" cents\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% в месте, которое и так не было узким. Гораздо полезнее сначала устранить очевидные фабрики мусора (конкатенация строк в цикле, лишние промежуточные коллекции, исключения в массовом сценарии), а уже потом думать о более тонких вещах.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ