Понимание утечек памяти в Java

Понимание утечек памяти в Java

1. Вступление

Одно из основных преимуществ Java - автоматическое управление памятью с помощью встроенного сборщика мусора (или для краткостиGC). GC неявно заботится о выделении и освобождении памяти и, таким образом, способен справиться с большинством проблем утечки памяти.

Хотя сборщик мусора эффективно обрабатывает значительную часть памяти, он не гарантирует надежного решения проблемы утечки памяти. GC довольно умный, но не безупречный. Утечки памяти могут все еще красться даже в приложениях добросовестного разработчика.

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

Утечки памяти - настоящая проблема в Java. В этом руководстве мы увидимwhat the potential causes of memory leaks are, how to recognize them at runtime, and how to deal with them in our application.

2. Что такое утечка памяти

Утечка памяти - это ситуацияwhen there are objects present in the heap that are no longer used, but the garbage collector is unable to remove them from memory, и поэтому они без надобности поддерживаются.

Утечка памяти - это плохо, потому что этоblocks memory resources and degrades system performance over time. И если с этим не справиться, приложение в конечном итоге исчерпает свои ресурсы, в конце концов завершив работу с фатальнымjava.lang.OutOfMemoryError.

Существует два разных типа объектов, которые находятся в памяти кучи - ссылаются и не ссылаются. Ссылочные объекты - это те, у которых все еще есть активные ссылки в приложении, тогда как объекты, на которые нет ссылок, не имеют активных ссылок.

The garbage collector removes unreferenced objects periodically, but it never collects the objects that are still being referenced. Вот где могут возникнуть утечки памяти:

 

image

Симптомы утечки памяти

  • Сильное снижение производительности, когда приложение постоянно работает в течение длительного времени

  • Ошибка кучиOutOfMemoryError в приложении

  • Спонтанные и странные сбои приложения

  • В приложении время от времени заканчиваются объекты подключения

Давайте подробнее рассмотрим некоторые из этих сценариев и способы их устранения.

3. Типы утечек памяти в Java

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

3.1. Утечка памяти через поляstatic

Первый сценарий, который может вызвать потенциальную утечку памяти, - это интенсивное использование переменныхstatic.

В Javastatic fields have a life that usually matches the entire lifetime of the running application (еслиClassLoader не получает право на сборку мусора).

Давайте создадим простую программу на Java, которая заполняетstaticList:

public class StaticTest {
    public static List list = new ArrayList<>();

    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
        Log.info("Debug Point 2");
    }

    public static void main(String[] args) {
        Log.info("Debug Point 1");
        new StaticTest().populateList();
        Log.info("Debug Point 3");
    }
}

Теперь, если мы проанализируем память кучи во время выполнения этой программы, то увидим, что между точками отладки 1 и 2, как и ожидалось, память кучи увеличилась.

Но когда мы оставляем методpopulateList() в точке отладки 3,the heap memory isn’t yet garbage collected, как мы можем видеть в этом ответе VisualVM:

 

image

Однако в приведенной выше программе в строке номер 2, если мы просто отбросим ключевое словоstatic, это приведет к резкому изменению использования памяти, этот ответ Visual VM показывает:

 

image

Первая часть до точки отладки почти такая же, как и в случаеstatic.. Но на этот раз после того, как мы выйдем из методаpopulateList(),all the memory of the list is garbage collected because we don’t have any reference to it.

Следовательно, нам нужно уделять очень пристальное внимание использованию переменныхstatic. Если коллекции или большие объекты объявлены какstatic, то они остаются в памяти на протяжении всего времени существования приложения, тем самым блокируя жизненно важную память, которая в противном случае могла бы использоваться в другом месте.

Как это предотвратить?

  • Минимизировать использование переменныхstatic

  • При использовании синглетонов полагайтесь на реализацию, которая лениво загружает объект, а не загружает его

3.2. Через незакрытые ресурсы

Всякий раз, когда мы устанавливаем новое соединение или открываем поток, JVM выделяет память для этих ресурсов. Несколько примеров включают соединения с базой данных, потоки ввода и объекты сеанса.

Если вы забудете закрыть эти ресурсы, это может заблокировать память, что сделает их недоступными для GC. Это может произойти даже в случае исключения, которое не позволяет выполнению программы достичь оператора, обрабатывающего код для закрытия этих ресурсов.

В любом случаеthe open connection left from resources consumes memory, и если мы не будем с ними разбираться, они могут ухудшить производительность и даже привести кOutOfMemoryError.

Как это предотвратить?

  • Всегда используйте блокfinally для закрытия ресурсов

  • Код (даже в блокеfinally), закрывающий ресурсы, сам по себе не должен иметь никаких исключений.

  • При использовании Java 7+ мы можем использовать блокtry-with-resources

3.3. Неправильные реализацииequals() иhashCode()

При определении новых классов очень распространенной ошибкой является написание надлежащих переопределенных методов для методовequals() иhashCode().

HashSet иHashMap используют эти методы во многих операциях, и если они не будут правильно переопределены, они могут стать источником потенциальных проблем с утечкой памяти.

