Поиск утечек памяти в Java
Опубликовано: 2022-03-11Неопытные программисты часто думают, что автоматическая сборка мусора в Java полностью освобождает их от забот об управлении памятью. Это распространенное заблуждение: хотя сборщик мусора делает все возможное, даже лучший программист вполне может стать жертвой утечек памяти. Позволь мне объяснить.
Утечка памяти происходит, когда ссылки на объекты, которые больше не нужны, поддерживаются без необходимости. Эти утечки плохи. Во-первых, они оказывают ненужную нагрузку на вашу машину, поскольку ваши программы потребляют все больше и больше ресурсов. Что еще хуже, обнаружение этих утечек может быть затруднено: статический анализ часто изо всех сил пытается точно идентифицировать эти избыточные ссылки, а существующие инструменты обнаружения утечек отслеживают и сообщают детализированную информацию об отдельных объектах, давая результаты, которые трудно интерпретировать и неточны.
Другими словами, утечки либо слишком сложно идентифицировать, либо они определяются с помощью терминов, которые слишком специфичны, чтобы быть полезными.
На самом деле существует четыре категории проблем с памятью с похожими и перекрывающимися симптомами, но разными причинами и решениями:
Производительность : обычно связана с чрезмерным созданием и удалением объектов, длительными задержками при сборке мусора, чрезмерной подкачкой страниц операционной системы и многим другим.
Ограничения ресурсов : возникает, когда либо мало доступной памяти, либо ваша память слишком фрагментирована, чтобы выделить большой объект — это может быть родным или, что чаще, связано с кучей Java.
Утечки кучи Java : классическая утечка памяти, при которой объекты Java постоянно создаются без освобождения. Обычно это вызвано скрытыми ссылками на объекты.
Утечки собственной памяти : связанные с любым постоянно растущим использованием памяти за пределами кучи Java, например выделениями, сделанными кодом JNI, драйверами или даже выделениями JVM.
В этом руководстве по управлению памятью я сосредоточусь на утечках кучи Java и опишу подход к обнаружению таких утечек на основе отчетов Java VisualVM и использовании визуального интерфейса для анализа приложений на основе технологии Java во время их работы.
Но прежде чем вы сможете предотвращать и находить утечки памяти, вы должны понять, как и почему они происходят. ( Примечание: если вы хорошо разбираетесь в тонкостях утечек памяти, можете пропустить этот шаг. )
Утечки памяти: введение
Во-первых, подумайте об утечке памяти как о болезни, а об OutOfMemoryError
(OOM) в Java — как о ее симптоме. Но, как и при любом заболевании, не все OOM обязательно подразумевают утечку памяти : OOM может возникать из-за генерации большого количества локальных переменных или других подобных событий. С другой стороны, не все утечки памяти обязательно проявляются как OOM , особенно в случае настольных приложений или клиентских приложений (которые не работают очень долго без перезагрузки).
Почему эти утечки так плохи? Среди прочего, утечка блоков памяти во время выполнения программы часто снижает производительность системы с течением времени, поскольку выделенные, но неиспользуемые блоки памяти необходимо будет выгружать, как только в системе закончится свободная физическая память. В конце концов, программа может даже исчерпать доступное виртуальное адресное пространство, что приведет к OOM.
Расшифровка OutOfMemoryError
Как упоминалось выше, OOM является распространенным признаком утечки памяти. По сути, ошибка возникает, когда недостаточно места для размещения нового объекта. Как бы он ни старался, сборщик мусора не может найти нужное место, и куча не может быть расширена дальше. Таким образом, появляется ошибка вместе с трассировкой стека.
Первым шагом в диагностике вашего OOM является определение того, что на самом деле означает ошибка. Это звучит очевидно, но ответ не всегда так ясен. Например: появляется ли OOM из-за того, что куча Java заполнена или из-за того, что собственная куча заполнена? Чтобы помочь вам ответить на этот вопрос, давайте проанализируем несколько возможных сообщений об ошибках:
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: PermGen space
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?
java.lang.OutOfMemoryError: <reason> <stack trace> (Native method)
«Пространство кучи Java»
Это сообщение об ошибке не обязательно означает утечку памяти. На самом деле проблема может быть такой же простой, как проблема с конфигурацией.
Например, я отвечал за анализ приложения, которое постоянно выдавало этот тип OutOfMemoryError
. После некоторого расследования я понял, что виновником было создание экземпляра массива, который требовал слишком много памяти; в данном случае это была не ошибка приложения, а скорее сервер приложений полагался на размер кучи по умолчанию, который был слишком мал. Я решил проблему, настроив параметры памяти JVM.
В других случаях, и в частности для долгоживущих приложений, сообщение может указывать на то, что мы непреднамеренно удерживаем ссылки на объекты , не позволяя сборщику мусора очистить их. Это эквивалент утечки памяти в языке Java . ( Примечание. API, вызываемые приложением, также могут непреднамеренно содержать ссылки на объекты. )
Другой потенциальный источник этих OOM «пространства кучи Java» возникает при использовании финализаторов . Если у класса есть метод finalize
, то объекты этого типа не освобождают свое пространство во время сборки мусора. Вместо этого после сборки мусора объекты помещаются в очередь для финализации, которая происходит позже. В реализации Sun финализаторы выполняются потоком демона. Если поток финализатора не может идти в ногу с очередью финализации, то куча Java может заполниться, и может быть сгенерировано OOM.
«ПермГен Пространство»
Это сообщение об ошибке указывает на то, что постоянное поколение заполнено. Постоянное поколение — это область кучи, в которой хранятся объекты классов и методов. Если приложение загружает большое количество классов, может потребоваться увеличить размер постоянного поколения с помощью -XX:MaxPermSize
.
Интернированные объекты java.lang.String
также хранятся в постоянном поколении. Класс java.lang.String
поддерживает пул строк. Когда вызывается внутренний метод, он проверяет пул, чтобы увидеть, присутствует ли эквивалентная строка. Если это так, он возвращается внутренним методом; если нет, строка добавляется в пул. Говоря более точно, метод java.lang.String.intern
возвращает каноническое представление строки; результатом является ссылка на тот же экземпляр класса, который был бы возвращен, если бы эта строка отображалась как литерал. Если приложение интернирует большое количество строк, может потребоваться увеличить размер постоянного поколения.
Примечание: вы можете использовать команду jmap -permgen
для вывода статистики, связанной с постоянным поколением, включая информацию о внутренних экземплярах String.
«Запрошенный размер массива превышает лимит ВМ»
Эта ошибка указывает на то, что приложение (или API-интерфейсы, используемые этим приложением) попыталось выделить массив, размер которого превышает размер кучи. Например, если приложение пытается выделить массив размером 512 МБ, но максимальный размер кучи составляет 256 МБ, будет выдано сообщение об ошибке OOM. В большинстве случаев проблема связана либо с конфигурацией, либо с ошибкой, возникающей, когда приложение пытается выделить массивный массив.
«Запрос <размер> байт для <причина>. Недостаточно места подкачки?»
Это сообщение похоже на OOM. Однако виртуальная машина HotSpot выдает это явное исключение, когда выделение из собственной кучи завершилось неудачей, и собственная куча может быть близка к исчерпанию. В сообщение включен размер (в байтах) отказавшего запроса и причина запроса памяти. В большинстве случаев <причина> — это имя исходного модуля, который сообщает об ошибке выделения.
Если возникает этот тип OOM, вам может потребоваться использовать утилиты устранения неполадок в вашей операционной системе для дальнейшей диагностики проблемы. В некоторых случаях проблема может быть даже не связана с приложением. Например, вы можете увидеть эту ошибку, если:
Операционная система настроена с недостаточным пространством подкачки.
Другой процесс в системе потребляет все доступные ресурсы памяти.
Также возможен сбой приложения из-за собственной утечки (например, если какой-то фрагмент кода приложения или библиотеки постоянно выделяет память, но не может освободить ее для операционной системы).
<причина> <трассировка стека> (собственный метод)
Если вы видите это сообщение об ошибке, а верхний кадр вашей трассировки стека является собственным методом, то этот собственный метод обнаружил ошибку выделения. Разница между этим сообщением и предыдущим заключается в том, что сбой выделения памяти Java был обнаружен в JNI или собственном методе, а не в коде Java VM.
Если возникает этот тип OOM, вам может потребоваться использовать утилиты в операционной системе для дальнейшей диагностики проблемы.
Сбой приложения без OOM
Иногда приложение может аварийно завершить работу вскоре после сбоя выделения из собственной кучи. Это происходит, если вы используете собственный код, который не проверяет наличие ошибок, возвращаемых функциями выделения памяти.
Например, системный вызов malloc
возвращает NULL
, если нет доступной памяти. Если возврат от malloc
не проверяется, приложение может аварийно завершить работу при попытке доступа к недопустимой ячейке памяти. В зависимости от обстоятельств, этот тип проблемы может быть трудно обнаружить.
В некоторых случаях будет достаточно информации из журнала фатальных ошибок или аварийного дампа. Если установлено, что причиной сбоя является отсутствие обработки ошибок в некоторых выделениях памяти, вы должны найти причину указанного сбоя выделения. Как и в случае любой другой проблемы с собственной кучей, в системе может быть недостаточно места подкачки, другой процесс может использовать все доступные ресурсы памяти и т. д.
Диагностика утечек
В большинстве случаев для диагностики утечек памяти требуется очень подробное знание рассматриваемого приложения. Предупреждение: процесс может быть длительным и повторяющимся.
Наша стратегия поиска утечек памяти будет относительно простой:
Определите симптомы
Включить подробную сборку мусора
Включить профилирование
Анализировать трассировку
1. Определите симптомы
Как уже говорилось, во многих случаях процесс Java в конечном итоге выдает исключение времени выполнения OOM, что является явным индикатором того, что ваши ресурсы памяти были исчерпаны. В этом случае нужно различать обычное исчерпание памяти и утечку. Анализ сообщения OOM и попытка найти виновника на основе приведенных выше обсуждений.
Часто, если приложение Java запрашивает больше места для хранения, чем предлагает куча времени выполнения, это может быть связано с плохим дизайном. Например, если приложение создает несколько копий изображения или загружает файл в массив, ему не хватит памяти, когда изображение или файл станут очень большими. Это нормальное исчерпание ресурсов. Приложение работает как задумано (хотя этот дизайн явно тупоголовый).
Но если приложение постоянно увеличивает использование памяти при обработке данных одного и того же типа, у вас может возникнуть утечка памяти.
2. Включите подробную сборку мусора
Один из самых быстрых способов подтвердить, что у вас действительно есть утечка памяти, — включить подробную сборку мусора. Проблемы с ограничением памяти обычно можно определить, изучив шаблоны в выводе verbosegc
.
В частности, аргумент -verbosegc
позволяет создавать трассировку каждый раз при запуске процесса сборки мусора (GC). То есть, когда память очищается, сводные отчеты печатаются со стандартной ошибкой, что дает вам представление о том, как осуществляется управление вашей памятью.

