Устранение утечек памяти в Java

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

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

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

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

  • Производительность: обычно связаны с созданием и удалением чрезмерного количества объектов, большими задержками при сборке мусора, избыточном своппинге страниц в операционной системе и т.д.
  • Ограниченность ресурсов: происходят, если у вас либо слишком мало памяти, либо ваша память слишком фрагментирована, чтобы выделить место для большого объекта — это может быть как внутренним ограничением, так и, чаще всего, связано с Java Heap (динамической памятью).
  • Утечки в Java Heap: классическая утечка памяти, при которой объекты Java непрерывно создаются, но не удаляются. Это  обычно связано со скрытыми ссылками на объекты.
  • Утечки внутренней памяти: связаны с любым непрерывно растущим использованием памяти, находящейся вне Java Heap.  Как в случаях выделения памяти кодом JNI, драйверами или даже JVM.

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

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

Утечки памяти: Введение

Если вы — новичок, то представьте, что утечка памяти — это болезнь, а ошибка Java OutOfMemoryError (OOM для краткости) — это симптом. Но как и в случае с болезнями, наличие OOM не обязательно подразумевает наличие утечек: ООМ может произойти из-за генерации большого количества локальных переменных или из-за других подобных событий. С другой стороны, не все утечки памяти обязательно обозначают себя как ООМ, особенно если речь идет о ПК-приложениях или клиентских приложениях (которые периодически необходимо перезагружать).

Представьте, что утечка памяти — это болезнь, а ошибка OutOfMemoryError — это симптом. Но не все OutOfMemoryErrors подразумевают наличие утечек, и не все утечки называют себя OutOfMemoryErrors.

Чем же так плохи эти утечки? Помимо прочего, утечки блоков памяти в ходе выполнения программы часто со временем снижают производительность системы, так как выделенные, но не использованные участки памяти, необходимо будет выгрузить, когда у системы закончится свободная физическая память.  В конце концов программа может даже использовать всё доступное виртуальное адресное пространство, что приведет к появлению ООМ.

Расшифровка OutOfMemoryError

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

Первым шагом в обнаружении вашей ООМ является определение реального значения ошибки. Звучит очевидно, но ответ не всегда настолько прост. Например: возникает ли ООМ из-за того, что Heap заполнена, или из-за того, что заполнена внутренняя память? Чтобы помочь вам найти ответ на этот вопрос, давайте проанализируем несколько  возможных сообщений об ошибке:

  • 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 Heap”

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

К примеру, я отвечал за анализ приложения, которое постоянно выдавало этот тип ошибки OutOfMemoryError. Проведя небольшое расследование, я выяснил, что виновником был процесс создания экземпляра массива, который требовал слишком большого количества памяти; в этом случае утечка происходила не по вине приложения: сервер приложений полагался на используемый по умолчанию размер Heap, а он был слишком мал. Я решил эту проблему, настроив параметры памяти JVM.

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

Еще одним потенциальным источником ошибок ООМ «Java Heap» могут являться сборщики мусора (финализаторы). Если в классе имеется метод finalize, то объекты этого типа не возвращают выделенное им пространство в процессе сборки мусора. Вместо этого после сборки мусора объекты помещаются в очередь на финализацию, которая происходит позже. В версии компании Sun финализаторы выполняются потоком-демоном. Если поток финализатора не может справиться с очередью на финализацию, Java Heap может заполниться, что приведет к появлению ошибки ООМ.

“Пространство PermGen”

Эта ошибка означает, что постоянная генерация(?) заполнена. «Постоянная генерация» — это участок Heap, который хранит объекты классов и методов. Если приложение загружает большое количество классов, то размер постоянной генерации может потребовать увеличения с помощью опции -XX:MaxPermSize .

Интернированные объекты java.lang.String также хранятся в постоянной генерации. Класс java.lang.String обеспечивает поддержку пула строк. При вызове метода-интерна он проверяет пул на наличие эквивалентной строки. Если такая строка есть, она возвращается методу-интерну; если ее нет, строка добавляется в пул. Если говорить конкретнее, метод  java.lang.String.intern возвращает каноническое представлениестроки; результатом является ссылка на тот же экземпляр класса, что был бы возвращен, если бы эта строка выглядела как литерал. Если приложение интернирует слишком большое количество строк, вам может понадобиться увеличить размер постоянной генерации.

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

“Запрашиваемый размер массива превышает ограничения VM”

