Анализ памяти Java

Приложения на Java и Kotlin управляют памятью через кучу, в которой происходит сборка мусора. Когда объекты становятся недоступными, сборщик мусора (GC) в конечном итоге освобождает их место. Утечки памяти возникают, когда ненужные объекты всё ещё удерживаются «корневыми узлами GC», препятствуя их освобождению.

Основные концепции

корни GC

Корневой каталог сборщика мусора (GC Root) — это особый тип объекта, который сборщик мусора рассматривает как всегда доступный. Примеры:

  • Активные потоки (и объекты, на которые ссылаются кадры стека Java, выполняющиеся в данный момент).
  • Классы с активно выполняющимися методами.
  • Ссылки JNI (глобальные или локальные ссылки, хранящиеся в собственном коде).

Путь к корневому каталогу сборщика мусора

Пока существует цепочка ссылок от корня сборщика мусора к объекту, этот объект «достижим» и не может быть удален сборщиком мусора. Эта цепочка называется Путь к корню сборщика мусора . Для устранения утечки памяти необходимо выявить и разорвать эту цепочку.

Путь к корневому каталогу сборщика мусора

Деревья Доминатора

Хотя путь к корневому каталогу сборщика мусора (GC root) указывает на причину существования объекта, он не показывает, сколько памяти будет освобождено, если эта ссылка будет повреждена. Для этого мы используем деревья доминирования (Dominator Trees ).

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

На следующей диаграмме показан граф объектов и соответствующее ему дерево доминаторов. Обратите внимание, что объект D доступен как для A , так и для B в графе, поэтому ни A , ни B не доминируют над D ; вместо этого ближайшим доминатором является корень графа объектов .

Дерево Доминатора

Получение дампов кучи Java

Дамп кучи — это снимок всех объектов в куче Java в определенный момент времени.

Использование ADB

Чтобы получить дамп кучи из запущенного процесса, вы можете передать имя пакета непосредственно в am dumpheap . Для выполнения этой команды необходимо собрать приложение с <profileable android:shell="true"/> или <debuggable> .

# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof

# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .

Используя Perfetto

Perfetto также может собирать дампы кучи Java в рамках общесистемной трассировки, включив источник данных android.java_hprof в конфигурации Perfetto. Это полезно для сопоставления состояния кучи с другими системными событиями.

Для создания дампа памяти для приложения MemoryLab с помощью Perfetto можно использовать следующую команду:

# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
    config {
        name: "android.java_hprof"
        java_hprof_config {
            process_cmdline: "com.android.memorylab"
        }
    }
}
EOF

# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
  -t 10s -c /tmp/java_heap.pbtx

См.: Дампы кучи Java в документации Perfetto .

Анализ с помощью AHAT

AHAT (Android Heap Analysis Tool) — рекомендуемый инструмент для просмотра файлов .hprof в веб-браузере.

Начало AHAT

Если ваш ahat добавлен в переменную PATH, запустите его с помощью команды:

ahat heap.hprof

Или запустите автономный jar-файл:

java -jar ahat.jar heap.hprof

Затем откройте в браузере адрес http://localhost:7100 .

Подробную информацию о получении или сборке AHAT см. в репозитории исходного кода AHAT .

Ключевые рабочие процессы анализа

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

Найдите свой класс Activity ( MainActivity ) в представлении Allocations .

Представление AHAT, отображающее экземпляры.

Щёлкните по названию класса, чтобы найти все экземпляры .

Представление AHAT, отображающее MainActivity экземпляры Щелкните по экземпляру MainActivity , чтобы просмотреть его содержимое.

AHAT отображает подробную информацию об экземпляре.

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

Путь к образцу AHAT из корневого каталога сборщика мусора и размер объекта

Анализ растровых изображений

AHAT имеет специальную поддержку для просмотра объектов android.graphics.Bitmap , которые часто потребляют много памяти. Щелкнув по экземпляру Bitmap, вы увидите предварительное отображение его содержимого.

Предварительный просмотр растрового изображения AHAT

Страница утечек активности

AHAT имеет специализированный интерфейс для выявления утечек памяти в Activity, которые являются одним из наиболее распространенных и серьезных типов утечек памяти в Android.

  1. Действие : В MemoryLab нажмите «Утечка активности» . Это запустит LeakedActivity , которая намеренно сливает свою активность.
  2. Свалка : Сделать свалку.
  3. Анализ : Нажмите на «Утечки активности» на боковой панели AHAT.
  4. Проверка : AHAT покажет com.android.memorylab.LeakedActivity как утечку памяти, потому что поле mDestroyed имеет значение true (что указывает на завершение жизненного цикла Activity), но при этом объект по-прежнему доступен из корневого каталога сборщика мусора.

