Minecraft-сервер: как устранить "java.lang.OutOfMemoryError: Java heap space"

Опубликовано 12 мин. чтения

Сообщение java.lang.OutOfMemoryError: Java heap space не всегда означает нехватку RAM. Как правильно рассчитать Xmx и Xms, найти утечку памяти и разумно применять swap.

Сервер работает три часа, потом зависает, tickrate падает до 2, а в консоли появляется:

[Server thread/ERROR]: Encountered an unexpected exception
java.lang.OutOfMemoryError: Java heap space
	at java.base/java.util.Arrays.copyOf(Arrays.java:3537)
	at it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap.rehash(...)

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

Что означает это сообщение, а что нет

Java-heap — это область, в которой JVM размещает объекты: загруженные чанки, сущности, инвентари, данные плагинов. Его верхнюю границу задаёт -Xmx. Сообщение OutOfMemoryError: Java heap space означает следующее: JVM попыталась создать объект, heap оказался заполнен, а сборка мусора не смогла освободить достаточно места. О свободной памяти операционной системы это не говорит ничего. Сервер с 64 ГБ RAM выдаёт такое сообщение ровно так же надёжно, если задано -Xmx2G, а миру нужно 4 ГБ.

От этого следует чётко отличать два других отказа, которые часто путают:

  • Процесс исчезает без stacktrace, в логе остаётся только Killed, а в журнале Main process exited, code=killed, status=9/KILL. Это сработало ядро, а не JVM. Linux-OOM-killer убил процесс, потому что закончилась вся память системы. Проверить это можно командой dmesg | grep -i "out of memory", там будет строка вида Out of memory: Killed process 1337 (java).
  • Java вообще не запускается и сообщает Error occurred during initialization of VM / Could not reserve enough space for object heap. Значит, -Xmx больше того, что система в принципе может выделить.

Это разграничение и есть самый важный шаг. В первом случае heap слишком мал, во втором и третьем слишком велик. Кто путает эти случаи, тот крутит не ту ручку.

Сначала измерить, потом выделять

Прежде чем менять хотя бы одно значение, нужны две цифры: реально доступная память и текущее потребление.

free -h
cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|SwapTotal'

Решающее значение имеет MemAvailable, а не free. Linux использует незанятый RAM под файловый кэш, поэтому «free» почти всегда мал и почти всегда не имеет отношения к делу.

Реальное потребление сервера видно так:

ps -o pid,rss,cmd -C java

Значение RSS указано в килобайтах, это память, которую процесс занимает в RAM. Оно всегда больше, чем -Xmx, и именно на этом месте большинство инструкций заканчивается.

Почему «как можно больше» гарантированно оборачивается проблемой

Кроме heap, JVM нужен целый ряд других областей памяти, которые -Xmx вообще не учитывает:

  • Metaspace: загруженные классы. У модпака из 300 модов это легко 300-600 МБ.
  • Thread-стеки: каждый поток получает около 1 МБ. Chunk-worker, Netty-потоки, планировщики плагинов, в сумме набегает 100-300 МБ.
  • Direct Buffers: весь сетевой трафик Netty обрабатывает в памяти за пределами heap. При большом числе одновременных игроков несколько сотен мегабайт совершенно нормальны.
  • Code-cache и структуры GC: JIT-компилятор и собственный учёт G1 стоят примерно 5-10% от размера heap.

Надёжное практическое правило звучит так: считайте Xmx плюс 1-1,5 ГБ на саму JVM плюс минимум 512 МБ на операционную систему. Для модпака с большим количеством модов лучше Xmx плюс 2 ГБ.

На сервере с 8 ГБ RAM это означает -Xmx6G, а не -Xmx8G. Кто выделит 8, получит уже не ошибку heap, а кое-что похуже: процесс, который ядро без предупреждения убивает прямо посреди сохранения мира. Ошибка heap — это аккуратный, задокументированный отказ. OOM-kill может оставить после себя повреждённые region-файлы.

