Утечки памяти в Java

1. Введение
Одним из основных преимуществ Java является автоматизированное управление памятью с помощью встроенного сборщика мусора (Garbage Collector или GC). GC неявно заботится о распределении и освобождении памяти и, таким образом, способен обрабатывать большинство проблем, связанных с утечкой памяти.
Хотя сборщик мусора работает достаточно эффективно, но он все равно не может гарантировать стопроцентную защиту от утечек памяти. GC достаточно умен, но не идеален.
Могут возникать ситуации, когда приложение генерирует большое количество лишних объектов, создание которых может привести к истощению ресурсов памяти, а значит и к сбою всего приложения.
Утечки памяти — серьезная проблема в Java. В этом руководстве мы разберем потенциальные причины утечек, посмотрим как распознавать их во время выполнения и как справляться с ними.
2. Что такое утечка памяти?
Утечка памяти — это ситуация, когда в куче есть объекты, которые больше не используются, но сборщик мусора не может удалить их, что приводит к нерациональному расходованию памяти.
Утечка является проблемой, так как она блокирует ресурсы памяти, что со временем приводит к ухудшению производительности системы. И если ее не устранить, приложение исчерпает свои ресурсы и завершиться с ошибкой java.lang.OutOfMemoryError.
Существует два типа объектов, располагающихся в куче: те, которые имеют активные ссылки в приложении и те, на которые ни одна переменная ссылочного типа не ссылается.
Сборщик мусора периодически удаляет объекты на которые не осталось активных ссылок, но никогда не удаляет объекты на которые ссылаются.
Вот, где могут произойти утечки:

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

  • Серьезное ухудшение производительности, когда оно работает продолжительное время;
  • Возникновение в приложении ошибки java.lang.OutOfMemoryError;
  • Спонтанные и странные сбои в приложении;
  • Иногда в приложении заканчиваются объекты подключения;
