Кто и когда освобождает память: free, владение в Rust, подсчёт ссылок, трассирующие сборщики. Как устроены GC в Go, JVM, V8 и CPython, откуда берутся паузы и утечки и как их искать.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.
Стек освобождается сам при возврате из функции (подробнее — в презентации про стек и кучу). С кучей сложнее: объект живёт неизвестно сколько, и кто-то должен решить, что он больше не нужен. Ответов четыре.
malloc / freeТам, где написан freeНичем в рантайме — и всеми ошибками: утечки, use-after-free, double freeshared_ptrСразу, как пропала последняя ссылкаЗапись в счётчик на каждое присваивание, циклы текутfree не знает, остались ли на блок другие указатели, а malloc охотно отдаёт только что освобождённый блок следующему. Отсюда утечки, use-after-free и double free.
По данным Microsoft и Chrome, около 70% серьёзных уязвимостей в их C/C++ коде — ошибки работы с памятью. Ловят их AddressSanitizer (-fsanitize=address) и Valgrind.
Выберите сценарий и нажимайте «Шаг». Аллокатор, как tcache в glibc, первым отдаёт последний освобождённый блок того же размера.
drop. Это free, который нельзя забыть или вызвать дважды.&b заимствует без владения; borrow checker следит, чтобы она не пережила владельца.unique_ptr — один владелец, shared_ptr — счётчик ссылок. Но без проверки при компиляции.В заголовке каждого объекта — счётчик ссылок. Присваивание увеличивает его у нового объекта и уменьшает у старого. Дошёл до нуля — объект освобождается сразу, и уменьшаются счётчики всего, на что он ссылался.
shared_ptr и Swift ARC, а CPython из-за этого держал GIL.Поверх счётчиков CPython периодически проверяет контейнеры — списки, словари, экземпляры классов. Корней он не знает, поэтому идёт от обратного: вычитает ссылки изнутри проверяемой группы. Если у объекта что-то осталось, на него ссылаются снаружи.
Запускается после нескольких сотен новых контейнеров (gc.get_threshold()), объекты разбиты на поколения. Строки и числа не контейнеры — их он не трогает. Нажимайте «Шаг».
Корни — то, что программа может прочитать прямо сейчас: стеки всех потоков, регистры, глобальные переменные. Живо всё, до чего можно дойти от корней по указателям. Остальное — мусор, циклы тоже.
Нажимайте «Шаг». Попробуйте обнулить user до начала сборки.
Чтобы не останавливать программу, сборщик размечает параллельно с ней. А программа (мутатор) тем временем переставляет указатели. Опасно, если она сохранит ссылку на белый объект в уже чёрный и удалит её из серого: к чёрным сборщик не возвращается, и живой объект освободят.
Барьер записи — несколько инструкций, которые компилятор добавляет к каждой записи указателя в кучу. Пока идёт разметка, он красит затронутый объект в серый. Go использует гибрид обоих барьеров. Выберите режим и нажимайте «Шаг».
Сборщик без перемещения оставляет дыры: свободного много, а большой объект не помещается. Перемещающий сдвигает живых вплотную, и свободное место становится одним куском. Выделение памяти тогда — просто сдвиг указателя: top += size.
Большинство объектов умирает молодыми: строки, DTO запроса, итераторы. Поэтому новые объекты кладут в небольшую область eden и часто собирают только её — копированием. Цена minor GC пропорциональна выжившим, а их единицы процентов.
Увеличьте темп выделения: minor GC пойдут чаще, «средние» объекты будут доживать до старого поколения и умирать уже там.
Молодое поколение JVM — 1 ГБ. К моменту minor GC живы 2% объектов в нём. Сколько работы у сборщика?
Stop-the-world сборщик останавливает все потоки и размечает кучу всеми ядрами — эффективно, но пауза растёт вместе с живой кучей. Конкурентный размечает параллельно с программой: паузы — доли миллисекунды, зато во время цикла программа делит CPU со сборщиком и работает медленнее.
Модель: сервис на 4 ядрах, загрузка 50%, запрос — 5 мс CPU, один цикл GC за 10 секунд. Разметка — условно 1 ГБ/с на ядро. Выберите сборщик и размер живой кучи.
Go запускает сборку, когда куча выросла на GOGC% от живого объёма после прошлой сборки. При GOGC=100 пик вдвое больше живых данных. Меньше GOGC — меньше памяти и чаще сборки. Стоимость одной сборки пропорциональна живой куче, а не мусору.
GOMEMLIMIT — мягкий лимит: рядом с ним GC запускается чаще, но не тратит больше ~50% CPU, иначе сервис встал бы. В контейнере его ставят на 80–90% лимита памяти. В JVM похожие ручки — -Xmx и -XX:MaxRAMPercentage.
GOGC, GOMEMLIMIT-Xmx, -XX:MaxGCPauseMillis-XX:+UseZGC, -Xmx--max-old-space-size, --max-semi-space-sizeid(x) — их адрес.Сборка циклов в старшем поколении — до сотен мс на больших кучахgc.set_threshold, gc.freezeСборщик освобождает недостижимое. Утечка в Go, Java, JS или Python — объект, который достижим, но больше не нужен: на него всё ещё смотрит глобальная map, кэш, подписка или замыкание.
Включите источник утечки и следите за «полом» пилы — объёмом после сборки. Потом сделайте два снимка кучи с паузой и сравните: что выросло, то и течёт. Так работают heap snapshot в DevTools, pprof -diff_base и tracemalloc.
Слабая ссылка не удерживает объект. Если других ссылок нет, сборщик его освободит, а слабая ссылка опустеет. Так к объекту привязывают метаданные, не продлевая ему жизнь.
WeakMap, WeakRef. Python: weakref.WeakValueDictionary. Java: WeakHashMap. Go 1.24: weak.Pointer.Создайте пару объектов, «забудьте» их в приложении и запустите сборку — сначала с Map, потом с WeakMap.
В Python-сервисе вызвали gc.disable(). Что будет с памятью?
gc.disable() выключает только сборщик циклов, подсчёт ссылок продолжает работать. Instagram так и делал, чтобы уменьшить копирование страниц после fork: сборщик пишет в заголовки объектов, и общие с родителем страницы копируются. Позже появился gc.freeze() — он убирает уже созданные объекты из-под сборщика без отключения. Подробнее о копировании при записи — в презентации про виртуальную память.Go-сервис читает строки лога по 64 КБ и складывает в глобальную map префикс каждой: seen[line[:16]]++. Сколько памяти удерживает одна запись?
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.PoolGOMEMLIMIT, -XX:MaxRAMPercentage=75, --max-old-space-sizeSystem.gc() / runtime.GC() «на всякий случай» — это полная сборка с паузойdefer, with, try-with-resources-Xmx и тип сборщика двигают этот баланс.Мусор — это не то, что не нужно, а то, до чего не дотянуться.