Полагаю, многие из вас попадали в ситуации, когда написанный вами код, который прекрасно работал на тестовых данных, стабильно падает с OutOfMemoryException в продуктиве. Или замечали, что ваше приложение со временем начинает потреблять всё больше памяти, хотя утечек вроде бы нет? А может быть, вам задавали на собеседовании вопрос «чем отличается стек от кучи», и вы отвечали «ну, стек — это стек, а куча — это куча»?
Понимание того, как .NET управляет памятью — это не просто теория для собеседований. Это фундамент, на котором строится производительность, стабильность и масштабируемость ваших приложений.
В этой статье мы заглянем под капот CLR (Common Language Runtime) и разберём как хранятся разные типы данных. Также поговорим о том, что такое стек и куча, и как они взаимодействуют. И в заключении рассмотрим, как происходит выделение памяти и разберем принципы работы сборщика мусора.
Архитектура памяти в.NET
Прежде чем погружаться в детали, нужно понять общую картину. Когда вы запускаете.NET‑приложение, операционная система выделяет ему виртуальное адресное пространство. Далее CLR организует это пространство в несколько логических областей, но для нас важны две основные: стек и куча.
Здесь необходимо сделать важное уточнение: когда мы говорим о памяти в контексте .NET, мы говорим о виртуальной памяти. CLR запрашивает у ОС виртуальные адреса, а ОС уже отображает их на физическую память. Это позволяет каждому процессу иметь своё изолированное адресное пространство.

В высокоуровневом представлении память.NET‑приложения делится на:
Стек (Stack) — область для хранения локальных переменных и параметров методов.
Управляемая куча (Managed Heap) — область для хранения объектов, создаваемых через new.
Кроме этого существуют ещё JIT‑код — скомпилированный машинный код, домены приложений — изоляция между приложениями (в.NET Core/Framework) и Large Object Heap (LOH) — специальная куча для больших объектов (≥ 85 000 байт).
И для того, чтобы понять, как данные хранятся в памяти, нужно разобраться с фундаментальным разделением типов в.NET.
Начнем со значимых типов. К ним относятся как все числовые типы (int, long, float, double, decimal), так и bool, char, enum, а также структуры (struct) и кортежи (ValueTuple).
И здесь главная особенность заключается в том, что переменная значимого типа содержит само значение, а не ссылку на него.
Например:
int a = 42; // a хранит число 42 int b = a; // b получает КОПИЮ числа 42 b = 100; // a остаётся 42, b становится 100
Далее рассмотрим ссылочные типы (Reference Types). К ним относятся: Классы, Интерфейсы, Делегаты, Массивы и Строки.
Отличительной чертой ссылочных типов является то, что переменная данного типа хранит ссылку (адрес) на объект в куче, а сам объект хранится отдельно.
Вот небольшой пример:
class Point { public int X; public int Y; } Point p1 = new Point(); // p1 хранит ССЫЛКУ на объект в куче Point p2 = p1; // p2 получает ту же ссылку (копируется ссылка, не объект!) p2.X = 100; // p1.X тоже станет 100 — это один и тот же объект
Стоит отметить, что мы рассмотрели архитектуру памяти в несколько упрощенном виде. На самом деле всё сложнее — оптимизации JIT, регистры процессора, стековые фреймы. Но для понимания базовых принципов этой модели нам будет вполне достаточно.
Стек — быстрая, но ограниченная память
Стек — это структура данных, работающая по принципу LIFO (Last In, First Out — последним пришёл, первым ушёл). В контексте памяти — это непрерывный блок памяти, выделенный под нужды одного потока. У каждого потока свой собственный стек.