Страница утечек информации об активности AHAT

Сравнение дампов кучи

Сравнение двух дампов памяти — один из наиболее эффективных способов выявления проблем с памятью. Сравнивая «чистый» базовый дамп с дампом, полученным после выполнения определенных действий, можно сразу увидеть, какие объекты накопились.

Упражнение: Выявление утечек с помощью дифференциального анализа.

  1. Базовый уровень : Запустите MemoryLab и сделайте дамп кучи базового уровня:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof
    adb pull /data/local/tmp/base.hprof .
    
  2. Действие : Несколько раз нажмите в приложении «Выделить память Java (10 МБ)» .

  3. В заключение : Сделайте вторую выгрузку памяти:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof
    adb pull /data/local/tmp/leaked.hprof .
    
  4. Сравнение : Запустите AHAT, используя второй дамп в качестве основного, а первый — в качестве базового:

    java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprof
    
  5. Анализ обзора : На странице обзора теперь есть столбец Δ (дельта) . Вы увидите большую положительную дельту для кучи app , указывающую на значительный рост объема используемой памяти.

Обзор AHAT с Delta

  1. Детализация : Щелкните «rooted» в меню. На этой странице отображаются объекты, доступные из корневых каталогов сборщика мусора, отсортированные по их сохраненному размеру. Вверху вы увидите MainActivity с большим положительным значением дельты.

AHAT Rooted View with Delta

Запись трассировки стека выделения памяти

Хотя путь к корневому каталогу сборщика мусора (GC Root) показывает , почему объект всё ещё существует, он не показывает , как он был создан. Трассировка стека выделения памяти предоставляет точную строку кода, которая привела к выделению объекта.

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

Упражнение: Определение источника байтовых массивов

  1. Начните с отслеживания : принудительно остановите MemoryLab и перезапустите его с флагом --track-allocation . Увеличьте глубину стека по умолчанию, чтобы получить больше контекста.

    # Increase the allocation tracker's stack depth (requires a process restart)
    adb shell setprop dalvik.vm.allocTrackerMaxStack 16
    
    adb shell am force-stop com.android.memorylab
    adb shell am start --track-allocation -n com.android.memorylab/.MainActivity
    
  2. Действие : Несколько раз нажмите кнопку «Выделить память Java (10 МБ)» .

  3. Дамп : Сделать дамп кучи и извлечь его.

  4. Анализ : Откройте дамп в AHAT. Перейдите к большому массиву byte[] (например, изучите MainActivitymJavaAllocations ( ArrayList ) → elementData ( Object[] ) → array element [0] ).

  5. Проверка : В представлении экземпляра посмотрите раздел « Место выделения памяти» . Там будет показана полная трассировка стека, ведущая к MainActivity.allocateJava .

Место распределения AHAT

Анализ динамики использования памяти в Java (комбинированный профиль)

Для получения полной картины поведения приложения в отношении памяти можно объединить счетчики памяти, активность потоков и профилирование выделения памяти на основе стека вызовов в единый трассировочный файл Perfetto. Это позволяет сопоставить общесистемные метрики памяти (такие как RSS и размер кучи) с конкретными местами выполнения кода и выделения памяти.

Мы будем использовать комбинированную конфигурацию, которая позволяет:

  • Счетчики памяти ( linux.process_stats ): Опрашивает RSS и другие метрики памяти.
  • ATrace ( dalvik , memory , sched categories): Захватывает состояния потоков и события сборщика мусора.
  • Heapprofd ( android.heapprofd ): Запускает дампы памяти для кучи com.android.art (Java) и libc.malloc (native) с непрерывным созданием дампов каждые 5 секунд.

Упражнение: комбинированный анализ памяти

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

  1. Базовый уровень : состояние простоя.
  2. Java Churn : Временное выделение памяти, которое немедленно удаляется сборщиком мусора.
  3. Постоянное выделение памяти в Java : выделение объектов Java, которые остаются в памяти.
  4. Выделение растровых изображений : выделение больших графических ресурсов (которые находятся в собственной куче/графической памяти).
  5. Восстановление : Освобождение всех выделенных ресурсов.

1. Запуск и подготовка

  1. Для обеспечения корректного состояния приложения принудительно остановите и перезапустите его:

    adb shell am force-stop com.android.memorylab
    adb shell am start -W -n com.android.memorylab/.MainActivity
    

2. Начать трассировку и запустить последовательность запуска.

