Studium przypadku: debugowanie awarii spowodowanej brakiem pamięci

Awarie z powodu braku pamięci (OutOfMemoryError) występują, gdy aplikacja wyczerpie przydzieloną stertę Javy i nie może zrealizować nowej alokacji pamięci. Zrzuty stosu z raportów o awariach OutOfMemoryError rzadko ujawniają główną przyczynę, ponieważ pokazują tylko wywołanie alokacji, które nie powiodło się, gdy zabrakło pamięci, a nie obiekty, które zajmują resztę sterty.

ProfilingManager umożliwia przechwytywanie zrzutów sterty Java z urządzeń w terenie i lokalnie, dzięki czemu możesz sprawdzić wykres przechowywania obiektów w interfejsie Perfetto i ustalić, co zajmuje pamięć.

Scenariusz

Załóżmy, że narzędzie do zgłaszania awarii wykrywa rosnącą liczbę awarii OutOfMemoryError w środowisku produkcyjnym, gdy użytkownicy korzystają z OomSampleActivity.

Aby zbadać problem, rejestrujesz aktywator TRIGGER_TYPE_OOM za pomocą ProfilingManager, dzięki czemu system automatycznie przechwytuje zrzut sterty Javy, gdy wystąpi OutOfMemoryError.

Przechwytywanie zrzutu stosu

Na Androidzie 17 (poziom interfejsu API 37) i nowszym, aby przechwycić zrzut stosu w przypadku awarii OutOfMemoryError, skonfiguruj profilowanie oparte na aktywatorach za pomocą ProfilingTrigger.TRIGGER_TYPE_OOM. Gdy aplikacja zgłosi nieobsługiwany błąd OutOfMemoryError, system automatycznie rozpocznie nowe przechwytywanie PROFILING_TYPE_JAVA_HEAP_DUMP.

Proces aplikacji kończy się w momencie awarii, więc system kończy zapisywanie zrzutu stosu w tle i dodaje wynik do kolejki (patrz Cykl życia aktywatora). Zarejestruj globalny obiekt nasłuchujący wyników w tle Executor po zakończeniu uruchamiania aplikacji (aby nie spowalniać jej uruchamiania) za pomocą registerForAllProfilingResults, aby aplikacja otrzymała oczekujący obiekt ProfilingResult, gdy użytkownik uruchomi ją następnym razem. W przeciwieństwie do metody addProfilingTriggers (która jest zachowywana po ponownym uruchomieniu aplikacji i musi zostać wywołana tylko raz lub podczas aktualizowania reguł) metodę registerForAllProfilingResults należy wywoływać przy każdym uruchomieniu aplikacji:

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

Usuwanie zaciemnienia kodu profilu

Gdy OutOfMemoryError wywoła przechwytywanie, a wywołanie zwrotne otrzyma ProfilingResult przy następnym uruchomieniu aplikacji, prześlij plik profilu na serwer lub pobierz go na komputer używany do programowania za pomocą adb. Jeśli profil został przechwycony z kompilacji do publikacji, która korzysta z zmniejszania i zaciemniania kodu R8, przed analizą usuń zaciemnienie kodu offline na stacji roboczej lub serwerze. Bez usuwania zaciemnienia kodu R8 zaciemnia nazwy klas, pól i metod Javy i Kotlina w nieprzetworzonym zrzucie stosu (np. zmienia oryginalne nazwy klas na a.b).

Użyj polecenia trace_processor bundle Perfetto z flagą --proguard-map, aby spakować przechwycony profil razem z plikiem mapping.txt R8 wygenerowanym dla tej konkretnej kompilacji do publikacji:

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

To polecenie generuje plik deobfuscated_oom.tar, czyli wzbogacone archiwum, które zawiera oryginalny profil z mapowaniem usuwania zaciemnienia kodu. Gdy otworzysz to archiwum w interfejsie Perfetto lub wyślesz do niego zapytanie za pomocą trace_processor, Perfetto automatycznie przywróci wszystkie oryginalne nazwy klas, pól i metod. Więcej informacji znajdziesz w dokumentacji Perfetto dotyczącej symbolizacji i usuwania zaciemnienia kodu.

