🧠 Раздел 3 · Вопрос #19

Что произойдёт при OutOfMemoryError

4. Смерть потока: Поток, в котором произошла ошибка, начинает размотку стека (stack unwinding). Если ошибка не перехвачена, поток аварийно завершается. 5. Поведение процесса: ⚠️...


🟢 Junior Level

java.lang.OutOfMemoryError (OOME) — это критическая ошибка виртуальной машины (Error, наследник Throwable), возникающая, когда JVM не может выделить память для нового объекта, а Garbage Collector не способен освободить достаточный объем памяти.

Что происходит при возникновении OOME:

  1. Попытка аллокации: Поток пытается создать объект через оператор new, выделить массив или буфер.
  2. Запуск Garbage Collection: Памяти недостаточно → JVM инициирует экстренный сбор мусора (включая очистку SoftReference и Full GC).
  3. Генерация ошибки: Если после всех этапов очистки свободного непрерывного блока памяти нужного размера всё ещё нет, JVM бросает OutOfMemoryError.
  4. Смерть потока: Поток, в котором произошла ошибка, начинает размотку стека (stack unwinding). Если ошибка не перехвачена, поток аварийно завершается.
  5. Поведение процесса: ⚠️ Виртуальная машина JVM НЕ завершает работу автоматически! Погибает только поток, запросивший память. Остальные потоки приложения продолжают работать, если не настроены специальные флаги.
// Пример кода, вызывающего OOME
public class OomExample {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            // Рано или поздно куча заполнится, и будет выброшен:
            // Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
            list.add(new byte[1024 * 1024 * 10]); // 10 МБ
        }
    }
}

🟡 Middle Level

1. Механика возникновения OOME внутри JVM

Аллокация объекта в JVM следует строгому пути эскалации:

[Поток: new Object()]
         │
         ▼
[1. Попытка быстрой аллокации в TLAB (Eden)] ───► Успех ──► Завершено
         │ (TLAB полон / объект больше TLAB)
         ▼
[2. Медленная аллокация в Eden под локом] ─────► Успех ──► Завершено
         │ (Eden переполнен)
         ▼
[3. Minor / Young GC] ──────────────────────────► Успех ──► Аллокация
         │ (Не помогло / объект слишком большой)
         ▼
[4. Очистка SoftReference + Full GC] ──────────► Успех ──► Аллокация
         │ (Памяти всё ещё нет)
         ▼
[5. Проверка GC Overhead Limit]
         │ (Если >98% времени тратится на GC, а освобождается <2% кучи)
         ▼
[6. Выброс java.lang.OutOfMemoryError]

[!NOTE] Предсозданный объект OOME: Когда в куче вообще нет свободного места, JVM не может даже создать новый объект OutOfMemoryError со стек-трейсом. Поэтому HotSpot JVM использует заранее созданный статический экземпляр OOME, из-за чего в логах иногда отображается сообщение об ошибке с пустым стек-трейсом.

2. Состояние зомби (Zombie State) и порча данных

Поскольку по умолчанию OOME убивает только один поток, приложение переходит в крайне опасное неконсистентное состояние (Zombie / Corrupted State):

  1. Разрушение бизнес-транзакций: Поток прерывается в произвольной строке кода. Данные в разделяемых коллекциях (ConcurrentHashMap, очередях) могут остаться частично обновлёнными, нарушая системные инварианты.
  2. Утечка блокировок (Deadlock):
    • Блокировки мониторов (synchronized) снимаются автоматически при размотке стека.
    • Однако явные блокировки из java.util.concurrent.locks (ReentrantLock, StampedLock), если OOME произошёл до входа в блок finally или внутри критической секции с нарушением инварианта, могут остаться не снятыми. Другие потоки зависнут на них навсегда.
  3. Подавление ошибок пулами потоков: В серверах приложений (Tomcat, Netty) или в ThreadPoolExecutor ошибка OOME может быть перехвачена внутренним обработчиком пула. Пул просто завершит рабочий поток или создаст новый, скрывая катастрофу от логов верхнего уровня, пока все потоки пула по очереди не упадут.

