[Перевод] Понимание утечек памяти в Java

image-loader.svg

1. Введение

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

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

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

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

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

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

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

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

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

image-loader.svg

Признаки утечки памяти

  • Серьезное снижение производительности при длительной непрерывной работе приложения

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

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

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

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

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

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

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

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

В 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:

image-loader.svg

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

image-loader.svg

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

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

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

  • Минимизируйте использование статических переменных

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

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

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

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

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

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

image-loader.svg

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

Давайте рассмотрим правильную имплементацию 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-loader.svg

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

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

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

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

  • Недостаточно их просто переопределить, это необходимо сделать оптимальным образом. Для получения дополнительной информации ознакомьтесь с нашими учебными пособиями Generate equals () and hashCode () with Eclipse и Guide to hashCode () in Java.

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

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

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

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

image-loader.svg

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

image-loader.svg

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

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

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

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

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

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

0d77c063b8f87057e9095b3b41a7ac45.jpg

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

51a0ba1ac9f722d67366b2414f20edee.jpg

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

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

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

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

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

PermGen для этого случая в JVM 1.6 выглядит в VisualVM следующим образом :

image-loader.svg

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

image-loader.svg

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

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

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

-XX:MaxPermSize=512m

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

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

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

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

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

Утечки памяти при использовании ThreadLocals

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

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

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

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

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

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

  • Не используйте ThreadLocal.set (null) для очистки значения — он в действительности не очищает, а вместо этого ищет Map, связанную с текущим потоком, и устанавливает пару ключ-значение как текущий поток и null соответственно

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

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

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

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

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

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

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

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

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

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

-verbose:gc

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

image-loader.svg

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

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

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

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

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

088ddd8f07045bfd27139f04dd040c06.jpg

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

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

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

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

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

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

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

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

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

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

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

Материал подготовлен в рамках курса «Нагрузочное тестирование». Если вам интересно узнать подробнее о формате обучения и программе, познакомиться с преподавателем курса — приглашаем на день открытых дверей онлайн. Регистрация здесь.

© Habrahabr.ru