Стек и куча

Как работает программа: где лежат переменные, что происходит при вызове функции, почему рекурсия падает, как устроен malloc и почему в Go, Java и Python одно и то же значение может стоить ноль наносекунд или сотню.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Адресное пространство процесса

Каждый процесс видит одну и ту же картину: внизу код и глобальные данные из исполняемого файла, над ними растёт куча, наверху — стек главного потока, между ними — библиотеки и большие блоки через mmap.

Нажмите на строку программы или на область карты. Адреса настоящие по форме, но с ASLR меняются при каждом запуске. Свою карту видно в cat /proc/<pid>/maps. Почему адреса виртуальные — в презентации про виртуальную память.

Проверьте себя

Что сделает эта программа на Linux, собранная обычным gcc?

int main(void) {
    char *s = "hello";
    s[0] = 'H';
    puts(s);
}
Литерал "hello" лежит в .rodata, а эта область отображена только на чтение (r--p в /proc/pid/maps). Запись в неё — аппаратное исключение и сигнал SIGSEGV. Компилятор пропускает код, потому что тип литерала в C — char[], а не const char[]. Нужна изменяемая копия — пишите char s[] = "hello";: тогда массив окажется на стеке.

Стек вызовов

  • Каждый вызов кладёт на стек кадр: адрес возврата, сохранённый указатель кадра вызывающего, локальные переменные.
  • Стек растёт вниз: регистр rsp уменьшается при вызове и возвращается при выходе. Освободить кадр — значит просто сдвинуть указатель.
  • Кадры строго вложены: выйти можно только из самого нижнего. Поэтому стек — идеальное место для данных, живущих не дольше функции.

Нажимайте «Шаг»: вызовы sum_sq и рекурсивный fact. Обратите внимание, что второй вызов sq занимает те же адреса, что и первый.

Соглашение о вызовах

  • x86-64 System V (Linux, macOS): первые шесть целых аргументов — в rdi, rsi, rdx, rcx, r8, r9, вещественные — в xmm0–7, результат — в rax. В стек идут только остальные. На ARM64 — x0–x7.
  • call кладёт адрес возврата, ret снимает его и прыгает туда.
  • Место под все локальные выделяет одна инструкция sub rsp, N. Вот почему локальные переменные «бесплатны»: их цена не зависит от количества.

Пройдите по инструкциям и следите за rsp, rbp и ячейками стека. Свой код так же смотрят в objdump -d или на godbolt.org.

Переполнение стека

  • Стек конечен. В C ниже стека стоит защитная страница без прав доступа: дошли до неё — SIGSEGV.
  • Рантаймы проверяют глубину сами и бросают исключение: StackOverflowError, RangeError. Python считает не байты, а кадры: sys.getrecursionlimit() = 1000.
  • У горутины стек растущий, до 1 ГБ. Бесконечная рекурсия в Go — это фатальная ошибка, а не panic, её не поймать через recover.

Выберите рантайм и двигайте глубину рекурсии. Размер кадра зависит от числа локальных переменных — увеличьте его.

Стек у каждого потока

  • Поток ОС получает стек фиксированного размера: в Linux по умолчанию 8 МБ (ulimit -s), в JVM — 1 МБ (-Xss). Это резерв адресов: физическая память выделяется только под затронутые страницы.
  • Горутина начинает с 2 КБ. Когда места не хватает, рантайм выделяет стек вдвое больше и копирует туда старый, поправляя указатели.
  • Поэтому миллион горутин — нормально, а миллион потоков — нет: дело не только в памяти, но и в лимитах ядра. Подробнее — в презентации про процессы и потоки.

Проверьте себя

Java-сервис держит 2000 потоков с -Xss1m. Большинство из них ждут на сокете, глубина стека — пара десятков кадров. Сколько физической памяти занимают их стеки?

-Xss резервирует адреса, а физическая страница появляется только при первом обращении. Неглубокий стек затрагивает несколько страниц по 4 КБ — 2000 потоков дадут порядка 20–60 МБ RSS. Виртуальная память (VSZ в top) при этом покажет 2 ГБ, и это не повод для паники. Смотрите RSS или -XX:NativeMemoryTracking=summary.