Эта ошибка свидетельствует о том, что приложение (или API, используемые этим приложением) попыталось выделить для массива количество памяти, превышающее размер Heap. Например, если приложение пытается выделить для массива 512МБ памяти, но максимальный размер Heap — 256МБ, то будет вызвана ООМ с этим сообщением. В большинстве случае проблема заключается либо в настройках, либо в баге, из-за которого приложение пытается выделить место для такого большого массива.

“Запрос <количество> байт для <причина>. Недостаточно пространства свопинга?”

Это сообщение похоже на ООМ. Однако, VM HotSpot выдает это исключение, если выделить память из Native Heap не удалось, и свободное место в ней скоро закончится. В сообщении указывается размер (в байтах) запроса, который потерпел неудачу, и причина, вызвавшая запрос на выделение памяти. В большинстве случае <причина> — это название исходного модуля, который сообщил об ошибке выделения памяти.

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

  • В операционной системе настроено недостаточно большое пространство своппинга.
  • Другой процесс, запущенный в системе, потребляет все доступные ресурсы памяти.

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

<причина> <стектрейс> (Внутренний метод)

Если вы видите данное сообщение об ошибке, и в начале стектрейса находится внутренний метод, значит во внутреннем методе произошла ошибка выделения памяти. Разница между этим сообщением и предыдущим состоит в том, что ошибка выделения была обнаружена в JNI или во внутреннем методе, а не в коде Java VM.

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

Сбой приложения без OOM

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

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

В некоторых случаях может быть достаточно информации в журнале о фатальных ошибках либо в дампе ошибки. Если причиной возникновения является недостаточная обработка ошибок при некоторых выделениях памяти, то вам необходимо обнаружить причину указанного сбоя при выделении. Как и при любых других проблемах с Native Heap, в настройках системы может быть выделено недостаточно пространства для своппинга, другой процесс может потреблять все доступные ресурсы памяти и т.д.

Диагностирование утечек

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

Наша стратегия для выявления утечек памяти будет относительно простой:

  1. Определить симптомы
  2. Разрешить глубокую сборку мусора
  3. Разрешить профайлинг
  4. Проанализировать  стектрейс

1. Определить симптомы

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

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

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

2. Разрешить глубокую сборку мусора

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

В частности, аргумент -verbosegc позволяет вам создавать стектрейс при каждом запуске процесса сборки мусора (СМ). При этом в процессе сборки мусора в памяти отчеты о результатах выводятся в стандартное поле ошибки, давая вам представление о том, как происходит управление памятью.

Вот несколько типовых результатов, сгенерированных с помощью параметра verbosegc :

toptal-blog-verbosegc

Каждый блок (или строка) в файле стектрейса СМ пронумерован в порядке возрастания. Чтобы понять, что означает этот стектрейс, вам следует взглянуть на идущие друг за другом строки Ошибки выделения памяти (Allocation Failure) и проследить за освобождаемой памятью (байты и проценты), которая со временем будет сокращаться, в то время как общее количество памяти (в данном примере 19725304) увеличивается. Это типичные признаки уменьшения количества памяти.

3. Разрешить профайлинг

Различные JVM предоставляют различные способы создания файлов стектрейса для отображения активности Heap. Эти файлы обычно включают подробную информацию о типе и размере объектов. Это называется профайлинг Heap.

4. Проанализировать стектрейс

В этом посте мы сосредоточимся на стектрейсе, создаваемом Java VisualVM. Таблицы могут иметь различный формат, потому что они могут создаваться различными инструментами, но идея, лежащая у них в основе всегда одинакова: найти блок объектов в Heap, которого там быть не должно, и определить, накапливаются ли эти объекты вместо того, чтобы освобождать используемое пространство. Особый интерес представляют временные объекты, для которых, как известно, память выделяется при каждом срабатывании определенного события в приложении Java. Присутствие множества экземпляров объекта, который должен существовать лишь в виде нескольких экземпляров, обычно указывает на баг приложения.

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

Как работает сборка мусора в JVM?

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

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

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

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