Есть и вторая причина не выделять максимум: слишком большой heap замедляет сборку мусора. G1 приходится просматривать больше памяти, паузы при mixed GC становятся длиннее, и из редкого подлагивания вырастает заметный freeze. Выше примерно 12 ГБ соотношение в Minecraft, как правило, уходит в минус. Кому нужно больше, тому стоит разделить мир, а не увеличивать heap.

Ориентиры по числу игроков и модпаку

Эти значения — отправная точка, а не закон природы. Они рассчитаны на мир обычного размера и включённую предгенерацию.

Тип сервераИгрокиXmxRAM в системе
Vanilla или Paper, без плагиновдо 102G4 ГБ
Paper с 15-30 плагинами10-304G8 ГБ
Paper, большой набор плагинов, база данных30-806G-8G12-16 ГБ
Лёгкий модпак, до 120 модовдо 106G8 ГБ
Средний модпак, 150-250 модовдо 208G-10G16 ГБ
Тяжёлый модпак, от 300 модовдо 2010G-12G16-24 ГБ
Proxy (Velocity, BungeeCord)любое512M-1G2 ГБ

Два замечания к таблице. Во-первых, у модпаков потребность в heap растёт почти исключительно с числом модов и размером мира, а не с числом игроков. Во-вторых, самый действенный рычаг вообще: параметр view-distance в файле server.properties. Снижение с 10 до 8 часто экономит больше памяти, чем 2 ГБ дополнительного heap, потому что количество загруженных чанков растёт квадратично относительно дальности прорисовки. Установка simulation-distance в 6 дополнительно снижает нагрузку на CPU.

Xms равен Xmx: прогрев

-Xms определяет, с каким объёмом heap стартует JVM. Если там стоит значение меньше -Xmx, heap во время работы растёт постепенно. Каждое увеличение означает полную сборку мусора и свежие page faults на стороне операционной системы, а так как G1 умеет и уменьшать heap обратно, вся история повторяется. Именно отсюда берутся печально известные подлагивания каждые несколько минут в первые часы после старта.

Всегда задавайте -Xms равным -Xmx. Вместе с -XX:+AlwaysPreTouch JVM при старте один раз затрагивает каждую отдельную страницу памяти heap. Старт из-за этого длится на 2-15 секунд дольше, в зависимости от размера heap, зато во время игры page faults больше не возникают. Приятный побочный эффект: если -Xmx выбран слишком большим, это, как правило, выясняется уже при старте, а не тремя часами позже посреди игры.

Но не полагайтесь при этом на короткую пробную команду с -version. Проверено на практике: java -Xms4G -Xmx4G -XX:+AlwaysPreTouch -version без нареканий отработала в окружении, ограниченном 2 ГБ, потому что вызов завершается раньше, чем heap реально начинает использоваться. Настоящее доказательство даёт только полноценный запуск сервера с последующим наблюдением за ps -o rss= -C java.

Проверенная команда запуска для Paper выглядит так (так называемые Aikar-флаги):

java -Xms6G -Xmx6G \
  -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 \
  -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch \
  -XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M \
  -XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 \
  -XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 \
  -XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 \
  -XX:+PerfDisableSharedMem -XX:MaxTenuringThreshold=1 \
  -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/minecraft/dumps \
  -XX:+ExitOnOutOfMemoryError \
  -jar paper.jar nogui

Начиная с 12 ГБ heap PaperMC рекомендует изменённые значения: G1NewSizePercent=40, G1MaxNewSizePercent=50, G1HeapRegionSize=16M, G1ReservePercent=15 и InitiatingHeapOccupancyPercent=20.

Настоящая польза заключена в двух последних строках, и почти в каждой инструкции их не хватает. -XX:+HeapDumpOnOutOfMemoryError при аварии записывает полный образ памяти, по которому потом можно доказать причину. -XX:+ExitOnOutOfMemoryError немедленно завершает JVM вместо того, чтобы оставлять её работать в полумёртвом состоянии, когда игроки подключаются и теряют прогресс. Вместе с Restart=on-failure в unit-файле получается корректный перезапуск, смотрите об этом автоматический запуск Minecraft-сервера и создание systemd-сервиса.

