خرابیهای «کمبود حافظه» (OutOfMemoryError) زمانی رخ میدهد که برنامه شما پشته جاوای اختصاصیافته به خود را تمام میکند و نمیتواند تخصیص حافظه جدیدی را انجام دهد. ردیابیهای پشتهای از
OutOfMemoryError گزارشهای خرابی بهندرت علت ریشهای را آشکار میکنند زیرا آنها
فقط فراخوانی تخصیص را نشان میدهند که هنگام تمام شدن حافظه ناموفق بوده است، نه اشیایی که
بقیه توده را نگه میدارند.
ProfilingManager به شما امکان میدهد رونوشتهای پشته جاوا را از دستگاهها در محل و بهصورت محلی ضبط کنید تا بتوانید نمودار نگهداری شیء را در رابط کاربری Perfetto بررسی کنید و دقیقاً مشخص کنید چه چیزی حافظه را نگه میدارد.
سناریو
فرض کنید ابزار گزارش خرابی شما، خوشه درحال رشدی از OutOfMemoryError
خرابی را در تولید هنگام تعامل کاربران با OomSampleActivity پرچمگذاری میکند.
برای بررسی، راهانداز TRIGGER_TYPE_OOM را با
ProfilingManager ثبت میکنید تا سیستم بهطور خودکار رونوشت پشته Java را هنگام وقوع
OutOfMemoryError ضبط کند.
ضبط رونوشت پشته
در Android 17 (سطح ایپیآی ۳۷) و بالاتر، برای ضبط رونوشت پشته هنگام وقوع ازکارافتادگی
OutOfMemoryError، نمایهسازی مبتنی بر راهانداز را با
ProfilingTrigger.TRIGGER_TYPE_OOM پیکربندی کنید. وقتی برنامهتان
OutOfMemoryError مدیریتنشدهای را ایجاد میکند، سیستم بهطور خودکار ضبط
PROFILING_TYPE_JAVA_HEAP_DUMP جدیدی را شروع میکند.
ازآنجاییکه فرایند برنامه هنگام وقوع خرابی خاتمه مییابد، سیستم ذخیره کردن رونوشت پشته را در پسزمینه بهپایان میرساند و نتیجه را در صف قرار میدهد (به چرخه حیات راهانداز مراجعه کنید). بااستفاده از registerForAllProfilingResults، یک شنونده نتیجه سراسری را در Executor پسزمینه پساز تکمیل راهاندازی برنامه ثبت کنید (تا سرعت راهاندازی برنامه را کاهش ندهید) تا برنامه شما دفعه بعد که کاربر آن را راهاندازی میکند، ProfilingResult صفبندیشده را دریافت کند. برخلاف
addProfilingTriggers (که در بازراهاندازیهای برنامه باقی میماند و فقط باید
یکبار یا هنگام بهروزرسانی راهاندازها فراخوانی شود)، registerForAllProfilingResults باید
در هر راهاندازی برنامه فراخوانی شود:
کاتلین
@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)) }
جاوا
@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)); }
ابهامزدایی نمایه
پساز اینکه OutOfMemoryError ضبط را راهاندازی کرد و بازخوانی شما
ProfilingResult را در راهاندازی بعدی برنامه دریافت کرد، فایل نمایه را در
سرورتان بارگذاری کنید یا آن را با adb به ماشین توسعه نرمافزارتان بکشید. اگر نمایه را از ساخت نسخهٔ پخش که از کوچک کردن و مبهمسازی کد R8 استفاده میکند ضبط کردهاید، آن را قبلاز تجزیهوتحلیل بهصورت آفلاین در ایستگاه کاری یا سرور خود ابهامزدایی کنید. بدون لغو مبهمسازی، R8 نامهای کلاس، فیلد، و روش جاوا و Kotlin را در رونوشت پشته خام مبهمسازی میکند (برای مثال، نامهای کلاس اصلی را به a.b تبدیل میکند).
از فرمان trace_processor bundle Perfetto با پرچم --proguard-map
استفاده کنید تا نمایه ضبطشده را همراه با فایل R8 mapping.txt
تولیدشده برای همان ساخت نسخه پخش بستهبندی کنید:
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
این فرمان deobfuscated_oom.tar را تولید میکند، بایگانی غنیشدهای که نمایه اصلی را با نگاشتهای ابهامزدایی آن بستهبندی میکند. وقتی این بایگانی را در میانای کاربر Perfetto باز میکنید یا با trace_processor آن را پُرسمان میکنید، Perfetto بهطور خودکار همه نامهای اصلی کلاس، فیلد، و روش را بازیابی میکند. برای اطلاعات بیشتر،
به سند نمادگذاری و ابهامزدایی Perfetto مراجعه کنید.
تجزیهوتحلیل رونوشت پشته
میتوانید رونوشت پشته ابهامزداییشده را بهصورت دستی در رابط کاربری Perfetto بررسی کنید، همانطور که در مراحل زیر نشان داده شده است، یا بررسی کمبود حافظه را با عامل هوش مصنوعی خودکارسازی کنید.
رونوشت پشته ابهامزداییشده را در Perfetto UI باز کنید و Heapdump Explorer را در نوار پیمایش سمت راست انتخاب کنید. در برگه Flamegraph با Measure روی Object Size تنظیم شده است، سلسله مراتب نگهداری در شکل ۱ بلافاصله برجسته میشود.
OomSampleActivity را نشان میدهد.بررسی نمودار شعله در شکل ۱ چندین یافته کلیدی را نشان میدهد:
- Massive memory retention: Out of a 254.79 MiB total heap (
root),com.example.sampleapp.OomSampleActivityretains a Cumulative size of 240.28 MiB (94.31% of the entire app heap), most of which sits in a childbyte[]array. - چند نمونه فعالیت زنده: با انتخاب قاب
OomSampleActivity، Self Count: 8 نشان داده میشود. بااینکه فقط یک نمونه ازOomSampleActivityباید در هر زمان مشخصی روی صفحه وجود داشته باشد، ۸ نمونه جداگانه بهطور همزمان در حافظه فعال هستند. - مسیر شیء نگهدارنده: خواندن قابهای اصلی
OomSampleActivityدر نمودار شعلهای زنجیره ارجاعی را نشان میدهد که آن نمونهها را زنده نگه میدارد:com.example.sampleapp.StatsReporterحاویjava.util.concurrent.CopyOnWriteArrayList(پشتیبانیشده توسطjava.lang.Object[]) است که مستقیماً به هر ۸ نمونهOomSampleActivityارجاع میدهد.
شناسایی علت ریشهای در کد
با مسیر حفظی که از StatsReporter به OomSampleActivity اشاره میکند،
هر دو کلاس را در پایگاه کدتان بررسی کنید.
StatsReporter یک تکقلو است که فهرستی از StatsListener
فراخوانیهای برگشتی را حفظ میکند و بهطور دورهای بهروزرسانیها را از یک رشته نظرسنجی پسزمینه پست میکند:
کاتلین
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 } } } }
جاوا
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(); } }
سپس، OomSampleActivity را بازرسی کنید:
کاتلین
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" } }
جاوا
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); } }
این کد تأیید میکند که چرا StatsReporter ۸ نمونه از
OomSampleActivity را حفظ میکند:
- هر نمونه
OomSampleActivityیک میانگیر ۳۰ مبیبایتیbyte[]اختصاص میدهد (heavyDataدر Kotlin،mHeavyDataدر Java) و خودش را بهعنوان شنونده در سینگلتونStatsReporterدرonCreateثبت میکند. OomSampleActivityهرگزunregisterListenerرا هنگام تخریب سیستم فراخوانی نمیکند. چونStatsReporterبیشتر از فعالیت عمر میکند و مرجع قویای برای هر شنونده ثبتشده نگه میدارد، زبالهروب نمیتواند نمونههای تخریبشده یا آرایههایbyte[]۳۰ مبیبایتی آنها را بازیابی کند.- هر بار که سیستم
OomSampleActivityرا ازبین میبرد و دوباره ایجاد میکند، مثلاً درطول چرخش صفحهنمایش در چرخه حیات فعالیت Android، یک نمونه ۳۰ مبیبایتی دیگر درStatsReporterنشت میکند. پساز ۷ چرخش (۸ نمونه × ۳۰ مبیبایت ≈ ۲۴۰ مبیبایت)، پشته پر میشود و برنامه باOutOfMemoryErrorازکار میافتد.
رفع نشتی
برای رفع نشت حافظه، onDestroy را در فعالیت لغو کنید و
StatsReporter.getInstance().unregisterListener(this) را فراخوانی کنید تا وقتی سیستم فعالیت را ازبین میبرد،
تکقلو مرجع خود را آزاد کند:
کاتلین
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" } }
جاوا
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); } }
خودکارسازی بررسی کمبود حافظه با عامل هوشوارهای
بهجای بازرسی دستی نمودار شعلهای و ردیابی ارجاعها در فایلها، میتوانید از یک عامل کدنویسی هوش مصنوعی پیکربندیشده با مهارت هوش مصنوعی Perfetto برای تجزیهوتحلیل رونوشت پشته و مرتبط کردن آن با پایگاه کد خود استفاده کنید:
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.
عامل رونوشت پشته را با trace_processor پُرسمان میکند، مسیر غالب را از StatsReporter تا نمونههای OomSampleActivity نشتشده ردیابی میکند، و هر دو کلاس را در پایگاه کد شما بازرسی میکند. بدون دخالت دستی، به همان نتیجه میرسد که OomSampleActivity شنوندهای را در onCreate ثبت میکند بدون اینکه ثبت آن را لغو کند، و وصلهای تولید میکند که unregisterListener(this) را در onDestroy اضافه میکند (گزارش زیر از یک ضبط جداگانه تولید شده است که در زمان خرابی ۷ نمونه حفظشده داشت):
# 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.تأیید رفع مشکل
بررسی کنید که نشت بهصورت محلی در دستگاه آزمایشی یا در نسخه تولید برطرف شده باشد.
اگر مشکل بهصورت محلی قابل بازتولید است
چون TRIGGER_TYPE_OOM فقط وقتی رونوشت پشته را ضبط میکند که OutOfMemoryError
رخ دهد، رفع موفقیتآمیز مشکل از فعال شدن راهانداز جلوگیری میکند. برای بازرسی پشته و تأیید اینکه نشت برطرف شده است، صفحهنمایش را بچرخانید و سپس یک رونوشت پشته Java درخواستی از خط فرمان بگیرید:
- محدودکننده نرخ را غیرفعال کنید (
rate_limiter.disabled true) و نتایج موقت را حفظ کنید (delete_temporary_results.disabled true) تا سیستم ضبط خط فرمان را در/data/misc/perfetto-traces/profiling/نگه دارد. OomSampleActivityFixedرا در دستگاه آزمایشیتان باز کنید، صفحهنمایش را ۸ بار یا بیشتر بچرخانید تا مطمئن شوید برنامه دیگر ازکار نمیافتد، و آن را در پیشزمینه نگه دارید.بااستفاده از فرمان پوسته
cmd profiling_service request-profiling، رونوشت پشته جاوا (profiling_type1) را بهصورت درخواستی راهاندازی کنید:adb shell cmd profiling_service request-profiling 1 com.example.sampleappنمایه حفظشده را از
/data/misc/perfetto-traces/profiling/بکشید و آن را در واسط کاربری Perfetto باز کنید.
OomSampleActivityFixed را نشان میدهد.همانطور که در شکل ۲ نشان داده شده است، پساز چندین چرخش صفحهنمایش:
com.example.sampleapp.OomSampleActivityFixedتعداد خود ۱ دارد بهجای اینکه در هر چرخش یک نمونه جمعآوری کند.- اندازه حفظشده تجمعی آن در ۳۰٫۰۱ مبیبایت (از مجموع توده ۴۴٫۲۷ مبیبایت) ثابت میماند، که تأیید میکند جمعآوریکننده زباله نمونههای فعالیت قبلی را بازیابی میکند.
اگر بازتولید مشکل بهصورت محلی دشوار است
وقتی پیمایش پیچیده چندصفحهای، جلسههای طولانی، یا دادههای خاص کاربر
باعث ایجاد OutOfMemoryError میشود که نمیتوانید آن را بهطور مطمئن در دستگاه آزمایشی بازتولید کنید،
پساز عرضه، اصلاحیه را در نسخه تولیدی اعتبارسنجی کنید:
- خوشه خرابی
OutOfMemoryErrorرا در ابزار گزارش خرابیتان بهعنوان سنجه پراکسی در نسخههای پخش پایش کنید تا مطمئن شوید خرابیهای مربوط بهOomSampleActivityکاهش مییابد. - هر رونوشت پشته
TRIGGER_TYPE_OOMجدیدی را که از نسخه بهروزشده جمعآوری شده است بررسی کنید تا مطمئن شویدStatsReporterدیگر نمونههایOomSampleActivityتخریبشده را حفظ نمیکند.