Давайте подробнее рассмотрим некоторые из этих симптомов и способы их устранения.
    3. Типы утечек памяти
    В приложениях утечки памяти могут возникать по разным причинам. В этом разделе мы обсудим наиболее распространенные из них.
    3.1 Утечки памяти из-за статических полей
    Первый сценарий, который может вызвать утечку памяти — это интенсивное использование статических переменных.
    В Java время жизни статических полей обычно совпадает со временем работы приложения.
    Давайте создадим простую программу которая заполняет статический список (List):
    public class StaticTest {
        public static List<Double> 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, память кучи еще не очищена сборщиком мусора:
    Однако, если мы отбросим слово static в строке номер 2, то это приведет к резкому изменению использования памяти:
    До первой контрольной точки поведение приложения практически не отличается в обоих случаях. Но во втором случае, после завершения метода populateList(), память была очищена, потому что были удалены все объекты на которых в приложении больше нет активных ссылок.
    Следовательно, мы должны быть внимательны при использовании статических переменных. Если коллекции или объекты объявлены как статические, то они остаются в памяти в течение всего срока работы приложения, тем самым блокируя ресурсы, которые можно было бы использовать в другом месте.

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

    • Минимизировать использование статических переменных в приложении.
    • При использовании синглтонов использовать реализацию с ленивый загрузкой объекта, вместо немедленной.
    3.2 Через незакрытые ресурсы
    Всякий раз, когда мы создаем новое соединение или открываем поток, JVM выделяет память для этих ресурсов. Это могут быть соединения с базой данных, входящие потоки или сессионные объекты.
    Забывая закрыть эти ресурсы, вы можете заблокировать память, тем самым делая их недоступными для сборщика мусора. Это может произойти даже в случае возникновения исключения, которое не позволит программе выполнить код, отвечающий за закрытие ресурсов.
    В любом случае, открытые соединения потребляеют память и если мы не будем корректно обрабатывать их закрытие, они могут ухудшить производительность системы и даже привести к 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<Person, Integer> map = new HashMap<>();
        for(int i = 0; i < 100; i++) {
            map.put(new Person("jon"), 1);
        }
        Assert.assertFalse(map.size() == 1);
    }
    Поскольку Map не позволяет использовать дубликаты ключей, многочисленные объекты Person, которые мы добавили, не должны увеличить занимаемую ими пространство в памяти.
    Поскольку мы не определили правильные метод equals(), дублирующие объекты накопились и заняли память. В этом случае потребление памяти кучи выглядит следующим образом:
    Однако, если бы мы правильно переопределили методы 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<Person, Integer> map = new HashMap<>();
        for(int i = 0; i < 2; i++) {
            map.put(new Person("jon"), 1);
        }
        Assert.assertTrue(map.size() == 1);
    }
    После правильного переопределения equals() и hashCode() для класса Person, использование памяти кучи выглядит следующим образом:
    Другим примером является использование ORM, например Hibernate, который использует методы equals() и hashCode() для анализа объектов и сохранения их в кеше.
    Если эти методы не переопределены, то шансы утечки памяти довольно высоки, потому что Hibernate не сможет сравнивать объекты и заполнит свой кеш их дубликатами.

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

    • Взять за правило, при создании новых сущностей (Entity), всегда переопределять методы equals() и hashCode() .
    • Не достаточно просто переопределить эти методы. Они должны быть переопределены оптимальным образом.
    3.4 Внутренние классы, которые ссылаются на внешние классы
    Не статическим внутренним классам (анонимным) для инициализации всегда требуется экземпляр внешнего класса.
    Каждый нестатический внутренний класс по умолчанию имеет неявную (скрытую) ссылку на класс в котором он находится. Если мы используем этот объект внутреннего класса в нашем приложении, то даже после того, как объект внешнего класса завершает свою работу, он не будет утилизирован сборщиком мусора.
    Рассмотрим класс, содержащий ссылку на множество громоздких объектов и имеющий не статический внутренний класс. Теперь, когда мы создаем объект только внутреннего класса, модель памяти выглядит так:
    Однако, если мы просто объявим внутренний класс как статический, то та же модель памяти будет выглядеть так:

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

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

    • Если внутреннему классу не нужен доступ к членам внешнего класса, подумайте о превращении его в статический класс.
    3.5 Через finalize() методы
    Использование финализаторов является еще одним потенциальным источником утечек памяти. Всякий раз, когда в классе переопределяется метод finalize(), объект этого класса не убирается сборщиком мусора немедленно. Вместо этого он помещается сборщиком в очередь на утилизацию, которая происходит немного позже.
    Кроме того, если код, написанный в методе finalize(), переопределен неоптимально, и если очередь финализатора не может идти в ногу со сборщиком мусора Java, то рано или поздно нашему приложению суждено встретить ошибку OutOfMemoryError.
    Пояснение: методы finalize() вызываются последовательно в том порядке, в котором были добавлены в список сборщиком мусора. Соответственно, если какой-то finalize() зависнет, он подвесит поток «Finalizer», но не сборщик мусора. Это в частности означает, что объекты, не имеющие метода finalize(), будут исправно удаляться, а вот имеющие будут добавляться в очередь, пока не отвиснет поток «Finalizer», не завершится приложение или не кончится память.
    Чтобы продемонстрировать это, давайте представим, что у нас есть класс, для которого мы переопределили метод finalize() и что для выполнения этого метода требуется немного времени. Когда большое количество объектов этого класса собираются сборщиком мусора, мы видим следующую картину использования памяти кучи:
    Однако, если мы просто удалим переопределенный метод finalize(), то эта же программа покажет следующий результат:

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

    • Мы всегда должны избегать финализаторов.
    3.6 Интернированные строки
    В Java 7 пул строк претерпел значительные изменения: он был перенесен из PermGen в HeapSpace (подробнее об этом можно прочитать в статье PermGen и Metaspace в среде Java). Но в приложениях, работающих на версии 6 и ниже, мы должны быть более внимательными при работе с большими строкам.
    Когда мы читаем большой строковый объект и вызываем у него метод intern(), то он сохраняется в пул строк, который находиться в PermGen (постоянная память) и остается там до тех пор, пока наше приложение работает. Это блокирует память и приводит к серьезным утечкам в приложении.
    В таком случае использование PermGen пространства в JVM 1.6 выглядит следующим образом:
    В том случае, если мы просто читаем строку из файла и не интернируем ее, то PermGen выглядит так:

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

    • Самый простой способ решить эту проблему — обновиться до последней версии Java, поскольку пул строк был перемещен в пространство кучи, начиная с 7 версии.
    • При работе с большими строками можно увеличить размер PermGen, что позволит избежать ошибки OutOfMemoryError:
    -XX:MaxPermSize=512m
    3.7 Использование ThreadLocals
    ThreadLocal — это механизм, который позволяет изолировать состояние (значения переменных) в определенном потоке, что делает его безопасным.
    При использовании этой конструкции, каждый поток будет содержать неявную ссылку на его копию переменной ThreadLocal и будет хранить свою собственную копию, вместо того чтобы совместно использовать ресурс через множество потоков, так долго, сколько поток будет жить.
    Несмотря на свои преимущества, использование переменных ThreadLocal противоречиво, поскольку они могут являться причиной утечек памяти, если они не используются должным образом.
    Утечки памяти по причине использования ThreadLocals.
    Предполагается что ThreadLocal переменные будут собраны сборщиком мусора после того, как содержащий их поток перестанет существовать. Но существует проблема с использованием ThreadLocal в современных серверах приложений.
    Современные сервера приложений используют пул потоков для обработки запросов, вместо создания нового потока на каждый запрос. Кроме того, они используют отдельный загрузчик классов.
    Поскольку пулы потоков в серверах приложений работают по принципу повторного использования потоков, они никогда не удаляются сборщиком мусора — вместо этого они повторно используются для обслуживания другого запроса.
    Итак, если класс создает ThreadLocal переменную, но не удаляет ее явно, то копия этого объекта останется в рабочем потоке даже после остановки веб-приложения, тем самым не позволяя сборщику удалить этот объект.

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

    • Хорошей практикой является очищение ThreadLocal переменных, когда они больше не используются. ThreadLocal предоставляет метод remove(), который удаляет значение переменной для текущего потока
    • Не используйте ThreadLocal. set (null) для очистки значения — на самом деле оно не очищает значение, а вместо этого ищет мапу, связанную с текущим потоком, и устанавливает пару ключ-значение — текущий поток и null соответственно
    • Еще лучше рассмотреть ThreadLocal как ресурс, который необходимо закрыть в блоке finally, чтобы убедиться, что он всегда будет закрыт, даже в случае исключения:
    try {
        threadLocal.set(System.nanoTime());
        //... further processing
    } finally {
        threadLocal.remove();
    }
    4. Другие стратегии для борьбы с утечками памяти
    Несмотря на то, что при работе с утечками памяти нет единого решения для всех случаев, есть некоторые способы, с помощью которых мы можем минимизировать эти утечки.
    4.1 Включить профилирование
    Java-профайлеры — это инструменты, которые контролируют и диагностируют утечки памяти. Они анализируют, что происходит внутри нашего приложения, например, как распределяется память.
    Используя профайлеры, мы можем сравнивать различные подходы и находить области, где мы можем оптимально использовать наши ресурсы.
    4.2 Детальная сборка мусора
    Включая режим детальной сборки мусора мы можем отслеживать подробности, происходящие при работе GC. Чтобы включить этот функционал, нужно добавить следующую настройку JVM:
    -verbose:gc
    Включив этот параметр, мы увидим детали того, что происходит внутри сборщика мусора:
    4.3 Используйте ссылочные объекты, чтобы избежать утечек памяти
    Мы также можем прибегнуть к ссылочным объектам в Java, которые встроены в пакет java.lang.ref для устранения утечек памяти. Используя пакет java.lang.ref, вместо прямых ссылки на объекты, мы используем специальные ссылки на объекты, которые способствуют легкому удалению сборщиком мусора этих объектов.
    4.4 Бенчмаркинг
    Мы можем измерять и анализировать производительность Java-кода, выполняя тесты. Таким образом, мы можем сравнить эффективность альтернативных подходов для выполнения одной и той же задачи. Это может помочь нам выбрать лучший подход и сохранить память.
    Для получения дополнительной информации о бенчмаркинге, пожалуйста, перейдите по ссылке Microbenchmarking with Java.
    4.5 Рецензия кода
    Наконец, у нас есть классический способ — просто, лишний раз пробежаться по нашему коду.
    В некоторых случаях даже этот тривиальный метод может помочь в устранении некоторых распространенных проблем утечки памяти.
    5. Заключение
    С точки зрения непрофессионала мы можем думать об утечке памяти, как о болезни, которая ухудшает производительность нашего приложения, блокируя жизненно важные ресурсы памяти. И, как и все другие болезни, со временем, может привести к летальному исходу приложения.
    Утечки памяти не просто исправить — их поиск требует высокого мастерства владения языком Java. При работе с утечками памяти нет единого решения для всех случаев, так как утечки могут возникать в результате широкого спектра разнообразных событий.
    Однако, если мы прибегаем к передовым практикам, регулярно проверяем наш код и используем профилирование, мы можем свести к минимуму риск утечек памяти в нашем приложении.
    Оригинал статьи «Understanding Memory Leaks in Java»
    Оцените статью, если она вам понравилась!