Garbage Collector от мала до велика или как создаются и отчищаются ваши объекты
- воскресенье, 22 февраля 2026 г. в 00:00:08

Когда мы говорим о сборщике мусора, мы часто ограничиваемся фразой «он удаляет неиспользуемые объекты», однако в реальности GC — это сложнейшая система, которая взаимодействует с виртуальной памятью, потоками, стеком, регистрами и графом ссылок, и без понимания этих взаимодействий невозможно осознанно писать высоконагруженные приложения. В этом материале мы сосредоточимся именно на GC, рассматривая его не как магию runtime, а как конкретный набор алгоритмов и инженерных компромиссов. За каждой строкой new, за каждой локальной переменной и за каждым вызовом функции стоит конкретная архитектура процессора, виртуальная память операционной системы и довольно агрессивная инженерная математика сборщика мусора. Чтобы действительно понимать GC, необходимо начать не с него, а с того, на чём он стоит — с регистров, стека и кучи, поскольку именно они формируют корневую модель, на которую опирается любой современный runtime.
В данной статье мы рассмотрим то какие хранилища памяти используют ваши программы и сравним разные GC на примере .NET GC & Go GC (Green Tea). Также рассмотрим их принцип работы.
Регистры — это небольшие участки памяти, расположенные непосредственно внутри процессора, предназначенные для хранения операндов, адресов и промежуточных результатов во время выполнения машинных инструкций. Их крайне мало по сравнению с RAM, но они работают на скорости самого CPU, что делает их самым быстрым уровнем хранения данных.
В архитектуре x86-64 существуют регистры общего назначения, такие как RAX, RBX, RCX, RDX, а также специальные регистры вроде RSP (указатель стека) и RIP (указатель инструкции). Когда компилятор генерирует машинный код, он старается держать активные значения именно в регистрах, потому что доступ к ним не требует обращения к памяти.
Вот пример того как мы создаем два числа и складываем их на ASM:
mov rax, 5 mov rbx, 10 add rax, rbx
Если переменных больше, чем регистров, компилятор вынужден «проливать» значения в стек, поскольку регистры не могут динамически расширяться. Именно поэтому невозможно положить 20000 int-ов в регистры — регистров фиксированное количество, и они перезаписываются при каждой новой инструкции. Грубо говоря это как уже готовые переменные, которые можно перезаписать, но нельзя создать новые.
Важно понимать, что при переключении потоков операционная система сохраняет контекст регистров одного потока в RAM и загружает контекст другого, благодаря чему создаётся иллюзия, что каждый поток обладает собственным набором регистров.
Стек — это область виртуальной памяти процесса, выделяемая для каждого операционного потока, предназначенная для хранения локальных переменных, аргументов функций и адресов возврата. Он работает по принципу LIFO, что позволяет выделять и освобождать память крайне быстро — достаточно лишь изменить указатель стека.
В кратце принцип работы стека:
У него есть указатель — RSP (Stack Pointer).
Когда вызывается функция:
Сохраняется адрес возврата
Выделяется место под локальные переменные
RSP сдвигается
При выходе из функции:
RSP возвращается назад
Память считается освобождённой
Никакой очистки не происходит.
Схема одного стека выглядит следующим образом:
┌──────── Stack frame ────────┐ │ local variable B │ │ local variable A │ │ return address │ └─────────────────────────────┘ <-- (Stack Pointer)
Когда создаётся новый ОС-поток в .NET, операционная система резервирует для него стек фиксированного размера, обычно около одного мегабайта. Это означает, что 100 потоков — это уже примерно 100 мегабайт только на стеки, даже если они пока пусты. ThreadPool в .NET не создаёт десятки тысяч потоков заранее, но каждый реально существующий поток имеет полноценный стек.
В Go модель иная. Goroutine не равна ОС-потоку, и её стек начинается с очень малого размера, порядка нескольких килобайт, после чего динамически растёт при необходимости. Это позволяет запускать сотни тысяч goroutine без линейного роста зарезервированной памяти, что является одним из ключевых отличий модели Go.
Heap — это большая область виртуальной памяти процесса, предназначенная для динамического размещения объектов, срок жизни которых не ограничен рамками одного вызова функции. В отличие от стека, heap может занимать гигабайты, но он не освобождается автоматически при выходе из метода, и именно поэтому ему требуется сборщик мусора.
Схематически виртуальное адресное пространство процесса можно представить так:
┌──────────────────────────────┐ │ Code Segment │ │ Static Data │ ├──────────────────────────────┤ │ Heap (растёт вверх) │ │ Gen0 / Gen1 / Gen2 / LOH │ │ │ │ │ ├──────────────────────────────┤ │ Stack (растёт вниз) │ └──────────────────────────────┘
Когда программа создаёт объект, он размещается в heap, после чего на него ссылаются переменные из стека или из других объектов. Именно наличие или отсутствие таких ссылок определяет, будет ли объект считаться живым во время работы GC.
В .NET используется generational garbage collector, построенный на гипотезе о том, что большинство объектов живут очень мало. Это означает, что выгоднее часто очищать маленькую область памяти, чем редко очищать большую.
Схема жизненного цикла объектов выглядит следующим образом:
Программа создаёт объекты → они попадают в Heap (Gen0) Gen0 постепенно заполняется Когда Gen0 достигает порога → запускается GC Живые объекты из Gen0 → перемещаются в Gen1 Объекты, пережившие несколько сборок → переходят в Gen2 Периодически → запускается полная сборка (Full GC)
┌───────────────┐ │ Gen2 │ ← долгоживущие объекты ├───────────────┤ │ Gen1 │ ├───────────────┤ │ Gen0 │ ← новые объекты └───────────────┘
Когда Gen0 заполняется, рантайм делает короткую stop-the-world фазу, в которой определяет корневой набор ссылок (root set), включающий:
регистры CPU,
локальные переменные в стеке,
статические поля,
pinned handles.
После этого GC выполняет маркировку достижимых объектов и переносит выжившие в Gen1. Таким образом, Gen0 очищается быстро, потому что он мал по размеру, а большинство объектов там умирают.
Gen2 содержит объекты, которые пережили несколько сборок и считаются долгоживущими. Когда запускается сборка Gen2, необходимо просканировать гораздо больший объём памяти, что увеличивает время паузы. Хотя современные версии .NET выполняют маркировку конкурентно, всё равно существуют критические фазы, где требуется остановка всех потоков, чтобы корректно обновить ссылки и выполнить compaction.
Именно Gen2 чаще всего является причиной latency spikes, особенно в API-сервисах с большим heap.
Go не использует поколения, а делает ставку на конкурентную маркировку и 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 фазы, тем самым уменьшая tail 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, современные рантаймы активно используют escape analysis — анализ, определяющий, покидает ли объект пределы метода. Если объект не «убегает», он может быть размещён в стеке или даже в регистре, что полностью исключает его из зоны ответственности GC. Escape analysis — это анализ, выполняемый компилятором, который определяет, выходит ли объект за пределы метода или потока. Если объект не «убегает», его можно разместить в стеке вместо heap.
В .NET это особенно заметно при использовании Span, ref struct и stackalloc, которые позволяют размещать временные буферы в стеке, избегая heap allocation. Это уменьшает количество объектов в Gen0, снижает частоту сборок и косвенно уменьшает вероятность Gen2 пауз.
В .NET JIT активно использует escape analysis для:
размещения временных структур в stack,
устранения лишних аллокаций,
оптимизации Span и stackalloc.
В Go escape analysis также встроен в компилятор, и вы можете увидеть результат через флаг компиляции -gcflags="-m".
Если объект размещён в стеке, GC вообще не знает о его существовании, что напрямую уменьшает pressure на heap.
В generational GC необходимо отслеживать ситуации, когда старый объект (Gen2) начинает ссылаться на новый (Gen0). Для этого используется механизм write barrier — специальная вставка в код, которая регистрирует изменения ссылок.
Каждая запись ссылки фактически становится:
store reference + notify GC (write barrier)
Это добавляет накладные расходы, но позволяет корректно поддерживать модель поколений.
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 объектов — частая причина роста и ухудшения производительности.
.NET выбирает throughput и оптимизацию краткоживущих объектов через generational модель, принимая риск периодических Gen2 пауз. Go выбирает предсказуемость latency и concurrent модель без поколений, принимая больший постоянный CPU overhead.
GC — это не просто механизм очистки памяти, а сложная система, включающая:
анализ достижимости
поколения
write barrier
escape analysis
compaction
работу со стеком потоков
управление фрагментацией
взаимодействие с ОС
Именно через понимание этих механизмов мы можем писать код, который минимизирует heap allocation, уменьшает GC pressure и избегает tail latency spikes в production.
GC — это не просто «удаление мусора», а статистически оптимизированный, конкурентный, глубоко интегрированный в рантайм механизм, который балансирует throughput, latency и потребление памяти. Понимание его архитектуры — это переход от уровня пользователя языка к уровню инженера рантайма.