קריסות בגלל אין זיכרון פנוי (OOM) (OutOfMemoryError) מתרחשות כשהאפליקציה מנצלת את כל הזיכרון שהוקצה לה ב-Java Heap ולא יכולה להקצות זיכרון חדש. דוחות קריסות של OutOfMemoryError לא חושפים את שורש הבעיה, כי הם מציגים רק את הקריאה להקצאה שנכשלה כשהזיכרון נגמר, ולא את האובייקטים ששומרים את שאר הנתונים בערימה.
ProfilingManager מאפשר לכם לתעד תמונות מצב של הזיכרון מ-Java ממכשירים בשטח ובאופן מקומי, כדי שתוכלו לבדוק את תרשים שמירת האובייקטים בממשק המשתמש של Perfetto ולזהות מה תופס מקום בזיכרון.
תרחיש
נניח שכלי הדיווח על קריסות מסמן אשכול הולך וגדל של קריסות של OutOfMemoryError בסביבת הייצור, כשהמשתמשים מבצעים אינטראקציה עם OomSampleActivity.
כדי לבדוק את הבעיה, אתם רושמים טריגר של TRIGGER_TYPE_OOM ב-ProfilingManager, כדי שהמערכת תתעד באופן אוטומטי תמונת מצב של הזיכרון ב-Java כשמתרחשת שגיאת OutOfMemoryError.
תיעוד תמונת המצב של הזיכרון
ב-Android 17 (רמת API 37) ומעלה, כדי לתעד תמונת מצב של הזיכרון כשמתרחשת קריסה מסוג OutOfMemoryError, צריך להגדיר יצירת פרופילים מבוססת-טריגר באמצעות ProfilingTrigger.TRIGGER_TYPE_OOM. כשהאפליקציה מפעילה OutOfMemoryError שלא טופל, המערכת מתחילה באופן אוטומטי תיעוד חדש של PROFILING_TYPE_JAVA_HEAP_DUMP.
תהליך האפליקציה מסתיים כשהקריסה מתרחשת, ולכן המערכת מסיימת לשמור את תמונת המצב של הזיכרון ברקע ומכניסה את התוצאה לתור (ראו מחזור החיים של הטריגר). רושמים מאזין גלובלי לתוצאות ב-Executor ברקע אחרי שהאפליקציה מסיימת את ההפעלה (כדי לא להאט את הפעלת האפליקציה) באמצעות registerForAllProfilingResults, כדי שהאפליקציה תקבל את ה-ProfilingResult בתור בפעם הבאה שהמשתמש יפעיל אותה. בניגוד ל-addProfilingTriggers (שנשמרת גם אחרי הפעלה מחדש של האפליקציה וצריך לקרוא לה רק פעם אחת או כשמעדכנים טריגרים), צריך לקרוא ל-registerForAllProfilingResults בכל פעם שמפעילים את האפליקציה:
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)); }
ביטול הערפול של הפרופיל
אחרי ש-OutOfMemoryError מפעיל תיעוד והקריאה החוזרת (callback) מקבלת את ProfilingResult בהפעלה הבאה של האפליקציה, מעלים את קובץ הפרופיל לשרת או מושכים אותו למחשב הפיתוח באמצעות adb. אם תיעדתם את הפרופיל מגרסת build להפצה שמשתמשת בכיווץ קוד וערפול קוד של R8, בטלו את ערפול הקוד אופליין בתחנת העבודה או בשרת לפני הניתוח. בלי פענוח קוד מעורפל (deobfuscate), R8 מערפל את שמות המחלקות, השדות והשיטות של Java ו-Kotlin בתמונת מצב של הזיכרון הגולמית (לדוגמה, הופך את שמות המחלקות המקוריים ל-a.b).
משתמשים בפקודה trace_processor bundle של Perfetto עם הדגל --proguard-map כדי לארוז את הפרופיל שנוצר יחד עם קובץ ה-R8 mapping.txt שנוצר לאותה גרסת build להפצה:
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, כמו שמתואר בשלבים הבאים, או להפוך את החקירה של ה-OOM לאוטומטית באמצעות סוכן AI.
פותחים את תמונת המצב של הזיכרון שעברה ביטול ערפול בממשק המשתמש של Perfetto ובוחרים באפשרות Heapdump Explorer בסרגל הניווט הימני. בכרטיסייה Flamegraph, כשהאפשרות Measure מוגדרת ל-Object Size, היררכיית השמירה באיור 1 בולטת מיד.
OomSampleActivity.בדיקה של תרשים הלהבות באיור 1 חושפת כמה ממצאים חשובים:
- שמירה של כמות עצומה של זיכרון: מתוך ערימה (heap) כוללת של 254.79MiB (
root),com.example.sampleapp.OomSampleActivityשומר על גודל מצטבר של 240.28MiB (94.31% מכל הערימה של האפליקציה), שרובו נמצא במערך צאצאbyte[]. - מספר מופעים של פעילות בזמן אמת: בחירה בפריים
OomSampleActivityמציגה את הערך Self Count: 8. למרות שרק מופע אחד שלOomSampleActivityאמור להופיע במסך בכל זמן נתון, 8 מופעים נפרדים פועלים בזיכרון בו-זמנית. - נתיב האובייקט ששומר על המופעים בזיכרון: קריאת הפריימים ההורים של
OomSampleActivityבתרשים הלהבות מראה את שרשרת ההפניות ששומרת על המופעים האלה בזיכרון:com.example.sampleapp.StatsReporterמכילjava.util.concurrent.CopyOnWriteArrayList(שמגובה על ידיjava.lang.Object[]), שמפנה ישירות לכל 8 המופעים שלOomSampleActivity.
זיהוי שורש הבעיה בקוד
כשהנתיב של שמירת הנתונים מצביע מ-StatsReporter אל OomSampleActivity, בודקים את שתי המחלקות בבסיס הקוד.
StatsReporter הוא סינגלטון שמנהל רשימה של קריאות חוזרות (callbacks) מסוג StatsListener ומפרסם עדכונים מדי פעם משרשור סקר ברקע:
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(); } }
בשלב הבא, בודקים את 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); } }
הקוד מסביר למה StatsReporter שומר 8 מופעים של OomSampleActivity:
- כל מופע של
OomSampleActivityמקצה מאגר נתונים זמני של 30MiBbyte[](heavyDataב-Kotlin,mHeavyDataב-Java) ורושם את עצמו כמאזין ב-singletonStatsReporterב-onCreate. -
OomSampleActivityאף פעם לא קורא ל-unregisterListenerכשהמערכת משמידה אותו. מכיוון ש-StatsReporterממשיך להתקיים אחרי הפעילות ומכיל הפניה חזקה לכל מאזין רשום, אוסף האשפה לא יכול להחזיר מופעים שנהרסו או את המערכים שלהם בגודל 30MiBbyte[]. - בכל פעם שהמערכת משמידה ויוצרת מחדש את
OomSampleActivity, למשל במהלך סיבוב מסך במחזור החיים של פעילות ב-Android, מופע נוסף של 30MiB דולף ב-StatsReporter. אחרי 7 סיבובים (8 מופעים × 30MiB ≈ 240MiB), ה-heap מתמלא והאפליקציה קורסת עםOutOfMemoryError.
תיקון הדליפה
כדי לתקן את דליפת הזיכרון, צריך לשנות את onDestroy בפעילות ולקרוא ל-StatsReporter.getInstance().unregisterListener(this), כדי שה-singleton ישחרר את ההפניה שלו כשהמערכת תשמיד את הפעילות:
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); } }
אוטומציה של חקירת OOM באמצעות סוכן AI
במקום לבדוק ידנית את תרשים הלהבות ולעקוב אחרי הפניות בין קבצים, אתם יכולים להשתמש בסוכן תכנות מבוסס-AI שמוגדר עם מיומנות Perfetto AI כדי לנתח את תמונת המצב של הזיכרון ולקשר אותה לבסיס הקוד:
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 (הדוח הבא נוצר מתיעוד נפרד שהיו בו 7 מופעים שנשמרו בזמן הקריסה):
# 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, תיקון מוצלח מונע את הפעלת הטריגר. כדי לבדוק את ה-heap ולוודא שהדליפה נעלמה, מסובבים את המסך ואז לוכדים תמונת מצב של ה-Java heap לפי דרישה משורת הפקודה:
- משביתים את מגביל הקצב (
rate_limiter.disabled true) ושומרים את התוצאות הזמניות (delete_temporary_results.disabled true) כדי שהמערכת תשמור את תיעוד שורת הפקודה ב-/data/misc/perfetto-traces/profiling/. - פותחים את
OomSampleActivityFixedבמכשיר הבדיקה, מסובבים את המסך 8 פעמים או יותר כדי לוודא שהאפליקציה לא קורסת יותר, ומשאירים אותה בחזית. מפעילים תמונת מצב של הזיכרון מ-Java לפי דרישה (
profiling_type1) באמצעות פקודת המעטפתcmd profiling_service request-profiling:adb shell cmd profiling_service request-profiling 1 com.example.sampleappשולפים את הפרופיל שנשמר מ-
/data/misc/perfetto-traces/profiling/ופותחים אותו בממשק המשתמש של Perfetto.
OomSampleActivityFixed.כמו שמוצג באיור 2, אחרי כמה סיבובי מסך:
com.example.sampleapp.OomSampleActivityFixedכולל ספירה עצמית של 1 במקום לצבור מופע בכל סיבוב.- הנפח שמתפנה (retained size) המצטבר נשאר יציב על 30.01MiB (מתוך ערימה כוללת של 44.27MiB), מה שמאשר שמנגנון איסוף הזבל מחזיר מופעי פעילות קודמים.
אם קשה לשחזר את הבעיה באופן מקומי
אם ניווט מורכב בין כמה מסכים, סשנים ארוכים או נתוני משתמשים ספציפיים גורמים ל-OutOfMemoryError שאי אפשר לשחזר באופן מהימן במכשיר בדיקה, צריך לאמת את התיקון בסביבת הייצור אחרי ההשקה:
- עוקבים אחרי אשכול הקריסות של
OutOfMemoryErrorבכלי לדיווח על קריסות, בתור מדד חלופי בכל הגרסאות, כדי לוודא שיש ירידה בקריסות שקשורות ל-OomSampleActivity. - בודקים את כל תמונות המצב החדשות של הזיכרון שנאספו מ-
TRIGGER_TYPE_OOMבגרסה המעודכנת, כדי לוודא ש-StatsReporterלא שומר יותר מופעים שלOomSampleActivityשנהרסו.