OutOfMemoryError: поймай, если сможешь +13


image


Всем привет! Сегдня хотел бы поделиться опытом обратоки ошибки ООМ. Эту статью меня побудила написать проблема, с которой я столкнулся. И которая, как позже выяснилось, долгое время оставалсь незамеченой. Меня заинтересовал этот вопрос, так что я решил изучить его немного глубже.


Предистория


У нас есть сервис, который по расписани закидывает задачу по обработке данных в 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 и продолжить работу, технически это возможно, но о таком код называют дурно пахнущим.




К сожалению, не доступен сервер mySQL