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.
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.OomSampleActivitymempertahankan ukuran Kumulatif sebesar 240,28 MiB (94,31% dari seluruh heap aplikasi), yang sebagian besar berada dalam arraybyte[]turunan. - Beberapa instance aktivitas langsung: Memilih frame
OomSampleActivityakan menampilkan Self Count: 8. Meskipun hanya satu instanceOomSampleActivityyang seharusnya ada di layar pada waktu tertentu, 8 instance terpisah aktif dalam memori secara bersamaan. - Jalur objek penahan: Membaca frame induk
OomSampleActivitydalam flame graph menunjukkan rantai referensi yang menjaga instance tersebut tetap aktif:com.example.sampleapp.StatsReportermenyimpanjava.util.concurrent.CopyOnWriteArrayList(didukung olehjava.lang.Object[]), yang secara langsung mereferensikan ke-8 instanceOomSampleActivity.
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:
- Setiap instance
OomSampleActivitymengalokasikan bufferbyte[]30 MiB (heavyDatadi Kotlin,mHeavyDatadi Java) dan mendaftarkan dirinya sebagai pemroses pada singletonStatsReporterdionCreate. OomSampleActivitytidak pernah memanggilunregisterListenersaat sistem menghancurkannya. KarenaStatsReporterbertahan 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 arraybyte[]30 MiB-nya.- Setiap kali sistem menghancurkan dan membuat ulang
OomSampleActivity, seperti selama rotasi layar dalam siklus proses aktivitas Android, instance 30 MiB lainnya akan bocor diStatsReporter. Setelah 7 rotasi (8 instance × 30 MiB ≈ 240 MiB), heap akan terisi dan aplikasi akan error denganOutOfMemoryError.
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:
- 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/. - Buka
OomSampleActivityFixeddi perangkat pengujian, putar layar 8 kali atau lebih untuk mengonfirmasi bahwa aplikasi tidak lagi error, dan tetap buka aplikasi di latar depan. Picu heap dump Java on demand (
profiling_type1) menggunakan perintah shellcmd profiling_service request-profiling:adb shell cmd profiling_service request-profiling 1 com.example.sampleappTarik profil yang dipertahankan dari
/data/misc/perfetto-traces/profiling/dan buka di UI Perfetto.
OomSampleActivityFixed.Seperti yang ditunjukkan pada Gambar 2, setelah beberapa rotasi layar:
com.example.sampleapp.OomSampleActivityFixedmemiliki 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
OutOfMemoryErrordi alat pelaporan error sebagai metrik proxy di seluruh rilis untuk mengonfirmasi bahwa error yang melibatkanOomSampleActivitymenurun. - Periksa heap dump
TRIGGER_TYPE_OOMbaru yang dikumpulkan dari rilis yang diupdate untuk memverifikasi bahwaStatsReportertidak lagi mempertahankan instanceOomSampleActivityyang dihancurkan.