сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · JVM и сборка мусора

Модель памяти JVM

Память JVM: где живут объекты и почему контейнер убивает процесс

Запустил процесс с -Xmx128m, набил сто direct-буферов по мегабайту и поднял 300 потоков. Занято в куче - 1 МиБ. RSS процесса - 168 МиБ. Контейнер с лимитом 192 МиБ прибьёт такой процесс, а в снимке кучи будет пусто и ни одной зацепки.

Отсюда растёт вся тема. Спрашивают про стек и кучу, а проверяют, понимаешь ли ты, что -Xmx ограничивает одну зону из нескольких, что OutOfMemoryError бывает четырёх разных сортов и лечатся они по-разному.

// Формулировки: «стек против кучи?», «какие бывают OutOfMemoryError?», «что такое Metaspace?», «почему под с Java падает по OOMKilled при живой куче?».

Стек: свой у каждого потока и меньше, чем кажется

Бесконечная рекурсия свалилась на глубине 17533 вызовов. Добавил тому же методу пять аргументов - три long, double и ссылку - и та же рекурсия умерла на 5895. Поставил -Xss256k: 1782 и 980 соответственно.

Считаем вслух. По умолчанию поток на Linux x64 получает 1 МиБ стека (флаг ThreadStackSize=1024, в килобайтах). Миллион байт на 17533 фрейма - около 60 байт на пустой фрейм. Фрейм с пятью аргументами занял втрое больше, потому что аргументы и локальные переменные лежат прямо в нём. Поэтому «на какой глубине упадёт рекурсия» - вопрос без универсального ответа: считай размер фрейма, а не вызовы.

В стеке лежат фреймы вызовов, а в них локальные примитивы и ССЫЛКИ. Объект от new всегда уходит в кучу, на стеке остаётся ссылка. Кончился стек - StackOverflowError, причём с пустым message: тексту уже негде разворачиваться.

static int depth = 0;
static void rec() { depth++; rec(); }

// пустой фрейм:          глубина 17533, message = null
// фрейм с 5 аргументами: глубина 5895
// то же с -Xss256k:      1782 и 980
-Xss
размер стека потока, по умолчанию 1 МиБ
StackOverflowError
кончился стек потока, message пустой

Куча: почему new стоит дешевле, чем ты думаешь

Прогнал 20 миллионов аллокаций по 100 байт - 279 мс. Выключил флагом -XX:-UseTLAB, повторил - 1556 мс. Сборок мусора при этом было поровну: 387 и 385.

Замедление в 5,6 раза дала не уборка, а сама выдача памяти. С TLAB (thread-local allocation buffer) поток забирает себе кусок eden и раздаёт из него объекты сдвигом указателя, без всякой синхронизации - несколько машинных инструкций на аллокацию. Без TLAB каждый new лезет к общему указателю через атомарную операцию, и потоки дерутся за одну кэш-линию.

Куча поколенческая, из наблюдения «большинство объектов умирает молодыми». Новые рождаются в eden, пережившие сборку копируются в survivor, зрелые переезжают в old - это называется promotion. Механика тут важнее названий: молодая сборка копирует ВЫЖИВШИХ и объявляет остальное свободным, мёртвых она не трогает вообще.

TLAB
потоковый буфер аллокации: new без синхронизации
eden / survivor / old
зоны поколений: рождение, отсев, долгожители

Четыре разных OutOfMemoryError

Набивал -Xmx64m мегабайтными массивами: на 58-м прилетело OutOfMemoryError: Java heap space. Грузил классы при -XX:MaxMetaspaceSize=5m - получил OutOfMemoryError: Metaspace. А при -Xmx128m аллокация direct-буферов упала на Cannot reserve 1048576 bytes of direct buffer memory (allocated: 128983040, limit: 129761280).

Посмотри на числа в третьей ошибке: лимит direct-памяти вышел 129761280 байт, то есть ровно заданные 128 мегабайт. По умолчанию MaxDirectMemorySize равен максимуму кучи, и процесс имеет право занять примерно вдвое больше -Xmx - две половины считаются раздельно. Четвёртый сорт - «unable to create native thread»: упёрлись в лимит потоков операционной системы, а память тут ни при чём.

// Metaspace - память под метаданные классов, и живёт она в нативной памяти: так называют память, которую процесс берёт у операционной системы напрямую, минуя кучу, поэтому сборщик её не убирает и -Xmx её не ограничивает. С Java 8 она пришла на смену PermGen и по умолчанию идёт без потолка: MaxMetaspaceSize равен 18446744073709551615. Растёт с числом загруженных классов, поэтому горячий редеплой и генерация прокси раздувают её тихо.

