Fallstudie: OOM-Absturz debuggen

Out of Memory (OutOfMemoryError) Abstürze treten auf, wenn Ihre App den zugewiesenen Java-Heap aufgebraucht hat und keine neue Arbeitsspeicherzuweisung erfüllen kann. Stacktraces aus OutOfMemoryError-Absturzberichten ermöglichen selten Rückschlüsse auf die Ursache, da sie nur den Zuweisungsaufruf zeigen, der fehlgeschlagen ist, als der Arbeitsspeicher voll war, und nicht die Objekte, die den Rest des Heaps belegen.

Mit ProfilingManager können Sie Java-Heap-Dumps von Geräten im Feld und lokal erfassen, um das Objektaufbewahrungsdiagramm in der Perfetto-Benutzeroberfläche zu prüfen und herauszufinden, was den Speicher belegt.

Szenario

Angenommen, Ihr Tool für Absturzberichte meldet eine wachsende Anzahl von OutOfMemoryError-Abstürzen in der Produktion, wenn Nutzer mit OomSampleActivity interagieren.

Um das Problem zu untersuchen, registrieren Sie einen TRIGGER_TYPE_OOM-Trigger bei ProfilingManager, damit das System automatisch einen Java-Heap-Dump erfasst, wenn ein OutOfMemoryError auftritt.

Heap-Dump erfassen

Unter Android 17 (API-Level 37) und höher können Sie einen Heap-Dump erstellen, wenn ein OutOfMemoryError-Absturz auftritt. Konfigurieren Sie dazu das triggerbasierte Profiling mit ProfilingTrigger.TRIGGER_TYPE_OOM. Wenn Ihre App eine nicht behandelte OutOfMemoryError auslöst, startet das System automatisch eine neue PROFILING_TYPE_JAVA_HEAP_DUMP-Erfassung.

Da der App-Prozess beim Absturz beendet wird, speichert das System den Heap-Dump im Hintergrund und stellt das Ergebnis in die Warteschlange (siehe Lebenszyklus des Triggers). Registrieren Sie nach dem Start der App einen globalen Ergebnis-Listener in einem Hintergrund-Executor (damit der App-Start nicht verlangsamt wird). Verwenden Sie dazu registerForAllProfilingResults, damit Ihre App die in der Warteschlange befindlichen ProfilingResult beim nächsten Start durch den Nutzer empfängt. Im Gegensatz zu addProfilingTriggers (das über App-Neustarts hinweg beibehalten wird und nur einmal oder beim Aktualisieren von Triggern aufgerufen werden muss) muss registerForAllProfilingResults bei jedem App-Start aufgerufen werden:

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));
}

Profil deobfuskieren

Nachdem ein OutOfMemoryError eine Erfassung ausgelöst hat und Ihr Callback beim nächsten App-Start die ProfilingResult empfängt, laden Sie die Profildatei auf Ihren Server hoch oder ziehen Sie sie mit adb auf Ihre Entwicklungsmaschine ab. Wenn Sie das Profil aus einem Release-Build erfasst haben, der die R8-Codekomprimierung und -obfuskation verwendet, müssen Sie es vor der Analyse offline auf Ihrer Workstation oder Ihrem Server deobfuskieren. Ohne Deobfuskation verschleiert R8 die Java- und Kotlin-Klassen-, Feld- und Methodennamen im Roh-Heap-Dump (z. B. werden ursprüngliche Klassennamen in a.b umgewandelt).

Verwenden Sie den Perfetto-Befehl trace_processor bundle mit dem Flag --proguard-map, um das erfasste Profil zusammen mit der R8-Datei mapping.txt zu verpacken, die für diesen genauen Release-Build generiert wurde:

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

Dieser Befehl erzeugt deobfuscated_oom.tar, ein angereichertes Archiv, das das ursprüngliche Profil mit seinen Deobfuskationszuordnungen bündelt. Wenn Sie dieses Archiv in der Perfetto-Benutzeroberfläche öffnen oder mit trace_processor abfragen, stellt Perfetto automatisch alle ursprünglichen Klassen-, Feld- und Methodennamen wieder her. Weitere Informationen finden Sie in der Perfetto-Dokumentation zu Symbolisierung und Deobfuskation.