toptal-blog-1_D

  • Young generation - Здесь объекты берут свое начало. Оно имеет два субпоколения:
    • Eden Space - Объекты появляются здесь. Большинство объектов создаются и уничтожаются в Eden Space. Здесь СМ проводит Малую СМ, являющуюся оптимизированной сборкой мусора. В ходе выполнения Малой СМ все ссылки на объекты, которые всё еще необходимы, переносятся в одно из пространств выживших (S0 или S1).
    • Survivor Space (S0 и S1) - Объекты, пережившие Eden Space, оказываются здесь. Этих пространств два, и в любой отдельно взятый момент времени используется лишь одно из них (если только у нас нет серьезной утечки памяти). Одно из них выполняет роль пустого, а другое — роль действующего. После каждого цикла СМ они меняются ролями.
  • Tenured Generation  - Также известное как ‘Old Generation’ (старое пространство (‘old space’) на Рис. 2), это пространство хранит более старые объекты с более продолжительным временем существования (прошедшие несколько пространств выживших, если они просуществовали достаточно долго). Когда это пространство заполняется, СМ проводит Полную СМ, которая требует большего количества ресурсов. Если это пространство не прекращает увеличиваться, JVM выдаст ошибку OutOfMemoryError - Java heap space (пространство Java Heap).
  • Permanent Generation - Третье поколение, тесно связанное с Tenured Generation. Permanent Generation отличается от других, потому что хранит данные, необходимые виртуальной машине для описания объектов, не имеющих эквивалентов на уровне языка Java. К примеру, объекты, описывающие классы и методы, хранятся в постоянном поколении.

Язык Java достаточно умен, чтобы применять различные методы сборки мусора к каждому из поколений. Young Generation обрабатывается с помощью трассирующего и копирующего сборщика, который называется Параллельный новый сборщик. Этот сборщик останавливает все вокруг, но поскольку размеры Young Generation очень малы, эта пауза очень короткая.

Для получения дополнительной информации о поколениях JVM и о том, как они работают, посетите страницу документации Управление памятью в Java HotSpot™ Virtual Machine.

Обнаружение утечки памяти

Для обнаружения и устранения утечки памяти вам нужны подходящие инструменты. Пришло время найти и устранить эту утечки с помощью Java VisualVM.

Удаленный профайлинг Heap с помощью Java VisualVM

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

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

Чтобы получить возможность использовать все функции JavaVisualVM вам необходимо запустить Java Platform, Standard Edition (Java SE) версии 6 или выше.

Разрешение удаленных подключений к JVM

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

Во-первых, нам необходимо разрешить себе доступ JVM на машину, являющуюся нашей целью. Для этого создайте файл с именемjstatd.all.policy со следующим содержимым:

grant codebase "file:${java.home}/../lib/tools.jar" {
   permission java.security.AllPermission;
};

Когда файл создан, нам нужно разрешить удаленные подключения к целевой VM с помощью инструмента  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 на целевой VM, мы получили возможность подключения к этому устройству и можем удаленно настроить приложение, в котором происходят утечки памяти.

Подключение к удаленному хосту

На машине клиента откройте командную строку и введите jvisualvm , откроется инструмент VisualVM.

После этого мы должны добавить в VisualVM удаленный хост. Как только мы включили на целевой JVM возможность принимать удаленные подключения с другого устройства с J2SE6 или выше, мы запускаем инструмент Java VisualVM и подключаемся к удаленному хосту. Если подключение к удаленному хосту прошло успешно, мы увидим приложения Java, которые запущены в JVM целевого устройства, выглядит это вот так:

WhHUVEG

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

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

Перед вами графическое представление Динамической памяти Java MemLeak в момент сразу после инициализации (вспомните наше обсуждение различных поколений):

toptal-blog-C

Всего спустя 30 секунд Tenured Generation уже практически заполнено, что демонстрирует, что даже с Полной сборкой мусора Tenured Generation непрерывно увеличивается, а это явный признак утечки памяти.

Один из инструментов обнаружения причины утечки показан на следующем рисунке (кликните для увеличения), созданном с помощью Java VisualVM с heapdump. Здесь мы видим, что 50% объектов Hashtable$Entry находятся в Heap, в то время как вторая строчка указывает нам на класс MemLeak . Следовательно, утечка памяти возникает по вине хеш-таблицы , используемой в классе MemLeak .

toptal-blog-A

Наконец, взгляните на Динамическую память Java Heap сразу после появления нашей ошибки OutOfMemoryError , где видно, что Young и Tenured Generation полностью заполнены.

toptal-blog-B

Вывод

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

Приложение

Помимо Java VisualVM, существует несколько других инструментов для обнаружения утечек памяти. Многие инструменты определения утечек работают на уровне библиотек, перехватывая вызовы к подпрограммам управления памятью. К примеру:HPROF — это простой инструмент с командной строкой, который поставляется вместе с Java 2 Platform Standard Edition (J2SE) для настройки динамической памяти и CPU. Результаты работы HPROF можно проанализировать либо использовать в качестве входных данных для других инструментов, например JHAT. Работая с приложениями Java 2 Enterprise Edition (J2EE) мы можем использовать множество решений heapdump, которые проще поддаются анализу. Например,  IBM Heapdumps для серверов приложений Websphere.

Оригинал


Комментарии:

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Можно использовать следующие HTML-теги и атрибуты: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>