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

Комментарии (15)


  1. fediq
    21.07.2018 16:47
    +4

    Не раскрыта очень важная тема, что память может сжирать один тред, а падать с OOMом — другой. Особенно забавно, когда с OOMом падает не готовый к этому сервисный тред, например, который разбирает очередь RPC.


    1. zharko_mi Автор
      23.07.2018 08:03

      Тут рассмотрены примеры, когда тред потребляющий память известен. Да и вообще основная идея в том, что пытаться ловить такие ошибки весьма плохая идея.


  1. svr_91
    21.07.2018 20:41
    -2

    Не java программист, но пришел в голову такой подход. Заранее создать статическую переменную небольшого объема, и когда произошел out of memory, эту переменную очищать перед логгированием


    1. ratijas
      22.07.2018 00:18
      -1

      char _dummy[16384]; // make program look bigger


    1. vedenin1980
      22.07.2018 00:36

      Заранее создать статическую переменную небольшого объема, и когда произошел out of memory, эту переменную очищать перед логгированием

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


      1. apangin
        22.07.2018 03:01

        Можно, если воспользоваться SoftReference. И для неё спецификация как раз гарантирует, что ссылка будет очищена до того, как выбросится OutOfMemoryError.

        Зря комментарий svr_91 заминусовали. Вполне годное решение, которое встречал в реальных проектах.


        1. vedenin1980
          22.07.2018 03:28

          Нет, в том-то и дело, что сначала будет очищена SoftReference, а только потом (если места для следующего выделения памяти не хватит) выбросится OutOfMemoryError. Никто не гарантирует, что все что было занято SoftReference не окажется занятым к моменту исчерпания памяти. По сути SoftReference это просто незанятая память, когда вся остальная память занята.

          То есть SoftReference никак не гарантирует, что вы сможете залогировать при OutOfMemoryError.


          1. apangin
            22.07.2018 04:05

            1. В качестве резерва памяти для обработки OOME создаём байтовый массив.
            2. Держим на него strong reference и SoftReference.
            3. При возникновении OOME обнуляем strong reference; массив станет softly reachable, а, значит, резерв заведомо освободится до возникновения следующего OOME.

            нет никакой гарантии, что между очисткой и логированием успеет пройти сборка и мусора.
            Либо сборка мусора пройдёт, либо логирование успешно выполнится безо всякой сборки. Аспект многопоточности оставлю в стороне — можно манипулировать размером резерва.


            1. vedenin1980
              22.07.2018 06:05

              А зачем тогда SoftReference, если можно просто strong reference тогда уж обнулить?


              1. apangin
                22.07.2018 14:48

                Да, на практике можно и одним strong reference обойтись. SoftReference лишь для того, чтобы ткнуть в конкретное место в спецификации :)


      1. svr_91
        22.07.2018 08:56

        Ну тут таже проблема, что и с переменной на стеке, которая якобы должна очиститься после пропадания со стека. Но мое решение хотябы не заставляет всех пользователей держать все переменные на стеке.

        Для очистки gc можно использовать System.gc() тоже без гарантии правда.

        Вообще, с gc в яве конечно сложно. Есть старое поколение, есть новое, и не факт, что они очищаются по одинаковым правилам. Плюс переменная может попасть в какую-нибудь constant memory, если такое в яве есть. Но мне все равно кажется, что мое решение имеет право на жизнь


    1. zharko_mi Автор
      22.07.2018 20:30

      Работать, скорее всего, будет, т.к. перед тем как выкинуть ООМ еще раз, JVM сделает full GC. Только это будет очень плохо поддерживаемый код. Да и вообще писать код, который использует специфику JVM — не самая лучшая идея. Есть шанс получить разное поведение на разных ОС и между разными версиями Java. Поведение, которое выходит за рамки JLS — уже черная магия. Я как раз пытался предостеречь от нее.


      1. svr_91
        22.07.2018 21:13

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

        А насчет неподдерживаемого кода, то можно просто вывести этот код в отдельный метод :)


  1. jeka_odessit
    21.07.2018 22:59
    +1

    А у нас часто было так что линукс кернел убивал процесс, и никаких дампов и т.п. небыло. Kernel OOM Killer вот


    1. vesper-bot
      23.07.2018 14:08

      А это вообще не ловится в рамках данной задачи — если прибили всю Java VM, несмотря на её -Xmx-предел, значит, проблема где-то ещё, а не в джава-приложении.