Metaspace
нативная память под метаданные классов, по умолчанию без потолка
MaxDirectMemorySize
лимит direct-буферов, по умолчанию равен -Xmx

Контейнер: сколько JVM возьмёт без спроса

Запустил java в контейнере с лимитом 1600 МиБ и распечатал итоговые флаги: MaxHeapSize=419430400, InitialHeapSize=27262976. Это 400 МиБ и 26 МиБ.

Проверяем: 400 из 1600 - ровно четверть, и действительно MaxRAMPercentage по умолчанию 25.000000. Стартовая куча - 1.562500 процента лимита, одна шестьдесят четвёртая. Считает JVM от лимита контейнера, а не от памяти всего сервера: лимит записан в cgroups - это механизм ядра Linux, которым Docker ограничивает процессу память и процессор, и JVM умеет его читать. Поэтому в контейнере удобнее задавать -XX:MaxRAMPercentage: переедешь на под пожирнее, и куча вырастет сама.

Ловушка, на которой горят: поставить -Xmx равным лимиту контейнера. Мой замер по шагам - старт процесса 36 МиБ RSS, после ста direct-буферов 140 МиБ при занятой куче в 1 МиБ, плюс 300 потоков 168 МиБ. Вне -Xmx живут Metaspace, стеки потоков, direct-буферы, скомпилированный код и сам рантайм. Оставь им запас, иначе OOMKilled придёт при пустой куче.

MaxRAMPercentage
куча как процент от лимита контейнера, по умолчанию 25
RSS
resident set size, реально занятая процессом память

Как отвечать: «Чем стек отличается от кучи и что где хранится?»

Стек свой у каждого потока: фреймы вызовов, в них локальные примитивы и ссылки. По умолчанию это мегабайт, и глубина рекурсии зависит от размера фрейма - у меня пустая рекурсия дошла до 17533 вызовов, а с пятью аргументами только до 5895. Куча общая, в ней лежат все объекты от new, её убирает сборщик. Куча поколенческая: новые объекты рождаются в eden через TLAB, то есть сдвигом указателя без синхронизации - когда я выключил TLAB, те же аллокации замедлились в пять с половиной раз. Пережившие сборку едут в survivor, потом в old. Отдельно от кучи живут Metaspace под метаданные классов, стеки потоков и direct-буферы, и для контейнеров это ключевое: -Xmx ограничивает только кучу, а процесс занимает заметно больше. Отсюда и разные падения: StackOverflowError, OutOfMemoryError по куче, по Metaspace и по direct-буферам.

Почему это сильный ответ: разведены владение и содержимое, названа механика аллокации вместо общего «куча общая», и сделан выход на прод - почему потребление процесса не равно -Xmx.

На чём валят

  • «Маленькие объекты живут на стеке». На стеке лежат ссылки; аллокацию может убрать escape analysis, но это работа JIT (just-in-time, компиляция на лету), а не правило языка.
  • -Xmx равен лимиту контейнера. В моём замере куча занимала 1 МиБ при RSS 168 МиБ: Metaspace, стеки и direct-буферы не поместились - OOMKilled.
  • «OutOfMemoryError один». Их четыре сорта, и лечатся они по-разному: куча, Metaspace, direct-буферы, потоки ОС. Читай хвост сообщения.
  • Считать direct-буферы частью -Xmx. У них отдельный лимит, по умолчанию равный -Xmx, то есть суммарно вдвое больше.
  • Ловить OutOfMemoryError и продолжать работу. Свободной памяти по-прежнему нет, а часть структур уже недостроена; правильный ход - падать и рестартовать.

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