Мы запустим 40-секундную трассировку и запустим события, связанные с памятью, используя команды am broadcast .

  1. Начать трассировку :

    adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF
    buffers: {
        size_kb: 65536
        fill_policy: RING_BUFFER
    }
    data_sources: {
        config {
            name: "android.java_hprof"
            java_hprof_config {
                process_cmdline: "com.example.myapp"
            }
        }
    }
    duration_ms: 10000
    EOF
    
  2. Запустите последовательность (выполните эти команды в терминале вашего компьютера во время выполнения трассировки, соблюдая рекомендуемое время):

    # Wait ~5s for trace initialization, then start Java churn:
    adb shell am broadcast -a com.android.memorylab.CHURN_JAVA
    
    # Wait ~10s (at 15s mark), allocate 10MB of persistent Java memory:
    adb shell am broadcast -a com.android.memorylab.ALLOC_JAVA
    
    # Wait ~5s (at 20s mark), allocate 20MB of Bitmaps (native/graphics):
    adb shell am broadcast -a com.android.memorylab.LEAK_BITMAP
    
    # Wait ~10s (at 30s mark), free everything:
    adb shell am broadcast -a com.android.memorylab.FREE_ALL
    
  3. Альтернативный вариант (инструмент командной строки) : Вы также можете запустить профилирование, используя скрипт heap_profile напрямую, с непрерывным созданием дампов как для Java-кучи, так и для нативной кучи:

    external/perfetto/tools/heap_profile -n com.android.memorylab \
      --heaps com.android.art,libc.malloc \
      -c 5000 \
      -d 40000 \
      -o java_memory_profile
    

3. Анализ объединенной трассы

Откройте собранный файл java_memory.perfetto-trace в пользовательском интерфейсе Perfetto .

Ключевые треки Perfetto

Прежде чем анализировать временную шкалу, найдите следующие важные дорожки для процесса com.android.memorylab :

  1. mem.rss.anon (Анонимный RSS) : Находится в разделе «Память» процесса. Этот параметр измеряет объем физической памяти (ОЗУ), выделенной процессу операционной системой. Он представляет собой фактический объем используемой памяти.
  2. Heap size (KB) : Также находится в разделе «Память» . Это специфический для Dalvik/ART счетчик, представляющий виртуальное адресное пространство, зарезервированное для кучи Java. Он отражает внутренний лимит кучи виртуальной машины, который колеблется по мере выделения объектов и запуска сборщика мусора.
  3. HeapTaskDaemon : Находится в списке потоков процесса. Это фоновый поток, в котором сборщик мусора ART выполняет большую часть своей работы. Активность здесь указывает на количество активных проходов сборщика мусора.
  4. Непрерывные дампы выделения памяти (heapprofd) : отображаются в виде цветных сегментов вдоль верхней временной шкалы. Каждый сегмент представляет собой определенный промежуток времени. Щелкнув по отдельному сегменту или выбрав временной диапазон, вы можете просмотреть Flamegraph (в нижней панели) для com.android.art (выделение памяти Java) или libc.malloc (выделение памяти нативных приложений), чтобы увидеть, что было выделено за этот период.

Хронологический фазовый анализ

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

Фаза 1: исходный уровень (0–5 с)
  • Что происходит : приложение находится в режиме ожидания, ожидая команд.
  • Статус отслеживания :
    • mem.rss.anon : Прямая линия на базовом уровне (обычно около 60-80 МБ в зависимости от устройства).
    • Heap size (KB) : Прямая линия, соответствующая первоначальному выделению памяти в куче Java.
    • HeapTaskDaemon : Простой (нет фрагментов, отображающих выполнение).
    • Сводка выделенных ресурсов : Отображение минимального базового уровня выделения ресурсов.

Интерфейс Perfetto UI отображает базовый уровень Фазы 1.

Этап 2: Изменение объема выделяемых ресурсов Java (5–15 секунд)
  • Происходит следующее : Запускается поток AllocationChurnThread , который многократно выделяет массивы размером 1 МБ и затем удаляет их.
  • Статус отслеживания :
    • Heap size (KB) : демонстрирует быструю пилообразную динамику. Размер кучи увеличивается по мере накопления выделений памяти и резко уменьшается при запуске сборщика мусора.
    • HeapTaskDaemon : Демон демонстрирует практически постоянную активность, при этом фрагменты выполнения идеально совпадают с уменьшением Heap size графике.
    • mem.rss.anon : Отслеживает активность кучи Java.
    • Дампы выделения памяти в куче Java : выбор фрагментов в этом треке показывает выделение памяти в куче для com.android.art .

