Garbage Collector: жизнь без иллюзий
- понедельник, 23 февраля 2026 г. в 00:00:05

Когда мы говорим о сборщике мусора (GC), большинство представлений об этой технологии вызывает что-то вроде «он автоматически удаляет объекты, когда они не нужны». Но на практике GC — это не магия, а сложнейшая система, тесно связанная со структурой памяти, которую использует программа. Чтобы понять, как именно GC работает, почему он влияет на производительность и почему одни алгоритмы лучше других, нам нужно пройти по всем уровням памяти — от регистров процессора до большого heap’а, и только затем погрузиться в архитектуры GC на примерах .NET и Go.
Память, с которой работает ваша программа, не однородна. Сначала CPU выполняет инструкции, которые оперируют регистровыми значениями. Регистры — это самые быстрые области данных, существующие не в RAM, а внутри CPU, и они хранят текущее состояние исполнения. Пример машинных инструкций:
mov rax, 5 mov rbx, 10 add rax, rbx
Здесь rax и rbx — регистры архитектуры x86-64, и именно они участвуют в вычислениях. Если переменных больше, чем регистров, часть значений «проливается» в стек, поскольку регистров слишком мало. Это фундаментальная причина того, почему регистры не могут хранить большие объемы данных: их назначение — максимально быстрый доступ, а не вместительность Грубо говоря регистры это уже готовые переменные, которые можно перезаписать, но нельзя создать новые.
Важно понимать, что при переключении потоков операционная система сохраняет контекст регистров одного потока в RAM и загружает контекст другого, благодаря чему создаётся иллюзия, что каждый поток обладает собственным набором регистров.
За регистры следует стек — участок виртуальной памяти, выделенный для каждого потока. В отличие от регистров стек расположен в RAM, но управляется очень просто: при входе в функцию вниз растёт стек и выделяет место, при выходе из функции указатель стека сдвигается вверх, тем самым освобождая память.
В кратце принцип работы стека:
У него есть указатель — RSP (Stack Pointer).
Когда вызывается функция:
Сохраняется адрес возврата
Выделяется место под локальные переменные
RSP сдвигается
При выходе из функции:
RSP возвращается назад
Память считается освобождённой
Никакой очистки не происходит.
Схема одного стека выглядит следующим образом:
┌──────── Stack frame ────────┐ │ local variable B │ │ local variable A │ │ return address │ └─────────────────────────────┘ <-- (Stack Pointer)
Стек никогда не фрагментируется, не требует GC и освобождается автоматически — просто удалением фрейма. Однако он ограничен по размеру (например, ~1 MB для ОС-потока в .NET), и для каждого потока ОС выделяет свой стек. Именно из информации в стеке и регистрах сборщик мусора получает часть корневого множества (root set) — набор ссылок, от которых начинается анализ достижимости объектов. 
В Go стек у goroutine начальный очень маленький (несколько килобайт) и растёт динамически, что позволяет запускать тысячи единиц лёгких потоков без огромных резервов памяти. 
Все объекты, созданные явно через new или аналогичные операции, оказываются в heap — большой области памяти, которая может занимать гигабайты, но не освобождается автоматически при выходе из функции, как стек. Именно за heap отвечает GC.
Схема виртуального адресного пространства процесса:
┌──────────────────────────────┐ │ Code Segment │ │ Static Data │ ├──────────────────────────────┤ │ Heap (растёт вверх) │ │ Gen0 / Gen1 / Gen2 / LOH │ │ │ │ │ ├──────────────────────────────┤ │ Stack (растёт вниз) │ └──────────────────────────────┘
Когда программа создаёт объект, он размещается в heap, после чего на него ссылаются переменные из стека или из других объектов. Именно наличие или отсутствие таких ссылок определяет, будет ли объект считаться живым во время работы GC.
В .NET реализован Generational GC: память разделена на поколения, основанные на статистическом наблюдении: большинство объектов умирает молодыми. Это означает, что выгоднее часто очищать небольшую область памяти, чем редко очищать всю кучу.
Жизненный цикл:
Программа создаёт объекты → они попадают в Heap (Gen0) Gen0 постепенно заполняется Когда Gen0 достигает порога → запускается GC Живые объекты из Gen0 → перемещаются в Gen1 Объекты, пережившие несколько сборок → переходят в Gen2 Периодически → запускается полная сборка (Full GC)
Схема поколений:
┌───────────────┐ │ Gen2 │ ← долгоживущие объекты ├───────────────┤ │ Gen1 │ ├───────────────┤ │ Gen0 │ ← новые объекты └───────────────┘
Преимущество этой модели в том, что вновь созданные объекты, которые живут недолго, быстро умирают в Gen0 и редко достигают старших поколений.
При этом каждый GC-проход начинается с анализа корневого множества — стеков, регистров, статических данных, pinned handles — и помечает только достижимые объекты.
Gen2 содержит объекты, которые пережили несколько сборок и считаются долгоживущими. Когда запускается сборка Gen2, объём памяти, которую нужно просканировать, существенно больше, чем для Gen0, поэтому такие сборки занимают больше времени и могут привести к заметным паузам, особенно в высоконагруженных API. Даже если рантайм выполняет часть работы фоново (concurrent marking), существуют критические точки, где приложение всё равно приходится останавливать.
Когда объектов становится слишком много, а их фрагментированный heap растёт в размере без эффективной очистки Gen2/LOH, GC вынужден тратить больше времени на анализ и перемещение данных, что напрямую влияет на latency — особенно на tail latency, где 99-й процентиль задержек резко увеличивается во время сборок Gen2
*LOH - Large Object Heap присутствует в .NET и хранит там объекты >85KB
Язык Go отказался от generational модели в пользу concurrent mark-and-sweep с page-based allocator. Heap разбит на страницы фиксированного размера (примерно 8 KB), сгруппированные в span’ы по размерным классам.
Go Heap:
[Page][Page][Page][Page] Span (size class 32 bytes) ├── obj ├── obj └── obj
GC выполняет маркировку параллельно с работой приложения, минимизируя stop-the-world паузы. Эта стратегия ориентирована на предсказуемую latency, ценой некоторого увеличения общего CPU overhead.
В .NET каждый ОС-поток получает собственный стек фиксированного размера, обычно около 1 MB. Когда создаётся новый поток через Thread, ОС выделяет новый стек. ThreadPool не создаёт 50000 потоков сразу, он масштабируется динамически, но каждый поток действительно имеет полноценный стек потому-что являются ОС Thread-ом.
Process ├── Thread 1 → Stack 1 (1 MB) ├── Thread 2 → Stack 2 (1 MB) └── Thread 3 → Stack 3 (1 MB)
В Go модель иная. Go использует goroutine, которые не соответствуют напрямую ОС-потокам. У goroutine стек начинается очень маленьким (около 2 KB) и может динамически расти и сжиматься. Это означает, что Go может запускать сотни тысяч goroutine, не резервируя огромные стеки заранее.
OS Thread ├── Goroutine A (stack 2KB → grows) ├── Goroutine B (stack 2KB → grows)
Это фундаментальное отличие, влияющее на поведение памяти.
Накопление объектов в heap создаёт нагрузку на GC, поэтому компиляторы используют escape analysis — анализ, который определяет, уходит ли объект за пределы метода. Если объект не «убегает», он может быть размещён в стеке (или даже в регистрах), что исключает его из зоны ответственности GC.
Чтобы снизить давление на heap, современные рантаймы активно используют escape analysis — анализ, определяющий, покидает ли объект пределы метода. Если объект не «убегает», он может быть размещён в стеке или даже в регистре, что полностью исключает его из зоны ответственности GC. Escape analysis — это анализ, выполняемый компилятором, который определяет, выходит ли объект за пределы метода или потока.
.NET активно использует такие оптимизации через Stackalloc, Span<T> и ref struct, что позволяет создавать временные буферы и временные структуры в стеке, а не в heap, снижая объём Gen0 и уменьшая частоту сборок. В Go аналогично используется escape analysis, который можно увидеть через флаг компиляции -gcflags="-m".
В .NET JIT активно использует escape analysis для:
размещения временных структур в stack,
устранения лишних аллокаций,
оптимизации Span и stackalloc.
Если объект размещён в стеке, GC вообще не знает о его существовании, что напрямую уменьшает pressure на heap.
Generational GC должен отслеживать ситуации, когда старые объекты начинают ссылаться на новые (например, когда поле объекта Gen2 получает ссылку на объект Gen0). Для этого используется write barrier, механизм, который вставляет дополнительный код при записи ссылок и сообщает GC об изменениях.
Каждая запись ссылки фактически становится:
store reference + notify GC (write barrier)
Это увеличивает накладные расходы на запись, но даёт GC корректную модель ссылок между поколениями.
Go также использует write barrier в своей concurrent модели, поскольку изменение графа объектов во время маркировки должно быть безопасно учтено GC.
Когда объекты удаляются, в heap остаются пустые области. Если GC не выполняет compaction, heap со временем становится фрагментированным. Перемещение объектов позволяет в увеличить размер свободных областей для того, чтобы можно было вставить большой объект и не искать долго для него свободное место.
Когда объекты удаляются, в heap остаются пустые участки:
[Obj][Free][Obj][Free][Obj]
Compaction перемещает живые объекты ближе друг к другу:
[Obj][Obj][Obj][Free][Free]
При этом не стоит забывать о Pinned memory. Это объект, который запрещено перемещать в памяти, потому что на него есть внешняя ссылка, например из unmanaged кода. Однако pinned объекты не могут быть перемещены, что приводит к «дыркам» в памяти и ухудшению локальности кеша. Большое количество pinned memory серьёзно усложняет работу GC и может приводить к росту Gen2.
Если объект pinned, GC не может его двигать, что приводит к фрагментации heap и усложняет compaction. Большое количество pinned объектов — частая причина роста и ухудшения производительности.
Generational GC (.NET) оптимизирует throughput, уменьшая работу с краткоживущими объектами, но принимает риск периодических Gen2 пауз.
Go GC делает упор на минимальные паузы и предсказуемость latency, принимая больший постоянный overhead. Это разные компромиссы, подходящие под разные архитектуры приложений.
GC — это не просто «удаление мусора» в heap, а интегрированный комплекс алгоритмов, включающий:
анализ достижимости объектов из регистров и стеков,
generational продвижение объектов,
concurrent маркировку,
write barriers,
escape analysis,
compaction и борьбу с фрагментацией,
влияние ассоциации потоков и стеков.
Понимание этих механизмов помогает не только объяснить, почему GC работает именно так, но и писать код, который меньше нагружает память, снижает GC pressure и минимизирует latency в высоконагруженных системах.