3. Fail-Fast стратегия в Production

Попытка восстановить работу приложения после OOME внутри Java-кода бессмысленна и опасна. Единственно правильный паттерн — Fail-Fast: мгновенно сбросить диагностический дамп памяти и перезапустить процесс через оркестратор (Kubernetes / systemd).

# Обязательные production-флаги JVM:
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/dumps/heap_dump.hprof \
     -XX:+ExitOnOutOfMemoryError \
     -jar app.jar
  • -XX:+HeapDumpOnOutOfMemoryError: сохраняет полный снимок кучи в момент первого выброса OOME.
  • -XX:HeapDumpPath: путь сохранения дампа. Каталог должен находиться на смонтированном постоянном томе (Persistent Volume в K8s), иначе при перезапуске пода дамп будет уничтожен.
  • -XX:+ExitOnOutOfMemoryError: немедленно завершает JVM с ненулевым кодом при первом возникновении OOME, предотвращая зависание процесса в зомби-состоянии.
  • -XX:+CrashOnOutOfMemoryError: альтернатива, вызывающая аварийный сбой JVM с генерацией hs_err_pid.log и core dump (полезно для низкоуровневой отладки в C++ коде).

🔴 Senior Level

1. JVM OutOfMemoryError против Linux OS OOM Killer

Важнейшая концептуальная разница в production и микросервисных архитектурах:

Критерий JVM OutOfMemoryError Linux OOM Killer (cgroups)
Кто инициирует Сама виртуальная машина HotSpot Ядро операционной системы Linux
Причина Превышен лимит Java Heap, Metaspace или GC Overhead Процесс превысил лимит физической RAM (RSS > cgroup memory limit)
Сигнал ОС Нет (штатная обработка ошибки в runtime) Жесткий сигнал SIGKILL (Signal 9, Exit Code 137)
Стек-трейс в Java логах Есть (java.lang.OutOfMemoryError: ...) Отсутствует (процесс моментально исчезает)
Heap Dump Снимается при наличии -XX:+HeapDumpOnOutOfMemoryError Не снимается, так как JVM не успевает среагировать
Где искать причину Логи приложения, Heap Dump, GC Logs Системный лог ядра: dmesg -T \| grep -i oom или journalctl
# Диагностика OOM Killer в Linux/Kubernetes:
$ dmesg -T | grep -i "killed process"
# [Fri Sep 25 14:30:00 2026] Memory cgroup out of memory: Kill process 1234 (java) score 1024 or sacrifice child
# Killed process 1234 (java) total-vm:4582912kB, anon-rss:2097152kB, file-rss:0kB, shmem-rss:0kB

2. Почему нельзя ловить catch (Throwable t) или catch (OutOfMemoryError e)?

// ❌ АНТИПАТТЕРН: попытка обработки OOME в коде
try {
    loadHugeDatasetIntoMemory();
} catch (OutOfMemoryError e) {
    logger.error("Память кончилась, очищаем кэш и продолжаем", e);
    cache.clear(); 
}

Почему это катастрофа в production:

  1. Каскадная порча состояния: Пока выполнялся loadHugeDatasetIntoMemory(), другие потоки параллельно аллоцировали объекты. Невозможно предсказать, какие из этих потоков также получили OOME и в каком состоянии остались внутренние структуры сервиса.
  2. Вторичный OOME внутри catch/finally: При попытке залогировать ошибку (logger.error(...)) создаются новые объекты: строки, массивы символов, события логгера. Попытка выделить память внутри блока обработки ошибки вызовет новый OOME, и поток мгновенно рухнет без записи в лог.
  3. Блокировки: Если метод находился внутри синхронизированного блока или работал со структурами, требующими атомарного обновления двух указателей, прерывание оставляет память в полуразрушенном виде.

[!CAUTION] Единственное легитимное место перехвата OOME — это специализированные системы профилирования, тестовые фреймворки (проверяющие поведение при стресс-нагрузке) или мониторинговый UncaughtExceptionHandler, задача которого — отправить алерт в систему мониторинга и вызвать Runtime.getRuntime().halt(1).