Давайте возьмем пример тривиального классаPerson и используем его в качестве ключа вHashMap:

public class Person {
    public String name;

    public Person(String name) {
        this.name = name;
    }
}

Теперь мы вставим повторяющиеся объектыPerson вMap, который использует этот ключ.

Помните, чтоMap не может содержать повторяющиеся ключи:

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map map = new HashMap<>();
    for(int i=0; i<100; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertFalse(map.size() == 1);
}

Здесь мы используемPerson в качестве ключа. ПосколькуMap не допускает дублирования ключей, многочисленные повторяющиеся объектыPerson, которые мы вставили в качестве ключа, не должны увеличивать память.

Ноsince we haven’t defined proper equals() method, the duplicate objects pile up and increase the memory, поэтому мы видим в памяти более одного объекта. Память кучи в VisualVM для этого выглядит следующим образом:

 

image

Однакоif we had overridden the equals() and hashCode() methods properly, then there would only exist one Person object in this*Map*.

Давайте посмотрим на правильные реализацииequals() иhashCode() для нашего классаPerson:

public class Person {
    public String name;

    public Person(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Person)) {
            return false;
        }
        Person person = (Person) o;
        return person.name.equals(name);
    }

    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }
}

И в этом случае были бы верны следующие утверждения:

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map map = new HashMap<>();
    for(int i=0; i<2; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertTrue(map.size() == 1);
}

После правильного переопределенияequals() иhashCode() память кучи для той же программы выглядит так:

 

image

Другой пример - использование инструмента ORM, такого как Hibernate, который использует методыequals() иhashCode() для анализа объектов и сохранения их в кеше.

The chances of memory leak are quite high if these methods are not overridden, потому что тогда Hibernate не сможет сравнивать объекты и заполнит свой кеш повторяющимися объектами.

Как это предотвратить?

  • Как показывает опыт, при определении новых сущностей всегда переопределяйте методыequals() иhashCode().

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

Для получения дополнительной информации посетите наши руководстваGenerate equals() and hashCode() with Eclipse иGuide to hashCode() in Java.

3.4. Внутренние классы, которые ссылаются на внешние классы

Это происходит в случае нестатических внутренних классов (анонимных классов). Для инициализации этим внутренним классам всегда требуется экземпляр включающего класса.

Каждый нестатический внутренний класс по умолчанию имеет неявную ссылку на свой содержащий класс. Если мы используем объект этого внутреннего класса в нашем приложении, тоeven after our containing class' object goes out of scope, it will not be garbage collected.

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

 

image

Однако, если мы просто объявим внутренний класс как статический, то та же модель памяти будет выглядеть следующим образом:

image

Это происходит потому, что внутренний объект класса неявно содержит ссылку на внешний объект класса, что делает его недопустимым кандидатом на сборку мусора. То же самое происходит в случае анонимных классов.

Как это предотвратить?

  • Если внутреннему классу не нужен доступ к содержащим его членам класса, подумайте о том, чтобы превратить его в классstatic

3.5. Через методыfinalize()

Использование финализаторов является еще одним источником потенциальных проблем утечки памяти. Каждый раз, когда метод класса 'finalize() переопределяется, затемobjects of that class aren’t instantly garbage collected. Вместо этого GC ставит их в очередь для завершения, что происходит позже.

Кроме того, если код, написанный в методеfinalize(), не является оптимальным, и если очередь финализатора не успевает за сборщиком мусора Java, то рано или поздно нашему приложению суждено встретитьOutOfMemoryError.

Чтобы продемонстрировать это, давайте предположим, что у нас есть класс, для которого мы переопределили методfinalize(), и что для его выполнения требуется немного времени. Когда большое количество объектов этого класса собирается сборщиком мусора, то в VisualVM это выглядит так:

 

image

Однако, если мы просто удалим переопределенный методfinalize(), то та же программа даст следующий ответ:

image

Как это предотвратить?

  • Мы всегда должны избегать финализаторов

Подробнее оfinalize() читайте в разделе 3 (Avoiding Finalizers) in ourGuide to the finalize Method in Java.

3.6. ИнтернированоStrings

Пул JavaString претерпел серьезные изменения в Java 7, когда он был переведен из PermGen в HeapSpace. Но для приложений, работающих на версии 6 и ниже, следует быть внимательнее при работе с большимиStrings.

If we read a huge massive String object, and call intern() on that object, then it goes to the string pool, which is located in PermGen (permanent memory) and will stay there as long as our application runs. Это блокирует память и создает серьезную утечку памяти в нашем приложении.

PermGen для этого случая в JVM 1.6 выглядит так в VisualVM:

 

image

В отличие от этого, в методе, если мы просто читаем строку из файла и не интернируем ее, тогда PermGen выглядит следующим образом:

image

 

Как это предотвратить?

  • Самым простым способом решения этой проблемы является обновление до последней версии Java, когда пул строк перемещается в HeapSpace с версии 7 Java и выше.

  • При работе с большимиStrings увеличьте размер пространства PermGen, чтобы избежать любого потенциальногоOutOfMemoryErrors:

    -XX:MaxPermSize=512m

