Приложения на Java и Kotlin управляют памятью с помощью кучи со сборщиком мусора. Когда объекты становятся недоступными, сборщик мусора (GC) в конечном итоге освобождает занимаемое ими пространство. Утечки памяти происходят, когда объекты, которые больше не нужны, по-прежнему удерживаются корнями сборщика мусора, что препятствует их утилизации.
Основные понятия
Корни сборщика мусора
GC Root – это особый тип объекта, который сборщик мусора считает всегда доступным. Примеры:
- Активные потоки (и объекты, на которые ссылаются их выполняемые в данный момент стековые фреймы Java).
- Классы с активно выполняющимися методами.
- Ссылки JNI (глобальные или локальные ссылки, которые хранятся в нативном коде).
Путь к корню GC
Пока существует цепочка ссылок от корня GC к объекту, этот объект считается достижимым и не может быть удален сборщиком мусора. Эта цепочка называется путем к корню сборщика мусора. Чтобы устранить утечку памяти, необходимо найти и разорвать эту цепочку.

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

Как получить дампы кучи 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 в рамках трассировки всей системы, если в конфигурации Perfetto включен источник данных android.java_hprof. Это полезно для сопоставления состояния кучи с другими системными событиями.
Чтобы получить снимок дампа кучи для приложения 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
Для просмотра файлов .hprof в браузере рекомендуется использовать AHAT (Android Heap Analysis Tool).
Запуск AHAT
Если у вас установлен ahat, запустите его с помощью следующей команды:
ahat heap.hprof
Или запустите отдельный JAR-файл:
java -jar ahat.jar heap.hprof
Затем откройте в браузере страницу http://localhost:7100.
Подробную информацию о том, как получить или создать AHAT, можно найти в репозитории исходного кода AHAT.
Основные рабочие процессы анализа
Поиск утечек
Найдите класс Activity (MainActivity) в представлении Allocations (Распределения).

Нажмите на класс, чтобы посмотреть все экземпляры.
Нажмите на экземпляр MainActivity, чтобы проверить его.

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

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

Страница "Утечки активности"
В AHAT есть специальный режим просмотра для выявления утечек, связанных с действиями. Это один из самых распространенных и серьезных типов утечек памяти в Android.
- Действие. В MemoryLab нажмите Leak an Activity (Утечка действия). При этом запускается
LeakedActivity, который намеренно допускает утечку. - Dump (Дамп). Создать дамп кучи.
- Анализ. На боковой панели AHAT нажмите Activity Leaks (Утечки действий).
- Проверка. AHAT укажет, что произошла утечка
com.android.memorylab.LeakedActivity, поскольку полеmDestroyedимеет значение true (это означает, что жизненный цикл объекта activity завершен), но объект по-прежнему доступен из корня GC.

Сравнение дампов кучи
Сравнение двух дампов кучи – один из самых эффективных способов выявить проблемы с памятью. Сравнивая "чистый" базовый дамп с дампом, полученным после выполнения некоторых действий, вы можете сразу увидеть, какие объекты накопились.
Упражнение. Выявление утечек с помощью сравнения
Контрольный показатель. Запустите MemoryLab и сделайте контрольный дамп кучи:
adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof adb pull /data/local/tmp/base.hprof .Действие. Нажмите Allocate Java Memory(10MB) (Выделить память Java (10 МБ)) несколько раз в приложении.
Final. Получите второй снимок дампа кучи.
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 в меню. На этой странице показаны объекты, доступные из корней GC, отсортированные по сохраненному размеру. Вверху вы увидите
MainActivityс большим положительным изменением.

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

