Сборщик мусора (Garbage Collector, GC) - это подсистема рантайма, которая автоматически находит неиспользуемые объекты в куче и возвращает занятую ими память операционной системе или пулу переиспользования. Разработчик на C#, Java или Go редко задумывается о том, куда девается объект после последнего обращения, но именно этот невидимый механизм определяет латентность сервиса, частоту пауз и стоимость выделения памяти. Ниже разобрано устройство GC от ручного управления памятью до современных низкопаузных алгоритмов.

Ручное управление памятью и его реальная цена

До широкого распространения GC программист сам вызывал malloc для выделения блока в куче и free для его возврата. Модель проста на бумаге, но порождает три классические катастрофы. Первая - утечка памяти: указатель перезаписали, а free забыли, и блок висит в адресном пространстве до конца жизни процесса. Вторая - висячий указатель (dangling pointer): память освободили, а ссылка осталась, и следующий malloc выдаст тот же адрес совершенно другому объекту с непредсказуемыми разрушениями. Третья - двойное освобождение (double free): повторный free портит служебные структуры аллокатора и открывает класс уязвимостей, на котором построены эксплойты heap corruption.

Статистика уязвимостей крупных кодовых баз регулярно показывает, что около семидесяти процентов серьёзных проблем безопасности в C и C++ связаны именно с ошибками работы с памятью. Цена ручного контроля - не только баги, но и инженерное время: соглашения о владении (кто должен звать free) размазываются по всему API. GC снимает эту нагрузку, заменяя глобальную договорённость локальным свойством: объект жив, пока на него есть достижимая ссылка.

Подсчёт ссылок и трассировка как два фундаментальных подхода

Первый механизм - подсчёт ссылок (reference counting): у каждого объекта есть счётчик, инкрементируемый при появлении ссылки и декрементируемый при её исчезновении. Достиг нуля - можно освобождать, причём детерминированно, в точке последнего выхода из scope. Так работают std::shared_ptr, COM и ARC в Swift, где компилятор сам вставляет retain и release. Слабость подхода - циклические ссылки: два объекта держат друг друга, счётчики никогда не обнулятся, память течёт. Поэтому появляются weak-ссылки, требующие от программиста ручного разрыва циклов.

Второй механизм - трассировка (tracing): GC периодически стартует от корней (стеки потоков, статические поля, регистры, handle-таблицы), обходит граф достижимых объектов и помечает живые. Всё непомеченное - мусор. Циклы для трассировки не проблема: недостижимость решается глобально. Плата - паузы и необходимость точно знать, где лежат указатели, а где просто числа; поэтому managed-рантаймы хранят точные карты типов стека и объектов.

Классический трассирующий цикл состоит из трёх фаз: маркировка (mark) раскрашивает живые объекты, чистка (sweep) возвращает непомеченные блоки в свободные списки, компактификация (compact) сдвигает выживших к началу сегмента, устраняя фрагментацию и поправляя все ссылки. Компактификация превращает выделение памяти в простое продвижение bump-указателя - malloc уровня "один cas-инкремент".

Поколенческая гипотеза и поколения Gen0 Gen1 Gen2 в .NET

Эмпирическое наблюдение, известное как поколенческая гипотеза (generational hypothesis), гласит: большинство объектов умирают молодыми. Строка для лог-сообщения, временный массив, итератор - живут микросекунды, тогда как singleton-кэши и контейнеры зависимостей существуют часами. Если концентрировать проверки на молодых объектах, дешёвая частичная сборка найдёт почти весь мусор.

.NET материализует эту идею тремя поколениями. Новый объект рождается в Gen0; выжил одну сборку - продвигается в Gen1; выжил ещё - попадает в Gen2, где собирается только при полной сборке. Сборка Gen0 занимает доли миллисекунды, потому что сегмент мал, а из старших поколений в него смотрят редкие ссылки. Чтобы не сканировать весь Gen2 при сборке Gen0, применяется write barrier: каждая запись ссылки в поле проходит через пометку в card table - битовой карте памяти, где каждый бит отвечает за блок размером примерно в страницу. GC читает только "грязные" карты и точно знает, какие старые регионы могут ссылаться на молодых. Жертва - небольшой налог на каждое присваивание поля ссылочного типа.