Вот некоторые типичные выходные данные, сгенерированные с опцией –verbosegc
:
Каждый блок (или раздел) в этом файле трассировки сборщика мусора пронумерован в порядке возрастания. Чтобы разобраться в этой трассировке, вы должны посмотреть на последовательные строфы сбоя выделения и посмотреть, освобождается ли память (в байтах и процентах), уменьшающаяся со временем, в то время как общая память (здесь, 19725304) увеличивается. Это типичные признаки истощения памяти.
3. Включить профилирование
Различные JVM предлагают разные способы создания файлов трассировки для отражения активности кучи, которые обычно содержат подробную информацию о типе и размере объектов. Это называется профилированием кучи .
4. Проанализируйте трассировку
Этот пост посвящен трассировке, созданной Java VisualVM. Трассировки могут быть разных форматов, так как они могут генерироваться разными инструментами обнаружения утечек памяти Java, но идея, стоящая за ними, всегда одна и та же: найти в куче блок объектов, которых там быть не должно, и определить, накапливаются ли эти объекты вместо выпуска. Особый интерес представляют временные объекты, которые, как известно, выделяются каждый раз, когда в приложении Java инициируется определенное событие. Наличие многих экземпляров объекта, которые должны существовать только в небольшом количестве, обычно указывает на ошибку приложения.
Наконец, устранение утечек памяти требует от вас тщательного анализа кода. Изучение типа утечки объекта может быть очень полезным и значительно ускорить отладку.
Как работает сборка мусора в JVM?
Прежде чем мы начнем анализ приложения с проблемой утечки памяти, давайте сначала посмотрим, как работает сборка мусора в JVM.
JVM использует форму сборщика мусора, называемого сборщиком трассировки , который, по сути, работает, приостанавливая мир вокруг себя, помечая все корневые объекты (объекты, на которые ссылаются непосредственно запущенные потоки) и следуя их ссылкам, помечая каждый объект, который он видит по пути.
Java реализует нечто, называемое сборщиком мусора поколений , основанное на допущении гипотезы поколений, которая утверждает, что большинство созданных объектов быстро отбрасываются , а объекты, которые не собираются быстро, скорее всего, будут существовать какое-то время .
Основываясь на этом предположении, Java разделяет объекты на несколько поколений. Вот визуальная интерпретация:
Молодое поколение - здесь начинаются объекты. Он имеет два подпоколения:
Пространство Эдема — здесь начинаются объекты. Большинство объектов создаются и уничтожаются в пространстве Эдема. Здесь GC выполняет Minor GC , которые являются оптимизированными сборщиками мусора. Когда выполняется дополнительный сборщик мусора, любые ссылки на объекты, которые все еще необходимы, переносятся в одно из оставшихся пространств (S0 или S1).
Survivor Space (S0 и S1) — здесь попадают объекты, пережившие Эдем. Их два, и только один используется в любой момент времени (если только у нас нет серьезной утечки памяти). Один обозначается как empty , а другой как live , чередующийся с каждым циклом GC.
Постоянное поколение — также известное как старое поколение (старое пространство на рис. 2), это пространство содержит более старые объекты с более длительным сроком службы (перемещенные из выживших пространств, если они живут достаточно долго). Когда это пространство заполнено, сборщик мусора выполняет полный сборщик мусора , что обходится дороже с точки зрения производительности. Если это пространство неограниченно растет, JVM выдаст
OutOfMemoryError - Java heap space
.Постоянное поколение — третье поколение, тесно связанное с постоянным поколением, постоянное поколение является особенным, поскольку оно содержит данные, необходимые виртуальной машине для описания объектов, которые не имеют эквивалента на уровне языка Java. Например, объекты, описывающие классы и методы, хранятся в постоянной генерации.
Java достаточно умен, чтобы применять разные методы сборки мусора к каждому поколению. Молодое поколение обрабатывается с помощью отслеживающего и копирующего сборщика под названием Parallel New Collector . Этот коллекционер останавливает мир, но поскольку молодое поколение, как правило, малочисленно, пауза короткая.
Дополнительные сведения о поколениях JVM и о том, как они работают, см. в разделе Управление памятью в документации по виртуальной машине Java HotSpot.
Обнаружение утечки памяти
Чтобы найти утечки памяти и устранить их, вам нужны соответствующие инструменты утечки памяти. Пришло время обнаружить и устранить такую утечку с помощью Java VisualVM.
Удаленное профилирование кучи с помощью Java VisualVM
VisualVM — это инструмент, предоставляющий визуальный интерфейс для просмотра подробной информации о приложениях на основе технологии Java во время их работы.
С помощью VisualVM вы можете просматривать данные, относящиеся к локальным приложениям и приложениям, работающим на удаленных хостах. Вы также можете собирать данные об экземплярах программного обеспечения JVM и сохранять данные в локальной системе.
Чтобы воспользоваться всеми функциями Java VisualVM, вы должны запустить платформу Java Standard Edition (Java SE) версии 6 или выше.
Включение удаленного подключения для JVM
В производственной среде часто бывает сложно получить доступ к реальной машине, на которой будет выполняться наш код. К счастью, мы можем удаленно профилировать наше Java-приложение.
Во-первых, нам нужно предоставить себе доступ к JVM на целевой машине. Для этого создайте файл jstatd.all.policy со следующим содержимым:
grant codebase "file:${java.home}/../lib/tools.jar" { permission java.security.AllPermission; };
После создания файла нам необходимо включить удаленные подключения к целевой виртуальной машине с помощью инструмента jstatd — Virtual Machine jstat Daemon следующим образом:
jstatd -p <PORT_NUMBER> -J-Djava.security.policy=<PATH_TO_POLICY_FILE>
Например:
jstatd -p 1234 -J-Djava.security.policy=D:\jstatd.all.policy
Запустив jstatd на целевой виртуальной машине, мы можем подключиться к целевой машине и удаленно профилировать приложение с проблемами утечки памяти.
Подключение к удаленному хосту
На клиентском компьютере откройте приглашение и введите jvisualvm
, чтобы открыть инструмент VisualVM.
Далее мы должны добавить удаленный хост в VisualVM. Поскольку целевая JVM включена для разрешения удаленных подключений с другой машины с J2SE 6 или выше, мы запускаем инструмент Java VisualVM и подключаемся к удаленному хосту. Если соединение с удаленным хостом было успешным, мы увидим Java-приложения, работающие на целевой JVM, как показано здесь:
Чтобы запустить профилировщик памяти в приложении, достаточно дважды щелкнуть его имя на боковой панели.
Теперь, когда мы все настроили анализатор памяти, давайте исследуем приложение с проблемой утечки памяти, которое мы назовем MemLeak .
MemLeak
Конечно, есть несколько способов создать утечку памяти в Java. Для простоты мы определим класс как ключ в HashMap
, но мы не будем определять методы equals() и hashcode().
HashMap — это реализация хеш-таблицы для интерфейса Map, и поэтому она определяет основные понятия ключа и значения: каждое значение связано с уникальным ключом, поэтому, если ключ для данной пары ключ-значение уже присутствует в HashMap, его текущее значение заменяется.
Обязательно, чтобы наш ключевой класс обеспечивал правильную реализацию методов equals()
и hashcode()
. Без них нет гарантии, что будет сгенерирован хороший ключ.
Не определяя методы equals()
и hashcode()
, мы добавляем один и тот же ключ в HashMap снова и снова, и вместо замены ключа, как следует, HashMap постоянно растет, не в состоянии идентифицировать эти идентичные ключи и OutOfMemoryError
.
Вот класс MemLeak:
package com.post.memory.leak; import java.util.Map; public class MemLeak { public final String key; public MemLeak(String key) { this.key =key; } public static void main(String args[]) { try { Map map = System.getProperties(); for(;;) { map.put(new MemLeak("key"), "value"); } } catch(Exception e) { e.printStackTrace(); } } }
Примечание: утечка памяти не связана с бесконечным циклом в строке 14: бесконечный цикл может привести к исчерпанию ресурсов, но не к утечке памяти. Если бы мы правильно реализовали методы equals()
и hashcode()
, код работал бы нормально даже с бесконечным циклом, поскольку у нас был бы только один элемент внутри HashMap.
(Для тех, кто заинтересован, вот несколько альтернативных способов (преднамеренного) создания утечек.)
Использование Java VisualVM
С помощью Java VisualVM мы можем отслеживать память Java Heap и определять, свидетельствует ли ее поведение об утечке памяти.
Вот графическое представление анализатора MemLeak Java Heap сразу после инициализации (вспомните наше обсуждение различных поколений):
Всего через 30 секунд старое поколение почти заполнено, что указывает на то, что даже при полном сборщике мусора старое поколение постоянно растет, что является явным признаком утечки памяти.
Один из способов обнаружения причины этой утечки показан на следующем изображении ( щелкните, чтобы увеличить ), сгенерированном с помощью Java VisualVM с дампом кучи . Здесь мы видим, что 50% объектов Hashtable$Entry находятся в куче , а вторая строка указывает нам на класс MemLeak . Таким образом, утечка памяти вызвана хеш-таблицей , используемой в классе MemLeak .
Наконец, обратите внимание на кучу Java сразу после OutOfMemoryError
в которой поколения Young и Old полностью заполнены .
Заключение
Утечки памяти являются одними из самых сложных для решения проблем Java-приложений, поскольку их симптомы разнообразны и их трудно воспроизвести. Здесь мы описали пошаговый подход к обнаружению утечек памяти и выявлению их источников. Но прежде всего внимательно читайте сообщения об ошибках и обращайте внимание на трассировку стека — не все утечки так просты, как кажутся.
Приложение
Наряду с Java VisualVM существует несколько других инструментов, которые могут выполнять обнаружение утечек памяти. Многие детекторы утечек работают на уровне библиотек, перехватывая вызовы процедур управления памятью. Например, HPROF
— это простой инструмент командной строки в комплекте с Java 2 Platform Standard Edition (J2SE) для профилирования кучи и ЦП. Выходные HPROF
можно анализировать напрямую или использовать в качестве входных данных для других инструментов, таких как JHAT
. Когда мы работаем с приложениями Java 2 Enterprise Edition (J2EE), существует ряд более удобных решений для анализа дампов кучи, таких как IBM Heapdumps для серверов приложений Websphere.