Heap-Dump analysieren

Sie können den deobfuskierten Heap-Dump manuell in der Perfetto-Benutzeroberfläche prüfen, wie in den folgenden Schritten beschrieben, oder die OOM-Untersuchung mit einem KI-Agenten automatisieren.

Öffnen Sie den deobfuskierten Heap-Dump in der Perfetto-Benutzeroberfläche und wählen Sie in der linken Navigationsleiste Heapdump Explorer aus. Auf dem Tab Flamegraph, auf dem Messwert auf Objektgröße festgelegt ist, fällt die Aufbewahrungshierarchie in Abbildung 1 sofort auf.

Flame-Diagramm des Heapdump-Explorers der Perfetto-Benutzeroberfläche, das zeigt, dass StatsReporter 8 Instanzen von OomSampleActivity mit insgesamt 240,28 MiB beibehält.
Abbildung 1. Flame-Diagramm des Heap-Dumps mit 8 beibehaltenen Instanzen von OomSampleActivity.

Die Untersuchung des Flame-Diagramms in Abbildung 1 liefert mehrere wichtige Erkenntnisse:

  • Massive Speicherbelegung: Von einem Heap mit insgesamt 254,79 MiB (root) belegt com.example.sampleapp.OomSampleActivity eine kumulative Größe von 240,28 MiB (94,31 % des gesamten App-Heaps). Der Großteil davon befindet sich in einem untergeordneten byte[]-Array.
  • Mehrere Live-Aktivitätsinstanzen: Wenn Sie den Frame OomSampleActivity auswählen, wird Self Count: 8 angezeigt. Obwohl jeweils nur eine Instanz von OomSampleActivity auf dem Bildschirm angezeigt werden sollte, sind gleichzeitig acht separate Instanzen im Arbeitsspeicher aktiv.
  • Der Pfad des beibehaltenden Objekts: Wenn Sie die übergeordneten Frames von OomSampleActivity im Flame-Diagramm lesen, sehen Sie die Referenzkette, die diese Instanzen aktiv hält: com.example.sampleapp.StatsReporter enthält ein java.util.concurrent.CopyOnWriteArrayList (unterstützt durch java.lang.Object[]), das direkt auf alle 8 OomSampleActivity-Instanzen verweist.

Ursache im Code ermitteln

Da der Aufbewahrungspfad von StatsReporter zu OomSampleActivity führt, sollten Sie beide Klassen in Ihrer Codebasis prüfen.

StatsReporter ist ein Singleton, der eine Liste von StatsListener-Callbacks verwaltet und regelmäßig Updates von einem Hintergrund-Polling-Thread postet:

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();
    }
}

Prüfen Sie als Nächstes 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);
    }
}

Der Code bestätigt, warum StatsReporter acht Instanzen von OomSampleActivity beibehält:

  1. Jede OomSampleActivity-Instanz weist einen 30-MiB-byte[]-Zwischenspeicher zu (heavyData in Kotlin, mHeavyData in Java) und registriert sich als Listener für das Singleton StatsReporter in onCreate.
  2. OomSampleActivity ruft unregisterListener nie auf, wenn es vom System gelöscht wird. Da StatsReporter die Aktivität überdauert und einen starken Verweis auf jeden registrierten Listener enthält, kann der Garbage Collector zerstörte Instanzen oder ihre 30-MiB-byte[]-Arrays nicht zurückfordern.
  3. Jedes Mal, wenn das System OomSampleActivity zerstört und neu erstellt, z. B. während einer Bildschirmdrehung im Android-Aktivitätslebenszyklus, wird eine weitere 30-MiB-Instanz in StatsReporter geleakt. Nach 7 Rotationen (8 Instanzen × 30 MiB ≈ 240 MiB) ist der Heap voll und die Anwendung stürzt mit einem OutOfMemoryError ab.

Leck beseitigen

Um das Speicherleck zu beheben, überschreiben Sie onDestroy in der Aktivität und rufen Sie StatsReporter.getInstance().unregisterListener(this) auf, damit das Singleton seine Referenz freigibt, wenn das System die Aktivität löscht:

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);
    }
}

OOM-Untersuchung mit einem KI-Agenten automatisieren