Указатель на мёртвый кадр

После выхода из функции её кадр никто не стирает: байты остаются на месте, пока следующий вызов не займёт те же адреса. Указатель на локальную переменную продолжает «работать» — до поры.

Пройдите программу по шагам в C, а потом в Go. Современный GCC с -O2 вообще подменяет такой адрес на NULL и выдаёт предупреждение. Rust подобный код не скомпилирует.

Переполнение буфера на стеке

  • Массив на стеке лежит рядом с адресом возврата. Запись за его конец без проверки длины портит соседние данные, и ret уходит по мусорному адресу.
  • Это классическая уязвимость, поэтому защита стоит в несколько слоёв: канарейка (-fstack-protector-strong) — случайное значение перед адресом возврата, проверяется при выходе; NX — стек нельзя исполнять; ASLR — адреса случайны при каждом запуске; теневой стек (Intel CET) хранит копию адресов возврата.
  • В Go, Java, Python и JS выход за границы массива — исключение или panic, а не порча памяти.

Стек или куча

Стек

  • Выделение и освобождение — сдвиг rsp, одна инструкция на всю функциюничего не ищет, не блокирует, не фрагментируется
  • Вершина стека почти всегда в кэше L1
  • Время жизни — строго до выхода из функцииразмер обычно должен быть известен при компиляции
  • Мал: килобайты–мегабайты на поток

Куча

  • Произвольное время жизни и размер: объект можно вернуть, передать другому потоку, положить в кэш
  • malloc ищет подходящий блок: 20–100 нс в быстром пути, микросекунды, если нужен системный вызов
  • Кто-то должен освободить: программист (free), подсчёт ссылок или сборщик мусора
  • Объекты разбросаны по памяти — больше промахов кэша

Как работает malloc

  • Куча — большой кусок памяти, полученный у ОС через brk или mmap. Аллокатор режет его на блоки, у каждого — заголовок с размером и флагом «занят».
  • Свободные блоки связаны в список. First fit берёт первый подходящий и отрезает от него нужное.
  • free возвращает блок в список и склеивает его со свободными соседями. Без склейки память дробится на крошки.

Выделяйте и освобождайте блоки. Потом нажмите «Сценарий»: много мелких блоков, каждый второй освобождён — свободно половина кучи, а 200 байт уже не выделить.

Классы размеров и кэши потоков

  • Список свободных блоков — медленно и плохо для многопоточности. Современные аллокаторы (tcmalloc, jemalloc, mimalloc, рантайм Go) округляют размер до класса и держат для каждого класса отдельные страницы с блоками одного размера.
  • У каждого потока свой кэш таких блоков — быстрый путь без блокировок. В Go это mcache на каждом P, в glibc — tcache.
  • Большие блоки идут сразу к ОС через mmap — это системный вызов и обнуление страниц.

Двигайте размер запроса и смотрите, куда он попадёт и сколько байт пропадёт на округлении.

Значения и ссылки

Массив значений — один непрерывный кусок памяти. Массив ссылок — массив указателей плюс отдельный объект в куче на каждый элемент: больше выделений, заголовки объектов, прыжки по памяти при обходе.

Сравните миллион точек (x, y) в разных языках. В Java объекты с заголовком 12 байт, в Python каждое число — отдельный объект. Для чисел берите array/NumPy, в JS — Float64Array.

Escape-анализ

  • В Go вы не выбираете между стеком и кучей — выбирает компилятор. Если значение может пережить функцию («убегает»), оно уходит в кучу. Иначе — на стек, даже если создано через new или &T{}.
  • Решения показывает go build -gcflags=-m.
  • JIT в JVM делает то же для горячего кода: объект, который не покидает метод, разбирается на поля в регистрах (scalar replacement).

Угадайте для каждого фрагмента, где окажется значение.

Цена роста

  • Динамический массив при переполнении выделяет новый, больший, и копирует всё старое. Старый становится мусором.
  • Go удваивает ёмкость до 256 элементов, потом растёт примерно в 1,25 раза и округляет до класса размера. Python добавляет около ⅛.
  • Знаете размер — выделите сразу: make([]T, 0, n), new ArrayList<>(n), strings.Builder.Grow.