Рассмотрим подробнее, как работает стек в C#. При вызове метода в стеке выделяется фрейм в котором хранятся: локальные переменные метода, параметры метода и адрес возврата (чтобы знать, куда вернуться после завершения). При завершении метода фрейм «выталкивается» из стека.
Ниже показан пример работы стека:
void MethodA() { int x = 10; // x в стеке MethodB(x); // вызов MethodB Console.WriteLine(x); // после возврата x всё ещё 10 } void MethodB(int param) // param в стеке { int y = 20; // y в стеке // здесь param и y видны } // после выхода фрейм MethodB уничтожается
Стек обладает рядом преимуществ. Прежде всего, это скорость работы, так как для выделения памяти необходимо просто переместить указатель. Также стек достаточно предсказуем, так как в нем память освобождается строго в обратном порядке выделения. И, соответственно, в нем отсутствует фрагментация, так как выделение и освобождение памяти ведется последовательно.
Говоря о недостатках стека, стоит упомянуть его ограничения. Так, размер стека в зависимости от архитектуры ограничен 1–4 МБ на 32-битных и 4–8 МБ на 64-битных системах.
Еще одним недостатком стеков является то, что глубокие рекурсии могут вызвать StackOverflowException, так как чрезмерное количество вложенных вызовов методов приводит к переполнению стека.
Также нельзя хранить данные, которые должны пережить вызов метода. Например, тип Span может ссылаться на стековую память, которая действительна только в рамках текущего метода. Если Span «утек» за пределы этого контекста (например, сохранившись в поле класса или пережив асинхронную операцию), то он будет ссылаться на уже невалидную память, а это классическая проблема висячих указателей.
Куча — гибкая, но сложная память
Теперь поговорим о том, что же из себя представляет куча в.NET. Начнем с того, что куча — это область памяти, предназначенная для долгоживущих объектов, размер которых неизвестен на этапе компиляции. В отличие от стека, порядок выделения и освобождения памяти здесь произвольный.
CLR организует кучу в несколько поколений (generations) для оптимизации работы сборщика мусора:
Gen 0 — молодое поколение (недавно созданные объекты)
Gen 1 — буферное поколение (пережившие одну сборку)
Gen 2 — старшее поколение (долгоживущие объекты)

Когда сборщик мусора приступает к работе, он сначала анализирует объекты из поколения 0. Далее, те объекты, которые остаются актуальными после очистки, повышаются до поколения 1. Если после обработки объектов поколения 0 всё ещё необходима дополнительная память, сборщик мусора приступает к объектам из поколения 1. Затем, те объекты, на которые уже нет ссылок, уничтожаются, а те, которые по‑прежнему актуальны, повышаются до поколения 2.
При этом, важно учитывать, что из‑за отнесения объектов в куче к определённому поколению более новые объекты (вроде локальных переменных) удаляются быстрее, а более старые (такие как объекты приложений) — реже.
Отдельно стоит отметить то, как осуществляется выбор поколения для сборки мусора. Так, если превышено пороговое значение для определённого поколения, в нем начинается сборка мусора. Пороговое значение устанавливается в зависимости от количества выживающих в этом поколении объектов: если это количество высоко, пороговое значение становится выше.
Если поколение сильно фрагментировано, сбор мусора в нём, скорее всего, будет продуктивным.
Также, если память машины слишком загружена, сборщик может провести более глубокую очистку, и в результате такая очистка с большой долей вероятности освободит пространство.
Когда вы создаёте новый объект через new:
var list = new List<int>();
CLR вычисляет, сколько памяти нужно объекту (заголовок + поля)
Затем проверяет, есть ли место в Gen 0. Если место есть, то выделяет память, двигая указатель. А если нет, то запускает сборку мусора.
И вот мы плавно подошли к следующему важному вопросу — сборке мусора. В отличие от C/C++, где разработчик сам обязан освобождать память (free, delete), в.NET этим занимается сборщик мусора. Это избавляет от огромного класса ошибок (висячие указатели, двойное освобождение, утечки), но требует понимания, как он работает, чтобы не стрелять себе в ногу.
Основной принцип работы garbage Collector заключается в следующем: объект жив, если на него есть корневые ссылки (roots).
Корневые ссылки включают в себя:
Локальные переменные в стеке (текущие методы).
Статические поля.
Ссылки в регистрах CPU.
Финализируемые объекты (готовящиеся к финализации).
Ссылки из GC‑дескрипторов (например, закреплённые объекты для P/Invoke).
GC строит граф достижимости, начиная с корней. Все объекты, недостижимые из корней, считаются мусором.

Затем запускается процесс сборки мусора. Для рабочих станций данный процесс состоит из следующих фаз:
Mark (Маркировка) — GC приостанавливает потоки и помечает достижимые объекты
Sweep/Compact (Очистка/Уплотнение) — освобождает память и уплотняет кучу (сдвигает живые объекты вместе)
Для серверов процесс имеет следующий вид: создаётся отдельная куча и поток сборки мусора на ядро процессора для максимальной производительности.
Пример, демонстрирующий время жизни переменных.
void Example() { int a = 10; // в стеке string s = "hello"; // s в стеке, "hello" в куче List<int> list = new List<int>(); // list в стеке, объект List в куче for (int i = 0; i < 10; i++) // i в стеке { Guid g = Guid.NewGuid(); // g в стеке (значимый тип) list.Add(i); // i копируется в список (в куче) } // g уничтожается при выходе из итерации } // a, s, list уничтожаются при выходе из метода // Объект "hello" и объект List могут стать мусором (если нет других ссылок)
Можно продолжить знакомство с темой в более прикладном формате:
Пройти тестирование и оценить, насколько уверенно вы разбираетесь в устройстве памяти и внутренних механизмах.NET ☛ [Проверить знания]
2 апреля в 20:00 — открытый урок «Анатомия памяти: типы данных, способы хранения, аллокации и работа сборщика мусора.»
16 апреля в 20:00 — открытый урок «Облегчённые примитивы синхронизации».
После этого уже проще понять, хочется ли вам просто закрыть отдельные пробелы или двигаться в сторону более глубокого и системного изучения C#.
Заключение
Подведем итоги: cтек — быстрый, самоочищающийся, для локальных переменных и параметров, а куча — гибкая, для долгоживущих объектов, управляется Garbage Collector. Сборщик мусора использует поколения и уплотнение для эффективного управления памятью.
Понимание устройства памяти в.NET — это не академическое знание, а практический инструмент. Оно помогает писать более производительный код, избегая утечек памяти, а также понимать, почему приложение «тормозит» под нагрузкой.

Если по ходу чтения вы поймали себя на мысли, что какие‑то вещи понимаете скорее интуитивно, чем уверенно, это нормальный сигнал не «срочно всё учить», а спокойно проверить базу и посмотреть, куда двигаться дальше. Для этого можно начать с тестирования и открытых уроков, а если захочется собрать знания о платформе в цельную систему — записаться на курс «C# разработчик. Экспертный уровень».
Тем более что 30–31 марта, в рамках дня рождения OTUS, на любые курсы действует дополнительная скидка 10% по промокоду
birthday.Она суммируется с другими скидками, поэтому тем, кто и так присматривался к обучению, не откладывайте решение: сейчас курс можно взять на более выгодных условиях. ☛[Получить курс со скидкой]
