Пример: отладка сбоя из-за нехватки памяти

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

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

Сценарий

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

Чтобы разобраться в проблеме, вы регистрируете триггер TRIGGER_TYPE_OOM с помощью ProfilingManager, чтобы система автоматически создавала дамп кучи Java при возникновении OutOfMemoryError.

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

Чтобы получить дамп кучи при сбое OutOfMemoryError на устройстве с Android 17 (уровень API 37) или более поздней версии, настройте профилирование на основе триггеров с помощью ProfilingTrigger.TRIGGER_TYPE_OOM. Когда в приложении возникает необработанная ошибка OutOfMemoryError, система автоматически начинает новую запись PROFILING_TYPE_JAVA_HEAP_DUMP.

Поскольку при сбое процесс приложения завершается, система заканчивает сохранение дампа кучи в фоновом режиме и ставит результат в очередь (см. раздел Жизненный цикл триггера). Зарегистрируйте глобальный прослушиватель результатов в фоновом режиме Executor после запуска приложения (чтобы не замедлять его), используя registerForAllProfilingResults. Так приложение получит поставленные в очередь ProfilingResult при следующем запуске. В отличие от метода addProfilingTriggers, который сохраняется при перезапуске приложения и должен вызываться только один раз или при обновлении триггеров, метод registerForAllProfilingResults должен вызываться при каждом запуске приложения.

Kotlin

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
fun registerOomTrigger(context: Context) {
    val profilingManager =
        context.getSystemService(ProfilingManager::class.java) ?: return
    val callbackExecutor: Executor = Executors.newSingleThreadExecutor()

    val resultCallback = Consumer<ProfilingResult> { profilingResult ->
        if (
            profilingResult.errorCode == ProfilingResult.ERROR_NONE &&
            profilingResult.triggerType == ProfilingTrigger.TRIGGER_TYPE_OOM
        ) {
            val heapDumpPath = profilingResult.resultFilePath
            // Schedule a background job to compress and upload heapDumpPath
        }
    }

    profilingManager.registerForAllProfilingResults(callbackExecutor, resultCallback)

    val oomTrigger = ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM)
        .build()
    profilingManager.addProfilingTriggers(listOf(oomTrigger))
}

Java

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
public static void registerOomTrigger(Context context) {
    ProfilingManager profilingManager = context.getSystemService(ProfilingManager.class);
    if (profilingManager == null) return;
    Executor callbackExecutor = Executors.newSingleThreadExecutor();

    Consumer<ProfilingResult> resultCallback =
        profilingResult -> {
            if (profilingResult.getErrorCode() == ProfilingResult.ERROR_NONE
                && profilingResult.getTriggerType() == ProfilingTrigger.TRIGGER_TYPE_OOM) {
                String heapDumpPath = profilingResult.getResultFilePath();
                // Schedule a background job to compress and upload heapDumpPath
            }
        };

    profilingManager.registerForAllProfilingResults(callbackExecutor, resultCallback);

    ProfilingTrigger oomTrigger =
        new ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM)
            .build();
    profilingManager.addProfilingTriggers(Collections.singletonList(oomTrigger));
}

Деобфусцируйте профиль

После того как OutOfMemoryError запустит запись и ваш обратный вызов получит ProfilingResult при следующем запуске приложения, загрузите файл профиля на сервер или перенесите его на компьютер для разработки с помощью adb. Если вы получили профиль из конечной сборки, в которой используется сокращение и обфускация кода R8, перед анализом деобфусцируйте его на рабочей станции или сервере. Без деобфускации R8 обфусцирует имена классов, полей и методов Java и Kotlin в необработанном дампе кучи (например, превращает исходные имена классов в a.b).

Используйте команду trace_processor bundle в Perfetto с флагом --proguard-map, чтобы упаковать записанный профиль вместе с файлом mapping.txt R8, сгенерированным для этой конечной сборки:

trace_processor bundle \
  --proguard-map /path/to/mapping.txt \
  profile_trigger-type-7_2026-05-06-14-12-40-748_uid-10316.perfetto-java-heap-dump \
  deobfuscated_oom.tar

Эта команда создает расширенный архив deobfuscated_oom.tar, который объединяет исходный профиль с сопоставлениями деобфускации. Когда вы откроете этот архив в Perfetto UI или запросите его с помощью trace_processor, Perfetto автоматически восстановит все исходные названия классов, полей и методов. Дополнительную информацию можно найти в документации Perfetto по символизации и деобфускации.

Как проанализировать дамп кучи

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

Откройте деобфусцированный дамп кучи в Perfetto UI и на панели навигации слева выберите Heapdump Explorer (Проводник по дампу кучи). На вкладке Flamegraph (Пламенная диаграмма), где для параметра Measure (Измерение) задано значение Object Size (Размер объекта), сразу выделяется иерархия удержания, показанная на рисунке 1.

Флейм-граф Perfetto UI Heapdump Explorer, показывающий, что StatsReporter сохраняет восемь экземпляров OomSampleActivity общим размером 240,28 МиБ.
Рисунок 1. Флейм-граф дампа кучи, на котором показано восемь сохраненных экземпляров OomSampleActivity.

Изучение флейм-графа на рисунке 1 позволяет сделать несколько ключевых выводов:

  • Значительное удержание памяти. Из 254,79 МиБ общей кучи (root) com.example.sampleapp.OomSampleActivity удерживает совокупный размер 240,28 МиБ (94,31 % всей кучи приложения), большая часть которого находится в дочернем массиве byte[].
  • Несколько экземпляров интерактивных действий. Если выбрать фрейм OomSampleActivity, вы увидите Self Count: 8. Хотя на экране одновременно должен отображаться только один экземпляр OomSampleActivity, в памяти одновременно находятся восемь отдельных экземпляров.
  • Путь к удерживающему объекту. Прочитав родительские фреймы OomSampleActivity на флейм-графе, можно увидеть цепочку ссылок, которая поддерживает работу этих экземпляров: com.example.sampleapp.StatsReporter содержит java.util.concurrent.CopyOnWriteArrayList (поддерживается java.lang.Object[]), который напрямую ссылается на все восемь экземпляров OomSampleActivity.

Определите основную причину в коде

Поскольку путь удержания указывает от StatsReporter к OomSampleActivity, проверьте оба класса в базе кода.

StatsReporter – это синглтон, который поддерживает список обратных вызовов StatsListener и периодически публикует обновления из фонового потока опроса:

Kotlin

class StatsReporter private constructor() {
    fun interface StatsListener {
        fun onReportStats(stats: String)
    }

    private val listeners: MutableList<StatsListener> = CopyOnWriteArrayList()
    private var isReporting = false

    init {
        startReporting()
    }

    fun registerListener(listener: StatsListener) {
        if (!listeners.contains(listener)) {
            listeners.add(listener)
        }
    }

    fun unregisterListener(listener: StatsListener) {
        listeners.remove(listener)
    }

    private fun startReporting() {
        if (isReporting) return
        isReporting = true

        Thread({
            while (true) {
                try {
                    // Poll every 10 seconds
                    Thread.sleep(10000)
                } catch (e: InterruptedException) {
                    Thread.currentThread().interrupt()
                    break
                }

                for (listener in listeners) {
                    // Send periodic mock stats update from the background thread
                    listener.onReportStats("Update from StatsReporter background polling thread")
                }
            }
        }, "StatsReporterPollingThread").start()
    }

    companion object {
        private var instance: StatsReporter? = null

        @JvmStatic
        fun getInstance(): StatsReporter {
            return instance ?: StatsReporter().also { instance = it }
        }
    }
}

Java

public class StatsReporter {
    public interface StatsListener {
        void onReportStats(String stats);
    }

    private static StatsReporter sInstance;

    private final List<StatsListener> mListeners = new CopyOnWriteArrayList<>();
    private boolean mIsReporting = false;

