אפליקציות Java ו-Kotlin מנהלות את הזיכרון באמצעות heap שמתבצע בו איסוף אשפה. כשאובייקטים כבר לא נגישים, איסוף האשפה (GC) בסופו של דבר משחרר את המקום שלהם. דליפות זיכרון מתרחשות כשאובייקטים שכבר לא נחוצים עדיין מוחזקים על ידי 'שורשי GC', מה שמונע את השחזור שלהם.
מושגי ליבה
שורשי GC
GC Root הוא סוג מיוחד של אובייקט שאיסוף האשפה מתייחס אליו כאובייקט שתמיד אפשר להגיע אליו. דוגמאות:
- שרשורים פעילים (ואובייקטים שההפניה אליהם מתבצעת מתוך מסגרות מחסנית Java שמופעלות כרגע).
- Classes עם שיטות שפועלות באופן פעיל.
- הפניות ל-JNI (הפניות גלובליות או מקומיות שמוחזקות על ידי קוד Native).
הנתיב לשורש של איסוף הזבל
כל עוד יש שרשרת של הפניות מ-GC Root לאובייקט, האובייקט הזה הוא 'נגיש' ואי אפשר לבצע עליו איסוף אשפה. השרשרת הזו נקראת נתיב לשורש GC. כדי לתקן דליפת זיכרון, צריך לזהות את השרשרת הזו ולנתק אותה.

עצי דומיננטיות
הנתיב אל שורש ה-GC מציין למה אובייקט פעיל, אבל הוא לא מציין כמה זיכרון יפונה אם ההפניה הזו תוסר. לשם כך, אנחנו משתמשים בעצי דומיננטיות.
אומרים שאובייקט A שולט באובייקט B אם כל נתיב משורש GC כלשהו אל B חייב לעבור דרך A. אם A דומיננטי ב-B, אז שחזור של A יבטיח גם שניתן יהיה לשחזר את B, כי אין נתיבים אחרים משום שורש ל-B.
בתרשים הבא מוצג גרף אובייקטים ועץ הדומיננטים המתאים לו. שימו לב שאפשר להגיע לאובייקט D גם דרך A וגם דרך B בתרשים, ולכן אף אחד מהאובייקטים A או B לא שולט באובייקט D. במקום זאת, GC Root הוא השולט הקרוב ביותר שלו.

קבלת תמונות מצב של הזיכרון מ-Java
תמונת מצב של הזיכרון היא תמונת מצב של כל האובייקטים ב-Java heap בנקודת זמן מסוימת.
שימוש ב-ADB
כדי לתעד תמונת מצב של הזיכרון מתהליך שפועל, אפשר להעביר את שם החבילה ישירות אל am dumpheap. כדי להריץ את הפקודה הזו, צריך ליצור את האפליקציה באמצעות <profileable android:shell="true"/> או <debuggable>.
# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof
# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .
שימוש ב-Perfetto
בנוסף, אפשר להשתמש ב-Perfetto כדי לתעד את נתוני ה-heap של Java כחלק מתיעוד עקבות המערכת, על ידי הפעלת מקור הנתונים android.java_hprof בהגדרות של Perfetto. האפשרות הזו שימושית כדי ליצור קורלציה בין מצב הערימה לבין אירועים אחרים במערכת.
כדי ללכוד תמונת מצב של הזיכרון לאפליקציית MemoryLab באמצעות Perfetto, אפשר להשתמש בפקודה הבאה:
# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
config {
name: "android.java_hprof"
java_hprof_config {
process_cmdline: "com.android.memorylab"
}
}
}
EOF
# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
-t 10s -c /tmp/java_heap.pbtx
מידע נוסף זמין במאמר בנושא תמונות מצב של הזיכרון מ-Java ב-Perfetto.
ניתוח באמצעות AHAT
AHAT (Android Heap Analysis Tool) הוא הכלי המומלץ לצפייה בקובצי .hprof בדפדפן אינטרנט.
התחלת השימוש ב-AHAT
אם התקנתם את ahat בנתיב שלכם, מפעילים אותו באמצעות הפקודה:
ahat heap.hprof
או מריצים את קובץ ה-jar העצמאי:
java -jar ahat.jar heap.hprof
פותחים את הדפדפן בכתובת http://localhost:7100.
פרטים על קבלת AHAT או על פיתוח AHAT זמינים במאגר המקור של AHAT.
תהליכי עבודה מרכזיים לניתוח
איתור הדלפות
מחפשים את הכיתה של הפעילות (MainActivity) בתצוגה הקצאות.