Пять вопросов из банка по этой подтеме. Всего их 15, остальные разбираются в тренажёре.

  1. #memory_model1 / 5
    Что такое metaspace (Java 8+) и чем он отличается от PermGen?
    A)Это новое имя молодого поколения кучи, куда попадают все свежесозданные объекты приложения
    B)Область метаданных классов в нативной памяти, заменившая PermGen
    C)Это стек потоков, вынесенный за пределы кучи для ускорения вызовов методов в новых версиях Java
    D)Это кэш скомпилированного JIT-кода, который в Java 8 объединили с пулом строковых литералов
    показать ответ и разбор
    +B)Область метаданных классов в нативной памяти, заменившая PermGen

    // разбор: Metaspace (с Java 8) хранит МЕТАДАННЫЕ классов (структура классов, методы) и располагается в НАТИВНОЙ памяти, а не в куче. Он заменил PermGen, который жил в куче с фиксированным лимитом и часто переполнялся (OutOfMemoryError: PermGen). Metaspace по умолчанию растёт автоматически (ограничивается -XX:MaxMetaspaceSize). Утечка metaspace обычно означает утечку загрузчиков классов (например, при бесконечной перезагрузке классов).

  2. #memory_model2 / 5
    Как Java передаёт аргументы в методы — по значению или по ссылке?
    A)Примитивы по значению, а объекты по ссылке — метод может переприсвоить переданную переменную снаружи
    B)Всё по ссылке: метод получает прямой доступ к переменной вызывающего и может её переприсвоить
    C)Всегда по значению; для объектов копируется значение ссылки
    D)Зависит от модификатора final: без него — по ссылке, с ним — по значению копией переменной
    показать ответ и разбор
    +C)Всегда по значению; для объектов копируется значение ссылки

    // разбор: Java ВСЕГДА передаёт по значению. Для примитива копируется его значение. Для объекта копируется ЗНАЧЕНИЕ ССЫЛКИ (адрес) — метод и вызывающий указывают на один объект, поэтому изменения ЕГО полей видны обоим. Но переприсваивание параметра внутри метода (param = new ...) не влияет на переменную снаружи — менялась копия ссылки. Это частый источник путаницы «Java передаёт объекты по ссылке» — нет, по значению ссылки.

  3. #memory_model3 / 5
    Что такое escape analysis и что она даёт?
    A)Проверка, не вышел ли поток за пределы своего стека, чтобы вовремя бросить StackOverflowError
    B)Механизм экранирования спецсимволов в строках перед их размещением в пуле строковых литералов
    C)Поиск объектов, которые не будут собраны сборщиком мусора, чтобы удалить их принудительно
    D)Анализ JIT: не «убегает» ли объект за метод — если нет, его можно не класть в кучу
    показать ответ и разбор
    +D)Анализ JIT: не «убегает» ли объект за метод — если нет, его можно не класть в кучу

    // разбор: Escape analysis — оптимизация JIT: компилятор смотрит, «убегает» ли объект за пределы метода/потока (возвращается, сохраняется в поле, передаётся наружу). Если объект локален и не убегает, JVM может не аллоцировать его в куче: разложить на скалярные поля (scalar replacement) или разместить на стеке, а также снять ненужные блокировки (lock elision). Это снижает нагрузку на GC и ускоряет код — но происходит только в скомпилированном (не интерпретируемом) коде.

  4. #memory_model4 / 5
    Что произойдёт при слишком глубокой рекурсии?
    A)StackOverflowError — переполнение стека потока кадрами вызовов
    B)OutOfMemoryError, потому что каждый рекурсивный вызов создаёт новый объект в переполняющейся куче
    C)Программа зациклится, но ошибки не будет: JVM просто продолжит выделять стеку новую память бесконечно
    D)NullPointerException в самом глубоком вызове, когда стек исчерпает адреса для локальных переменных
    показать ответ и разбор
    +A)StackOverflowError — переполнение стека потока кадрами вызовов

    // разбор: Каждый вызов метода кладёт в стек потока кадр (frame) с локальными переменными и адресом возврата. Стек ограничен по размеру (настраивается -Xss), поэтому слишком глубокая (или бесконечная) рекурсия исчерпывает его и бросает StackOverflowError. Это отдельная от кучи область: OutOfMemoryError — про кучу/metaspace, StackOverflowError — про стек. Глубокую рекурсию часто переписывают в итерацию или делают хвостовой (JVM хвостовую не оптимизирует).

  5. #memory_model5 / 5
    Где хранится пул строковых литералов (String pool) в современной JVM (Java 7+)?
    A)В стеке главного потока, поэтому литералы уничтожаются при выходе из метода main
    B)В куче (heap) — туда его перенесли из PermGen
    C)В metaspace вместе с метаданными классов, отдельно от обычных объектов приложения
    D)В отдельной защищённой области, недоступной сборщику мусора, поэтому строки-литералы вечны
    показать ответ и разбор
    +B)В куче (heap) — туда его перенесли из PermGen

    // разбор: До Java 7 пул строк жил в PermGen (ограниченном), из-за чего активный intern() легко переполнял его. С Java 7 пул перенесён в КУЧУ — он больше, а неиспользуемые интернированные строки могут собираться сборщиком мусора. Литералы автоматически интернируются (одинаковые ссылаются на один объект), а intern() кладёт/находит строку в пуле. Расположение в куче — причина, почему массовый intern() уже не роняет PermGen, но всё ещё расходует память.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.