    private StatsReporter() {
        startReporting();
    }

    public static StatsReporter getInstance() {
        if (sInstance == null) {
            sInstance = new StatsReporter();
        }
        return sInstance;
    }

    public void registerListener(StatsListener listener) {
        if (!mListeners.contains(listener)) {
            mListeners.add(listener);
        }
    }

    public void unregisterListener(StatsListener listener) {
        mListeners.remove(listener);
    }

    private void startReporting() {
        if (mIsReporting) return;
        mIsReporting = true;

        new Thread(() -> {
            while (true) {
                try {
                    // Poll every 10 seconds
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }

                for (StatsListener listener : mListeners) {
                    // Send periodic mock stats update from the background thread
                    listener.onReportStats("Update from StatsReporter background polling thread");
                }
            }
        }, "StatsReporterPollingThread").start();
    }
}

Затем проверьте OomSampleActivity:

Kotlin

class OomSampleActivity : Activity(), StatsReporter.StatsListener {
    // Allocate some memory.
    private val heavyData = ByteArray(30 * 1024 * 1024)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_oom_sample)

        StatsReporter.getInstance().registerListener(this)
    }

    override fun onReportStats(stats: String) {
        // Read from heavyData so R8 doesn't strip it out as dead code during release builds
        Log.d(TAG, "Received stats in heavy leaked activity: $stats | Data size: ${heavyData.size}")
    }

    companion object {
        private const val TAG = "OomSampleActivity"
    }
}

Java

public class OomSampleActivity extends Activity implements StatsReporter.StatsListener {
    private static final String TAG = "OomSampleActivity";

    // Allocate some memory.
    private final byte[] mHeavyData = new byte[30 * 1024 * 1024];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oom_sample);

        StatsReporter.getInstance().registerListener(this);
    }

    @Override
    public void onReportStats(String stats) {
        // Read from mHeavyData so R8 does not strip it out as dead code during release builds
        Log.d(TAG, "Received stats in heavy leaked activity: " + stats + " | Data size: " + mHeavyData.length);
    }
}

Код подтверждает, почему StatsReporter сохраняет 8 экземпляров OomSampleActivity:

  1. Каждый экземпляр OomSampleActivity выделяет буфер byte[] размером 30 МиБ (heavyData в Kotlin, mHeavyData в Java) и регистрирует себя как прослушиватель в синглтоне StatsReporter в onCreate.
  2. OomSampleActivity никогда не вызывает unregisterListener, когда система уничтожает его. Поскольку StatsReporter существует дольше, чем действие, и содержит сильную ссылку на каждый зарегистрированный прослушиватель, сборщик мусора не может освободить память, занятую уничтоженными экземплярами или их массивами byte[] размером 30 МиБ.
  3. Каждый раз, когда система уничтожает и заново создает OomSampleActivity, например при повороте экрана в жизненном цикле объекта activity в Android, в StatsReporter происходит утечка ещё одного экземпляра размером 30 МиБ. После семи поворотов (8 экземпляров × 30 МиБ ≈ 240 МиБ) куча заполняется и приложение аварийно завершает работу с ошибкой OutOfMemoryError.

Устраните утечку

Чтобы устранить утечку памяти, переопределите onDestroy в операции и вызовите StatsReporter.getInstance().unregisterListener(this). Тогда синглтон удалит ссылку, когда система уничтожит операцию.

Kotlin

class OomSampleActivityFixed : Activity(), StatsReporter.StatsListener {
    // Allocate some memory.
    private val heavyData = ByteArray(30 * 1024 * 1024)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_oom_sample)

        // Register the activity as a listener
        StatsReporter.getInstance().registerListener(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        // FIX: Unregister the listener to prevent the memory leak
        // when the Activity is destroyed (for example, during screen rotation).
        StatsReporter.getInstance().unregisterListener(this)
    }

    override fun onReportStats(stats: String) {
        // Read from heavyData so R8 doesn't strip it out as dead code during release builds
        Log.d(TAG, "Received stats in fixed heavy activity: $stats | Data size: ${heavyData.size}")
    }

    companion object {
        private const val TAG = "OomSampleFixed"
    }
}