לוחצים על הכיתה כדי לראות את כל המופעים.
לוחצים על המופע MainActivity כדי לבדוק אותו.

בתצוגת המופע, אפשר למצוא את נתיב לדוגמה משורש GC, שבו מוצגת שרשרת ההפניות שמונעת את איסוף האובייקט כזבל, ואת גודל האובייקט, שבו מוצג כמה זיכרון נשמר על ידי המופע הספציפי הזה.

ניתוח מפות סיביות
ל-AHAT יש תמיכה מיוחדת בצפייה באובייקטים מסוג android.graphics.Bitmap, שלרוב צורכים הרבה זיכרון. לוחצים על מופע של Bitmap כדי לראות תצוגה מקדימה של התוכן שלו.

דף הפעילות
ל-AHAT יש תצוגה מיוחדת לזיהוי פעילויות שדלפו, שהן אחת מדליפות הזיכרון הנפוצות והמשפיעות ביותר ב-Android.
- פעולה: ב-MemoryLab, מקישים על Leak an Activity (הדלפת פעילות). הפעולה הזו מפעילה את
LeakedActivity, שיוצר דליפה בכוונה. - Dump: יצירת תמונת מצב של הזיכרון.
- ניתוח: לוחצים על Activity Leaks (דליפות של פעילות) בסרגל הצד של AHAT.
- אימות: הכלי AHAT יציג את
com.android.memorylab.LeakedActivityכדליפה כי השדהmDestroyedשלו הוא true (מה שמציין שמחזור החיים של הפעילות הסתיים), אבל עדיין אפשר להגיע אליו משורש GC.

השוואה בין תמונות מצב של הזיכרון
השוואה בין שני קובצי dump של ה-heap היא אחת הדרכים הכי יעילות לזיהוי בעיות בזיכרון. אפשר להשוות בין dump בסיסי "נקי" לבין dump שנוצר אחרי ביצוע פעולות מסוימות, וכך לראות מיד אילו אובייקטים הצטברו.
תרגיל: זיהוי דליפות באמצעות השוואה
ערך בסיס: מפעילים את MemoryLab ויוצרים תמונת מצב של הזיכרון של ערך הבסיס:
adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof adb pull /data/local/tmp/base.hprof .פעולה: מקישים על הקצאת זיכרון Java(10MB) כמה פעמים באפליקציה.
השלב האחרון: יוצרים עוד dump של ה-heap:
adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof adb pull /data/local/tmp/leaked.hprof .השוואה: מפעילים את AHAT עם קובץ ה-dump השני כקובץ הראשי והראשון כקובץ הבסיס:
java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprofסקירה כללית של הניתוח: הדף סקירה כללית כולל עכשיו עמודה של Δ (דלתא). תראו דלתא חיובית גדולה עבור ה-heap של
app, שמצביעה על גידול משמעותי בזיכרון.

- פירוט: לוחצים על rooted בתפריט. בדף הזה מוצגים אובייקטים שאפשר להגיע אליהם משורשי GC, במיון לפי הנפח שמתפנה (retained size). בחלק העליון יופיע
MainActivityעם דלתא חיובית גדולה.

