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.
OomSampleActivity.Die Untersuchung des Flame-Diagramms in Abbildung 1 liefert mehrere wichtige Erkenntnisse:
- Massive Speicherbelegung: Von einem Heap mit insgesamt 254,79 MiB (
root) belegtcom.example.sampleapp.OomSampleActivityeine kumulative Größe von 240,28 MiB (94,31 % des gesamten App-Heaps). Der Großteil davon befindet sich in einem untergeordnetenbyte[]-Array. - Mehrere Live-Aktivitätsinstanzen: Wenn Sie den Frame
OomSampleActivityauswählen, wird Self Count: 8 angezeigt. Obwohl jeweils nur eine Instanz vonOomSampleActivityauf dem Bildschirm angezeigt werden sollte, sind gleichzeitig acht separate Instanzen im Arbeitsspeicher aktiv. - Der Pfad des beibehaltenden Objekts: Wenn Sie die übergeordneten Frames von
OomSampleActivityim Flame-Diagramm lesen, sehen Sie die Referenzkette, die diese Instanzen aktiv hält:com.example.sampleapp.StatsReporterenthält einjava.util.concurrent.CopyOnWriteArrayList(unterstützt durchjava.lang.Object[]), das direkt auf alle 8OomSampleActivity-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:
- Jede
OomSampleActivity-Instanz weist einen 30-MiB-byte[]-Zwischenspeicher zu (heavyDatain Kotlin,mHeavyDatain Java) und registriert sich als Listener für das SingletonStatsReporterinonCreate. OomSampleActivityruftunregisterListenernie auf, wenn es vom System gelöscht wird. DaStatsReporterdie 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.- Jedes Mal, wenn das System
OomSampleActivityzerstört und neu erstellt, z. B. während einer Bildschirmdrehung im Android-Aktivitätslebenszyklus, wird eine weitere 30-MiB-Instanz inStatsReportergeleakt. Nach 7 Rotationen (8 Instanzen × 30 MiB ≈ 240 MiB) ist der Heap voll und die Anwendung stürzt mit einemOutOfMemoryErrorab.
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:
- 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. - Öffne
OomSampleActivityFixedauf deinem Testgerät, drehe das Display mindestens achtmal, um zu prüfen, ob die App nicht mehr abstürzt, und lasse sie im Vordergrund. Lösen Sie mit dem Shell-Befehl
cmd profiling_service request-profilingeinen On-Demand-Java-Heap-Dump (profiling_type1) aus:adb shell cmd profiling_service request-profiling 1 com.example.sampleappRufen Sie das beibehaltene Profil aus
/data/misc/perfetto-traces/profiling/ab und öffnen Sie es in der Perfetto-UI.
OomSampleActivityFixed.Wie in Abbildung 2 dargestellt, gilt nach mehreren Bildschirmdrehungen Folgendes:
com.example.sampleapp.OomSampleActivityFixedhat 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 mitOomSampleActivitysinkt. - Prüfen Sie alle neuen
TRIGGER_TYPE_OOM-Heap-Dumps, die aus dem aktualisierten Release erfasst wurden, um sicherzustellen, dassStatsReporterkeine zerstörtenOomSampleActivity-Instanzen mehr enthält.