Stack, Heap & Metaspace
הזיכרון ב-Java מחולק למספר אזורים מרכזיים, כאשר שניים מהם מרכזיים לניהול משתנים ואובייקטים: ה-Stack (מחסנית) וה-Heap (ערמה).
ה-Stack (מחסנית)
ה-Stack הוא אזור זיכרון שמוקצה לכל Thread בנפרד. הוא פועל בשיטת LIFO (Last In, First Out - האחרון שנכנס הוא הראשון שיוצא).
מה נשמר בו?
- משתנים מקומיים (Local Variables): משתנים שמוצהרים בתוך מתודות (כמו int x = 5).
- פרמטרים של מתודות: הנתונים שמועברים למתודה בזמן קריאה.
- כתובות זיכרון (References): מצביעים (כתובות) שמובילים לאובייקטים האמיתיים שיושבים ב-Heap.
- Frames : בכל פעם שמתודה נקראת, נוצרת ב-Stack חתימה (Stack Frame) שמכילה את המשתנים שלה. ברגע שהמתודה מסתיימת, ה-Frame נמחק מיד והזיכרון משוחרר אוטומטית.
תכונות מרכזיות:
- מהירות: הגישה ל-Stack מהירה מאוד משום שהניהול שלו לינארי ומנוהל ישירות על ידי ה-CPU.
- גודל: קטן בהרבה מה-Heap. אם ממלאים אותו יותר מדי (למשל ברקורסיה אינסופית בלי תנאי עצירה), מקבלים את השגיאה המוכרת java.lang.StackOverflowError.
2. ה-Heap (ערמה)
ה-Heap הוא אזור זיכרון מרכזי ומשותף שמשמש את כל התוכנית (לכל ה-Threads יש גישה אליו). הוא מיועד לאחסון דינמי של אובייקטים ומערכים.
מה נשמר בו?
- כל אובייקט שנוצר באמצעות המילה new (למשל: new Duck(), new String("Hello")).
- שדות של אובייקטים (Instance Fields / Attributes): המשתנים הפנימיים של האובייקט (גם אם הם פרימיטיביים כמו int או boolean, כל עוד הם שייכים לאובייקט שיושב ב-Heap, הם נשמרים שם).
- Wrapper Classes (כמו Integer, Long) גם נשמרות כאן כי הן אובייקטים.
ה־Heap ב־Java מחולק לאזורים שונים (Generations) בהתבסס על ההנחה המרכזית בניהול זיכרון שנקראת "ההיפותזה הדורית" (Weak Generational Hypothesis) והיא שרוב האובייקטים בתוכנית מתים צעירים מאוד (למשל, אובייקטים שנוצרו לצורך חישוב מקומי במתודה ומיד נזרקים).
חלוקה זו נועדה לייעל דרמטית את עבודתו של ה־Garbage Collector (ה־GC), כך שלא יצטרך לסרוק את כל מיליוני האובייקטים בזיכרון בכל פעם שהוא מנקה. למידע נוסף על Garbage Collector:
ה־Heap מתחלק לשני אזורים מרכזיים:
1. Young Generation (הדור הצעיר)
זהו האזור שבו נוצרים כל האובייקטים החדשים ברגע שאנחנו כותבים new. האזור הזה מתמלא מהר מאוד, ולכן ה־GC מבקר בו בתדירות גבוהה מאוד (תהליך שנקרא Minor GC).
הדור הצעיר עצמו מחולק לשלושה תתי־אזורים:
- Eden Space (גן עדן): המקום שבו האובייקטים החדשים נולדים ונכנסים לראשונה לזיכרון. ברגע שה־Eden מתמלא, מופעל Minor GC ראשוני: אובייקטים שעדיין בשימוש מועברים החוצה, ואלו שמתים נמחקים ונעלמים לחלוטין באפס מאמץ.
- Survivor Spaces (S0 ו־S1 או From ו־To): שני אזורי מעבר קטנים המשמשים כ"תחנת ביניים". בכל פעם שמתבצע Minor GC ב־Eden, האובייקטים ששרדו מועברים לאחד מתושבי ה־Survivor. האזורים הללו מחליפים תפקידים לסירוגין (פעם אחד הוא From והשני הוא To). לכל אובייקט ששורד ניקוי כזה נוסף "מונה גיל" (Age). כשהוא שורד מספיק סבבים ב־Survivor (לרוב ברירת המחדל היא 15 סבבים), הוא מקודם אל הדור הישן.
2. Old Generation / Tenured Generation (הדור הישן)
זהו האזור שאליו עוברים אובייקטים "ותיקים" ששרדו הרבה סבבי ניקוי בדור הצעיר (או אובייקטים ענקיים במיוחד שתפסו ישר מקום פה).
מה נמצא בו? אובייקטים לטווח ארוך, כמו אובייקטי Singleton, קונפיגורציות גלובליות, קשרי מסדי נתונים (Connections), או מטמונים (Caches) שנשארים לאורך כל חיי האפליקציה.
איך ה־GC מנקה אותו? ניתן להעמיק בנושא זה כאן למידע נוסף על Garbage Collection (GC)
תכונות מרכזיות:
- ניהול זיכרון (Garbage Collection): בניגוד ל-Stack שמתנקה לבד כשהמתודה נגמרת, אובייקטים ב-Heap נשארים שם כל עוד יש להם לפחות Reference אחד שמוביל אליהם. כשאובייקט מאבד את כל ה-References שלו, מנגנון האיסוף האוטומטי (Garbage Collector - GC) מוחק אותו ומפנה את השטח.
- גודל: גדול מאוד (ניתן להגדיר לו גבולות בעת הפעלת ה-JVM באמצעות דגמים כמו -Xmx ו־-Xms).
- חריגה: אם ממלאים את כל ה-Heap באובייקטים בלי שחרור, מקבלים את השגיאה java.lang.OutOfMemoryError: Java heap space.
המחשה קצרה: Stack מול Heap בקוד
public void createPerson() {
int age = 30; // 'age' (primitive) is stored directly on the Stack
String name = "Alice"; // 'name' reference is on the Stack, the actual String object is in the Heap
}
3. איפה יושבים המטא-דאטה, המחלקות והקבועים? (האבולוציה בגרסאות Java)
הגדרות של מחלקות, ממשקים, מתודות, משתנים סטטיים (static) ומטא-דאטה לא יושבים ב-Heap הרגיל ולא ב-Stack, אלא באזור נפרד ששמו השתנה משמעותית בין גירסאות Java:
עד Java 7 (כולל) אזור ה-PermGen (Permanent Generation)
אזור זה היה חלק מזיכרון ה-JVM שנקבע מראש בגודלו בזמן עליית התוכנית.
הבעיה: מכיוון שהגודל היה קבוע מראש, תוכניות גדולות או שימוש כבד בטעינת מחלקות דינמית (כמו שימוש ב-Frameworks כמו Spring או Hibernate, או יצירת Classes בזמן ריצה באמצעות Reflection) גרמו שוב ושוב לשגיאה המפורסמת: java.lang.OutOfMemoryError: PermGen space. היה קשה מאוד לנהל ולכוון את הגודל שלו נכון.
מ-Java 8 ועד היום מעבר ל-Metaspace
החל מגרסה Java 8, אזור ה-PermGen בוטל לחלוטין והוחלף ב-Metaspace.
השינוי המרכזי: ה-Metaspace אינו חלק עוד מזיכרון ה-JVM המוקצה, אלא הוא יושב ישירות על הזיכרון הפיזי של מערכת ההפעלה (Native Memory).
היתרון:
- כברירת מחדל, ה-Metaspace גדל באופן דינמי ככל שהאפליקציה צריכה עוד מקום (בהתאם לזיכרון הפנוי במחשב).
- שגיאות ה-PermGen נעלמו מהעולם. אם בכל זאת יאזל הזיכרון במערכת ההפעלה, תקבל שגיאה חדשה: java.lang.OutOfMemoryError: Metaspace.
Java 7-
JVM Memory
├── Heap
│ ├── Young
│ └── Old
└── PermGen (fixed)
java 8+
JVM Memory
├── Heap
│ ├── Young
│ └── Old
└── Metaspace (native memory)
Java 8 החליפה אותו ב־Metaspace שמשתמש ב־native memory ויכול לגדול דינמית, מה שהפך class loading והרצת frameworks להרבה יותר יציבים. אבל! הזיכרון נגזר ממערכת ההפעלה, כמה שהוא גדול, הוא עדיין מוגבל ולכן הוא כן יכול להיגמר.