Analizowanie zrzutu stosu

Możesz ręcznie sprawdzić zrzut stosu z usuniętym zaciemnieniem kodu w interfejsie Perfetto, wykonując czynności opisane poniżej, lub zautomatyzować analizę błędów OOM za pomocą agenta AI.

Otwórz zrzut stosu z usuniętym zaciemnieniem kodu w interfejsie Perfetto i na pasku nawigacyjnym po lewej stronie wybierz Heapdump Explorer (Eksplorator zrzutów stosu). Na karcie Wykres płomieniowy z opcją Miara ustawioną na Rozmiar obiektu hierarchia przechowywania na rysunku 1 od razu się wyróżnia.

Wykres płomieniowy w eksploratorze zrzutów stosu w interfejsie Perfetto pokazujący, że StatsReporter zachowuje 8 instancji OomSampleActivity o łącznym rozmiarze 240,28 MiB.
Rysunek 1. Wykres płomieniowy zrzutu stosu pokazujący 8 zachowanych instancji OomSampleActivity.

Analiza wykresu płomieniowego na rysunku 1 ujawnia kilka kluczowych informacji:

  • Ogromne wykorzystanie pamięci: z całkowitej sterty 254,79 MiB (root) com.example.sampleapp.OomSampleActivity zachowuje skumulowany rozmiar 240,28 MiB (94,31% całej sterty aplikacji), z czego większość znajduje się w podrzędnej tablicy byte[].
  • Wiele instancji aktywności na żywo: po wybraniu ramki OomSampleActivity wyświetla się Liczba własna: 8. Mimo że na ekranie powinna być widoczna tylko jedna instancja OomSampleActivity, w pamięci jednocześnie działa 8 osobnych instancji.
  • Ścieżka obiektu przechowującego: odczytanie ramek nadrzędnych elementu OomSampleActivity na wykresie płomieniowym pokazuje łańcuch odwołań, który utrzymuje te instancje przy życiu: com.example.sampleapp.StatsReporter przechowuje java.util.concurrent.CopyOnWriteArrayList (obsługiwany przez java.lang.Object[]), który bezpośrednio odwołuje się do wszystkich 8 instancji OomSampleActivity.

Identyfikowanie głównej przyczyny w kodzie

Ścieżka przechowywania prowadzi od StatsReporter do OomSampleActivity. Sprawdź obie klasy w bazie kodu.

StatsReporter to singleton, który przechowuje listę wywołań zwrotnych StatsListener i okresowo publikuje aktualizacje z wątku odpytywania w tle:

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

Następnie sprawdź 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);
    }
}

Kod potwierdza, dlaczego StatsReporter zachowuje 8 instancji OomSampleActivity:

  1. Każda instancja OomSampleActivity przydziela bufor byte[] o rozmiarze 30 MiB (heavyData w Kotlin, mHeavyData w Java) i rejestruje się jako detektor w pojedynczym elemencie StatsReporter w onCreate.
  2. OomSampleActivity nigdy nie wywołuje unregisterListener, gdy system niszczy obiekt. Ponieważ StatsReporter działa dłużej niż aktywność i zawiera silne odwołanie do każdego zarejestrowanego detektora, moduł odśmiecania pamięci nie może odzyskać zniszczonych instancji ani ich tablic byte[] o rozmiarze 30 MiB.
  3. Za każdym razem, gdy system niszczy i ponownie tworzy OomSampleActivity, np. podczas obracania ekranu w cyklu życia działania Androida, w StatsReporter wycieka kolejna instancja o rozmiarze 30 MiB. Po 7 obrotach (8 instancji × 30 MiB ≈ 240 MiB) sterta się zapełnia i aplikacja ulega awarii z błędem OutOfMemoryError.

Naprawianie wycieku

Aby naprawić wyciek pamięci, zastąp onDestroy w aktywności i wywołaj StatsReporter.getInstance().unregisterListener(this), aby singleton zwolnił odwołanie, gdy system zniszczy aktywność:

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

