Главная › Блог › Память JVM: heap, stack и за что отвечает каждая область

Память JVM: heap, stack и за что отвечает каждая область

Зачем это спрашивают

Вопрос «расскажи про устройство памяти JVM» — это не про зазубренную схему из учебника. Это проверка, понимаешь ли ты, почему StackOverflowError и OutOfMemoryError — разные ошибки из разных областей памяти, почему утечка возможна в языке со сборщиком мусора, и как размер стека влияет на глубину рекурсии. Если путаешь heap и stack — дальше про GC и многопоточность разговаривать бессмысленно.

Основные области памяти

JVM делит память на несколько зон, каждая со своим назначением и жизненным циклом.

  • Heap — куча, общая для всех потоков. Здесь живут все объекты и массивы, создаваемые через new. Делится на молодое (Young, с Eden и двумя Survivor-зонами) и старое (Old/Tenured) поколения. Управляется GC.
  • Stack — у каждого потока свой стек вызовов. Хранит фреймы методов: локальные переменные примитивных типов, ссылки на объекты в куче, адрес возврата. Удаляется автоматически при выходе из метода, GC сюда не заходит.
  • Metaspace (до Java 8 — PermGen) — метаданные классов: байткод методов, пул констант, информация о структуре классов. Растёт при динамической генерации классов (проксирование, CGLIB).
  • PC Register — указатель на текущую выполняемую инструкцию в потоке.
  • Native memory / direct buffers — память вне управления GC: JNI, ByteBuffer.allocateDirect, память самой JVM под структуры.
Heap — это общий склад компании, куда все отделы кладут товар, и кладовщик (GC) периодически обходит полки и выбрасывает то, на что больше нет заявок. Stack — личный рабочий стол каждого сотрудника: как только он закончил задачу (вышел из метода), стол сам освобождается, никто не убирает специально.

Что именно лежит в стеке

Частая путаница: «примитивы в стеке, объекты в куче». Это правда лишь отчасти. В стеке лежат локальные переменные метода — и если это примитив (int, boolean), в стеке хранится само значение. Если это ссылка на объект, в стеке хранится сама ссылка (адрес), а объект — в куче.

void method() {
    int x = 5;            // значение 5 — в стеке фрейма
    String s = new String("hi"); // ссылка s — в стеке, объект String — в куче
}

Поля объектов (instance-переменные) всегда лежат в куче вместе с объектом, независимо от того, примитив это или ссылка.

Ловушка: «static переменные хранятся в стеке». Нет — статические поля класса живут в heap (в Java 8+ метаданные класса переехали в Metaspace, но сами значения static-полей — в куче, как часть Class-объекта).

StackOverflowError vs OutOfMemoryError

Это классический вопрос на понимание разделения областей.

  • StackOverflowError — переполнение стека потока. Типичная причина — бесконечная или слишком глубокая рекурсия. Размер стека фиксирован на поток (настраивается через -Xss), и каждый вызов метода добавляет фрейм.
  • OutOfMemoryError: Java heap space — куча заполнена, GC не смог освободить достаточно памяти. Причины: утечка памяти (объекты держатся ссылками дольше, чем нужно), или просто данных больше, чем выделено (-Xmx).
  • OutOfMemoryError: Metaspace — переполнены метаданные классов. Частая причина в проде — динамическая генерация классов без выгрузки (например, частые перезагрузки контекста Spring с CGLIB-проксями в тестах или при хот-деплое).

Как возникает утечка памяти в managed-языке

GC удаляет только недостижимые объекты. Если на объект остаётся живая ссылка — пусть даже забытая — GC его не тронет. Классические источники: статические коллекции, в которые только добавляют и никогда не чистят; незакрытые слушатели событий; ThreadLocal, не вычищенный после завершения задачи в пуле потоков.

private static final List<byte[]> cache = new ArrayList<>();

void leak() {
    cache.add(new byte[1024 * 1024]); // никто не удаляет — растёт вечно
}
Что произойдёт, если в двух потоках объект передаётся по ссылке — где физически лежат данные?
Объект всегда один экземпляр в куче. Каждый поток хранит в своём стеке собственную копию ссылки на этот объект, но указывают они на одни и те же данные в heap — отсюда и нужна синхронизация при одновременном доступе.
Можно ли увеличить размер стека потока и для чего это нужно?
Да, через -Xss глобально или передав размер в конструктор Thread. Нужно для глубокой рекурсии или парсеров со вложенными структурами, где глубина вызовов превышает дефолтные ~512 КБ–1 МБ.
Почему direct ByteBuffer не в heap и чем это опасно?
Direct-буферы выделяются в native-памяти через Unsafe/malloc, минуя heap и GC-паузы на копирование. Опасность — GC не видит эту память напрямую и может не успеть освободить native-ресурс через Cleaner, что приводит к OOM за пределами heap, невидимому в обычных дампах кучи.

Ещё разборы