В пользовательском интерфейсе Perfetto отображается отток клиентов на этапе 2. Анализ примеров выделения памяти показывает, что основным распределителем является AllocationChurnThread , при этом все выделения используют один и тот же стек вызовов, указывающий на лямбда-функцию внутри MainActivity.java .

Интерфейс Perfetto UI отображает выделение памяти для Java на втором этапе.

Этап 3: постоянное выделение памяти для Java (15–20 секунд)
  • Что происходит : Мы выделяем 10 МБ Java-объектов и храним ссылки на них в mJavaAllocations .
  • Статус отслеживания :
    • Heap size (KB) : Базовый размер пилообразного сигнала увеличивается примерно на 10 МБ.
    • mem.rss.anon : Размер увеличивается примерно на 10 МБ, поскольку ОС должна поддерживать это постоянное выделение новыми физическими страницами.
    • Дампы выделения памяти в куче Java : выбор фрагментов в этом треке показывает выделение памяти в куче для com.android.art .
    • Дампы выделения памяти (Flamegraph) : Анализ кучи com.android.art для дампа, созданного в этом окне, показывает новый путь выделения памяти из MainActivity.allocateJava , который вносит вклад в сохраняемый размер.

Пользовательский интерфейс Perfetto отображает фазу 3 постоянного выделения памяти Java. Выберите фрагмент данных о выделении памяти, охватывающий период, совпадающий с увеличением на 10 МБ для постоянного выделения. Вы должны увидеть, как стеки вызовов выделения расходятся на два разных узла: один отвечает за то же кратковременное изменение выделения, которое мы наблюдали ранее, а другой — за новое долговременное выделение.

Интерфейс Perfetto UI отображает выделение памяти для Java на этапе 3.

Этап 4: выделение битовой карты (20–30 с)
  • Что происходит : Мы также выделяем 20 МБ растровых изображений.
  • Статус отслеживания :
    • Heap size (KB) : такой же, как и раньше.
    • mem.rss.anon : Наблюдается значительное увеличение объема памяти примерно на 20 МБ, что соответствует выделению памяти для пиксельных данных растрового изображения.
    • Дампы выделения памяти (Flamegraph) : На этот раз сосредоточимся на фрагментах кучи libc.malloc (Native).

Пользовательский интерфейс Perfetto, отображающий растровое изображение фазы 4 Распределение Стек вызовов при выделении памяти в нативных библиотеках показывает, что выделение памяти для Bitmap происходит из нативных графических библиотек. Это хороший пример использования отслеживания выделения памяти в нативных библиотеках, поскольку вы не увидите эти выделения Bitmap в куче Java.

Интерфейс Perfetto UI отображает распределение ресурсов нативного уровня на 4-м этапе.

Этап 5: рекультивация (30-40 с)
  • Что происходит : Мы запускаем FREE_ALL , очищая ссылки на все постоянные выделения памяти и битовые карты Java, после чего выполняем явный вызов System.gc() .
  • Статус отслеживания :
    • Heap size (KB) : Возвращается к базовому уровню.
    • mem.rss.anon : Возвращается в исходное положение, показывая, что ОС освобождает физические страницы.
    • HeapTaskDaemon : Отображает заключительный всплеск активности в процессе сборки мусора.

Интерфейс Perfetto UI отображает 5-й этап восстановления.

Мониторинг исторических ошибок нехватки памяти (ApplicationExitInfo)

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

ActivityManager am = getSystemService(ActivityManager.class);
List<ApplicationExitInfo> exitReasons = am.getHistoricalProcessExitReasons(null, 0, 1);
if (!exitReasons.isEmpty()) {
    ApplicationExitInfo info = exitReasons.get(0);
    if (info.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY) {
        // App was killed by the system Low Memory Killer
    }
}

Передовые методы

  1. Сначала создайте базовый дамп памяти : всегда создавайте «базовый» дамп памяти после инициализации приложения, но до выполнения тестируемого действия.
  2. Используйте страницу утечек активности AHAT : AHAT включает специальную страницу утечек активности , которая автоматически определяет экземпляры Activity, которые были уничтожены, но все еще находятся в памяти. Зачастую это самый быстрый способ обнаружения распространенных утечек.
  3. Проверка пути к корням сборщика мусора : для любого объекта, в который произошла утечка данных, используйте представление «Путь от корня» в AHAT, чтобы точно понять, какая ссылка поддерживает его работоспособность (например, статическое поле, длительно работающий поток или зарегистрированный слушатель).

← Инструменты | ↑ Вверх | Растровые изображения →