Виды памяти и управление ресурсами
Что хранится в стеке, что делает new, почему delete нельзя вызывать дважды и как работает static.
Карта памяти программы
Длительность хранения (storage duration) — свойство объекта, определяющее, как долго для него существует хранилище. В этой лекции изучаются статическая, автоматическая и динамическая длительности хранения.
Для первого понимания процесс можно представить как области с кодом, статическими данными, стеком и свободным хранилищем. В реальности это виртуальное адресное пространство со множеством отображений, а не один кусок RAM Почему не «единый непрерывный кусок»Современная ОС предоставляет процессу виртуальное адресное пространство. Страницы могут иметь разные права доступа, физически размещаться в разных местах и вообще не находиться непрерывно в RAM. Схема ниже — учебная модель..
На схеме удобно искать ответ на вопрос «как долго существует объект?». Локальное имя, выделенная память и машинный код — разные части программы. Само наличие указателя ничего не говорит о времени жизни объекта: указатель можно передать из функции, а созданный через new объект продолжит существовать до освобождения (выход из функции сам по себе его не удаляет).
#include <iostream>
int global_value = 10;
int main() {
int local_value = 20;
static int persistent_value = 30;
int* dynamic_value = new int{40};
std::cout << global_value << ' ' << local_value << ' '
<< persistent_value << ' ' << *dynamic_value << '\n';
delete dynamic_value;
}#include <iostream>
int* make_value() {
int* result = new int{42};
return result;
}
int main() {
int* value = make_value();
std::cout << *value << '\n';
delete value;
}| Область | Содержимое | Когда существует |
|---|---|---|
TEXT | Машинные инструкции программы | Обычно пока образ процесса загружен |
STATIC / DATA | Глобальные объекты, статические локальные данные | Всё время выполнения программы |
STACK / AUTO | Кадры вызовов, часть локальных переменных | При выполнении блоков и вызовов |
DYNAMIC / HEAP | Объекты, созданные через new | До соответствующего освобождения |
Стек обычно обслуживает объекты с автоматической длительностью хранения; объекты со статической длительностью хранения живут до завершения программы. Это разные свойства, а не названия одного сегмента.
Стек, области видимости и вызовы
Стек вызовов (call stack) — механизм выполнения вложенных вызовов функций. Обычно организован как набор кадров, где есть локальные данные и информация для возврата к вызывающему коду.
Вложенные блоки
При входе в блок появляется локальный y, при выходе его время жизни заканчивается. Когда вызывается f(x), появляется новый кадр со своим параметром и адресом возврата Указатель стекаВо многих архитектурах специальный регистр хранит текущую вершину стека. Реальное расположение параметров и адресов возврата зависит от ABI, архитектуры и оптимизаций; компилятор не обязан класть каждую переменную в стек..
Набор данных одного вызова функции. Кадры обычно добавляются при вызове и снимаются при возврате.
#include <iostream>
void f(int a) {
std::cout << a + 1;
}
int main() {
int x = 5;
{
int y = 9;
(void)y;
f(x);
} // время жизни y закончено
}При чтении кода отмечайте входы в блоки и выходы из них. Объект внутреннего блока уже может быть уничтожен, пока внешний продолжает существовать. Визуализация показывает эту последовательность условными кадрами; конкретный компилятор может хранить значения в регистрах и не повторять рисунок буквально.
Создана локальная переменная main.
Условный стек: компилятор вправе размещать и оптимизировать локальные объекты иначе.
#include <iostream>
int main() {
int x = 5;
std::cout << x << '\n';
{
int y = 9;
std::cout << x + y << '\n';
} // время жизни y закончилось
std::cout << x << '\n';
}#include <iostream>
int add_ten(int value) {
value += 10;
return value;
}
int main() {
int x = 5;
int result = add_ten(x);
std::cout << x << ' ' << result << '\n';
}После выхода из блока объект y не существует. Даже если прежние байты не перезаписаны, разыменование указателя на него недопустимо. Освободившийся стек может использоваться снова.
Конечная рекурсия: база, шаг и возврат
Функция вызывает себя для меньшей задачи. Для sum_to(n) база — n=0, результат 0. Для n>0 результат равен n+sum_to(n−1). Контракт: целое n от 0 до 100. Аргумент уменьшается, поэтому база достигается; число промежуточных результатов ограничено.
#include <iostream>
int sum_to(int n) {
if (n == 0) return 0;
return n + sum_to(n - 1);
}
int main() { std::cout << sum_to(4) << '\n'; }| Вызов | Ждёт результат | Возвращает |
|---|---|---|
sum_to(3) | 3 + sum_to(2) | 6 |
sum_to(2) | 2 + sum_to(1) | 3 |
sum_to(1) | 1 + sum_to(0) | 1 |
sum_to(0) | База | 0 |
При раскрытии вызовов результат ещё не готов. При возврате сначала получается 0, затем 1, 3 и 6. Задание: выпишите раскрытие и возвраты для n=4 и проверьте 0/1/4. Затем напишите факториал n≤12 с результатом long long. Аварийное переполнение стека изучается отдельно и не является способом вычисления результата.
Динамическое выделение: new и delete
Динамическое выделение — создание объекта с помощью new во время выполнения программы. Возвращается указатель, а время жизни объекта не связано напрямую с областью видимости самого указателя.
new int(5)создать и инициализировать*p = 7использовать объектdelete pуничтожить и освободитьСам указатель может быть локальной переменной (например, храниться в стеке), а объект, на который он указывает, имеет динамическую длительность хранения.
new int без инициализатора оставляет значение целого неопределённым; new int(5) создаёт целое со значением 5; new int{} даёт 0. new возвращает адрес объекта, а не «первого байта»С точки зрения C++ выражение new T возвращает указатель типа T* на созданный объект T, а не нетипизированный адрес. Создание может включать вызов конструктора. Если выделение памяти не удаётся, обычный new бросает std::bad_alloc.
int main() {
int* p = new int(5);
*p = 7;
delete p;
p = nullptr;
}После new важно следить за двумя сущностями: переменной-указателем и объектом, адрес которого она хранит. delete уничтожает выделенный объект, но не присваивает указателю nullptr автоматически. Зануление делает состояние этого указателя яснее; другие копии старого адреса от этого не становятся безопасными.
Переменная p и объект *p имеют разную длительность хранения.
#include <iostream>
int main() {
int* value = new int{};
std::cout << *value << '\n';
*value = 7;
std::cout << *value << '\n';
delete value;
value = nullptr;
std::cout << std::boolalpha << (value == nullptr) << '\n';
}#include <iostream>
#include <memory>
int main() {
auto value = std::make_unique<int>(5);
std::cout << *value << '\n';
*value = 7;
std::cout << *value << '\n';
value.reset(); // освобождает объект и очищает unique_ptr
std::cout << std::boolalpha << static_cast<bool>(value) << '\n';
}delete p завершает жизнь выделенного объекта. Хранящееся в p прежнее значение нельзя использовать для доступа; безопасно занулить p = nullptr, если это полезно в данном месте.
Владение ресурсом и RAII
Важно не только получить адрес объекта, но и знать, кто обязан освободить занятый ресурс. Обычный указатель сам по себе эту обязанность не выполняет. В учебном примере с new программист должен был вызвать соответствующий delete на нужном пути исполнения. Для повседневного C++ чаще выбирают объект-владелец. Unique_ptr хранит владение одним динамическим объектом и освобождает его, когда сам прекращает существование. Make_unique создаёт такой объект и возвращает владельца. В функции ниже есть ранний return, но оба выхода разрушают локального владельца, поэтому отдельные вызовы delete не нужны. Это пример RAII: ресурс связан со временем жизни управляющего объекта. Полученный через *owner доступ не продлевает жизнь динамического объекта отдельно от владельца. Если сохранить указатель или ссылку наружу, после уничтожения владельца пользоваться ими нельзя. Для последовательности чисел обычно выбирают vector; unique_ptr здесь объясняет владение одним объектом.
#include <iostream>
#include <memory>
int readValue(bool useValue) {
auto owner = std::make_unique<int>(5);
if (!useValue) {
return 0;
}
return *owner;
}
int main() {
std::cout << readValue(false) << ' ' << readValue(true) << '\n';
}Для массива значений выбирайте vector. Наблюдающий указатель или ссылка не удерживают ресурс; после разрушения владельца доступ к цели недопустим. Сам int в простой программе обычно вообще не требует динамического выделения: здесь оно нужно только для объяснения владельца.
Динамические массивы
new[] выделяет последовательность
new int[n] создаёт массив из n элементов и возвращает указатель на первый. В пределах массива допускается арифметика указателей и доступ по индексам 0 … n−1.
Форма удаления должна соответствовать форме создания: для new[] используется delete[].
new int[n]{} обнуляет все элементы; new int[n] без инициализатора не задаёт значения элементов int.
#include <cstddef>
int main() {
std::size_t n = 5;
int* a = new int[n]{}; // все нули
a[2] = 17;
delete[] a;
}Динамический размер не отменяет границы массива. Для n элементов допустимы индексы от 0 до n − 1; указатель a + n служит границей, а не адресом ещё одного доступного элемента. После delete[] недоступен весь массив. Для прикладного кода обычно удобнее std::vector: он помнит свой размер и сам освобождает память.
Выбран элемент с корректным индексом: доступ к нему определён.
#include <cstddef>
#include <iostream>
int main() {
std::size_t n = 4;
int* values = new int[n]{};
for (std::size_t i = 0; i < n; ++i) {
values[i] = static_cast<int>(i * i);
}
for (std::size_t i = 0; i < n; ++i) {
std::cout << values[i] << (i + 1 == n ? '\n' : ' ');
}
delete[] values;
}#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> values{4, 5, 6};
values.push_back(7);
for (std::size_t i = 0; i < values.size(); ++i) {
std::cout << values[i] << (i + 1 == values.size() ? '\n' : ' ');
}
} // values освобождает свою память
| Создание | Освобождение | Статус |
|---|---|---|
new int(5) | delete p | Корректная пара |
new int[5]{{}} | delete[] a | Корректная пара |
new int[5] | delete a | UB — несовпадение форм |
new int(5) | delete[] p | UB — несовпадение форм |
Утечки памяти и ошибки освобождения
Утечка памяти (memory leak) — ситуация, когда программа оставляет выделение неосвобождённым, теряя возможность выполнить корректное освобождение. Особенно опасна в длительно работающих процессах.
Если функция создаёт объект через new и выходит, не передав владение и не вызвав delete, локальный указатель исчезает, но выделение остаётся.
Адрес сохранён, но объекта по нему больше нет. В отличие от утечки указатель здесь остался, а объект уже уничтожен.
void bad() {
int* p = new int(5);
// забыли delete p;
} // указатель p исчез
void good() {
int* p = new int(5);
delete p;
}Утечку удобно понимать как потерю владельца: объект ещё выделен, а программа уже не знает, кто и когда его освободит. Это отличается от висячего указателя, при котором адрес сохранился после уничтожения объекта. Модель накапливает условные выделения; примеры кода показывают два способа завершать каждый цикл без утечки.
Шкала показывает долю от условных 64 KiB. Каждый вызов f() выполнил new int(5), но локальный указатель уничтожился при выходе. В безопасном варианте выделения сразу освобождаются.
#include <iostream>
int read_value() {
int* value = new int{5};
int result = *value;
delete value;
return result;
}
int main() {
int total = 0;
for (int i = 0; i < 100; ++i) total += read_value();
std::cout << total << '\n';
}#include <iostream>
#include <memory>
int read_value(bool use_value) {
auto value = std::make_unique<int>(5);
if (!use_value) return 0;
return *value;
} // unique_ptr освобождает объект при любом из этих выходов
int main() {
std::cout << read_value(false) << ' ' << read_value(true) << '\n';
}| Ошибка | Что не так | Результат |
|---|---|---|
Повторный delete | Одна и та же память освобождается дважды | UB |
Неправильный указатель | Передан адрес внутри выделения либо стековой переменной | UB |
delete вместо delete[] | Несоответствие вида выделения | UB |
Нет освобождения | Выделение теряется, объект остаётся выделенным | Утечка |
delete p, q; | Из-за приоритета операторов удалится лишь p | q не будет освобождён |
Отсутствие delete само по себе не означает неопределённое поведение. Оно расходует ресурсы и может привести к исчерпанию памяти. Но неверный delete или доступ после удаления — уже UB.
Локальные статические переменные
Локальная статическая переменная — объект с блочной областью видимости, но со статической длительностью хранения. Его значение сохраняется между вызовами функции.
В обычной локальной переменной int x = 0; новое значение 0 возникает при каждом вызове. Если написать static int x = 0;, память предназначена для одного объекта на всю программу, и следующие вызовы используют тот же x.
Инициализация выполняется один раз: для постоянного нуля она может произойти ещё до первого вызова, а для динамического инициализатора — при первом прохождении объявления Когда именно инициализируется local staticКонстантная / нулевая инициализация может выполняться до первого захода. Инициализация, требующая вычислений времени выполнения, выполняется при первом прохождении объявления; начиная с C++11 она потокобезопасна. В случае исключения при инициализации при следующем заходе выполняется новая попытка..
#include <iostream>
void f() {
int x = 0;
static int count = 0;
++x;
++count;
std::cout << x << ' ' << count << '\n';
}
int main() {
f(); f(); f(); // 1 1; 1 2; 1 3
}У static внутри функции есть два независимых свойства: имя видно только в своём блоке, а объект сохраняется между вызовами. Это похоже на записную книжку функции (новый вызов видит предыдущую запись). Обычная локальная переменная, напротив, создаётся заново; её инициализатор выполняется при каждом прохождении объявления.
Для int x = 0; ++x; при каждом вызове получается 1. Для static int count = 0; ++count; значения идут 1, 2, 3, …
#include <iostream>
void visit() {
int local = 0;
static int saved = 0;
++local;
++saved;
std::cout << local << ' ' << saved << '\n';
}
int main() {
visit();
visit();
visit();
}#include <iostream>
int make_initial_value() {
std::cout << "initialize\n";
return 40;
}
int next_value() {
static int saved = make_initial_value();
return ++saved;
}
int main() {
int first = next_value();
int second = next_value();
std::cout << first << ' ' << second << '\n';
}static здесь определяет длительность хранения; const запрещает изменять объект через соответствующее имя/ссылку. Они не исключают друг друга.
Почему переполняется стек
Рекурсивный вызов создаёт новый уровень исполнения. Даже если в функции почти нет локальных переменных, системе обычно нужно помнить контекст возврата.
Исчерпание доступного пространства стека. В типичных системах проявляется аварийным завершением, но точное поведение зависит от платформы и настройки исполнения.
Ограничение часто составляет несколько мегабайт, но 8 МиБ — не универсальное требование C++. Размер зависит от ОС, потока, лимитов и настроек запуска.
#include <iostream>
int sum_to(int n) {
if (n == 0) return 0;
return n + sum_to(n - 1);
}
int main() {
std::cout << sum_to(4) << '\n';
}
// Вызов без базы f(x+1) не запускаем: он не решает конечную задачу.Расход стека зависит и от числа одновременно активных вызовов, и от данных каждого вызова (грубо: глубина × размер кадра). Ползунки позволяют менять оба множителя без настоящего аварийного завершения. В примерах ниже одна задача решена рекурсией с условием выхода и циклом: сравните, какая информация должна сохраняться между шагами.
#include <iostream>
int sum_to(int n) {
if (n <= 0) return 0;
return n + sum_to(n - 1);
}
int main() {
std::cout << sum_to(4) << '\n';
}#include <iostream>
int sum_to(int n) {
int total = 0;
for (int value = 1; value <= n; ++value) {
total += value;
}
return total;
}
int main() {
std::cout << sum_to(4) << '\n';
}Оптимизация хвостовых вызовов, встраивание и другие преобразования могут изменить реальную глубину стека. Для эксперимента с переполнением нельзя полагаться на конкретное число вызовов.
Дополнительно⌄
Четыре длительности хранения, а не три сегмента
Стандарт C++ различает automatic, static, dynamic, а также thread storage duration для thread_local. Слова «стек», «куча» и «data» описывают распространённые механизмы реализации и раскладку исполняемого файла. Например, компилятор может хранить локальное число в регистре, вообще не выделяя для него слот в стеке.
Реальные секции образа процесса
Реализации обычно различают .text, .rodata, .data, .bss и другие секции. Стек каждого потока и динамические отображения могут находиться далеко друг от друга. Размер стека не зафиксирован стандартом и обычно индивидуален для потока.
Откуда new получает память?
Выражение new вызывает функцию выделения памяти (operator new), которая часто пользуется библиотечным аллокатором и лишь при необходимости запрашивает новые страницы у ОС. Каждый new не обязан делать отдельный системный вызов. Аналогично, delete не гарантирует немедленный возврат страниц ОС.
Сборщик мусора и владение ресурсами
В C++ по умолчанию нет обязательного сборщика мусора для new-объектов. Но современный стиль почти никогда не требует вручную писать new/delete для прикладного кода: std::vector хранит массив, std::unique_ptr владеет единичным объектом, а RAII связывает освобождение с деструктором владельца.
#include <memory>
#include <vector>
void example() {
std::vector<int> a(1000); // сам освободит массив
auto p = std::make_unique<int>(5);
*p = 7;
} // автоматическое освобождениеПоиск ошибок
При запуске с AddressSanitizer часто обнаруживаются use-after-free, buffer overflow и некоторые утечки. Пример:
# g++ -std=c++20 -g -O1 -Wall -Wextra \
# -fsanitize=address,undefined \
# demo.cpp -o demo
# ./demoДиагностика зависит от компилятора, ОС и конкретной ошибки; ни один санитайзер не доказывает полное отсутствие UB.
Термины
Длительность хранения
Категория времени, в течение которого предоставлено хранилище объекта: автоматическая, статическая, динамическая, а также thread-local.
Примеры кода
Конечная рекурсия с проверенным контрактом
#include <iostream>
int sum_to(int n) {
if (n == 0) return 0;
return n + sum_to(n - 1);
}
int main() {
int n = 0;
if (!(std::cin >> n) || n < 0 || n > 100) {
std::cerr << "Expected 0..100\n";
return 1;
}
std::cout << sum_to(n) << '\n';
}Владелец освобождает int при обоих выходах из функции.
#include <iostream>
#include <memory>
int readValue(bool useValue) {
auto owner = std::make_unique<int>(5);
if (!useValue) {
return 0;
}
return *owner;
}
int main() {
std::cout << readValue(false) << ' ' << readValue(true) << '\n';
}Обсудить лекцию с ИИ
Скопируйте промпт в свой ИИ-чат. В него уже включены материал этой лекции и примеры кода — можно сразу задавать вопросы и тренироваться.