Управление памятью и сборка мусора

Кто и когда освобождает память: free, владение в Rust, подсчёт ссылок, трассирующие сборщики. Как устроены GC в Go, JVM, V8 и CPython, откуда берутся паузы и утечки и как их искать.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Кто освобождает память

Стек освобождается сам при возврате из функции (подробнее — в презентации про стек и кучу). С кучей сложнее: объект живёт неизвестно сколько, и кто-то должен решить, что он больше не нужен. Ответов четыре.

Кто решаетКогда освобождаетсяЧем платим
ПрограммистC: malloc / freeТам, где написан freeНичем в рантайме — и всеми ошибками: утечки, use-after-free, double free
КомпиляторRust, C++ RAIIВ конце области видимости владельцаСтрогие правила владения, borrow checker
Счётчик ссылокCPython, Swift, shared_ptrСразу, как пропала последняя ссылкаЗапись в счётчик на каждое присваивание, циклы текут
Трассирующий GCGo, Java, JS, C#Когда-нибудь потом, при сборкеCPU на сборку, лишняя память, паузы

Ручное управление: три классические ошибки

free не знает, остались ли на блок другие указатели, а malloc охотно отдаёт только что освобождённый блок следующему. Отсюда утечки, use-after-free и double free.

По данным Microsoft и Chrome, около 70% серьёзных уязвимостей в их C/C++ коде — ошибки работы с памятью. Ловят их AddressSanitizer (-fsanitize=address) и Valgrind.

Выберите сценарий и нажимайте «Шаг». Аллокатор, как tcache в glibc, первым отдаёт последний освобождённый блок того же размера.

Владение: освобождает компилятор

  • У значения в куче один владелец. Владелец выходит из области видимости — компилятор вставляет вызов drop. Это free, который нельзя забыть или вызвать дважды.
  • Присваивание перемещает владение. Старая переменная больше недействительна — use-after-free просто не скомпилируется.
  • Ссылка &b заимствует без владения; borrow checker следит, чтобы она не пережила владельца.
  • C++ делает то же через RAII: unique_ptr — один владелец, shared_ptr — счётчик ссылок. Но без проверки при компиляции.

Подсчёт ссылок

В заголовке каждого объекта — счётчик ссылок. Присваивание увеличивает его у нового объекта и уменьшает у старого. Дошёл до нуля — объект освобождается сразу, и уменьшаются счётчики всего, на что он ссылался.

  • Плюс: память и ресурсы отпускаются предсказуемо, в момент потери последней ссылки.
  • Минус: запись в память на каждое присваивание. В многопоточном коде — атомарная: так стоит shared_ptr и Swift ARC, а CPython из-за этого держал GIL.
  • Цикл объектов держит сам себя — счётчики никогда не обнулятся.

Сборщик циклов в CPython

Поверх счётчиков CPython периодически проверяет контейнеры — списки, словари, экземпляры классов. Корней он не знает, поэтому идёт от обратного: вычитает ссылки изнутри проверяемой группы. Если у объекта что-то осталось, на него ссылаются снаружи.

Запускается после нескольких сотен новых контейнеров (gc.get_threshold()), объекты разбиты на поколения. Строки и числа не контейнеры — их он не трогает. Нажимайте «Шаг».

Трассирующая сборка: mark & sweep

Корни — то, что программа может прочитать прямо сейчас: стеки всех потоков, регистры, глобальные переменные. Живо всё, до чего можно дойти от корней по указателям. Остальное — мусор, циклы тоже.

  • Mark: обход графа от корней. Серые — найдены, но ещё не просмотрены; чёрные — просмотрены.
  • Sweep: проход по куче, белые освобождаются.
  • Разметка стоит пропорционально живым объектам, мусор она не трогает.

Нажимайте «Шаг». Попробуйте обнулить user до начала сборки.

Конкурентная разметка и барьер записи

Чтобы не останавливать программу, сборщик размечает параллельно с ней. А программа (мутатор) тем временем переставляет указатели. Опасно, если она сохранит ссылку на белый объект в уже чёрный и удалит её из серого: к чёрным сборщик не возвращается, и живой объект освободят.

Барьер записи — несколько инструкций, которые компилятор добавляет к каждой записи указателя в кучу. Пока идёт разметка, он красит затронутый объект в серый. Go использует гибрид обоих барьеров. Выберите режим и нажимайте «Шаг».

Фрагментация, уплотнение и bump-аллокация

