1. Введение
Одним из основных преимуществ Java является автоматизированное управление памятью с помощью встроенного сборщика мусора ( сокращенно GC ). Сборщик мусора неявно заботится о выделении и освобождении памяти и, таким образом, способен справиться с большинством проблем с утечкой памяти.
Хотя сборщик мусора эффективно обрабатывает значительную часть памяти, он не гарантирует надежного решения проблемы утечки памяти. GC довольно умен, но не безупречен. Утечки памяти все же могут подкрасться даже в приложениях добросовестного разработчика.
По-прежнему могут возникать ситуации, когда приложение создает значительное количество лишних объектов, тем самым истощая важные ресурсы памяти, что иногда приводит к сбою всего приложения.
Утечки памяти — настоящая проблема в Java. В этом руководстве мы увидим, каковы потенциальные причины утечек памяти, как распознать их во время выполнения и как бороться с ними в нашем приложении .
2. Что такое утечка памяти
Утечка памяти — это ситуация, когда в куче присутствуют объекты, которые больше не используются, но сборщик мусора не может удалить их из памяти и, таким образом, они обслуживаются без необходимости.
Утечка памяти — это плохо, потому что она блокирует ресурсы памяти и со временем снижает производительность системы . И если с этим не справиться, приложение в конечном итоге исчерпает свои ресурсы и, наконец, завершится с фатальным java.lang.OutOfMemoryError
.
Есть два разных типа объектов, которые находятся в памяти кучи — ссылки и ссылки. Объекты, на которые ссылаются, — это те, на которые все еще есть активные ссылки в приложении, тогда как объекты без ссылок не имеют активных ссылок.
Сборщик мусора периодически удаляет объекты, на которые нет ссылок, но никогда не собирает объекты, на которые все еще ссылаются. Здесь могут возникнуть утечки памяти:
Симптомы утечки памяти
- Серьезное снижение производительности, когда приложение непрерывно работает в течение длительного времени.
Ошибка кучи OutOfMemoryError
в приложении- Самопроизвольные и странные сбои приложений
- В приложении иногда заканчиваются объекты подключения
Давайте подробнее рассмотрим некоторые из этих сценариев и способы их решения.
3. Типы утечек памяти в Java
В любом приложении утечки памяти могут возникать по многим причинам. В этом разделе мы обсудим наиболее распространенные из них.
3.1. Утечка памяти через статические
поля
Первый сценарий, который может привести к потенциальной утечке памяти, — интенсивное использование статических
переменных.
В Java статические
поля имеют срок службы, который обычно соответствует всему сроку службы работающего приложения (если только ClassLoader
не становится пригодным для сборки мусора).
Давайте создадим простую программу Java, которая заполняет статический
список:
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:
Однако в приведенной выше программе в строке номер 2, если мы просто отбросим ключевое слово static
, это приведет к резкому изменению использования памяти, как показывает этот ответ Visual VM:
Первая часть до точки отладки почти такая же, как и в случае статики.
Но на этот раз, когда мы выходим из метода populateList() ,
вся память списка удаляется сборщиком мусора, потому что у нас нет на него ссылок .
Следовательно, нам нужно уделять очень пристальное внимание использованию статических
переменных. Если коллекции или большие объекты объявлены как static
, то они остаются в памяти на протяжении всего жизненного цикла приложения, тем самым блокируя жизненно важную память, которая в противном случае могла бы использоваться в другом месте.
Как это предотвратить?
- Минимизируйте использование
статических
переменных - При использовании синглетонов полагайтесь на реализацию, которая лениво загружает объект, а не жадно загружает его.
3.2. Через незакрытые ресурсы
Всякий раз, когда мы устанавливаем новое соединение или открываем поток, JVM выделяет память для этих ресурсов. Несколько примеров включают подключения к базе данных, входные потоки и объекты сеанса.
Если вы забудете закрыть эти ресурсы, это может привести к блокировке памяти, что сделает их недоступными для сборщика мусора. Это может произойти даже в случае исключения, которое не позволяет выполнению программы достичь оператора, обрабатывающего код для закрытия этих ресурсов.
В любом случае открытые соединения, оставшиеся от resources, потребляют память , и если мы с ними не разберемся, они могут ухудшить производительность и даже привести к 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 в
карту
, использующую этот ключ.
Помните, что карта
не может содержать повторяющиеся ключи:
@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);
}
Здесь мы используем Person
в качестве ключа. Поскольку Map
не допускает дублирования ключей, многочисленные повторяющиеся объекты Person
, которые мы вставили в качестве ключа, не должны увеличивать объем памяти.
Но поскольку мы не определили правильный метод equals()
, повторяющиеся объекты накапливаются и увеличивают память , поэтому мы видим более одного объекта в памяти. Память кучи в VisualVM для этого выглядит так:
Однако, если бы мы правильно переопределили методы equals()
и hashCode() , то в этой
`` карте
существовал бы только один объект 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()
память кучи для той же программы выглядит так:
Другой пример — использование инструмента ORM, такого как Hibernate, который использует методы equals()
и hashCode()
для анализа объектов и сохранения их в кэше.
Шансы на утечку памяти довольно высоки, если эти методы не переопределены, потому что тогда Hibernate не сможет сравнивать объекты и заполнит свой кэш повторяющимися объектами.
Как это предотвратить?
- Как правило, при определении новых сущностей всегда переопределяйте методы
equals()
иhashCode() .
- Недостаточно просто переопределить, эти методы также должны быть переопределены оптимальным образом.
Для получения дополнительной информации посетите наши руководства Генерация equals()
и hashCode()
с помощью Eclipse и Руководство по hashCode()
в Java .
3.4. Внутренние классы, которые ссылаются на внешние классы
Это происходит в случае нестатических внутренних классов (анонимных классов). Для инициализации этим внутренним классам всегда требуется экземпляр окружающего класса.
Каждый нестатический внутренний класс по умолчанию имеет неявную ссылку на содержащий его класс. Если мы используем объект этого внутреннего класса в нашем приложении, то даже после того, как объект нашего содержащего класса выйдет за пределы области видимости, он не будет удален сборщиком мусора .
Рассмотрим класс, который содержит ссылки на множество громоздких объектов и имеет нестатический внутренний класс. Теперь, когда мы создаем объект только внутреннего класса, модель памяти выглядит так:
Однако если мы просто объявим внутренний класс как статический, то та же самая модель памяти будет выглядеть так:
Это происходит потому, что объект внутреннего класса неявно содержит ссылку на объект внешнего класса, что делает его недопустимым кандидатом для сборки мусора. То же самое происходит и в случае анонимных классов.
Как это предотвратить?
- Если внутреннему классу не нужен доступ к членам содержащего класса, рассмотрите возможность превращения его в
статический
класс .
3.5. Через методы finalize()
Использование финализаторов — еще один источник потенциальных проблем с утечкой памяти. Всякий раз, когда метод finalize()
класса переопределяется, объекты этого класса не удаляются мгновенно сборщиком мусора. Вместо этого сборщик мусора ставит их в очередь для финализации, которая происходит позднее.
Кроме того, если код, написанный в методе finalize()
, не оптимален и если очередь финализатора не успевает за сборщиком мусора Java, то рано или поздно нашему приложению суждено встретить OutOfMemoryError
.
Чтобы продемонстрировать это, давайте представим, что у нас есть класс, для которого мы переопределили метод finalize()
и что для выполнения этого метода требуется немного времени. Когда сборщиком мусора становится большое количество объектов этого класса, то в VisualVM это выглядит так:
Однако если мы просто удалим переопределенный метод finalize()
, то та же программа выдаст следующий ответ:
Как это предотвратить?
- Мы всегда должны избегать финализаторов
Подробнее о finalize()
читайте в разделе 3 ( Избегание финализаторов)
нашего руководства по методу finalize в Java .
3.6. Интернированные строки
Пул Java String
претерпел серьезные изменения в Java 7, когда он был перенесен из PermGen в HeapSpace. А вот для приложений, работающих на версии 6 и ниже, нам следует быть более внимательными при работе с большими Strings
.
Если мы прочитаем огромный массивный объект String
и вызовем intern()
для этого объекта, то он попадет в пул строк, который находится в PermGen (постоянной памяти) и останется там, пока работает наше приложение. Это блокирует память и создает серьезную утечку памяти в нашем приложении.
PermGen для этого случая в JVM 1.6 выглядит в VisualVM так:
В отличие от этого, в методе, если мы просто читаем строку из файла и не интернируем ее, то PermGen выглядит так:
Как это предотвратить?
- Самый простой способ решить эту проблему — выполнить обновление до последней версии Java, поскольку пул строк перемещается в HeapSpace, начиная с версии Java 7.
- При работе с большими
строками
увеличьте размер пространства PermGen, чтобы избежать возможныхошибок OutOfMemoryErrors
:
-XX:MaxPermSize=512m
3.7. Использование ThreadLocal
_
ThreadLocal
(подробно обсуждается в руководстве Introduction to ThreadLocal
in Java ) — это конструкция, которая дает нам возможность изолировать состояние для определенного потока и, таким образом, позволяет нам достичь безопасности потоков.
При использовании этой конструкции каждый поток будет содержать неявную ссылку на свою копию переменной ThreadLocal
и будет поддерживать свою собственную копию вместо совместного использования ресурса несколькими потоками, пока поток жив.
Несмотря на свои преимущества, использование переменных ThreadLocal
вызывает споры, поскольку они печально известны тем, что приводят к утечкам памяти при неправильном использовании. Джошуа Блох однажды прокомментировал локальное использование треда:
«Небрежное использование пулов потоков в сочетании с небрежным использованием локальных переменных потоков может привести к непреднамеренному удержанию объектов, как отмечалось во многих местах. Но возлагать вину на местных жителей треда необоснованно».
Утечки памяти с ThreadLocals
Предполагается, что ThreadLocals
будут собирать мусор после того, как удерживающий поток больше не существует. Но проблема возникает, когда ThreadLocals
используются вместе с современными серверами приложений.
Современные серверы приложений используют пул потоков для обработки запросов вместо создания новых (например , Executor
в случае Apache Tomcat). Более того, они также используют отдельный загрузчик классов.
Поскольку пулы потоков на серверах приложений работают по принципу повторного использования потоков, они никогда не удаляются сборщиком мусора — вместо этого они повторно используются для обслуживания другого запроса.
Теперь, если какой-либо класс создает переменную ThreadLocal
, но не удаляет ее явно, то копия этого объекта останется в рабочем потоке
даже после остановки веб-приложения, что предотвратит сборку мусора для объекта.
Как это предотвратить?
- Хорошей практикой является очистка
ThreadLocals
, когда они больше не используются —ThreadLocals
предоставляет методremove()
, который удаляет значение текущего потока для этой переменной. - Не используйте
ThreadLocal.set(null)
для очистки значения — на самом деле он не очищает значение, а вместо этого ищеткарту
, связанную с текущим потоком, и устанавливает пару ключ-значение как текущий поток инуль
соответственно . - Еще лучше рассматривать
ThreadLocal
как ресурс, который нужно закрыть в блокеfinally
, чтобы убедиться, что он закрыт всегда, даже в случае исключения:
try {
threadLocal.set(System.nanoTime());
//... further processing
}
finally {
threadLocal.remove();
}
4. Другие стратегии борьбы с утечками памяти
Хотя универсального решения для устранения утечек памяти не существует, есть несколько способов минимизировать эти утечки.
4.1. Включить профилирование
Профилировщики Java — это инструменты, которые отслеживают и диагностируют утечки памяти через приложение. Они анализируют, что происходит внутри нашего приложения — например, как выделяется память.
Используя профилировщики, мы можем сравнить различные подходы и найти области, в которых мы можем оптимально использовать наши ресурсы.
Мы использовали Java VisualVM в разделе 3 этого руководства. Ознакомьтесь с нашим руководством по профилировщикам Java , чтобы узнать о различных типах профилировщиков, таких как Mission Control, JProfiler, YourKit, Java VisualVM и Netbeans Profiler.
4.2. Подробная сборка мусора
Включив подробный сбор мусора, мы отслеживаем подробную трассировку сборщика мусора. Чтобы включить это, нам нужно добавить следующее в нашу конфигурацию JVM:
-verbose:gc
Добавив этот параметр, мы можем увидеть детали того, что происходит внутри GC:
4.3. Используйте эталонные объекты, чтобы избежать утечек памяти
Мы также можем прибегнуть к ссылочным объектам в Java, которые встроены в пакет java.lang.ref
для устранения утечек памяти. Используя пакет java.lang.ref
, вместо прямых ссылок на объекты мы используем специальные ссылки на объекты, которые позволяют легко собирать мусор.
Справочные очереди предназначены для информирования нас о действиях, выполняемых сборщиком мусора. Для получения дополнительной информации прочитайте руководство Soft References in Java ForEach, в частности, раздел 4.
4.4. Предупреждения об утечке памяти Eclipse
Для проектов на JDK 1.5 и более поздних версиях Eclipse показывает предупреждения и ошибки всякий раз, когда обнаруживает очевидные случаи утечки памяти. Так что при разработке в Eclipse мы можем регулярно посещать вкладку «Проблемы» и быть более бдительными в отношении предупреждений об утечке памяти (если они есть):
4.5. Бенчмаркинг
Мы можем измерять и анализировать производительность кода Java, выполняя тесты. Таким образом, мы можем сравнить производительность альтернативных подходов к выполнению одной и той же задачи. Это может помочь нам выбрать лучший подход и сохранить память.
Для получения дополнительной информации о бенчмаркинге, пожалуйста, перейдите к нашему учебнику Microbenchmarking with Java .
4.6. Обзоры кода
Наконец, у нас всегда есть классический, олдскульный способ простого просмотра кода.
В некоторых случаях даже этот тривиальный метод может помочь в устранении некоторых распространенных проблем с утечкой памяти.
5. Вывод
С точки зрения непрофессионала, мы можем думать об утечке памяти как о болезни, которая снижает производительность нашего приложения, блокируя жизненно важные ресурсы памяти. И, как и все другие болезни, если его не вылечить, со временем это может привести к фатальным сбоям приложения.
Утечки памяти сложно решить, и их обнаружение требует сложного мастерства и владения языком Java. При работе с утечками памяти не существует универсального решения, поскольку утечки могут возникать в результате широкого спектра разнообразных событий.
Однако, если мы прибегнем к лучшим практикам и будем регулярно выполнять тщательное прохождение кода и профилирование, то мы сможем свести к минимуму риск утечек памяти в нашем приложении.
Как всегда, фрагменты кода, используемые для создания ответов VisualVM, описанных в этом руководстве, доступны на GitHub .