Двигайте число элементов и включите предварительное выделение.

Проверьте себя

Программа на C выделила 2 ГБ объектов по 100 байт, а потом освободила 95% из них вперемешку. Что покажет RSS процесса в top?

Мелкие блоки остаются в списках аллокатора для повторного использования. Вернуть ОС можно только целые страницы, а уцелевшие 5% объектов разбросаны по всем страницам — почти каждая страница всё ещё занята. Это фрагментация. Помогают malloc_trim, jemalloc и mimalloc, которые активнее возвращают память, и арены или пулы для объектов с общим временем жизни. Языки со сборкой мусора решают это уплотнением — об этом в презентации про управление памятью.

Как уменьшить выделения

  • Заранее выделяйте ёмкость и переиспользуйте буферы между запросами: buf = buf[:0], sync.Pool, пулы объектов.
  • Храните значения, а не указатели: []T вместо []*T, массивы примитивов вместо коллекций обёрток (int[], а не List<Integer>).
  • Не упаковывайте лишний раз: в Go передача в interface{} (и fmt.Sprintf) часто означает выделение, в Java — автоупаковка int → Integer.
  • Python: __slots__ убирает словарь у каждого объекта, генераторы не создают промежуточных списков, числа держите в NumPy.
  • Сначала измерьте: оптимизировать стоит только горячий путь.
// было: новый буфер на каждый запрос
func handle(w io.Writer, r *Req) {
    buf := make([]byte, 0, 4096) // уходит в кучу
    buf = r.AppendTo(buf)
    w.Write(buf)
}

// стало: буферы переиспользуются
var bufPool = sync.Pool{New: func() any {
    b := make([]byte, 0, 4096)
    return &b
}}

func handle(w io.Writer, r *Req) {
    bp := bufPool.Get().(*[]byte)
    buf := r.AppendTo((*bp)[:0])
    w.Write(buf)
    *bp = buf
    bufPool.Put(bp)
}

Как это увидеть у себя

  • Карта памяти: /proc/<pid>/maps, pmap -x; размер стека — ulimit -s.
  • Go: -gcflags=-m, go test -benchmem (allocs/op), профиль alloc_space в pprof.
  • Python: tracemalloc — какие строки кода выделяют больше всего.
  • Node и браузер: снимок кучи и Allocation sampling в Chrome DevTools (node --inspect).
  • Java: JFR (jdk.ObjectAllocationSample), async-profiler -e alloc.
  • C/C++: -fsanitize=address ловит выход за границы и висячие указатели, heaptrack и valgrind показывают выделения.
$ go build -gcflags=-m ./...
./user.go:12:9: &User{...} escapes to heap
./user.go:20:13: ... argument does not escape

$ go test -bench=Parse -benchmem
BenchmarkParse-8  1854213  642 ns/op
                 320 B/op  6 allocs/op

$ go test -bench=Parse -memprofile=mem.out
$ go tool pprof -sample_index=alloc_space mem.out
import tracemalloc
tracemalloc.start()
run()
snap = tracemalloc.take_snapshot()
for s in snap.statistics("lineno")[:5]:
    print(s)   # файл:строка, байты, число блоков

Главное

  • Процесс — это код и глобальные данные из файла, куча, отображённые библиотеки и стек на каждый поток.
  • Стек — кадры вызовов. Выделение на нём бесплатно, но живёт только до выхода из функции и ограничено по размеру.
  • Аргументы идут в регистрах, адрес возврата — на стеке рядом с локальными массивами. Отсюда переполнения и слои защиты.
  • Куча — аллокатор с классами размеров и кэшами потоков. Это быстро, но не бесплатно, и память дробится.
  • Значения дешевле ссылок. В Go и JVM компилятор сам переносит неубегающие объекты на стек.
  • Меряйте выделения профилировщиком, прежде чем оптимизировать. Кто убирает мусор из кучи — в следующей презентации.

Самое быстрое выделение памяти — то, которого не было.