Введение
Ранее мы упоминали «автоматическое» время жизни локальных переменных и динамическую память — в этой статье соберём эти концепции в единую картину, явно разобрав два основных региона памяти, используемых программой: стек (stack) и кучу (heap), их различия по скорости работы и управлению временем жизни.
Концепция
Стек — область памяти, организованная как структура данных того же названия (алгоритмического цикла) — каждый вызов функции добавляет новый «кадр» в стек, содержащий локальные переменные этой функции , и этот кадр автоматически удаляется при выходе из функции, что и обеспечивает автоматическое время жизни локальных переменных без необходимости явного освобождения. Куча — отдельная область памяти, используемая для динамического выделения — в отличие от стека, память в куче не освобождается автоматически при выходе из функции, в которой был вызван malloc/new, оставаясь выделенной до явного free/delete (или, для умных указателей, до автоматического срабатывания соответствующей логики освобождения). Выделение/осввобождение памяти на стеке существенно быстрее работы с кучей, поскольку стек — это просто сдвиг единственного указателя текущей вершины стека (без необходимости поиска свободного блока подходящего размера), тогда как куча требует более сложного управления, отслеживающего множество отдельных, не обязательно соседних выделенных и свободных блоков переменного размера.
Пример кода
#include <iostream>
void stackExample()
{
int localVariable = 42; // создаётся на СТЕКЕ — автоматически удаляется при выходе из функции
std::cout << "Адрес localVariable: " << &localVariable << std::endl;
} // localVariable больше не существует здесь — память автоматически "освобождена" (сдвигом стека)
void heapExample()
{
int *heapVariable = new int(42); // создаётся в КУЧЕ — НЕ удаляется автоматически при выходе из функции!
std::cout << "Адрес heapVariable: " << heapVariable << std::endl;
delete heapVariable; // ОБЯЗАТЕЛЬНОЕ явное освобождение — иначе утечка памяти
}
int main()
{
stackExample();
heapExample();
return 0;
}
// Демонстрация разницы в скорости — выделение в стеке практически бесплатно
#include <chrono>
#include <iostream>
void measureStackAllocation()
{
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 1000000; ++i) {
int stackArray[10]; // выделение НА СТЕКЕ — просто сдвиг указателя стека, очень быстро
stackArray[0] = i;
}
auto end = std::chrono::high_resolution_clock::now();
std::cout << "Стек: " << std::chrono::duration<double, std::milli>(end - start).count() << " мс" << std::endl;
}
void measureHeapAllocation()
{
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 1000000; ++i) {
int *heapArray = new int[10]; // выделение в КУЧЕ — заметно медленнее из-за более сложного управления
heapArray[0] = i;
delete[] heapArray;
}
auto end = std::chrono::high_resolution_clock::now();
std::cout << "Куча: " << std::chrono::duration<double, std::milli>(end - start).count() << " мс" << std::endl;
// Куча обычно ЗАМЕТНО медленнее стека для такого количества мелких, повторяющихся выделений
}
// Опасность висячей ссылки на возвращённый адрес стековой переменной
int *dangerousFunction()
{
int localValue = 100;
return &localValue; // ОШИБКА — возвращаем адрес переменной, которая ПЕРЕСТАНЕТ существовать!
} // localValue уничтожена ЗДЕСЬ, при выходе из функции — возвращённый указатель теперь "висячий"
int main()
{
int *danglingPointer = dangerousFunction();
// *danglingPointer — неопределённое поведение, обращение к уже несуществующей памяти стека
return 0;
}
Пояснения к коду
Сравнение stackExample()/heapExample() явно показывает разницу в управлении временем жизни — localVariable автоматически перестаёт существовать при выходе из функции без какого-либо явного кода для этого, тогда как heapVariable, указывающий на объект в куче, требует явного delete, иначе память останется выделенной (утечка,) даже после выхода из функции и потери самого указателя heapVariable. Замеры производительности (measureStackAllocation/measureHeapAllocation) количественно демонстрируют различие в скорости — стек выделяет память практически мгновенно (простой сдвиг указателя вершины стека), тогда как куча для каждого вызова new/delete выполняет значительно более сложную работу (поиск подходящего свободного блока, обновление внутренних структур учёта), что суммарно даёт заметно худшую производительность для большого количества мелких, частых выделений в куче по сравнению с эквивалентным использованием стека. dangerousFunction() явно демонстрирует классическую, серьёзную ошибку — возврат адреса локальной (стековой) переменной, время жизни которой заканчивается ровно в момент выхода из функции, что делает возвращённый указатель «висячим» немедленно после возврата из функции, до какого-либо реального использования этого указателя вызывающим кодом.
Подводные камни
- Размещение слишком больших объектов на стеке (большие массивы или структуры, объявленные как обычные локальные переменные) — размер стека ограничен (обычно несколько мегабайт по умолчанию, хотя точный размер зависит от платформы и настроек) и существенно меньше, чем размер доступной кучи, и попытка выделить на стеке объект, превышающий доступное пространство стека (особенно в сочетании с глубокой рекурсией, алгоритмического цикла, каждый уровень которой добавляет собственный кадр стека), приводит к переполнению стека (stack overflow) — обычно немедленному и трудно диагностируемому без специальных инструментов краху программы.
- Возврат указателя или ссылки на локальную (стековую) переменную из функции — как явно показано в примере
dangerousFunction(), это одна из самых классических ошибок, связанных с пониманием различия стека и кучи, приводящая к неопределённому поведению при последующем использовании возвращённого «висячего» указателя/ссылки, и компиляторы часто (хотя не гарантированно во всех случаях) выдают предупреждение для подобных, легко обнаруживаемых статическим анализом случаев. - Чрезмерное, неоправданное использование динамического выделения (куча) для объектов с коротким, предсказуемым временем жизни, которые могли бы быть выделены на стеке — для каждого случая, где объект реально нужен только в пределах текущей функции/блока без необходимости «переживать» выход из неё, стековое выделение (через обычную локальную переменную) обычно предпочтительнее по производительности (статья выше про сравнение скорости) и автоматически избегает риска утечки памяти, и динамическое выделение в куче стоит применять осознанно, именно когда объект должен существовать дольше текущей функции, должен иметь размер, неизвестный на этапе компиляции, или должен совместно использоваться разными частями кода через общий указатель/умный указатель .
- Игнорирование того, что объекты, выделенные на стеке (а не в куче), уничтожаются в строго обратном порядке относительно их создания (последний созданный — первый уничтоженный, LIFO-порядок, отражающий саму природу стека как структуры данных) — это поведение становится особенно важным при работе с RAII, где порядок уничтожения объектов (и, соответственно, порядок вызова их деструкторов,) может иметь значение для корректного, предсказуемого освобождения взаимозависимых ресурсов, и непонимание этого строгого, гарантированного порядка может привести к неожиданным последствиям для кода, неявно зависящего от конкретного порядка освобождения нескольких ресурсов.