הקלטת דוחות קריסות של הקצאות
האפשרות Sample Path from GC Root מסבירה למה אובייקט עדיין פעיל, אבל לא מסבירה איך הוא נוצר. עקבות מחסנית של הקצאת זיכרון מספקים את שורת הקוד המדויקת שהקצתה אובייקט.
מושג ופשרות: תיעוד של דוח קריסות של כל הקצאה הוא יקר מבחינת חישובים וצורך כמות משמעותית של זיכרון. באפליקציית ייצור גדולה, זה יכול להפוך את האפליקציה לבלתי שמישה כמעט. עם זאת, MemoryLab היא אפליקציה קטנה מספיק שאפשר להפעיל בה את המעקב הזה בבטחה כדי לאתר את מקור ההקצאות.
תרגיל: זיהוי המקור של מערכי בייטים
מתחילים עם מעקב: עוצרים בכוח את MemoryLab ומפעילים אותו מחדש עם הדגל
--track-allocation. כדי ללכוד יותר הקשר, מגדילים את עומק מחסנית ברירת המחדל.# Increase the allocation tracker's stack depth (requires a process restart) adb shell setprop dalvik.vm.allocTrackerMaxStack 16 adb shell am force-stop com.android.memorylab adb shell am start --track-allocation -n com.android.memorylab/.MainActivityפעולה: מקישים כמה פעמים על הקצאת זיכרון Java(10MB).
Dump: יצירת תמונת מצב של הזיכרון ושליפתה.
ניתוח: פתיחת ה-dump ב-AHAT. עוברים למופע גדול של
byte[]. (לדוגמה, בודקים אתMainActivity←mJavaAllocations(ArrayList) ←elementData(Object[]) ← רכיב המערך[0]).אימות: בתצוגת המופע, מעיינים בקטע אתר ההקצאה. יוצג דוח קריסות מלא שמוביל אל
MainActivity.allocateJava.