Учтите ещё одно: каталог для дампов должен существовать и располагать как минимум таким же объёмом, какой задан в -Xmx. Heap на 8 ГБ создаёт файл .hprof размером 8 ГБ. Если после этого накопитель окажется заполнен, вы получите вторую проблему, смотрите переполненный диск в Linux.

Версия Java и различия между системами

От того, какая JVM используется, поведение меняется заметно:

  • Java 8 по умолчанию использует Parallel-коллектор, а не G1. Здесь встречается и вариант java.lang.OutOfMemoryError: GC overhead limit exceeded, который означает, что более 98% времени было потрачено на сборку мусора. Логирование GC включается через -XX:+PrintGCDetails -Xloggc:gc.log.
  • Начиная с Java 9 G1 является стандартом на всех машинах минимум с двумя ядрами и 1792 МБ RAM. Логирование идёт через новый Unified Logging: -Xlog:gc*:file=logs/gc.log:time,uptime:filecount=5,filesize=10M. Старые флаги здесь отклоняются, и сервер не стартует.
  • Наличие пакетов: Debian 13 поставляет openjdk-21-jre-headless и openjdk-25-jre-headless, но не Java 17. Debian 12 поставляет Java 17, но ни 21, ни 25. В Ubuntu 22.04 и 24.04 есть 8, 11, 17, 21 и 25. Кому нужна конкретная версия, которой дистрибутив не знает, тот подключает репозиторий Adoptium (Temurin с 8 по 26 для trixie, bookworm, noble и jammy). Подробности в статьях установка Java 21 на Debian и установка Java 17 на Debian.

Ловушка, в которую попадают почти все: диагностических утилит jcmd, jmap, jstat и jstack в пакетах jre-headless нет. Они лежат в openjdk-XX-jdk-headless. Кто держит сервер на JRE, тот в критический момент остаётся без инструментов:

apt install -y openjdk-21-jdk-headless
dnf install -y java-21-openjdk-devel

Первая строка относится к Debian и Ubuntu, вторая к AlmaLinux, Rocky Linux и Oracle Linux. После этого jcmd и jmap находятся в /usr/bin/. Проверяйте это командой command -v jcmd, а не which jcmd: в минимальной установке семейства Red Hat пакета which нет, а в EL 10 он и без того объявлен устаревшим.

Как распознать утечку памяти из-за плагинов

Утечка памяти выглядит иначе, чем простая нехватка памяти. Разница в динамике: при слишком маленьком heap потребление после старта выходит на высокий уровень и там остаётся. При утечке базовый уровень продолжает расти после каждой полной сборки мусора. Именно этот базовый уровень и является ключевым показателем.

Его можно запросить напрямую. Сначала определить ID процесса, затем принудительно вызвать полную сборку и посмотреть на heap:

pgrep -f paper.jar
jmap -histo:live PID | head -30
jcmd PID GC.heap_info

Важное замечание: jmap -histo:live внутри себя запускает полную сборку и работает даже тогда, когда, как выше, задан -XX:+DisableExplicitGC. Напрашивающаяся команда jcmd PID GC.run в такой конфигурации не делает ничего, потому что идёт через System.gc(), а он как раз и отключён. По опыту это стоит получаса недоумения.

Записывайте значение сразу после старта, затем через два, шесть и двенадцать часов. Если после полной сборки значение стабильно растёт, хотя игроков онлайн не прибавилось, это утечка. Список классов из гистограммы обычно уже показывает направление: массово ItemStack, CraftPlayer давно вышедших игроков или объект с именем пакета какого-нибудь плагина.

Заметно удобнее это делается с плагином spark, который доступен для Paper, Fabric и Forge:

  • /spark healthreport --memory показывает загрузку heap, поведение GC и области вне heap одним взглядом.
  • /spark heapsummary строит рейтинг классов по потреблению памяти, не записывая при этом дамп размером в несколько гигабайт.
  • /spark gc показывает частоту и длительность сборок.

Если подозрение падает на конкретный плагин, опровергнуть его просто: удалить плагин, дать серверу поработать 24 часа, снова измерить базовый уровень. Классические кандидаты: плагины, которые кэшируют данные игроков в map-структурах и не очищают их при выходе, а также всё, что связано с редактированием мира и ведёт неограниченную историю отмены.