Поиск повторяющихся строк и раздувания гидратации
Даже если в приложении нет классических утечек GC-root, его активная куча Java может быть раздута тысячами дублирующихся экземпляров java.lang.String, созданных во время десериализации JSON, Protobuf, Cursor или базы данных Room. Повторяющиеся ключи, строки состояния, метки категорий или URL-адреса часто выделяются заново при каждом ответе сети или запросе к базе данных. В крупных приложениях с лентами, сообщениями и контентом повторяющиеся строки обычно занимают от 30 до 60 % активной памяти String.
Чтобы проверить повторяющиеся строки в AHAT:
- Откройте страницу Распределения и отфильтруйте данные по значению
java.lang.String. - При сравнении двух дампов кучи с помощью
--baselineпроверьте, не увеличивается ли количество экземпляровjava.lang.Stringи общий объем байтов непропорционально после загрузки фида или локального кеша. - Просмотрите таблицу экземпляров
java.lang.String(отсортированную по размеру или значению), чтобы найти идентичные строковые значения, сохраненные в нескольких объектах модели в памяти.
- Решение. Не вызывайте
String.intern()для произвольных пользовательских или сетевых входных данных, поскольку таблица интернирования среды выполнения является глобальной и может привести к конфликту блокировок или удерживать строки дольше, чем необходимо. Вместо этого удаляйте дубликаты часто встречающихся строк доменов во время десериализации, используя ограниченный кеш дедупликации (например,LruCache<String, String>в синтаксическом анализаторе или адаптере), или представляйте фиксированные наборы значений в виде перечислений или целочисленных констант.
Анализ динамики памяти Java (комбинированный профиль)
Чтобы получить полное представление о поведении памяти приложения, вы можете объединить счетчики памяти, активность потоков и профилирование распределения на основе стека вызовов в одну трассировку Perfetto. Это позволяет сопоставлять показатели памяти на уровне системы (например, RSS и размер кучи) с конкретными сайтами выполнения кода и распределения.
Мы будем использовать комбинированную конфигурацию, которая позволяет:
- Счетчики памяти (
linux.process_stats) – опрашивает RSS и другие показатели памяти. - ATrace (категории
dalvik,memory,sched) – фиксирует состояния потоков и события сборки мусора. - Heapprofd (
android.heapprofd) – непрерывно создает дампы кучиcom.android.art(Java) иlibc.malloc(нативной) каждые пять секунд.
Упражнение: комбинированный анализ памяти
В этом упражнении мы запустим приложение MemoryLab и выполним последовательность операций с памятью, чтобы увидеть разные шаблоны в трассировке.
- Baseline: Состояние бездействия.
- 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: 131072 fill_policy: RING_BUFFER } data_sources: { config { name: "linux.process_stats" target_buffer: 0 process_stats_config { scan_all_processes_on_start: true proc_stats_poll_ms: 100 } } } data_sources: { config { name: "linux.ftrace" target_buffer: 0 ftrace_config { ftrace_events: "sched/sched_switch" ftrace_events: "task/task_newtask" ftrace_events: "task/task_rename" ftrace_events: "ftrace/print" atrace_categories: "dalvik" atrace_categories: "am" atrace_categories: "res" atrace_categories: "memory" atrace_categories: "sched" atrace_apps: "com.android.memorylab" } } } data_sources: { config { name: "android.heapprofd" target_buffer: 0 heapprofd_config { sampling_interval_bytes: 4096 process_cmdline: "com.android.memorylab" heaps: "libc.malloc" heaps: "com.android.art" shmem_size_bytes: 8388608 block_client: true continuous_dump_config { dump_phase_ms: 1000 dump_interval_ms: 5000 } } } } duration_ms: 40000 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Альтернативный вариант (инструмент CLI). Вы также можете запустить профилирование, используя скрипт
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 UI.
Основные дорожки в Perfetto
Прежде чем анализировать временную шкалу, найдите эти важные дорожки для процесса com.android.memorylab:
mem.rss.anon(анонимный RSS) – в разделе Память для процесса. На этой дорожке показан объем физической памяти (ОЗУ), выделенный процессу операционной системой. Он отражает фактический объем потребляемой памяти.Heap size (KB)– также в разделе Память. Это счетчик, относящийся к Dalvik/ART и представляющий виртуальное адресное пространство, зарезервированное для кучи Java. Оно отражает внутренний лимит кучи виртуальной машины, который колеблется по мере выделения объектов и выполнения сборки мусора.HeapTaskDaemon: указан в списке потоков под процессом. Это фоновый поток, в котором сборщик мусора ART выполняет большую часть своей работы. Активность здесь указывает на активные пропуски GC.- Дампы непрерывного выделения памяти (heapprofd). Показаны в виде цветных фрагментов на верхней временной шкале. Каждый сектор представляет собой определенный период времени. Нажав на один фрагмент или выбрав диапазон времени, вы можете посмотреть пламенный график (на нижней панели) для 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): быстрое пилообразное движение. Размер кучи увеличивается по мере накопления выделений и резко уменьшается при запуске GC.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 МБ, соответствующее нативным распределениям для пиксельных данных Bitmap.- Allocation Dumps (Flamegraph) (Дампы распределения (диаграмма Flamegraph)). На этот раз сосредоточьтесь на фрагментах для кучи libc.malloc (Native).
Стеки вызовов нативного выделения памяти показывают, что выделение памяти для битовых карт происходит из нативных графических библиотек. Это хороший пример использования отслеживания распределения нативной памяти, поскольку вы не увидите эти распределения битовых карт в куче 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
}
}
Рекомендации
- Сначала создайте базовый дамп. Всегда создавайте базовый дамп кучи после инициализации приложения, но до выполнения действия, которое вы тестируете.
- Используйте страницу Activity Leaks в AHAT. В AHAT есть специальная страница Activity Leaks, на которой автоматически определяются экземпляры Activity, которые были уничтожены, но все ещё хранятся в памяти. Это самый быстрый способ найти распространенные утечки.
- Проверьте путь к корням GC. Для любого объекта с утечкой используйте представление Path from Root (Путь от корня) в AHAT, чтобы точно понять, какая ссылка поддерживает его активность (например, статическое поле, длительный поток или зарегистрированный прослушиватель).
← Инструменты | ↑ Наверх | Растровые изображения →