Приложения на 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 .

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

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

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

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

Сравнение дампов кучи
Сравнение двух дампов памяти — один из наиболее эффективных способов выявления проблем с памятью. Сравнивая «чистый» базовый дамп с дампом, полученным после выполнения определенных действий, можно сразу увидеть, какие объекты накопились.
Упражнение: Выявление утечек с помощью дифференциального анализа.
Базовый уровень : Запустите MemoryLab и сделайте дамп кучи базового уровня:
adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof adb pull /data/local/tmp/base.hprof .Действие : Несколько раз нажмите в приложении «Выделить память Java (10 МБ)» .
В заключение : Сделайте вторую выгрузку памяти:
adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof adb pull /data/local/tmp/leaked.hprof .Сравнение : Запустите AHAT, используя второй дамп в качестве основного, а первый — в качестве базового:
java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprofАнализ обзора : На странице обзора теперь есть столбец Δ (дельта) . Вы увидите большую положительную дельту для кучи
app, указывающую на значительный рост объема используемой памяти.

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

Запись трассировки стека выделения памяти
Хотя путь к корневому каталогу сборщика мусора (GC Root) показывает , почему объект всё ещё существует, он не показывает , как он был создан. Трассировка стека выделения памяти предоставляет точную строку кода, которая привела к выделению объекта.
Концепция и компромиссы : Запись трассировки стека каждого выделения памяти — ресурсоемкий процесс, требующий значительных вычислительных затрат и большого объема памяти. В крупном производственном приложении это может сделать его практически непригодным для использования. Однако MemoryLab — достаточно небольшое приложение, поэтому мы можем безопасно включить это отслеживание, чтобы точно определить источник выделения памяти.
Упражнение: Определение источника байтовых массивов
Начните с отслеживания : принудительно остановите 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Действие : Несколько раз нажмите кнопку «Выделить память Java (10 МБ)» .
Дамп : Сделать дамп кучи и извлечь его.
Анализ : Откройте дамп в AHAT. Перейдите к большому массиву
byte[](например, изучитеMainActivity→mJavaAllocations(ArrayList) →elementData(Object[]) → array element[0]).Проверка : В представлении экземпляра посмотрите раздел « Место выделения памяти» . Там будет показана полная трассировка стека, ведущая к
MainActivity.allocateJava.

Анализ динамики использования памяти в Java (комбинированный профиль)
Для получения полной картины поведения приложения в отношении памяти можно объединить счетчики памяти, активность потоков и профилирование выделения памяти на основе стека вызовов в единый трассировочный файл Perfetto. Это позволяет сопоставить общесистемные метрики памяти (такие как RSS и размер кучи) с конкретными местами выполнения кода и выделения памяти.
Мы будем использовать комбинированную конфигурацию, которая позволяет:
- Счетчики памяти (
linux.process_stats): Опрашивает RSS и другие метрики памяти. - ATrace (
dalvik,memory,schedcategories): Захватывает состояния потоков и события сборщика мусора. - Heapprofd (
android.heapprofd): Запускает дампы памяти для кучиcom.android.art(Java) иlibc.malloc(native) с непрерывным созданием дампов каждые 5 секунд.
Упражнение: комбинированный анализ памяти
В этом упражнении мы запустим приложение MemoryLab и выполним последовательность операций с памятью, чтобы наблюдать различные закономерности в трассировке:
- Базовый уровень : состояние простоя.
- Java Churn : Временное выделение памяти, которое немедленно удаляется сборщиком мусора.
- Постоянное выделение памяти в Java : выделение объектов Java, которые остаются в памяти.
- Выделение растровых изображений : выделение больших графических ресурсов (которые находятся в собственной куче/графической памяти).
- Восстановление : Освобождение всех выделенных ресурсов.
1. Запуск и подготовка
Для обеспечения корректного состояния приложения принудительно остановите и перезапустите его:
adb shell am force-stop com.android.memorylab adb shell am start -W -n com.android.memorylab/.MainActivity
2. Начать трассировку и запустить последовательность запуска.
Мы запустим 40-секундную трассировку и запустим события, связанные с памятью, используя команды am broadcast .
Начать трассировку :
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Запустите последовательность (выполните эти команды в терминале вашего компьютера во время выполнения трассировки, соблюдая рекомендуемое время):
# 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Альтернативный вариант (инструмент командной строки) : Вы также можете запустить профилирование, используя скрипт
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 :
-
mem.rss.anon(Анонимный RSS) : Находится в разделе «Память» процесса. Этот параметр измеряет объем физической памяти (ОЗУ), выделенной процессу операционной системой. Он представляет собой фактический объем используемой памяти. -
Heap size (KB): Также находится в разделе «Память» . Это специфический для Dalvik/ART счетчик, представляющий виртуальное адресное пространство, зарезервированное для кучи Java. Он отражает внутренний лимит кучи виртуальной машины, который колеблется по мере выделения объектов и запуска сборщика мусора. -
HeapTaskDaemon: Находится в списке потоков процесса. Это фоновый поток, в котором сборщик мусора ART выполняет большую часть своей работы. Активность здесь указывает на количество активных проходов сборщика мусора. - Непрерывные дампы выделения памяти (heapprofd) : отображаются в виде цветных сегментов вдоль верхней временной шкалы. Каждый сегмент представляет собой определенный промежуток времени. Щелкнув по отдельному сегменту или выбрав временной диапазон, вы можете просмотреть Flamegraph (в нижней панели) для com.android.art (выделение памяти Java) или libc.malloc (выделение памяти нативных приложений), чтобы увидеть, что было выделено за этот период.
Хронологический фазовый анализ
Давайте рассмотрим траекторию в хронологическом порядке, чтобы увидеть, как эти траектории взаимодействуют на каждом этапе упражнения.
Фаза 1: исходный уровень (0–5 с)
- Что происходит : приложение находится в режиме ожидания, ожидая команд.
- Статус отслеживания :
-
mem.rss.anon: Прямая линия на базовом уровне (обычно около 60-80 МБ в зависимости от устройства). -
Heap size (KB): Прямая линия, соответствующая первоначальному выделению памяти в куче Java. -
HeapTaskDaemon: Простой (нет фрагментов, отображающих выполнение). - Сводка выделенных ресурсов : Отображение минимального базового уровня выделения ресурсов.
-

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

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

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

Мониторинг исторических ошибок нехватки памяти (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
}
}
Передовые методы
- Сначала создайте базовый дамп памяти : всегда создавайте «базовый» дамп памяти после инициализации приложения, но до выполнения тестируемого действия.
- Используйте страницу утечек активности AHAT : AHAT включает специальную страницу утечек активности , которая автоматически определяет экземпляры Activity, которые были уничтожены, но все еще находятся в памяти. Зачастую это самый быстрый способ обнаружения распространенных утечек.
- Проверка пути к корням сборщика мусора : для любого объекта, в который произошла утечка данных, используйте представление «Путь от корня» в AHAT, чтобы точно понять, какая ссылка поддерживает его работоспособность (например, статическое поле, длительно работающий поток или зарегистрированный слушатель).
← Инструменты | ↑ Вверх | Растровые изображения →