Перейти к основному содержимому

Стек и куча

Почему это важно в реальной разработке

Тема "стек и куча" нужна не ради академии. Она напрямую влияет на:

  • производительность горячих участков;
  • объём аллокаций и частоту срабатывания 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.
  • Такой код полезен для понимания, где возникают скрытые аллокации и почему они влияют на производительность.

То визуально это будет так:

image-17.png

Хранение в стеке порой не навсегда. Локальные переменные удаляются при выходе из метода. Но если вы захватите переменную в замыкание (closure) — она может быть "перемещена" в кучу (через создание объекта-обёртки). Также стоить отметить, что массивы значимых типов всё равно будут в куче - сам массив в куче, а ссылка в стеке. Точнее, элементы массива в куче, внутри объекта массива.


Может ли экземпляр struct оказаться в куче?

Да. Типичные случаи:

  • поле ссылочного типа (class, string) — структура лежит внутри объекта в куче;
  • boxing — присвоение struct переменной object или интерфейсу;
  • замыкание — компилятор поднимает локальную struct в скрытый класс в куче;
  • ref struct — наоборот, ограничен стеком/Span и не может попасть в кучу как обычная struct.

Конструктор struct: до C# 10 у каждой struct был неявный parameterless конструктор (обнуление полей); явный parameterless запрещали. С C# 10+ можно объявить свой struct S() { }, если все поля инициализированы.