Модель памяти 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, остальные разбираются в тренажёре.
- Что такое metaspace (Java 8+) и чем он отличается от PermGen?A)Это новое имя молодого поколения кучи, куда попадают все свежесозданные объекты приложенияB)Область метаданных классов в нативной памяти, заменившая PermGenC)Это стек потоков, вынесенный за пределы кучи для ускорения вызовов методов в новых версиях JavaD)Это кэш скомпилированного JIT-кода, который в Java 8 объединили с пулом строковых литералов
показать ответ и разбор
+B)Область метаданных классов в нативной памяти, заменившая PermGen// разбор: Metaspace (с Java 8) хранит МЕТАДАННЫЕ классов (структура классов, методы) и располагается в НАТИВНОЙ памяти, а не в куче. Он заменил PermGen, который жил в куче с фиксированным лимитом и часто переполнялся (OutOfMemoryError: PermGen). Metaspace по умолчанию растёт автоматически (ограничивается -XX:MaxMetaspaceSize). Утечка metaspace обычно означает утечку загрузчиков классов (например, при бесконечной перезагрузке классов).
- Как Java передаёт аргументы в методы — по значению или по ссылке?A)Примитивы по значению, а объекты по ссылке — метод может переприсвоить переданную переменную снаружиB)Всё по ссылке: метод получает прямой доступ к переменной вызывающего и может её переприсвоитьC)Всегда по значению; для объектов копируется значение ссылкиD)Зависит от модификатора final: без него — по ссылке, с ним — по значению копией переменной
показать ответ и разбор
+C)Всегда по значению; для объектов копируется значение ссылки// разбор: Java ВСЕГДА передаёт по значению. Для примитива копируется его значение. Для объекта копируется ЗНАЧЕНИЕ ССЫЛКИ (адрес) — метод и вызывающий указывают на один объект, поэтому изменения ЕГО полей видны обоим. Но переприсваивание параметра внутри метода (param = new ...) не влияет на переменную снаружи — менялась копия ссылки. Это частый источник путаницы «Java передаёт объекты по ссылке» — нет, по значению ссылки.
- Что такое escape analysis и что она даёт?A)Проверка, не вышел ли поток за пределы своего стека, чтобы вовремя бросить StackOverflowErrorB)Механизм экранирования спецсимволов в строках перед их размещением в пуле строковых литераловC)Поиск объектов, которые не будут собраны сборщиком мусора, чтобы удалить их принудительноD)Анализ JIT: не «убегает» ли объект за метод — если нет, его можно не класть в кучу
показать ответ и разбор
+D)Анализ JIT: не «убегает» ли объект за метод — если нет, его можно не класть в кучу// разбор: Escape analysis — оптимизация JIT: компилятор смотрит, «убегает» ли объект за пределы метода/потока (возвращается, сохраняется в поле, передаётся наружу). Если объект локален и не убегает, JVM может не аллоцировать его в куче: разложить на скалярные поля (scalar replacement) или разместить на стеке, а также снять ненужные блокировки (lock elision). Это снижает нагрузку на GC и ускоряет код — но происходит только в скомпилированном (не интерпретируемом) коде.
- Что произойдёт при слишком глубокой рекурсии?A)StackOverflowError — переполнение стека потока кадрами вызововB)OutOfMemoryError, потому что каждый рекурсивный вызов создаёт новый объект в переполняющейся кучеC)Программа зациклится, но ошибки не будет: JVM просто продолжит выделять стеку новую память бесконечноD)NullPointerException в самом глубоком вызове, когда стек исчерпает адреса для локальных переменных
показать ответ и разбор
+A)StackOverflowError — переполнение стека потока кадрами вызовов// разбор: Каждый вызов метода кладёт в стек потока кадр (frame) с локальными переменными и адресом возврата. Стек ограничен по размеру (настраивается -Xss), поэтому слишком глубокая (или бесконечная) рекурсия исчерпывает его и бросает StackOverflowError. Это отдельная от кучи область: OutOfMemoryError — про кучу/metaspace, StackOverflowError — про стек. Глубокую рекурсию часто переписывают в итерацию или делают хвостовой (JVM хвостовую не оптимизирует).
- Где хранится пул строковых литералов (String pool) в современной JVM (Java 7+)?A)В стеке главного потока, поэтому литералы уничтожаются при выходе из метода mainB)В куче (heap) — туда его перенесли из PermGenC)В metaspace вместе с метаданными классов, отдельно от обычных объектов приложенияD)В отдельной защищённой области, недоступной сборщику мусора, поэтому строки-литералы вечны
показать ответ и разбор
+B)В куче (heap) — туда его перенесли из PermGen// разбор: До Java 7 пул строк жил в PermGen (ограниченном), из-за чего активный intern() легко переполнял его. С Java 7 пул перенесён в КУЧУ — он больше, а неиспользуемые интернированные строки могут собираться сборщиком мусора. Литералы автоматически интернируются (одинаковые ссылаются на один объект), а intern() кладёт/находит строку в пуле. Расположение в куче — причина, почему массовый intern() уже не роняет PermGen, но всё ещё расходует память.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.