איתור מחרוזות כפולות וניפוח של מאזן הנוזלים
גם אם באפליקציה אין דליפות של GC-root קלאסיות, יכול להיות שזיכרון ה-heap הפעיל של Java יתנפח בגלל אלפי מופעים כפולים של java.lang.String שנוצרו במהלך ביטול הסדרות של JSON, Protobuf, Cursor או מסד נתונים של Room. מקשים חוזרים, מחרוזות סטטוס, תוויות קטגוריה או כתובות URL מוקצים מחדש לעיתים קרובות בכל תגובה לרשת או שאילתה במסד נתונים. במגוון רחב של אפליקציות של פידים, הודעות ותוכן, מחרוזות כפולות מהוות בדרך כלל 30% עד 60% מהזיכרון הפעיל. String
כדי לבדוק מחרוזות כפולות ב-AHAT:
- פותחים את הדף הקצאות ומסננים לפי
java.lang.String. - כשמשווים שני קובצי dump של ערימה באמצעות
--baseline, בודקים אם ספירת המופעיםjava.lang.Stringוהבייטים הכוללים גדלים באופן לא פרופורציונלי אחרי טעינת פיד או טעינת מטמון מקומי. - מעיינים בטבלה של מופעי
java.lang.String(ממוינת לפי גודל או ערך) כדי לזהות ערכי מחרוזת זהים שנשמרו בכמה אובייקטים של מודל בזיכרון.
- פתרון: מומלץ להימנע מקריאה ל-
String.intern()באופן לא מובחן על קלט שרירותי של משתמש או רשת, כי טבלת ה-intern של זמן הריצה היא גלובלית ויכולה לגרום למאבק על נעילה או לשמור מחרוזות למשך זמן ארוך מהנדרש. במקום זאת, כדאי לבטל כפילויות של מחרוזות דומיין בתדירות גבוהה במהלך ביטול הסדרות, באמצעות מטמון לביטול כפילויות עם היקף מוגבל (כמוLruCache<String, String>בתוך מנתח התגים או המתאם), או לייצג קבוצות קבועות של ערכים כסוגי נתונים מנויים או כקבועים מסוג integer.
ניתוח של דינמיקת הזיכרון ב-Java (פרופיל משולב)
כדי לקבל תמונה מלאה של התנהגות הזיכרון של אפליקציה, אפשר לשלב בין מוני זיכרון, פעילות של שרשורים ופרופילים של הקצאת זיכרון שמבוססים על מחסנית קריאות, ולכלול אותם במעקב Perfetto אחד. כך אפשר לקשר בין מדדי זיכרון ברמת המערכת (כמו RSS וגודל הערימה) לבין ביצוע קוד ספציפי ואתרי הקצאה.
נשתמש בהגדרה משולבת שמאפשרת:
- Memory Counters (
linux.process_stats): ספירת RSS ומדדי זיכרון אחרים. - ATrace (קטגוריות
dalvik, memory, sched): מתעד מצבי שרשור ואירועי GC. - Heapprofd (
android.heapprofd): מכוון גם ל-com.android.art(Java) וגם ל-libc.malloc(native) עם פריקות רציפות כל 5 שניות.
תרגיל: ניתוח משולב של הזיכרון
בתרגיל הזה נריץ את אפליקציית MemoryLab ונבצע רצף של פעולות זיכרון כדי לראות דפוסים שונים בנתוני המעקב:
- Baseline: מצב בלי פעילות.
- Java Churn: הקצאות זמניות שנאספות מיד כזבל.
- הקצאת Java מתמשכת: הקצאת אובייקטים של Java שנשארים בזיכרון.
- הקצאת מפת סיביות: הקצאה של נכסים גרפיים גדולים (שנמצאים בזיכרון ה-GPU או ב-heap המקורי).
- החזרה לשימוש: שחרור כל המשאבים שהוקצו.
1. הפעלה והכנה
סוגרים ידנית את האפליקציה ומפעילים אותה מחדש כדי לוודא שהמצב תקין:
adb shell am force-stop com.android.memorylab adb shell am start -W -n com.android.memorylab/.MainActivity
2. התחלת מעקב והפעלת רצף
נתחיל מעקב למשך 40 שניות ונפעיל את אירועי הזיכרון באמצעות פקודות am
broadcast.
מתחילים את האיתור:
adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF buffers: { size_kb: 131072 fill_policy: RING_BUFFER } data_sources: { config { name: "linux.process_stats" target_buffer: 0 process_stats_config { scan_all_processes_on_start: true proc_stats_poll_ms: 100 } } } data_sources: { config { name: "linux.ftrace" target_buffer: 0 ftrace_config { ftrace_events: "sched/sched_switch" ftrace_events: "task/task_newtask" ftrace_events: "task/task_rename" ftrace_events: "ftrace/print" atrace_categories: "dalvik" atrace_categories: "am" atrace_categories: "res" atrace_categories: "memory" atrace_categories: "sched" atrace_apps: "com.android.memorylab" } } } data_sources: { config { name: "android.heapprofd" target_buffer: 0 heapprofd_config { sampling_interval_bytes: 4096 process_cmdline: "com.android.memorylab" heaps: "libc.malloc" heaps: "com.android.art" shmem_size_bytes: 8388608 block_client: true continuous_dump_config { dump_phase_ms: 1000 dump_interval_ms: 5000 } } } } duration_ms: 40000 EOFמפעילים את הרצף (מריצים את הפקודות האלה בטרמינל של המארח בזמן שהמעקב פועל, בהתאם לתזמון המוצע):
# Wait ~5s for trace initialization, then start Java churn: adb shell am broadcast -a com.android.memorylab.CHURN_JAVA # Wait ~10s (at 15s mark), allocate 10MB of persistent Java memory: adb shell am broadcast -a com.android.memorylab.ALLOC_JAVA # Wait ~5s (at 20s mark), allocate 20MB of Bitmaps (native/graphics): adb shell am broadcast -a com.android.memorylab.LEAK_BITMAP # Wait ~10s (at 30s mark), free everything: adb shell am broadcast -a com.android.memorylab.FREE_ALLאפשרות חלופית (כלי CLI): אפשר גם להפעיל את הפרופיל באמצעות הסקריפט
heap_profileישירות, ולטרגט גם את Java וגם את ה-native heaps עם dump רציף:external/perfetto/tools/heap_profile -n com.android.memorylab \ --heaps com.android.art,libc.malloc \ -c 5000 \ -d 40000 \ -o java_memory_profile
3. ניתוח העקבות המשולב
פותחים את הנתונים שנאספו java_memory.perfetto-trace בממשק המשתמש של Perfetto.
טראקים מרכזיים ב-Perfetto
לפני שמנתחים את ציר הזמן, מאתרים את הרצועות החיוניות האלה בתהליך com.android.memorylab:
-
mem.rss.anon(Anonymous RSS): נמצא בקטע Memory של התהליך. במסלול הזה נמדד הזיכרון הפיזי (RAM) שהוקצה לתהליך על ידי מערכת ההפעלה. הוא מייצג את הזיכרון שבשימוש בפועל. -
Heap size (KB): גם בקטע זיכרון. זהו מונה ספציפי ל-Dalvik/ART שמייצג את מרחב הכתובות הווירטואלי ששמור ל-Java heap. הוא משקף את מגבלת ה-heap הפנימית של המכונה הווירטואלית, שמשתנה כשמוקצים אובייקטים וכשמתבצעת פעולת איסוף נתונים. -
HeapTaskDaemon: נמצא ברשימת השרשורים בתהליך. זהו ה-thread ברקע שבו מתבצע רוב העבודה של איסוף האשפה ב-ART. הפעילות כאן מציינת כרטיסים פעילים של GC. - Continuous Allocation Dumps (heapprofd): מוצג כפרוסות צבעוניות לאורך ציר הזמן העליון. כל פלח מייצג משך זמן. אם לוחצים על פלח אחד או בוחרים טווח זמן, אפשר לבדוק את תרשים הלהבות (בחלונית התחתונה) כדי לראות מה הוקצה במהלך התקופה הזו, או com.android.art (הקצאות של Java) או libc.malloc (הקצאות של Native).
ניתוח שלבים כרונולוגי
בואו נבדוק את העקבות בסדר כרונולוגי כדי לראות איך המסלולים האלה פועלים בכל שלב של התרגיל.
שלב 1: ערך בסיס (0 שניות עד 5 שניות)
- מה קורה: האפליקציה בלי פעילות וממתינה לפקודות.
- סטטוס של טראק:
-
mem.rss.anon: קו ישר בערך הבסיסי (בדרך כלל בסביבות 60-80MB, בהתאם למכשיר). -
Heap size (KB): קו ישר, שמתאים להקצאת זיכרון בערימה (heap allocation) הראשונית ב-Java. -
HeapTaskDaemon: בלי פעילות (לא מוצגות פרוסות של הרצה). - Allocation Dumps: הצגת הקצאות מינימליות של ערכי בסיס.
-