3.7. ИспользованиеThreadLocals

ThreadLocal (подробно обсуждается в руководствеIntroduction to ThreadLocal in Java) - это конструкция, которая дает нам возможность изолировать состояние для определенного потока и, таким образом, позволяет нам достичь безопасности потоков.

При использовании этой конструкцииeach thread will hold an implicit reference to its copy of a ThreadLocal variable and will maintain its own copy, instead of sharing the resource across multiple threads, as long as the thread is alive.

Несмотря на свои преимущества, использование переменныхThreadLocal вызывает споры, поскольку они печально известны тем, что вызывают утечки памяти при неправильном использовании. Джошуа Блохonce commented on thread local usage:

«Неаккуратное использование пулов потоков в сочетании с небрежным использованием локальных потоков может привести к непреднамеренному удержанию объектов, как отмечалось во многих местах. Но возлагать вину на местных жителей неоправданно ».

Утечки памяти сThreadLocals

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

Современные серверы приложений используют пул потоков для обработки запросов вместо создания новых (например,the Executor в случае Apache Tomcat). Более того, они также используют отдельный загрузчик классов.

ПосколькуThread Pools на серверах приложений работают по концепции повторного использования потоков, они никогда не собираются сборщиком мусора - вместо этого они повторно используются для обслуживания другого запроса.

Теперь, если какой-либо класс создает переменнуюThreadLocal , но не удаляет ее явно, то копия этого объекта останется у рабочегоThread даже после остановки веб-приложения, таким образом предотвращая запуск объекта. сбор мусора.

Как это предотвратить?

  • Рекомендуется очищатьThreadLocals, когда они больше не используются -ThreadLocals предоставляют методremove(), который удаляет значение текущего потока для этой переменной.

  • Do not use ThreadLocal.set(null) to clear the value - он фактически не очищает значение, а вместо этого ищетMap, связанный с текущим потоком, и устанавливает пару ключ-значение как текущий поток иnull соответственно

  • Еще лучше рассматриватьThreadLocal  как ресурс, который необходимо закрыть в блокеfinally, чтобы убедиться, что он всегда закрыт, даже в случае исключения:

    try {
        threadLocal.set(System.nanoTime());
        //... further processing
    }
    finally {
        threadLocal.remove();
    }

4. Другие стратегии борьбы с утечками памяти

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

4.1. Включить профилирование

Профилировщики Java - это инструменты, которые отслеживают и диагностируют утечки памяти через приложение. Они анализируют, что происходит внутри нашего приложения, например, как распределяется память.

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

Мы использовалиJava VisualVM в разделе 3 этого руководства. Пожалуйста, ознакомьтесь с нашимиGuide to Java Profilers, чтобы узнать о различных типах профилировщиков, таких как Mission Control, JProfiler, YourKit, Java VisualVM и Netbeans Profiler.

4.2. Подробная сборка мусора

Включив подробную сборку мусора, мы отслеживаем подробную трассировку GC. Чтобы включить это, нам нужно добавить следующее в нашу конфигурацию JVM:

-verbose:gc

Добавив этот параметр, мы можем увидеть подробную информацию о том, что происходит внутри GC:

image

 

4.3. Используйте эталонные объекты, чтобы избежать утечек памяти

Мы также можем прибегнуть к ссылочным объектам в Java, которые встроены в пакетjava.lang.ref, чтобы справиться с утечками памяти. Используя пакетjava.lang.ref, вместо прямых ссылок на объекты мы используем специальные ссылки на объекты, которые позволяют легко собирать мусор.

Справочные очереди предназначены для информирования нас о действиях, выполняемых сборщиком мусора. Для получения дополнительной информации прочитайте пример руководстваSoft References in Java, особенно раздел 4.

4.4. Предупреждения об утечке памяти Eclipse

Для проектов на JDK 1.5 и выше Eclipse отображает предупреждения и ошибки всякий раз, когда он обнаруживает явные случаи утечек памяти. Поэтому при разработке в Eclipse мы можем регулярно посещать вкладку «Проблемы» и быть более бдительными в отношении предупреждений об утечке памяти (если есть):

image

 

4.5. Бенчмаркинг

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

Для получения дополнительной информации о сравнительном анализе перейдите к нашему руководствуMicrobenchmarking with Java.

4.6. Код Отзывы

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

В некоторых случаях даже этот простой метод может помочь устранить некоторые распространенные проблемы утечки памяти.

5. Заключение

С точки зрения непрофессионала, мы можем рассматривать утечку памяти как болезнь, которая снижает производительность нашего приложения, блокируя жизненно важные ресурсы памяти. И, как и все другие заболевания, если их не лечить, это может привести к фатальным сбоям приложения с течением времени.

Утечки памяти сложно решить, и для их поиска требуется сложное мастерство и владение языком Java. While dealing with memory leaks, there is no one-size-fits-all solution, as leaks can occur through a wide range of diverse events.с

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

Как всегда, фрагменты кода, используемые для генерации ответов VisualVM, описанных в этом руководстве, доступныon GitHub.