Рубрики
Без рубрики

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

Узнайте, что такое утечки памяти в Java, как распознать их во время выполнения, каковы их причины и стратегии их предотвращения.

Автор оригинала: baeldung.

1. введение

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

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

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

Утечки памяти-это настоящая проблема в Java. В этом уроке мы увидим каковы потенциальные причины утечек памяти, как распознать их во время выполнения и как справиться с ними в нашем приложении .

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

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

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

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

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

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

  • Серьезное снижение производительности при длительной непрерывной работе приложения
  • OutOfMemoryError ошибка кучи в приложении
  • Спонтанные и странные сбои приложений
  • В приложении иногда заканчиваются объекты подключения

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

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

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

3.1. Утечка Памяти Через статические Поля

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

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

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

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, память кучи еще не собрана как мы можем видеть в этом ответе VisualVM:

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

Первая часть до момента отладки почти совпадает с тем, что мы получили в случае static. Но на этот раз после того, как мы покинем метод populateList () , вся память списка будет собрана мусором, потому что у нас нет никакой ссылки на него .

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

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

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

3.2. Через Открытые Ресурсы

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

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

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

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

  • Всегда используйте finally block для закрытия ресурсов
  • Код (даже в блоке 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 объектов в Карту , использующую этот ключ.

Помните, что Карта не может содержать дубликаты ключей:

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

Но поскольку мы не определили правильный метод equals () , дубликаты объектов накапливаются и увеличивают объем памяти , поэтому мы видим в памяти более одного объекта. Память кучи в VisualVM для этого выглядит следующим образом:

Однако если бы мы правильно переопределили методы equals() и hashCode () , то в этой Карте существовал бы только один Человек объект .

Давайте рассмотрим правильные реализации 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 () Память кучи для одной и той же программы выглядит следующим образом:

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

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

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

  • Как правило, при определении новых сущностей всегда переопределяйте методы equals() и hashCode()
  • Этого недостаточно просто переопределить, но эти методы также должны быть переопределены оптимальным способом

Для получения дополнительной информации посетите наши учебные пособия Generate equals() и hashCode() с Eclipse и Руководство по hashCode() в Java .

3.4. Внутренние Классы, Ссылающиеся На Внешние Классы

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

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

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

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

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

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

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

3.5. С помощью методов finalize()

Использование финализаторов является еще одним источником потенциальных проблем с утечкой памяти. Всякий раз, когда метод класса finalize() переопределяется, объекты этого класса не собираются мгновенно. Вместо этого GC ставит их в очередь для завершения, что происходит в более поздний момент времени.

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

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

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

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

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

Для получения более подробной информации о finalize () прочитайте раздел 3 ( Избегая финализаторов) в нашем Руководстве по методу finalize в Java .

3.6. Интернированные строки

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

Если мы прочитаем огромный массивный объект String и вызовем intern() на этом объекте, то он перейдет в пул строк, который находится в PermGen (постоянная память) и будет оставаться там до тех пор, пока наше приложение работает. Это блокирует память и создает серьезную утечку памяти в нашем приложении.

ПермГен для этого случая в JVM 1.6 выглядит так в VisualVM:

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

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

  • Самый простой способ решить эту проблему-обновить до последней версии Java, поскольку пул строк перемещается в пространство кучи с версии Java 7 и далее
  • При работе с большими строками увеличьте размер пространства PermGen , чтобы избежать возможных OutOfMemoryErrors :

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

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

При использовании этой конструкции каждый поток будет содержать неявную ссылку на свою копию ThreadLocal переменная и будет поддерживать свою собственную копию, а не делиться ресурсом между несколькими потоками, пока поток жив.

Несмотря на свои преимущества, использование переменных ThreadLocal спорно, поскольку они печально известны тем, что при неправильном использовании приводят к утечкам памяти. Джошуа Блох однажды прокомментировал локальное использование потока :

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

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

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

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

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

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

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

  • Рекомендуется очистить ThreadLocals , когда они больше не используются- ThreadLocals предоставляет метод remove () , который удаляет значение текущего потока для этой переменной
  • Не используйте ThreadLocal.set(null) для очистки значения — на самом деле оно не очищает значение, а вместо этого ищет Карту , связанную с текущим потоком, и устанавливает пару ключ-значение в качестве текущего потока и null соответственно
  • Еще лучше рассматривать ThreadLocal как ресурс, который должен быть закрыт в блоке finally , чтобы убедиться, что он всегда закрыт, даже в случае исключения:

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

Хотя при работе с утечками памяти не существует универсального решения, есть несколько способов минимизировать эти утечки.

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

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

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

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

4.2. Подробный сбор Мусора

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

-verbose:gc

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

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

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

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

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

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

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

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

Для получения дополнительной информации о бенчмаркинге, пожалуйста, перейдите к нашему учебнику по микробенчмаркингу с Java.

4.6. Обзоры Кода

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

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

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

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

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

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

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