Java

public class OomSampleActivityFixed extends Activity implements StatsReporter.StatsListener {
    private static final String TAG = "OomSampleFixed";

    // Allocate some memory.
    private final byte[] mHeavyData = new byte[30 * 1024 * 1024];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oom_sample);

        // Register the activity as a listener
        StatsReporter.getInstance().registerListener(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // FIX: Properly unregister the listener to prevent the memory leak
        // when the Activity is destroyed (e.g., during screen rotation).
        StatsReporter.getInstance().unregisterListener(this);
    }

    @Override
    public void onReportStats(String stats) {
        // Read from mHeavyData so R8 does not strip it out as dead code during release builds
        Log.d(TAG, "Received stats in fixed heavy activity: " + stats + " | Data size: " + mHeavyData.length);
    }
}

Автоматизируйте анализ нехватки памяти с помощью ИИ-агента

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

Analyze the Perfetto OOM heap dump at </path/to/profile>, which corresponds to
the codebase located at </path/to/codebase> using the Perfetto AI skill and find
the root cause of the OutOfMemoryError.

Агент запрашивает дамп кучи с помощью trace_processor, отслеживает путь доминатора от StatsReporter до экземпляров OomSampleActivity, в которых произошла утечка, и проверяет оба класса в вашей базе кода. Без вмешательства человека он приходит к тому же выводу: OomSampleActivity регистрирует слушателя в onCreate, не отменяя регистрацию, и генерирует исправление, добавляющее unregisterListener(this) в onDestroy (следующий отчет был сгенерирован на основе отдельного дампа, в котором на момент сбоя было семь сохраненных экземпляров):

# Perfetto OOM Heap Dump Analysis Report

## 1. Orienting Context & First-Pass Triage

| Property | Value |
| :--- | :--- |
| **Process Name** | `com.example.sampleapp` |
| **Sample Timestamp (`graph_sample_ts`)** | `3433221241585` |
| **Leaf Retained Class** | `byte[]` (7 instances) |
| **Leaf Self Size** | `220,201,044` bytes (~210 MiB) |

### Dominator Tree Retention Path

Querying the `android.memory.heap_graph.dominator_tree` standard library module identifies the following largest dominated class path from GC root to leaf allocation:

    [ROOT_JAVA_FRAME] com.example.sampleapp.StatsReporter [1]
      -> java.util.concurrent.CopyOnWriteArrayList [1]
      -> java.lang.Object[] [1]
      -> com.example.sampleapp.OomSampleActivity [7]
      -> byte[] [7] (220,201,044 bytes)
## 2. Root Cause Analysis

> **Important:** The `OutOfMemoryError` is caused by an **Activity listener leak**: `OomSampleActivity` (`app/src/main/java/com/example/sampleapp/OomSampleActivity.java#L8-L27`) registers itself as a listener on the long-lived `StatsReporter` (`app/src/main/java/com/example/sampleapp/StatsReporter.java#L6-L61`) singleton in `onCreate()`, but never unregisters itself in `onDestroy()`.

### Step-by-Step Retention Mechanism

1. **Per-Activity 30 MiB Allocation:**
   Each instance of `OomSampleActivity` (`OomSampleActivity.java#L11-L12`) allocates a 30 MiB byte array (`30 * 1024 * 1024 = 31,457,280` bytes):
   ```java
   // OomSampleActivity.java:12
   private final byte[] mHeavyData = new byte[30 * 1024 * 1024];
   ```

2. **Strong Reference Registration in `onCreate()`:**
   When created, `OomSampleActivity.onCreate()` (`OomSampleActivity.java#L15-L20`) registers `this` with `StatsReporter` (`StatsReporter.java#L29-L33`):
   ```java
   // OomSampleActivity.java:19
   StatsReporter.getInstance().registerListener(this);
   ```