Сборщик без перемещения оставляет дыры: свободного много, а большой объект не помещается. Перемещающий сдвигает живых вплотную, и свободное место становится одним куском. Выделение памяти тогда — просто сдвиг указателя: top += size.

  • Копирующий сборщик переносит живых в пустую половину кучи: работа пропорциональна живым, зато памяти нужно вдвое больше.
  • Двигать объекты можно, только если рантайм знает все указатели и может их поправить. JVM, V8 и .NET двигают, Go и CPython — нет.

Поколения

Большинство объектов умирает молодыми: строки, DTO запроса, итераторы. Поэтому новые объекты кладут в небольшую область eden и часто собирают только её — копированием. Цена minor GC пропорциональна выжившим, а их единицы процентов.

  • Кто пережил несколько сборок, переезжает в старое поколение. Его собирают редко — это major GC.
  • Ссылки «старый → молодой» отслеживает барьер записи с таблицей карт (card table), иначе на каждой minor GC пришлось бы сканировать всю старую кучу.

Увеличьте темп выделения: minor GC пойдут чаще, «средние» объекты будут доживать до старого поколения и умирать уже там.

Проверьте себя

Молодое поколение JVM — 1 ГБ. К моменту minor GC живы 2% объектов в нём. Сколько работы у сборщика?

Копирующий сборщик идёт от корней (и от отмеченных карт старого поколения) и копирует только достижимое. Мёртвые объекты он даже не видит: после копирования eden целиком считается свободным. Поэтому в JVM и V8 короткоживущие объекты почти бесплатны. В Go поколений нет, и там частые выделения сильнее бьют по CPU.

Паузы против пропускной способности

Stop-the-world сборщик останавливает все потоки и размечает кучу всеми ядрами — эффективно, но пауза растёт вместе с живой кучей. Конкурентный размечает параллельно с программой: паузы — доли миллисекунды, зато во время цикла программа делит CPU со сборщиком и работает медленнее.

Модель: сервис на 4 ядрах, загрузка 50%, запрос — 5 мс CPU, один цикл GC за 10 секунд. Разметка — условно 1 ГБ/с на ядро. Выберите сборщик и размер живой кучи.

Память против CPU: GOGC и GOMEMLIMIT

Go запускает сборку, когда куча выросла на GOGC% от живого объёма после прошлой сборки. При GOGC=100 пик вдвое больше живых данных. Меньше GOGC — меньше памяти и чаще сборки. Стоимость одной сборки пропорциональна живой куче, а не мусору.

GOMEMLIMIT — мягкий лимит: рядом с ним GC запускается чаще, но не тратит больше ~50% CPU, иначе сервис встал бы. В контейнере его ставят на 80–90% лимита памяти. В JVM похожие ручки — -Xmx и -XX:MaxRAMPercentage.

Настоящие сборщики

РантаймКак устроенПаузыРучки
GoКонкурентный трёхцветный mark-sweep, без перемещения и без поколений. Мутатор, который много выделяет, сам помогает размечать (assist).Десятки–сотни мксGOGC, GOMEMLIMIT
JVM G1По умолчанию с Java 9. Куча из регионов по 1–32 МБ, поколения. Молодые регионы эвакуируются в паузе, старые размечаются конкурентно и собираются начиная с самых «мусорных».Цель 200 мс по умолчанию, обычно 10–100 мс-Xmx, -XX:MaxGCPauseMillis
JVM ZGCРазмечает и перемещает объекты конкурентно: барьер на чтении указателя и «цветные» указатели. С Java 21 — с поколениями.< 1 мс при любой куче-XX:+UseZGC, -Xmx
V8 (Node, Chrome)Orinoco. Молодое поколение — параллельный scavenger, копирование между полупространствами. Старое — конкурентная разметка и mark-compact с параллельным уплотнением.Scavenge — ~1 мс, старое — единицы–десятки мс--max-old-space-size, --max-semi-space-size
CPythonПодсчёт ссылок плюс сборщик циклов по поколениям. Объекты не двигаются: id(x) — их адрес.Сборка циклов в старшем поколении — до сотен мс на больших кучахgc.set_threshold, gc.freeze

Утечки в языках со сборщиком

Сборщик освобождает недостижимое. Утечка в Go, Java, JS или Python — объект, который достижим, но больше не нужен: на него всё ещё смотрит глобальная map, кэш, подписка или замыкание.

Включите источник утечки и следите за «полом» пилы — объёмом после сборки. Потом сделайте два снимка кучи с паузой и сравните: что выросло, то и течёт. Так работают heap snapshot в DevTools, pprof -diff_base и tracemalloc.

Слабые ссылки