3. Скрипты реагирования: -XX:OnOutOfMemoryError

HotSpot поддерживает возможность запуска внешней команды операционной системы в момент наступления OOME:

java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/dumps/heap.hprof \
     -XX:OnOutOfMemoryError="sh /scripts/notify_and_kill.sh %p" \
     -jar app.jar

Где %p автоматически заменяется на PID упавшего процесса JVM. Скрипт может собрать thread dump (jcmd %p Thread.print), зафиксировать сетевые подключения и принудительно убить зомби-процесс (kill -9 %p).


🎯 Шпаргалка для интервью

30-секундный ответ для собеседования

«При возникновении OutOfMemoryError JVM сначала выполняет полный цикл сборки мусора (Full GC) и очистку SoftReference. Если память освободить не удалось, выбрасывается OutOfMemoryError. При этом погибает только поток, запросивший память, а сама виртуальная машина продолжает работать. Это переводит приложение в опасное зомби-состояние с риском взаимных блокировок и искажения данных. Поэтому в production обязателен подход fail-fast: запуск с флагами -XX:+HeapDumpOnOutOfMemoryError и -XX:+ExitOnOutOfMemoryError, чтобы немедленно сохранить дамп для расследования и позволить оркестратору перезапустить под».

Вопросы повышенной сложности с ответами

  1. Завершится ли весь процесс JVM, если в фоновом потоке возникнет OutOfMemoryError? Ответ: Нет, по умолчанию виртуальная машина не завершает работу. Погибает только поток, в котором произошла ошибка (раскручивается его стек вызовов). Если этот поток был частью пула потоков, пул может скрыть падение и продолжить принимать запросы. Чтобы процесс гарантированно падал при OOME, необходимо использовать флаг -XX:+ExitOnOutOfMemoryError.

  2. В чём разница между JVM OutOfMemoryError и сбоем по OOM Killer в Kubernetes (Exit Code 137)? Ответ: При JVM OOME исчерпывается внутренний ресурс кучи или метаспейса JVM; при этом виртуальная машина контролирует процесс, формирует стек-трейс в логах и может снять heap dump. При Linux OOM Killer процесс превышает лимит физической памяти контейнера (RSS), и ядро ОС мгновенно уничтожает JVM сигналом SIGKILL. При OOM Killer процесс исчезает без логов Java и без возможности снять heap dump, а запись об убийстве процесса остаётся только в системном журнале ядра ОС (dmesg).

  3. Почему в логах java.lang.OutOfMemoryError иногда не имеет стек-трейса? Ответ: Генерация стек-трейса требует выделения в памяти новых объектов (экземпляров StackTraceElement). Если в момент наступления OOME куча исчерпана полностью (до последнего байта), JVM не может выделить память под массив элементов стека и выбрасывает заранее созданный глобальный статический синглтон OutOfMemoryError без трассировки вызовов.

  4. Выполнится ли блок finally, если внутри try произошёл OutOfMemoryError? Ответ: Блок finally попытается выполниться в процессе раскрутки стека. Однако, если внутри самого блока finally происходит любая аллокация объекта или если памяти недостаточно даже для загрузки стекового фрейма метода finally, JVM выбросит новый OOME, прервав выполнение блока. Полагаться на выполнение finally при OOME категорически нельзя.

Типичные ошибки и Red Flags

  • ❌ Утверждать, что OOME сразу «роняет» всю виртуальную машину Java. (Red Flag: непонимание многопоточной модели Java и механики обработки ошибок).
  • ❌ Предлагать оборачивать вызовы в catch (OutOfMemoryError e) для очистки коллекций. (Red Flag: непонимание состояния несогласованности памяти и риска скрытых дедлоков).
  • ❌ Путать JVM Heap OOME и завершение процесса ядром Linux (Exit Code 137 / OOM Killer). (Red Flag: разработчик будет часами искать утечку в Heap Dump, когда реальная проблема была в утечке Native Memory, Netty Direct Buffers или перегрузке стеков потоков).

Связанные темы