שלב 2: תנודות בהקצאת זיכרון ב-Java (5 שניות עד 15 שניות)
- מה קורה: התהליך
AllocationChurnThreadמופעל, ומקצה שוב ושוב מערכים בגודל 1MB ואז מבטל את ההקצאה שלהם. - סטטוס של טראק:
-
Heap size (KB): מוצג דפוס שיניים מהיר. גודל הערימה עולה ככל שההקצאות מצטברות, ויורד בחדות כשה-GC פועל. -
HeapTaskDaemon: מוצגת פעילות כמעט קבועה, עם פרוסות ביצוע שתואמות באופן מושלם לירידות בשיני המסורHeap size. -
mem.rss.anon: מעקב אחר פעילות הזיכרון מ-Java. - תמונות מצב של הקצאת זיכרון מ-Java: בחירת פרוסות במסלול הזה מציגה הקצאות של זיכרון מ-Java של com.android.art.
-
דוגמאות ההקצאה חושפות את AllocationChurnThread כהקצאה הראשית, כאשר כל ההקצאות חולקות את אותו callstack שמצביע על ה-lambda בתוך MainActivity.java.

שלב 3: הקצאת Java מתמשכת (15 שניות עד 20 שניות)
- מה קורה: אנחנו מקצים 10MB של אובייקטים של Java ושומרים הפניה
אליהם ב-
mJavaAllocations. - סטטוס של טראק:
-
Heap size (KB): קו הבסיס של שיני המסור עולה בכ-10MB. -
mem.rss.anon: העלייה היא בערך של 10MB, כי מערכת ההפעלה צריכה לגבות את ההקצאה המתמשכת הזו בדפים פיזיים חדשים. - תמונות מצב של הקצאת זיכרון מ-Java: בחירת פרוסות במסלול הזה מציגה הקצאות של זיכרון מ-Java של com.android.art.
- קובצי dump של הקצאת זיכרון (תרשים להבה): בדיקה של ה-heap של com.android.art
בקובץ ה-dump שנוצר בחלון הזה מראה נתיב הקצאה חדש מ-
MainActivity.allocateJavaשמשפיע על הגודל שנשמר.
-
בוחרים דוגמה להקצאה שכוללת משך זמן שחופף להגדלה של 10MB להקצאה הקבועה. אפשר לראות שהקצאות הזיכרון מתפצלות לשני אתרים שונים. אתר אחד אחראי על מחזור החיים הקצר של הקצאת הזיכרון שראינו קודם, והאתר השני אחראי על הקצאת הזיכרון החדשה עם מחזור החיים הארוך.