Слабая ссылка не удерживает объект. Если других ссылок нет, сборщик его освободит, а слабая ссылка опустеет. Так к объекту привязывают метаданные, не продлевая ему жизнь.

  • JS: WeakMap, WeakRef. Python: weakref.WeakValueDictionary. Java: WeakHashMap. Go 1.24: weak.Pointer.
  • Момент освобождения не определён, поэтому кэш на слабых ссылках непредсказуем. Обычному кэшу нужен лимит (LRU) и TTL.

Создайте пару объектов, «забудьте» их в приложении и запустите сборку — сначала с Map, потом с WeakMap.

Проверьте себя

В Python-сервисе вызвали gc.disable(). Что будет с памятью?

gc.disable() выключает только сборщик циклов, подсчёт ссылок продолжает работать. Instagram так и делал, чтобы уменьшить копирование страниц после fork: сборщик пишет в заголовки объектов, и общие с родителем страницы копируются. Позже появился gc.freeze() — он убирает уже созданные объекты из-под сборщика без отключения. Подробнее о копировании при записи — в презентации про виртуальную память.

Проверьте себя

Go-сервис читает строки лога по 64 КБ и складывает в глобальную map префикс каждой: seen[line[:16]]++. Сколько памяти удерживает одна запись?

Подстрока в Go — это указатель внутрь той же памяти плюс длина. Ключ map держит указатель, и сборщик не освободит 64 КБ, пока жив ключ. Исправление: strings.Clone(line[:16]). То же со срезами: buf[:16] держит весь массив. В V8 длинные подстроки тоже ссылаются на исходную строку; в Python и Java (с 7u6) срез копирует.

Как это увидеть у себя


# Go: строка на каждую сборку
$ GODEBUG=gctrace=1 ./server
gc 14 @2.315s 3%: 0.025+12+0.031 ms clock, …,
   48->51->26 MB, 52 MB goal, 8 P
#  пауза+разметка+пауза; куча до->после->живая

# что держит память сейчас / кто больше выделяет
$ go tool pprof -sample_index=inuse_space \
    http://localhost:6060/debug/pprof/heap
$ go tool pprof -sample_index=alloc_space …

# JVM
$ java -Xlog:gc:file=gc.log -jar app.jar
$ jcmd <pid> GC.heap_info
$ jcmd <pid> GC.heap_dump /tmp/heap.hprof  # → Eclipse MAT

# Node
$ node --trace-gc --heapsnapshot-signal=SIGUSR2 app.js
$ kill -USR2 <pid>   # снимок → DevTools → Memory → Comparison

# Python
tracemalloc.start(); s1 = tracemalloc.take_snapshot()
…; s2 = tracemalloc.take_snapshot()
s2.compare_to(s1, "lineno")[:10]

Сначала смотрите, растёт ли куча после сборок (утечка) или только частота сборок (много выделений). RSS процесса больше кучи: рантайм держит запас и отдаёт память ОС не сразу — подробнее в презентации про виртуальную память.

Что делать на практике

Помогает

  • Выделять меньше в горячем пути: заранее задавать ёмкость (make([]T, 0, n)), переиспользовать буферы, sync.Pool
  • Ставить лимит памяти рантайму под лимит контейнера: GOMEMLIMIT, -XX:MaxRAMPercentage=75, --max-old-space-size
  • Ограничивать каждый кэш и очередь по размеру, снимать подписки и таймеры
  • Для низких задержек на JVM — ZGC, на Go — поднять GOGC, если память есть

Вредит

  • Вызывать System.gc() / runtime.GC() «на всякий случай» — это полная сборка с паузой
  • Огромная куча «чтобы не падало» при STW-сборщике — паузы растут вместе с ней
  • Финализаторы для освобождения ресурсов: момент их запуска не определён. Закрывайте явно — defer, with, try-with-resources
  • Оптимизировать GC, не посмотрев профиль выделений

Главное

  • Вопрос один — кто решает, что объект больше не нужен: программист, компилятор, счётчик ссылок или трассирующий сборщик.
  • Подсчёт ссылок освобождает сразу, но платит на каждом присваивании и не видит циклов.
  • Трассирующий GC ищет достижимое от корней. Его работа пропорциональна живым объектам, а частота — темпу выделений.
  • Конкурентной разметке нужен барьер записи, перемещению — точное знание всех указателей. Поколения делают короткоживущие объекты почти бесплатными.
  • Каждый сборщик выбирает между паузами, CPU и памятью. GOGC, -Xmx и тип сборщика двигают этот баланс.
  • Утечка с GC — достижимое, но ненужное. Ищется сравнением двух снимков кучи.

Мусор — это не то, что не нужно, а то, до чего не дотянуться.