3. **GC Root Pinning via Background Thread & Singleton:**
   `StatsReporter` (`StatsReporter.java#L11-L60`) is both a static singleton (`sInstance`) and the owner of an active background thread (`StatsReporterPollingThread`, which holds a `[ROOT_JAVA_FRAME]` GC root reference while sleeping/looping in `startReporting()`). It stores listeners in `mListeners` (`CopyOnWriteArrayList<StatsListener>`).

4. **Leak Accumulation Across Activity Recreations:**
   Because `OomSampleActivity` does not override `onDestroy()` to call `StatsReporter.unregisterListener(this)`, every destroyed instance of `OomSampleActivity` remains strongly reachable in `mListeners`. After 7 Activity creations (7 * 30 MiB = 220,200,960 bytes plus array headers = `220,201,044` bytes), the Java heap is exhausted.

---

## 3. Actionable Implementation Plan

### 1. Unregister the Listener in `OomSampleActivity.onDestroy()`

Update `OomSampleActivity.java` to unregister the listener when the `Activity` is destroyed (matching the fix in `OomSampleActivityFixed.java`):

```diff
--- a/app/src/main/java/com/example/sampleapp/OomSampleActivity.java
+++ b/app/src/main/java/com/example/sampleapp/OomSampleActivity.java
@@ -19,4 +19,10 @@
         StatsReporter.getInstance().registerListener(this);
     }

+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        StatsReporter.getInstance().unregisterListener(this);
+    }
+
     @Override
```

### 2. Optional Architectural Hardening in `StatsReporter`

* **Lifecycle-Aware Registration:** Have `StatsReporter.registerListener()` accept a `LifecycleOwner` and attach a `DefaultLifecycleObserver` that automatically invokes `unregisterListener()` on `ON_DESTROY`.
* **Weak References:** Store `WeakReference<StatsListener>` entries in `StatsReporter` and evict cleared references during iteration so forgotten `unregisterListener()` calls cannot retain entire `Activity` hierarchies.

Проверьте, решена ли проблема

Убедитесь, что утечка устранена, локально на тестовом устройстве или в рабочей среде.

Если проблему можно воспроизвести локально

Поскольку TRIGGER_TYPE_OOM создает дамп кучи только при возникновении OutOfMemoryError, успешное исправление предотвращает срабатывание триггера. Чтобы проверить кучу и убедиться, что утечка устранена, поверните экран, а затем получите дамп кучи Java по запросу из командной строки:

  1. Отключите ограничение частоты запросов (rate_limiter.disabled true) и сохраните временные результаты (delete_temporary_results.disabled true), чтобы система сохранила данные, полученные с помощью командной строки, в /data/misc/perfetto-traces/profiling/.
  2. Откройте приложение "OomSampleActivityFixed" на тестовом устройстве, поверните экран не менее восьми раз, чтобы убедиться, что приложение больше не дает сбоев, и оставьте его на переднем плане.
  3. Запустите дамп кучи Java по запросу (profiling_type 1) с помощью команды оболочки cmd profiling_service request-profiling:

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. Извлеките сохраненный профиль из /data/misc/perfetto-traces/profiling/ и откройте его в Perfetto UI.

Флейм-граф в Heapdump Explorer в Perfetto UI после исправления.Показан один экземпляр OomSampleActivityFixed, который удерживает 30,01 МиБ.
Рисунок 2. Флейм-граф дампа кучи после исправления, показывающий один экземпляр OomSampleActivityFixed.

Как показано на рисунке 2, после нескольких поворотов экрана:

  • com.example.sampleapp.OomSampleActivityFixed имеет собственное количество 1, а не накапливает экземпляры при каждом повороте.
  • Совокупный сохраненный размер остается стабильным на уровне 30,01 МиБ (из общего объема кучи 44,27 МиБ), что подтверждает, что сборщик мусора восстанавливает предыдущие экземпляры действий.

Если проблему сложно воспроизвести локально

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

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