שלב 4: הקצאת מפת סיביות (20 שניות עד 30 שניות)
- מה קורה: אנחנו גם מקצים 20MB של מפות סיביות.
- סטטוס של טראק:
-
Heap size (KB): כמו קודם. -
mem.rss.anon: מוצג גידול משמעותי של כ-20MB, שמתאים להקצאות הנייטיב לנתוני הפיקסלים של Bitmap. - הקצאות (תרשים להבה): הפעם מתמקדים בפרוסות של libc.malloc (Native) heap.
-
מחסנית הקריאות (callstack) של הקצאת הזיכרון (allocation) ב-Native חושפת את הקצאת הזיכרון של Bitmap שמקורה בספריות גרפיקה ב-Native. זהו מקרה שימוש טוב למעקב אחר הקצאת זיכרון מקורי, כי לא תראו את הקצאות הזיכרון של מפת הסיביות האלה ב-Java heap.

שלב 5: שחזור (30 שניות עד 40 שניות)
- מה קורה: אנחנו מפעילים את
FREE_ALL, מנקים הפניות לכל ההקצאות המתמשכות של Java ולמפות הביטים, ואז מפעיליםSystem.gc()באופן מפורש. - סטטוס של טראק:
-
Heap size (KB): יורדת חזרה לרמת הבסיס. -
mem.rss.anon: יורד בחזרה, ומוצגות הדפים הפיזיים שמערכת ההפעלה משחררת. -
HeapTaskDaemon: מוצגת פעילות אחרונה כשהתהליך של איסוף האשפה מסתיים.
-

מעקב אחר שגיאות היסטוריות של חוסר זיכרון (OOM) (ApplicationExitInfo)
התפיסה של LMK בזמן אמת מצוינת לניפוי באגים פעיל, אבל לטלמטריה בשטח, אפשר להשתמש ב-ApplicationExitInfo API. כך האפליקציה יכולה לגלות למה היא נסגרה בסשן קודם.
ActivityManager am = getSystemService(ActivityManager.class);
List<ApplicationExitInfo> exitReasons = am.getHistoricalProcessExitReasons(null, 0, 1);
if (!exitReasons.isEmpty()) {
ApplicationExitInfo info = exitReasons.get(0);
if (info.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY) {
// App was killed by the system Low Memory Killer
}
}
שיטות מומלצות
- הגדרת בסיס לפני הכול: תמיד צריך ליצור תמונת מצב של הזיכרון אחרי שהאפליקציה מאותחלת, אבל לפני שמבצעים את הפעולה שרוצים לבדוק.
- שימוש בדף Activity Leaks ב-AHAT: AHAT כולל דף ייעודי בשם Activity Leaks שמזהה באופן אוטומטי מקרים של פעילות שהושמדה אבל עדיין נשמרת בזיכרון. זו לרוב הדרך הכי מהירה למצוא דליפות נפוצות.
- בדיקת הנתיב אל שורשי ה-GC: לכל אובייקט שהודלף, משתמשים בתצוגה Path from Root ב-AHAT כדי להבין בדיוק איזו הפניה שומרת אותו בחיים (לדוגמה, שדה סטטי, שרשור שפועל לאורך זמן או מאזין רשום).
← כלים | ↑ למעלה | מפות ביטים →