Swap: спасательный круг, а не расширение памяти

Swap и Java-heap — естественные враги. Сборка мусора регулярно затрагивает большие части heap. Если хотя бы малая их доля лежит на накопителе, пауза в 50 миллисекунд превращается в 20 секунд, и сервер считается зависшим. Никогда не закладывайте swap в размер heap.

Тем не менее swap должен быть, только небольшой и неохотный. Он работает как буфер, чтобы кратковременные пики не запускали сразу OOM-killer, и принимает редко используемые страницы других служб. Рекомендуются 2-4 ГБ и низкая склонность к вытеснению:

swapon --show
cat /proc/sys/vm/swappiness
sysctl -w vm.swappiness=10

На постоянной основе это задаётся в /etc/sysctl.d/99-swappiness.conf. Настройка в деталях описана в статье настройка swap и предотвращение out of memory.

Отдельный случай заслуживает внимания: -XX:+AlwaysPreTouch в сочетании с нехваткой RAM. Так как при старте затрагивается каждая страница heap, излишек сразу уходит в swap. Сервер при этом стартует, но с первого же тика работает недопустимо медленно. Если сервер после включения PreTouch внезапно стартует крайне вяло, виноват слишком большой -Xmx, а не PreTouch.

В качестве жёсткого верхнего предела дополнительно можно задать MemoryMax в systemd-unit, например MemoryMax=7G при 8 ГБ RAM. Тогда при выходе ситуации из-под контроля ядро прицельно убьёт процесс Minecraft, а не базу данных или доступ по SSH.

Как понять, что проблема действительно устранена

Перезапуск без немедленного падения ничего не доказывает. Проверьте вместо этого пять пунктов после 24 часов работы под обычной нагрузкой:

  1. jcmd PID GC.heap_info: занятый heap сразу после полной сборки должен быть заметно ниже 70% от -Xmx и оставаться стабильным со временем.
  2. ps -o rss= -C java: значение должно установиться примерно на уровне Xmx плюс 1-1,5 ГБ и дальше не расти. Если оно растёт, а heap остаётся стабильным, утечка находится за пределами heap, обычно в Metaspace или в Direct Buffers.
  3. free -h: значение available не должно опускаться ниже примерно 500 МБ.
  4. swapon --show: объём занятого swap должен оставаться близким к нулю.
  5. Лог GC: полные сборки («Pause Full») практически не должны встречаться, обычные паузы должны оставаться ниже 200 миллисекунд. Серия Full GC одна за другой — верный предвестник следующего OutOfMemoryError, часто он наступает через четверть часа.

Дополнительно команда /tps или /spark tps в игре показывает, держится ли tickrate стабильно на 20,0. Сервер, здоровый с точки зрения памяти, удерживает это значение и спустя часы работы.

Если Java вообще не запускается

Четыре сообщения дословно, которые типично появляются при изменении параметров памяти:

  • Invalid maximum heap size: -Xmx8GB — самая частая опечатка. Единица называется G, а не GB. Допустимы k, m и g, в верхнем или нижнем регистре.
  • Initial heap size set to a larger value than the maximum heap size означает, что -Xms больше, чем -Xmx, чаще всего потому, что при копировании поправили только одно из двух значений.
  • Could not reserve enough space for object heap означает, что выделение превышает доступную память. На 32-битной JVM предел в любом случае наступает чуть ниже 4 ГБ, независимо от установленного объёма RAM. Проверяется командой java -version, там должно стоять 64-Bit Server VM.
  • Unrecognized VM option 'UseG1GC' или похожее указывает на слишком старую или неподходящую JVM. Некоторым экспериментальным флагам обязательно нужен предшествующий -XX:+UnlockExperimentalVMOptions, причём перед соответствующим флагом в командной строке.

С какими значениями JVM в итоге действительно работает, можно перепроверить в любой момент. Без запущенного сервера, по значениям по умолчанию:

java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -w MaxHeapSize

