Стек и куча
Почему это важно в реальной разработке
Тема "стек и куча" нужна не ради академии. Она напрямую влияет на:
- производительность горячих участков;
- объём аллокаций и частоту срабатывания GC;
- предсказуемость времени ответа сервиса;
- нагрузку при обработке больших коллекций и строк.
Практические ориентиры
- Избегайте лишнего boxing в циклах и LINQ-пайплайнах.
- Для временной обработки срезов памяти используйте
Span<T>/ReadOnlySpan<T>. - В долгоживущих сервисах измеряйте аллокации профилировщиком, а не "на глаз".
Мини-пример лишнего boxing
var list = new List<object>();
for (int i = 0; i < 1_000_000; i++)
{
list.Add(i); // boxing int -> object на каждой итерации
}
Здесь будет много дополнительных объектов в куче. В подобных случаях лучше хранить List<int>.
Смежные статьи
Стек и куча
Разработчику АрхитекторуСтек и куча
Хранение данных
Как мы помним, данные записываются в объекты и хранятся в памяти, а платформа управляет этой памятью благодаря возможностям языка. И именно для фиксации и структурирования объектов и используются различные типы данных.
В .NET (и в большинстве языков с управляемой памятью) используется две основные области памяти для хранения данных:
- Стек (stack) - хранит локальные переменные, параметры методов, адреса возврата. Он управляется автоматически, и доступ к нему быстрый.
- Куча (heap) - хранит объекты (ссылочные типы), динамические данные. Управление кучей выполняется через сборщик мусора (GC).
Стек
Значимые типы в основном хранятся в стеке. Почему в основном? Потому что есть исключение - если значимый тип — поле в классе, он хранится в куче, потому что весь объект — в куче.
Получается так:
| Тип данных | Категория | Хранение |
|---|---|---|
int, long, short (целые числа) | Значимый (struct) | Стек (если локальная переменная); Куча (если поле в классе) |
byte (двоичные данные) | Значимый (struct) | Стек (если локальная переменная); Куча (если поле в классе) |
float, double, decimal (числа с запятой) | Значимый (struct) | Стек (локальная); Куча (в классе) |
char (символ) | Значимый (struct) | Стек (локальная); Куча (в классе) |
bool (булево) | Значимый (struct) | Стек (локальная); Куча (в классе) |
string (строка) | Ссылочный (class) | Стек (ссылка); Куча (сам объект) |
object (объект) | Ссылочный (class) | Стек (ссылка); Куча (объект) |
struct (структура) | Значимый (struct) | Стек (если локальная переменная); Куча (если поле в классе) |
class (класс) | Ссылочный (class) | Стек (ссылка); Куча (объект) |
enum (перечисление) | Значимый (обёртка над int и пр.) | Стек (локальная); Куча (в классе) |
array (массив) | Ссылочный | Стек (ссылка); Куча (весь массив) |
delegate | Ссылочный (class) | Стек (ссылка); Куча (объект) |
interface | Ссылочный (по ссылке) | Стек (ссылка); Куча (реализующий объект) |
ValueTuple (кортеж) | Значимый (struct) | Стек (локальная); Куча (в классе) |
| Анонимные типы | Ссылочный (class) | Стек (ссылка); Куча (объект) |
Стек работает по принципу LIFO (Last In, First Out), каждый вызов метода создаёт кадр стека (stack frame), где хранятся локальные переменные, параметры и адрес возврата (куда вернуться после завершения).
К примеру у нас есть три метода:
Код ITЗагрузка примера кода…
Разбор:
MainвызываетMethodA, аMethodAвнутри вызываетMethodB, формируя вложенную цепочку вызовов.- Для каждого вызова создаётся отдельный кадр стека (stack frame) с локальными переменными (
a,b,c). - Переменные принадлежат своим методам и недоступны вне их области видимости.
- Когда
MethodBзавершается, её кадр снимается первым (LIFO), затем управление возвращается вMethodA.
Во время выполнения MethodB() стек будет следующим:
[ MethodB: c = 30 ] ← вершина стека
[ MethodA: b = 20 ]
[ Main: a = 10 ]
Разбор:
- Это схематичное представление текущего состояния call stack во время выполнения
MethodB. - Верхняя строка — активный кадр, который исполняется прямо сейчас.
- Ниже лежат кадры вызывающих методов, ожидающих возврата управления.
- После выхода из
MethodBверхний кадр удаляется, и вершиной становитсяMethodA.
При завершении MethodB её кадр удаляется — c исчезает.
Благодаря этому в стеке очень быстрый доступ и автоматическое освобождение при выходе из метода. Есть ограничение размера (обычно 1-8 МБ).
Куча
Интерактивная модель — фазы mark-and-sweep и достижимость объектов (языконезависимо). Подробнее: автоматическое управление памятью.
Play ITЗагрузка интерактивного демо…
Куча чуть сложнее. Управление в ней осуществляется через сборщик мусора (Garbage Collector, GC). Объекты в куче не удаляются сразу после выхода из области видимости. GC сам решает, когда объект больше не нужен, и освобождает память.
Значимые и ссылочные типы связываются через object или интерфейсы. Этот процесс связи включает в себя упаковку (boxing) и распаковку (unboxing). Сводная таблица Java Integer и C# int? — Значимые типы и boxing.
Boxing — преобразование значимого типа в ссылочный. Когда значимый тип присваивается переменной типа object или интерфейсу, он копируется в кучу, и создаётся ссылка на него.
int i = 123; // i — в стеке
object o = i; // Boxing: i копируется в кучу, o — ссылка на него
Разбор:
iхранится как значимый типint(само значение, а не ссылка).- Присваивание в
objectзапускает boxing: создаётся объект-обёртка в куче. - Переменная
oхранит ссылку на эту обёртку, а не исходныйi. - Операция удобна для универсальных API, но создаёт дополнительную аллокацию.
При таком раскладе, в куче создаётся объект-обёртка с копией значения i, а переменная o (в стеке) получает ссылку на этот объект.
Unboxing — обратное преобразование. Преобразование object обратно в значимый тип.
int j = (int)o; // Unboxing: копирование из кучи в стек
Разбор:
(int)oвыполняет unboxing: извлекает исходное значение из объектной обёртки.- Перед копированием runtime проверяет, что внутри
oдействительно хранитсяint. - Если фактический тип другой, будет исключение
InvalidCastException. - После распаковки
jснова становится обычной значимой переменной типаint.
Здесь проверяется, что объект в куче - действительно int, и значение копируется из кучи в стек.
При таких преобразованиях главное учесть те самые ограничения размеров переменных, ведь C# строго типизирован. К примеру, нельзя сделать распаковку int как long. Также важно учесть то, что это дорогие в части ресурсов операции - выделение памяти, проверка типа, копирование данных - всё это в критичных по производительности участках (например, циклах) может принести проблемы производительности.
И если мы возьмём пример:
class Program
{
static void Main()
{
int x = 5; // x — в стеке
string s = "Hello"; // s (ссылка) — в стеке; "Hello" — в куче
List<int> list = new List<int> { 1, 2, 3 }; // list — ссылка в стеке; объект — в куче
object o = x; // Boxing: x копируется в кучу, o — ссылка
int y = (int)o; // Unboxing: значение копируется обратно в стек
}
}
Разбор:
- Пример объединяет несколько моделей хранения: значения (
int) и ссылки (string,List<int>). - Для
stringиList<int>в стеке лежат только ссылки, а реальные объекты расположены в куче. object o = x;показывает boxing, аint y = (int)o;— обратный unboxing.- Такой код полезен для понимания, где возникают скрытые аллокации и почему они влияют на производительность.
То визуально это будет так:

Хранение в стеке порой не навсегда. Локальные переменные удаляются при выходе из метода. Но если вы захватите переменную в замыкание (closure) — она может быть "перемещена" в кучу (через создание объекта-обёртки). Также стоить отметить, что массивы значимых типов всё равно будут в куче - сам массив в куче, а ссылка в стеке. Точнее, элементы массива в куче, внутри объекта массива.
Может ли экземпляр struct оказаться в куче?
Да. Типичные случаи:
- поле ссылочного типа (
class,string) — структура лежит внутри объекта в куче; - boxing — присвоение
structпеременнойobjectили интерфейсу; - замыкание — компилятор поднимает локальную struct в скрытый класс в куче;
ref struct— наоборот, ограничен стеком/Span и не может попасть в кучу как обычная struct.
Конструктор struct: до C# 10 у каждой struct был неявный parameterless конструктор (обнуление полей); явный parameterless запрещали. С C# 10+ можно объявить свой struct S() { }, если все поля инициализированы.