Studi kasus: Men-debug error OOM

Error Kehabisan Memori (OutOfMemoryError) terjadi saat aplikasi Anda menghabiskan heap Java yang dialokasikan dan tidak dapat memenuhi alokasi memori baru. Stack trace dari laporan error OutOfMemoryError jarang mengungkapkan akar masalah karena hanya menunjukkan panggilan alokasi yang gagal saat memori habis, bukan objek yang mempertahankan sisa heap.

ProfilingManager memungkinkan Anda mengambil heap dump Java dari perangkat di lapangan dan secara lokal sehingga Anda dapat memeriksa grafik retensi objek di Perfetto UI dan menentukan apa yang menahan memori.

Skenario

Misalnya, alat pelaporan error Anda menandai semakin banyak cluster error OutOfMemoryError dalam produksi saat pengguna berinteraksi dengan OomSampleActivity.

Untuk menyelidikinya, Anda mendaftarkan pemicu TRIGGER_TYPE_OOM dengan ProfilingManager sehingga sistem secara otomatis mengambil heap dump Java saat OutOfMemoryError terjadi.

Merekam heap dump

Di Android 17 (level API 37) dan yang lebih baru, untuk mengambil heap dump saat terjadi error OutOfMemoryError, konfigurasikan pembuatan profil berbasis pemicu dengan ProfilingTrigger.TRIGGER_TYPE_OOM. Saat aplikasi Anda menampilkan OutOfMemoryError yang tidak tertangani, sistem akan otomatis memulai pengambilan PROFILING_TYPE_JAVA_HEAP_DUMP baru.

Karena proses aplikasi berhenti saat error terjadi, sistem akan menyelesaikan penyimpanan heap dump di latar belakang dan mengantrekan hasilnya (lihat Siklus proses pemicu). Daftarkan pemroses hasil global di Executor latar belakang setelah startup aplikasi selesai (agar Anda tidak memperlambat startup aplikasi) menggunakan registerForAllProfilingResults agar aplikasi Anda menerima ProfilingResult yang diantrekan saat pengguna meluncurkannya lagi. Tidak seperti addProfilingTriggers (yang tetap ada di seluruh mulai ulang aplikasi dan hanya perlu dipanggil sekali atau saat memperbarui pemicu), registerForAllProfilingResults harus dipanggil pada setiap startup aplikasi:

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

Melakukan deobfuskasi profil

Setelah OutOfMemoryError memicu pengambilan dan callback Anda menerima ProfilingResult pada peluncuran aplikasi berikutnya, upload file profil ke server Anda atau tarik ke mesin pengembangan dengan adb. Jika Anda mengambil profil dari build rilis yang menggunakan penyingkatan kode dan obfuscation R8, lakukan deobfuscate secara offline di workstation atau server Anda sebelum analisis. Tanpa deobfuscation, R8 akan meng-obfuscate nama class, kolom, dan metode Java dan Kotlin dalam heap dump mentah (misalnya, mengubah nama class asli menjadi a.b).

Gunakan perintah trace_processor bundle Perfetto dengan flag --proguard-map untuk memaketkan profil yang diambil bersama dengan file mapping.txt R8 yang dihasilkan untuk build rilis yang sama:

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

Perintah ini menghasilkan deobfuscated_oom.tar, arsip yang diperkaya yang menggabungkan profil asli dengan pemetaan deobfuskasinya. Saat Anda membuka arsip ini di UI Perfetto atau mengkuerinya dengan trace_processor, Perfetto akan otomatis memulihkan semua nama class, kolom, dan metode asli. Untuk mengetahui informasi selengkapnya, lihat dokumentasi Simbolisasi dan deobfuskasi Perfetto.

Menganalisis heap dump

Anda dapat memeriksa heap dump yang telah di-deobfuscate secara manual di Perfetto UI seperti yang ditunjukkan dalam langkah-langkah berikut, atau mengotomatiskan investigasi OOM dengan agen AI.

Buka heap dump yang telah di-deobfuscate di UI Perfetto, lalu pilih Heapdump Explorer di menu navigasi sebelah kiri. Di tab Flamegraph dengan Measure ditetapkan ke Object Size, hierarki retensi di Gambar 1 langsung terlihat.

Grafik flame Heapdump Explorer UI Perfetto yang menampilkan StatsReporter yang mempertahankan 8 instance OomSampleActivity dengan total 240,28 MiB.
Gambar 1. Grafik flame heap dump yang menampilkan 8 instance OomSampleActivity yang dipertahankan.

Memeriksa flame graph pada Gambar 1 menunjukkan beberapa temuan penting:

  • Retensi memori yang sangat besar: Dari total heap 254,79 MiB (root), com.example.sampleapp.OomSampleActivity mempertahankan ukuran Kumulatif sebesar 240,28 MiB (94,31% dari seluruh heap aplikasi), yang sebagian besar berada dalam array byte[] turunan.
  • Beberapa instance aktivitas langsung: Memilih frame OomSampleActivity akan menampilkan Self Count: 8. Meskipun hanya satu instance OomSampleActivity yang seharusnya ada di layar pada waktu tertentu, 8 instance terpisah aktif dalam memori secara bersamaan.
  • Jalur objek penahan: Membaca frame induk OomSampleActivity dalam flame graph menunjukkan rantai referensi yang menjaga instance tersebut tetap aktif: com.example.sampleapp.StatsReporter menyimpan java.util.concurrent.CopyOnWriteArrayList (didukung oleh java.lang.Object[]), yang secara langsung mereferensikan ke-8 instance OomSampleActivity.