Отдельная куча - LOH (Large Object Heap) - принимает объекты от 85000 байт. Большие массивы дорого двигать, поэтому LOH исторически не компактифицируется по умолчанию, живёт в Gen2 и очищается только полными сборками. Фреймворки уровня ASP.NET борются с нагрузкой на LOH через ArrayPool и буферы переиспользования, потому что один крупный массив на запрос быстро утрамбовывает бюджет памяти.

Паузы stop-the-world и фоновая сборка

Простейший GC останавливает все managed-потоки (stop-the-world), делает работу и будит их обратно. Для десктопного приложения пауза в десять миллисекунд незаметна, для биржевого движка или игрового кадра с бюджетом в шестнадцать миллисекунд - уже деградация. Поэтому .NET предлагает два режима. Workstation GC (десктопный) обслуживает процесс одной кучей с приоритетом минимальной латентности; Server GC создаёт по куче и сборочному потоку на ядро и рассчитан на пропускную способность веб-служб. Background GC, включённый по умолчанию, проводит эфемерные сборки Gen0 и Gen1 параллельно с чисткой Gen2 фоновым потоком, сокращая блокировку до коротких синхронизационных точек.

Общая логика выбора режима сводится к нескольким пунктам:

  • Workstation GC подходит UI-приложениям и инструментам с одним активным пользователем;
  • Server GC подходит многопоточным сервисам, где важна суммарная пропускная способность;
  • Background GC снижает длину фазы stop-the-world ценой дополнительной памяти и небольшого оверхеда;
  • SustainedLowLatency как временный режим уместен на коротких критичных участках, но его злоупотребление приводит к полным сборкам под давлением.

Детерминированное освобождение ресурсов и финализаторы

GC владеет только памятью. Файловый дескриптор, сокет, хэндл окна или соединение с базой - внешние ресурсы, и их лимит в операционной системе не связан с давлением кучи. Отдать дескриптор на волю GC - значит позволить недетерминированному моменту сборки решать, когда файл реально закроется: под нагрузкой легко исчерпать таблицу дескрипторов при почти пустой куче. Поэтому существует паттерн dispose: интерфейс IDisposable и конструкция using гарантируют вызов Dispose при выходе из блока, освобождая ресурс предсказуемо.