Anstatt das Flame-Diagramm manuell zu prüfen und Verweise über Dateien hinweg zu verfolgen, können Sie einen KI-Codierungs-Agenten verwenden, der mit dem Perfetto AI-Skill konfiguriert ist, um den Heap-Dump zu analysieren und mit Ihrer Codebasis zu korrelieren:

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.

Der KI-Agent fragt den Heap-Dump mit trace_processor ab, verfolgt den Dominatorpfad von StatsReporter zu den geleakten OomSampleActivity-Instanzen und prüft beide Klassen in Ihrer Codebasis. Ohne manuelles Eingreifen kommt es zum selben Ergebnis, dass OomSampleActivity einen Listener in onCreate registriert, ohne ihn abzumelden, und generiert einen Patch, der unregisterListener(this) in onDestroy hinzufügt (der folgende Bericht wurde aus einer separaten Erfassung generiert, die zum Zeitpunkt des Absturzes 7 beibehaltene Instanzen hatte):

# 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.

Fehlerbehebung prüfen

Prüfen Sie, ob das Problem lokal auf einem Testgerät oder in der Produktion behoben wurde.

Wenn das Problem lokal reproduzierbar ist

Da TRIGGER_TYPE_OOM nur dann einen Heap-Dump erfasst, wenn ein OutOfMemoryError auftritt, wird der Trigger durch eine erfolgreiche Fehlerbehebung nicht ausgelöst. Um den Heap zu prüfen und zu bestätigen, dass das Leck behoben ist, drehen Sie den Bildschirm und erfassen Sie dann einen On-Demand-Java-Heap-Dump über die Befehlszeile:

  1. Deaktivieren Sie die Ratenbegrenzung (rate_limiter.disabled true) und behalten Sie temporäre Ergebnisse bei (delete_temporary_results.disabled true), damit das System die Befehlszeilenerfassung in /data/misc/perfetto-traces/profiling/ beibehält.
  2. Öffne OomSampleActivityFixed auf deinem Testgerät, drehe das Display mindestens achtmal, um zu prüfen, ob die App nicht mehr abstürzt, und lasse sie im Vordergrund.
  3. Lösen Sie mit dem Shell-Befehl cmd profiling_service request-profiling einen On-Demand-Java-Heap-Dump (profiling_type 1) aus:

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. Rufen Sie das beibehaltene Profil aus /data/misc/perfetto-traces/profiling/ ab und öffnen Sie es in der Perfetto-UI.

Flame-Diagramm im Heapdump Explorer der Perfetto-Benutzeroberfläche nach der Fehlerbehebung, das eine einzelne Instanz von „OomSampleActivityFixed“ mit 30,01 MiB zeigt.
Abbildung 2. Flame-Diagramm des Heap-Dumps nach der Fehlerbehebung mit einer einzelnen Instanz von OomSampleActivityFixed.

Wie in Abbildung 2 dargestellt, gilt nach mehreren Bildschirmdrehungen Folgendes:

  • com.example.sampleapp.OomSampleActivityFixed hat einen Self Count von 1, anstatt bei jeder Drehung eine Instanz zu akkumulieren.
  • Die kumulative beibehaltene Größe bleibt konstant bei 30,01 MiB (von insgesamt 44,27 MiB Heap). Das bestätigt, dass die Garbage Collection vorherige Aktivitätsinstanzen zurückfordert.

Wenn das Problem lokal schwer zu reproduzieren ist

Wenn komplexe Navigation auf mehreren Bildschirmen, langlebige Sitzungen oder bestimmte Nutzerdaten einen OutOfMemoryError verursachen, den Sie auf einem Testgerät nicht zuverlässig reproduzieren können, prüfen Sie die Fehlerbehebung nach der Einführung in der Produktion:

  • Beobachten Sie den OutOfMemoryError-Absturzcluster in Ihrem Crash Reporting-Tool als Proxy-Messwert über Releases hinweg, um zu bestätigen, dass die Anzahl der Abstürze mit OomSampleActivity sinkt.
  • Prüfen Sie alle neuen TRIGGER_TYPE_OOM-Heap-Dumps, die aus dem aktualisierten Release erfasst wurden, um sicherzustellen, dass StatsReporter keine zerstörten OomSampleActivity-Instanzen mehr enthält.