Mengidentifikasi akar masalah dalam kode

Dengan jalur retensi yang mengarah dari StatsReporter ke OomSampleActivity, periksa kedua class di codebase Anda.

StatsReporter adalah singleton yang mengelola daftar callback StatsListener dan secara berkala memposting update dari thread polling latar belakang:

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

Selanjutnya, periksa 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);
    }
}

Kode ini mengonfirmasi alasan StatsReporter mempertahankan 8 instance OomSampleActivity:

  1. Setiap instance OomSampleActivity mengalokasikan buffer byte[] 30 MiB (heavyData di Kotlin, mHeavyData di Java) dan mendaftarkan dirinya sebagai pemroses pada singleton StatsReporter di onCreate.
  2. OomSampleActivity tidak pernah memanggil unregisterListener saat sistem menghancurkannya. Karena StatsReporter bertahan lebih lama daripada aktivitas dan menyimpan referensi yang kuat ke setiap pemroses yang terdaftar, pembersih sampah memori tidak dapat mengambil kembali instance yang dihancurkan atau array byte[] 30 MiB-nya.
  3. Setiap kali sistem menghancurkan dan membuat ulang OomSampleActivity, seperti selama rotasi layar dalam siklus proses aktivitas Android, instance 30 MiB lainnya akan bocor di StatsReporter. Setelah 7 rotasi (8 instance × 30 MiB ≈ 240 MiB), heap akan terisi dan aplikasi akan error dengan OutOfMemoryError.

Memperbaiki kebocoran

Untuk memperbaiki kebocoran memori, ganti onDestroy dalam aktivitas dan panggil StatsReporter.getInstance().unregisterListener(this) agar singleton merilis referensinya saat sistem menghancurkan aktivitas:

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

Mengotomatiskan investigasi OOM dengan agen AI

Daripada memeriksa grafik flame secara manual dan melacak referensi di seluruh file, Anda dapat menggunakan agen coding AI yang dikonfigurasi dengan kemampuan AI Perfetto untuk menganalisis heap dump dan menghubungkannya dengan codebase Anda:

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.

Agen mengkueri heap dump dengan trace_processor, melacak jalur dominator dari StatsReporter ke instance OomSampleActivity yang bocor, dan memeriksa kedua class di codebase Anda. Tanpa intervensi manual, alat ini mencapai kesimpulan yang sama, bahwa OomSampleActivity mendaftarkan pemroses di onCreate tanpa membatalkan pendaftarannya, dan menghasilkan patch yang menambahkan unregisterListener(this) di onDestroy (laporan berikut dibuat dari pengambilan terpisah yang memiliki 7 instance yang dipertahankan pada saat error):

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

Memverifikasi perbaikan

Pastikan kebocoran telah diatasi, baik secara lokal di perangkat pengujian maupun dalam produksi.

Jika masalah dapat direproduksi secara lokal

Karena TRIGGER_TYPE_OOM hanya mengambil heap dump saat OutOfMemoryError terjadi, perbaikan yang berhasil akan mencegah pemicu diaktifkan. Untuk memeriksa heap dan mengonfirmasi bahwa kebocoran telah hilang, putar layar, lalu ambil heap dump Java on-demand dari command line:

  1. Nonaktifkan pembatas kapasitas (rate_limiter.disabled true) dan pertahankan hasil sementara (delete_temporary_results.disabled true) agar sistem menyimpan rekaman command line di /data/misc/perfetto-traces/profiling/.
  2. Buka OomSampleActivityFixed di perangkat pengujian, putar layar 8 kali atau lebih untuk mengonfirmasi bahwa aplikasi tidak lagi error, dan tetap buka aplikasi di latar depan.
  3. Picu heap dump Java on demand (profiling_type 1) menggunakan perintah shell cmd profiling_service request-profiling:

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. Tarik profil yang dipertahankan dari /data/misc/perfetto-traces/profiling/ dan buka di UI Perfetto.

Grafik flame Heapdump Explorer UI Perfetto setelah perbaikan, yang menampilkan satu instance OomSampleActivityFixed yang mempertahankan 30,01 MiB.
Gambar 2. Grafik flame heap dump setelah perbaikan yang menampilkan satu instance OomSampleActivityFixed.

Seperti yang ditunjukkan pada Gambar 2, setelah beberapa rotasi layar:

  • com.example.sampleapp.OomSampleActivityFixed memiliki Self Count 1, bukan mengakumulasi instance pada setiap rotasi.
  • Retained size Kumulatif-nya tetap stabil di 30,01 MiB (dari total heap 44,27 MiB), yang mengonfirmasi bahwa pembersih sampah memori mengklaim kembali instance aktivitas sebelumnya.

Jika masalah sulit direproduksi secara lokal

Jika navigasi multilayar yang kompleks, sesi yang berjalan lama, atau data pengguna tertentu menyebabkan OutOfMemoryError yang tidak dapat Anda reproduksi dengan andal di perangkat pengujian, validasi perbaikan dalam produksi setelah peluncuran:

  • Pantau cluster error OutOfMemoryError di alat pelaporan error sebagai metrik proxy di seluruh rilis untuk mengonfirmasi bahwa error yang melibatkan OomSampleActivity menurun.
  • Periksa heap dump TRIGGER_TYPE_OOM baru yang dikumpulkan dari rilis yang diupdate untuk memverifikasi bahwa StatsReporter tidak lagi mempertahankan instance OomSampleActivity yang dihancurkan.