Всем привет! Сегдня хотел бы поделиться опытом обратоки ошибки ООМ. Эту статью меня побудила написать проблема, с которой я столкнулся. И которая, как позже выяснилось, долгое время оставалсь незамеченой. Меня заинтересовал этот вопрос, так что я решил изучить его немного глубже.
Предистория
У нас есть сервис, который по расписани закидывает задачу по обработке данных в ExecutorService. Это достаточно тяжелая задача. И в один прекрасный момент информации просто стало больше и она не влезла в наш -Xmx.
ООМ своими руками
Для тестирования мне необходимо забить всю память объектами, которые GC будет считать живыми. Для этого я использовал следующий код:
public class MemoryGrabber {
static final List<Object[]> arrays = new LinkedList<>();
public static void grabAllMemory() {
for (; ; ) {
arrays.add(new Object[100]);
}
}
}
Тут тоже присутствует некоторая проблема, но об этом позже.
Обычный код
public class BadExecutor {
private static final Logger logger = LogManager.getLogger(BadThread.class);
private static final ExecutorService executor = Executors.newFixedThreadPool(5);
public static void main(String[] args) throws Exception {
executor.submit(() -> {
try {
grabAllMemory();
} catch (Exception e) {
logger.error(e.getMessage());
}
});
}
}
Этот код вроде бы выглядит неплохо, ничего особенного тут нет. Наверное, многие писали что-то подобное не один раз. Но проблема в том, что прм ООМ, не будет выведено вообще ничего. Ни в лог ни в поток вывода.
Лови Throwable — говорили они
Да точно, ведь OutOfMemoryError — это Error, а не Exception. Поэтому тут он успешно прлетает мимо catch блока и перехватывается уже в коде ThreadPoolExecutor. Где проглатывается, а сам поток начинает ждать новой задачи. Всем известно, что в самой корневой точке кода лучше ловить Throwable.
К сожалению, если вместо Exception в данной ситуации поймать Throwable, ничего не изменится. При вызове logger.error(), мы просто получим новый ООМ, который так же канет в недрах ThreadPoolExecutor.
Стоит заметить, что если бы вместо ExecutorService создавался бы новый Thread, то все ошибки в конечном счете были бы обработаны UncaughtExceptionHandler в случае смерти потока, и в stderr была бы информация. ThreadPoolExecutor же пытается переиспользовать потоки, что в принципе ожидаемо.
Потерянный OutOfMemoryError
Закидывая задачу в ExecutorService, мы забыли очень важную вещь — воспользоваться Future, который возвращает метод submit().
public class GetFuture {
private static final Logger logger = LogManager.getLogger(BadThread.class);
private static final ExecutorService executor = Executors.newFixedThreadPool(5);
public static void main(String[] args) throws Exception {
try {
executor.submit(MemoryGrabber::grabAllMemory).get();
} catch (Throwable e) {
logger.error(e);
}
}
}
Теперь стало немного лучше. Если logger.error() выкинет новый ООМ, то main поток свалится и, возможно, выведет ошибку. Это помогает вытащить результат из ExecutorService наружу. Все видели что-то подобное:
Exception in thread "main"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
Это сообщение выводит обработчик ошибок по умолчанию, который вызывается в случае непредвиденной смерти потока.
UncaughtExceptionHandler — не панацея
Не стоит радоваться раньше вреени, т.к. лучше стало совсем немного. Если не переопределить обработчик, то вызывается ThreadGroup.uncaughtException(), в котором есть следующий код:
System.err.print("Exception in thread ""
+ t.getName() + "" ");
e.printStackTrace(System.err);
Первая же строка создает новый объект при помощи конкатенации и, если там не вылетит новый ООМ, то есть большая вероятность получить его в printStackTrace(). Тут все зависит от обстоятельств. Но суть в том, что даже получив ООМ в главном потоке, есть шанс ничего о нем не узнать.
Финализируй это
Итак, теперь наша проблема в том, что нет памяти для логирования. Из-за чего получаем вторую ошибку. Так может быть попробуем освободить пространство? Проблема заключчается в том, что MemoryGrabber.array — статическая переменная. И объекты доступные через нее GC считает живыми. Попробую ее почистить.
public class FinalizeIt {
private static final Logger logger = LogManager.getLogger(BadThread.class);
private static final ExecutorService executor = Executors.newFixedThreadPool(5);
public static void main(String[] args) throws Exception {
try {
executor.submit(() -> {
try {
grabAllMemory();
} finally {
MemoryGrabber.arrays.clear(); // Очищаем память
}
}).get();
} catch (Throwable e) {
logger.error(e);
}
executor.shutdownNow();
}
}
Теперь во время логирования будет вызван сборщик мусора, который уже сможет удалить ненужную структуру данных.
Ода функциональному программированию
Вначале я сказал, что в MemoryGrabber есть проблема. Она заключается в статической переменной array. Дело в том, что эта переменная продолжает жить после того момента, как все свалилось с ошибкой. Очевыдным костылем является ее обнуление в блоке finaly. Было бы намного лучше, если она хранилась на стеке вызова.
public class FunctionalGrabber {
public static void grabAllMemory() {
List<Object[]> arrays = new LinkedList<>();
for (; ; ) {
arrays.add(new Object[10]);
}
}
}
Теперь нашь лист List превратится в мусор как только завершится метод grabAllMemory. Не важно, с ошибкой или без. Почти Scala.
Как надо делать
Надеюсь, мне удалось донести мысль о том, что попытки поймать и обработать OutOfMemoryError в коде — сомнительная затея по ряду причин. Для этих целей лучше полагаться на следующие параметры JVM:
- -XX:+HeapDumpOnOutOfMemoryError и -XX:HeapDumpPath — сгенерируют дамп кучи во время ООМ, даже если приложение осталось работать
- -XX:+ExitOnOutOfMemoryError и -XX:ExitOnOutOfMemoryErrorExitCode — позволяют просто завершить процесс с определенным кодом
- -XX:+CrashOnOutOfMemoryError — остановит с ошибкой и создаст лог JVM
Последние два параметра появились только в JDK 8u92, остальные еще в 1.4. Оптимальным поведением является завершение процесса в случае OutOfMemoryError. Такая логика — самая понятная для всех разработчиков и тех, кто будет поддерживать приложение. Попытки обработать подобные ошибки могут привести к последствиям, неочевидным даже для самого автора.
Выводы
В статье я постарался разобраться в некоторых ошибках, из-за которых могут возникнуть проблемы при появлении ООМ. Чтобы их избежать, нужно иметь в виду:
- Процесс нужно правильно конфигурировать, чтобы завершить его работу, если память закончится
- При определенных условиях можно не получить явных доказательст ООМ, даже если есть логирование и вывод перенаправлен в файл
- Можно попытаться отловить OutOfMemoryError и продолжить работу, технически это возможно, но о таком код называют дурно пахнущим.
Автор: Жарков Михаил