Память в C++: стек, куча и выравнивание на практике
Где живут ваши объекты, почему new дороже автоматической переменной и как выравнивание съедает память в структурах. Разбираем на конкретных размерах и замерах.

Структура из char, double и char занимает 24 байта вместо ожидаемых 10. Функция с рекурсией на 100 тысяч уровней падает с Segmentation fault, хотя памяти в системе гигабайты. Оба случая — про то, как C++ на самом деле раскладывает данные. Разбираем стек, кучу и выравнивание не в терминах стандарта, а в терминах того, что вы увидите в отладчике и в sizeof. Примеры собраны и проверены на GCC 13 и Clang 17, стандарт C++17, x86-64 Linux.
Стек: быстро, автоматически, ограниченно
Локальные переменные и параметры функций живут на стеке. Выделение — это сдвиг указателя стека на нужное число байт, освобождение — обратный сдвиг при выходе из области видимости. Никакого поиска свободного блока, никакой фрагментации.
void process() {
int counter = 0; // на стеке
double values[128]; // 1024 байта на стеке
// при выходе из функции всё исчезает автоматически
}
Плата за скорость — размер. Стек потока по умолчанию на Linux обычно 8 МБ (проверьте ulimit -s), в потоках, созданных вручную, часто меньше. Массив на миллион double — это 8 МБ, и одна такая локальная переменная кладёт поток целиком.
Правило простое: если размер известен на этапе компиляции и невелик — держите данные на стеке. Как только размер зависит от ввода или измеряется мегабайтами — это куча.
Где стек ломается первым
Глубокая рекурсия. Каждый вызов — новый кадр со своими локальными переменными и адресом возврата. Рекурсия без явного ограничения глубины на данных пользователя — это stack overflow, который на большинстве систем проявится как Segmentation fault без внятного сообщения. Такую рекурсию либо переписывают в цикл, либо переносят состояние в явный контейнер на куче.
Куча: гибко, но за деньги
Динамическая память живёт в куче и управляется вручную через new/delete или через контейнеры и умные указатели, которые делают это за вас. Выделение — обращение к аллокатору, который ищет подходящий свободный блок. Это в разы дороже сдвига указателя стека.
#include <vector>
#include <memory>
void dynamic_data(std::size_t n) {
// размер известен только в рантайме — на куче
std::vector<double> buffer(n);
// один объект в куче под управлением умного указателя
auto node = std::make_unique<int>(42);
} // и vector, и unique_ptr освободят память в деструкторе
Голый new без парного delete — это утечка. Именно поэтому в современном C++ ручные new/delete почти не пишут: за временем жизни следят std::vector, std::string, std::unique_ptr и std::shared_ptr. Они кладут данные в кучу, но освобождают их детерминированно, при разрушении владельца.
Цена аллокации в цифрах
Точные числа зависят от аллокатора и системы, поэтому мерьте у себя, а не верьте round-числам из чужих статей. Метод замера: в цикле на несколько миллионов итераций сравните new int/delete с той же логикой на стековой переменной, оборачивая замер в std::chrono::steady_clock. На типичной x86-64 машине разрыв обычно на один-два порядка. Практический вывод от этого не зависит: миллион мелких аллокаций в горячем цикле — это узкое место, которое видно в профайлере.
Выравнивание: почему sizeof больше суммы полей
Процессор читает память не побайтово, а блоками. Чтобы доступ был эффективным, каждый тип должен лежать по адресу, кратному его выравниванию (alignment). Для double на x86-64 это обычно 8 байт, для int — 4, для char — 1. Компилятор вставляет между полями структуры невидимые байты-заполнители (padding), чтобы соблюсти это правило.
#include <cstdio>
struct Bad {
char a; // 1 байт
// 7 байт padding до выравнивания double
double b; // 8 байт
char c; // 1 байт
// 7 байт padding до конца, чтобы sizeof был кратен 8
}; // sizeof(Bad) == 24
struct Good {
double b; // 8 байт
char a; // 1 байт
char c; // 1 байт
// 6 байт padding до кратности 8
}; // sizeof(Good) == 16
int main() {
std::printf("Bad=%zu Good=%zu\n", sizeof(Bad), sizeof(Good));
// выведет: Bad=24 Good=16
}
Одна и та же тройка полей — 24 или 16 байт в зависимости от порядка. На массиве в миллион элементов это разница в 8 МБ и в числе кеш-промахов. Правило перестановки: располагайте поля от большего выравнивания к меньшему.
Как узнать выравнивание и задать своё
Оператор alignof(T) возвращает требуемое выравнивание типа, alignas(N) задаёт его явно. Это нужно, когда данные читаются SIMD-инструкциями или лежат на границе кеш-линии.
#include <cstddef>
// выровнять по границе кеш-линии (обычно 64 байта),
// чтобы избежать false sharing между потоками
struct alignas(64) CacheLinePadded {
std::atomic<long> counter;
};
static_assert(alignof(double) == 8); // проверка на этапе компиляции
False sharing — отдельная большая тема про многопоточность, здесь важно только то, что alignas позволяет развести данные разных потоков по разным кеш-линиям.
Стек против кучи: когда что
| Критерий | Стек | Куча |
|---|---|---|
| Скорость выделения | сдвиг указателя, почти бесплатно | поиск блока в аллокаторе, дороже на порядки |
| Размер | ограничен (часто 8 МБ на поток) | ограничен доступной памятью процесса |
| Время жизни | до конца области видимости | до явного освобождения или разрушения владельца |
| Размер известен на компиляции | обязательно | не обязательно |
| Риск утечки | нет | есть при ручном new без delete |
Критерий выбора без «зависит от задачи»: маленький объект с известным на компиляции размером — стек. Большой объём, размер из рантайма или объект должен пережить функцию — куча, желательно через контейнер или умный указатель.
Типичные ошибки
- Возврат указателя на локальную переменную.
int* f() { int x = 5; return &x; }—xуничтожен при выходе, указатель висячий. Компилятор с-Wallпредупредит, но не всегда. - Огромный массив на стеке.
double buf[2'000'000]внутри функции — 16 МБ, переполнение стека без внятной ошибки. - Ручной new без delete. Утечка, которую поймает Valgrind или AddressSanitizer, но не компилятор.
- Расчёт на «плотную» структуру. Код, который считает, что
sizeof(struct)равен сумме полей — например, при чтении бинарного формата, — сломается из-за padding. Для точной раскладки используют#pragma packили сериализацию поле за полем.
Что делать дальше
Соберите проект с -fsanitize=address — AddressSanitizer ловит висячие указатели, выход за границы и утечки в рантайме почти бесплатно на этапе тестов. Для профиля памяти под нагрузкой смотрите Valgrind Massif или heaptrack. Разложите горячие структуры полями от большего выравнивания к меньшему и проверьте sizeof до и после. Следующая тема для изучения — кастомные аллокаторы и std::pmr из C++17: они убирают цену частых мелких аллокаций, перенося блоки на заранее выделенный буфер.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: cppreference: alignof, alignas и объектная модель памяти, cppreference: std::make_unique и умные указатели
Частые вопросы
У меня другой компилятор и платформа — размеры структур совпадут?
Как понять, что программа падает именно из-за переполнения стека?
Если я не буду вручную выравнивать структуры — будут проблемы в проде?
make_unique и vector кладут данные в стек или в кучу?
Стоит ли вообще использовать голые new и delete?
Как замерить, действительно ли аллокации тормозят мой код?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.