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.
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.OomSampleActivityzachowuje skumulowany rozmiar 240,28 MiB (94,31% całej sterty aplikacji), z czego większość znajduje się w podrzędnej tablicybyte[]. - Wiele instancji aktywności na żywo: po wybraniu ramki
OomSampleActivitywyświetla się Liczba własna: 8. Mimo że na ekranie powinna być widoczna tylko jedna instancjaOomSampleActivity, w pamięci jednocześnie działa 8 osobnych instancji. - Ścieżka obiektu przechowującego: odczytanie ramek nadrzędnych elementu
OomSampleActivityna wykresie płomieniowym pokazuje łańcuch odwołań, który utrzymuje te instancje przy życiu:com.example.sampleapp.StatsReporterprzechowujejava.util.concurrent.CopyOnWriteArrayList(obsługiwany przezjava.lang.Object[]), który bezpośrednio odwołuje się do wszystkich 8 instancjiOomSampleActivity.
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:
- Każda instancja
OomSampleActivityprzydziela buforbyte[]o rozmiarze 30 MiB (heavyDataw Kotlin,mHeavyDataw Java) i rejestruje się jako detektor w pojedynczym elemencieStatsReporterwonCreate. OomSampleActivitynigdy nie wywołujeunregisterListener, gdy system niszczy obiekt. PonieważStatsReporterdział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 tablicbyte[]o rozmiarze 30 MiB.- Za każdym razem, gdy system niszczy i ponownie tworzy
OomSampleActivity, np. podczas obracania ekranu w cyklu życia działania Androida, wStatsReporterwycieka 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łędemOutOfMemoryError.
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ń:
- 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/. - Otwórz aplikację
OomSampleActivityFixedna urządzeniu testowym, obróć ekran co najmniej 8 razy, aby potwierdzić, że aplikacja już nie ulega awarii, i pozostaw ją na pierwszym planie. Wygeneruj zrzut stosu Java na żądanie (
profiling_type1) za pomocą polecenia powłokicmd profiling_service request-profiling:adb shell cmd profiling_service request-profiling 1 com.example.sampleappPobierz zachowany profil z
/data/misc/perfetto-traces/profiling/i otwórz go w interfejsie Perfetto.
OomSampleActivityFixed.Jak pokazano na rysunku 2, po wielokrotnym obróceniu ekranu:
com.example.sampleapp.OomSampleActivityFixedma 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
OutOfMemoryErrorw narzędziu do zgłaszania awarii jako dane zastępcze w różnych wersjach, aby potwierdzić, że liczba awarii związanych zOomSampleActivityspada. - Sprawdź nowe zrzuty stosu
TRIGGER_TYPE_OOMzebrane ze zaktualizowanej wersji, aby upewnić się, żeStatsReporternie zachowuje już zniszczonych instancjiOomSampleActivity.