Финализатор (деструктор в синтаксисе C#) - страховочная сетка: если Dispose не вызвали, GC рано или поздно выполнит финализатор и закроет хэндл. Но объект с финализатором живёт дольше минимум на одну сборку, потому что сначала попадает в очередь финализации, и часто продвигается в старшее поколение. Корректная реализация паттерна вызывает GC.SuppressFinalize в Dispose, чтобы не платить дважды. Правило рантайм-инженера простое: память отдаём GC, ресурсы отдаём dispose/using, финализатор пишем только при прямом владении небезопасным хэндлом.

Наблюдаемость, pool трюки и современные GC

Практическая диагностика начинается со счётчиков. Метрика "% Time in GC" показывает долю процессорного времени рантайма, ушедшего на сборку; устойчивые значения выше десяти процентов намекают на чрезмерные аллокации. Полезно смотреть частоту сборок Gen2 - их рост говорит о том, что мусор доживает до старшего поколения, - а также размер LOH и скорость промоушена. В dotnet-counters и PerfView эти числа видны вживую, что превращает настройку GC из гадания в инженерию.

Когда аллокации нельзя убрать, их переиспользуют. Object pooling в играх возвращает отработанные пули частиц, снаряды и временные структуры в заранее прогретый пул вместо кучи, устраняя пики пауз в середине кадра. Тот же приём в серверном мире - пулы буферов и MemoryPool - держит Gen0 почти пустым даже при сотнях тысяч запросов в секунду.

Полезно сравнить GC с альтернативами. Rust заменяет сборщика статической системой ownership с анализом времени жизни при компиляции: ноль пауз, ноль gc-потоков, но стоимость - строгость borrow checker и перестройка привычных архитектур. ARC в Swift - автоматизированный компилятором подсчёт ссылок, детерминированный и дешёвый по паузам, но уязвимый к retain-циклам и требующий weak-аннотаций. Трассирующий GC проигрывает им в предсказуемости, зато выигрывает в скорости выделения памяти и простоте модели для мутабельных графов.

Историческая линия показательна. Первый GC Джон Маккарти описал для LISP в 1959 году уже с алгоритмом mark-sweep. Далее пришли подсчёт ссылок, копирующие сборщики Чейни, поколенческие схемы восьмидесятых, параллельные и конкурентные варианты в Java. Сегодняшний фронтир - низкопаузные коллекторы Shenandoah и ZGC, выполняющие маркировку и компактификацию конкурентно с приложением через read barrier и цветные указатели, с паузами в единицы миллисекунд независимо от размера кучи в терабайт. Абстракция невидимой уборки, придуманная для списков Лиспа, столь же спокойно обслуживает кластерные кучи - и остаётся одним из самых недооценённых механизмов, определяющих производительность прикладного кода.

Полезно держать в голове и обратную сторону поколенческой гипотезы: объекты, пережившие первую сборку, начинают стоить дороже, потому что теперь их приходится обходить и ухаживать за ними на каждом полном цикле маркировки. Поэтому классический антипаттерн «создать всё ещё на старте, а потом не трогать» играет со сборщиком злую шутку: молодая куча дешевле старой, и длинные сессии с накоплением вещей гарантируют рост пауз. Практики лечат это простыми приёмами: крупные кэши выносятся вне управляемой кучи, временные структуры переиспользуются через пулы, а горячие циклы вообще обходятся без аллокаций, заранее готовя буферы под обороты. Там, где паузы вообще недопустимы, рынок ответил процедурными языками с полным ручным управлением поместьём, но за остальную индустрию сборщик отыгрывает время и аспектуальное спокойствие разработчика - и это честная сделка в большинстве узлов.

При работе с публичными приложениями инженер руководствуется хроникой применимости современных сборщиков: высокая интенсивность сборки в Gen0 с частотой в десятки раз в секунду не болезнь, а конструкция, тогда как полный цикл Gen2, встречающийся ежеминутно, означает, что живой набор объектов не сжимается и страницы стареют массой. Процент времени GC в счётчиках Performance Monitor считается первым индикатором: две-три десятых доли процента здоровы для настольного приложения, устойчивые десятки процентов служат знаком, что программа падает в churn. Дисциплина профилирования требует честных замеров: снимают распределение времени в GC по поколениям, а не наблюдение средней паузы, потому что хвост редких долгих пауз решает судьбу живучести интерфейса. После диагностики лечение стартовое: проверка на утечки через события и обратные ссылки, когорта пулов для горячих объектов, перевод больших структур на неуправляемую память там, где она действительно оправдана. Опытный кодер при этом не требует от GC чудес: он лишь просит его делать ту скучную ежесекундную уборку, которую ручной код делать забыл бы, забыв и об освобождении, и о том, что каждая забвенная ссылка когда-то была чьей-то уверенностью.
Логичный итог этой экскурсии лаконичен: сборщик мусора не отменил необходимость понимать стоимость объекту - он лишь переместил её в момент проектирования жизненного цикла, и именно там экономика памяти платит самые честные проценты.

Всё это не инвалидирует главного обещания технологии: память как ресурс перестала быть бременем каждой строки кода. Она стала фоновым сервисом, который, как все фоновые сервисы, лучше всего заметен тогда, когда он работает безупречно и молча.