Automatyzacja analizy błędu OOM za pomocą agenta AI

Zamiast ręcznie sprawdzać wykres płomieniowy i śledzić odwołania w plikach, możesz użyć agenta kodowania AI skonfigurowanego z umiejętnością Perfetto AI, aby przeanalizować zrzut sterty i powiązać go z bazą kodu:

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.

Agent wysyła zapytanie do zrzutu sterty za pomocą trace_processor, śledzi ścieżkę dominatora od StatsReporter do instancji OomSampleActivity, w których wystąpił wyciek, i sprawdza obie klasy w bazie kodu. Bez ręcznej interwencji dochodzi do tego samego wniosku, że OomSampleActivity rejestruje detektor w onCreate bez wyrejestrowywania go, i generuje poprawkę dodającą unregisterListener(this) w onDestroy (poniższy raport został wygenerowany na podstawie osobnego zrzutu, który w momencie awarii zawierał 7 zachowanych instancji):

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

Weryfikowanie poprawki

Sprawdź, czy wyciek został usunięty lokalnie na urządzeniu testowym lub w środowisku produkcyjnym.

Jeśli problem można odtworzyć lokalnie

TRIGGER_TYPE_OOM przechwytuje zrzut stosu tylko wtedy, gdy wystąpi OutOfMemoryError, więc skuteczne rozwiązanie problemu zapobiega uruchomieniu aktywatora. Aby sprawdzić stertę i potwierdzić, że wyciek został usunięty, obróć ekran, a następnie przechwyć zrzut sterty Javy na żądanie z wiersza poleceń:

  1. Wyłącz ogranicznik liczby żądań (rate_limiter.disabled true) i zachowaj wyniki tymczasowe (delete_temporary_results.disabled true), aby system zachował przechwycone dane z wiersza poleceń w /data/misc/perfetto-traces/profiling/.
  2. Otwórz aplikację OomSampleActivityFixed na urządzeniu testowym, obróć ekran co najmniej 8 razy, aby potwierdzić, że aplikacja już nie ulega awarii, i pozostaw ją na pierwszym planie.
  3. Wygeneruj zrzut stosu Java na żądanie (profiling_type 1) za pomocą polecenia powłoki cmd profiling_service request-profiling:

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. Pobierz zachowany profil z /data/misc/perfetto-traces/profiling/ i otwórz go w interfejsie Perfetto.

Wykres płomieniowy w eksploratorze zrzutów pamięci w interfejsie Perfetto po wprowadzeniu poprawki, pokazujący pojedynczą instancję OomSampleActivityFixed, która zajmuje 30,01 MiB.
Rysunek 2. Wykres płomieniowy zrzutu stosu po wprowadzeniu poprawki, pokazujący pojedynczą instancję OomSampleActivityFixed.

Jak pokazano na rysunku 2, po wielokrotnym obróceniu ekranu:

  • com.example.sampleapp.OomSampleActivityFixed ma liczbę własną równą 1, a nie liczbę instancji, która zwiększa się przy każdym obrocie.
  • Jego skumulowany rozmiar pozostaje na stałym poziomie 30,01 MiB (z całkowitej sterty 44,27 MiB), co potwierdza, że moduł odzyskiwania pamięci odzyskuje poprzednie instancje aktywności.

Jeśli problem jest trudny do odtworzenia lokalnie

Gdy złożona nawigacja na wielu urządzeniach, długotrwałe sesje lub określone dane użytkownika powodują wystąpienie błędu OutOfMemoryError, którego nie można wiarygodnie odtworzyć na urządzeniu testowym, sprawdź poprawkę w środowisku produkcyjnym po wdrożeniu:

  • Monitoruj grupę awarii OutOfMemoryError w narzędziu do zgłaszania awarii jako dane zastępcze w różnych wersjach, aby potwierdzić, że liczba awarii związanych z OomSampleActivity spada.
  • Sprawdź nowe zrzuty stosu TRIGGER_TYPE_OOM zebrane ze zaktualizowanej wersji, aby upewnić się, że StatsReporter nie zachowuje już zniszczonych instancji OomSampleActivity.