Две мелочи здесь сделаны намеренно. 2>/dev/null поглощает баннер версии, который JVM пишет в поток ошибок и который иначе нефильтрованным проходит мимо grep прямо в вывод. А grep -w MaxHeapSize вместо grep -i maxheapsize действительно возвращает всего одну строку: нечёткий поиск находит дополнительно SoftMaxHeapSize, и тот, кто ждёт одно значение, легко считывает не то.

А на работающем процессе, по реально активным флагам:

jcmd PID VM.flags

Это самый надёжный способ вскрыть удивительно распространённую ситуацию, когда скрипт запуска изменили, а сервер по-прежнему работает со старыми значениями из второго файла скрипта.


Коротко: сначала измерьте, действительно ли проблема в heap. Затем выделите столько, сколько нужно миру, и оставьте минимум 1,5 ГБ на JVM и систему. Задайте -Xms равным -Xmx, включите heap-дамп на случай аварии и понаблюдайте за базовым уровнем после полной сборки в течение нескольких часов. Разница между «работает» и «работает стабильно» заключена именно в этом последнем шаге.

Базовая настройка самого сервера описана в статьях установка Minecraft-сервера на Debian и чек-лист для нового root-сервера.

Частые вопросы

Сколько RAM выделять Minecraft-серверу?
Выделяйте столько, сколько реально нужно миру, и оставляйте минимум 1-1,5 ГБ для JVM за пределами heap плюс 512 МБ для операционной системы. На сервере с 8 ГБ RAM это означает -Xmx6G. Vanilla с 10 игроками обходится 2G, Paper с плагинами и 30 игроками 4G, среднему модпаку нужно 8G-10G. Выше примерно 12 ГБ heap паузы сборки мусора становятся длиннее, вместо того чтобы росла производительность.
Почему Xms должен быть равен Xmx?
Если -Xms меньше -Xmx, heap во время работы растёт и снова уменьшается. Каждое изменение размера вызывает полную сборку мусора и новые page faults, что проявляется как повторяющиеся подлагивания. Одинаковые значения вместе с -XX:+AlwaysPreTouch резервируют весь heap уже при старте. Старт из-за этого длится на несколько секунд дольше, зато работа остаётся ровной.
В чём разница между OutOfMemoryError и процессом, который просто исчезает с сообщением Killed?
OutOfMemoryError приходит от JVM и означает, что заданный через -Xmx heap заполнен. Процесс, который завершается без stacktrace с Killed или status=9/KILL, был убит ядром Linux, потому что закончилась память всей системы. В первом случае -Xmx слишком мал, во втором слишком велик. Случай с ядром подтверждается командой dmesg | grep -i "out of memory".
Как распознать утечку памяти из-за плагина?
Определяющий показатель: занятый heap сразу после полной сборки мусора. Принудительно вызвать её можно через jmap -histo:live PID, посмотреть результат через jcmd PID GC.heap_info. Записывайте значение после старта, а также через два, шесть и двенадцать часов. Если оно стабильно, heap просто слишком мал. Если оно постоянно растёт при том же числе игроков, есть утечка. Плагин spark даёт ту же картину удобнее, через /spark healthreport --memory и /spark heapsummary.
Помогает ли больший swap против ошибки Java heap space?
Нет. Swap не увеличивает heap, ведь его верхняя граница задана в -Xmx. Хуже того: вытесненный в swap heap делает сборку мусора крайне медленной, из паузы в 50 миллисекунд получается 20 секунд полного зависания. Разумны 2-4 ГБ swap с vm.swappiness=10 как буфер против OOM-killer, но никогда как запланированное расширение памяти.
Почему я не нахожу jcmd и jmap на своём сервере?
Эти утилиты не входят в пакеты openjdk-XX-jre-headless, они есть только в openjdk-XX-jdk-headless. Кто держит сервер на чистом пакете среды выполнения, тот должен доустановить пакет JDK, например командой apt install openjdk-21-jdk-headless. Учитывайте при этом наличие пакетов: Debian 13 поставляет Java 21 и 25, Debian 12 поставляет Java 17.

Minecraft Java JVM Gameserver Troubleshooting Arbeitsspeicher Garbage Collection Linux