Java - HashMap Internals

מבנה הנתונים HashMap הוא אחד הכלים החשובים והשימושיים ביותר ב Java. כדי להבין לעומק איך הוא עובד מאחורי הקלעים, עלינו לצלול אל המבנה הפנימי שלו, אל האופן שבו הוא מטפל בזיכרון, ואל השינויים המשמעותיים שהוא עבר בגרסאות השונות של Java.

Key Value הוא המבנה הבסיסי שעליו מבוסס ה HashMap. במקום לשמור פריטים ברשימה רגילה לפי אינדקס מספרי רגיל (כמו 0, 1, 2), אנחנו שומרים זוגות של מידע שבהם כל נתון מנוהל באמצעות מזהה ייחודי.

הצמד מורכב משני חלקים:

כלל חשוב: ה Key חייב להיות ייחודי. אם ננסה להכניס ל HashMap מפתח שכבר קיים, המערכת לא תיצור Key חדש, אלא תדרוס את ה Value הישן ב Value החדש.

בניגוד למפתחות, ערכים יכולים להיות כפולים (שני מפתחות שונים לחלוטין יכולים להצביע על בדיוק אותו ערך).

מבנה הנתונים הבסיסי מאחורי הקלעים

בבסיסו, HashMap מורכב ממערך של צמתים (Array of Nodes / Buckets). כל תא במערך הזה נקרא Bucket, ובתוכו נשמרים האובייקטים (Key Value).

כשאנחנו יוצרים HashMap, מוקצה ב Heap מערך ברירת מחדל (לרוב בגודל התחלתי של 16 תאים). לכל תא במערך יש אינדקס מספרי שנקבע על בסיס פונקציית גיבוב (Hash Function) של המפתח.

איך עובדות הפעולות המרכזיות?

פעולת ההכנסה put(K key, V value)

כשאנחנו מוסיפים זוג Key Value ל HashMap, התהליך הבא מתבצע מאחורי הקלעים:

איך הנתונים נשמרים באותו תא? (ההבדל בין גרסאות Java):

ב Java 7 ומטה:

הנתונים באותו תא אורגנו בתוך Linked List. כל צומת הצביע לצומת הבא אחריו. הבעיה הייתה שאם נוצר מצב קיצוני עם המון התנגשויות באותו תא, ה HashMap נאלץ לעבור אחד אחד על כל הרשימה (חיפוש לינארי), מה שהאט את הביצועים לזמן ריצה גרוע של O(n).

עץ אדום שחור (Red Black Tree) הוא עץ חיפוש בינארי מאוזן אוטומטית, שבו כל צומת צבוע באדום או בשחור לפי חוקי מבנה קפדניים המבטיחים שהעץ יישאר מאוזן תמיד. הודות לאיזון זה, פעולות חיפוש, הוספה ומחיקה מובטחות להתבצע בזמן ריצה יעיל מאוד של O(log n), מה שמונע מהעץ להידרדר למבנה לינארי איטי ומאפשר ל HashMap לשמור על ביצועים גבוהים במצבים של התנגשויות רבות.

מ Java 8 ואילך:

כדי לפתור את צוואר הבקבוק של הרשימה המקושרת, הוסיפו מנגנון חכם שנקרא Treeification (הפיכה לעץ).

אם מספר האלמנטים באותו תא עובר סף של 8 אלמנטים, והמערך כולו גדול מספיק (לפחות 64 תאים), ה Linked List הופכת אוטומטית לעץ אדום שחור (Red Black Tree).

התוצאה: במקום חיפוש איטי ברשימה, החיפוש בעץ מתבצע בזמן ריצה יעיל בהרבה של O(log n), מה שמונע קריסות ביצועים גם תחת עומסים כבדים או פקודות זדוניות (Hash Flooding).

פעולת השליפה get(K key)

⚠️ שימו לב!

כשהתא הפך לעץ אדום שחור (8 אלמנטים ומעלה ב Java 8+): הוא לא! עובר עליהם אחד אחד, כאן ההבדל העצום. אם התא עבר "עציות" (Treeification), ה HashMap אינו עובר אחד אחד בצורה לינארית. במקום זאת, הוא מנווט בתוך העץ האדום שחור לפי חוקי חיפוש בינארי, מה שמביא אותו לצומת המבוקש בזמן ריצה יעיל של O(log n), ורק אז משתמש ב equals() כדי לאשר סופית את ההתאמה.

האם תמיד חייבים לממש את המטודות hashCode() ו equals()?

מתי אין צורך לממש ידנית? כש key הוא מחלקה קיימת של Java

אם אנחנו משתמשים במחלקות סטנדרטיות של Java בתור key כמו String, Integer, Long, או Double לא צריכים לממש כלום.

מתי חובה לממש? כשה key הוא מחלקה מותאמת אישית שאנחנו כתבנו

אם אנחנו יוצרים מחלקה משלנו (למשל Employee, Product, UserId) ורוצים להשתמש באובייקטים ממנה בתור key ב HashMap (או כ value ב HashSet), אז ורק אז אנחנו חייבים לבצע override ולממש את שתי המתודות (hashCode ו equals).

למה? כי ברירת המחדל של Java למחלקות שאנחנו כותבים מבוססת על כתובת הזיכרון (Memory Address). אם ניצור שני אובייקטים נפרדים עם בדיוק אותו תוכן (למשל שני משתמשים עם ID 1), Java תתייחס אליהם כאל שתי ישויות זרות לחלוטין כי הם יושבים במקומות שונים ב Heap.

⚠️ אזהרה

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

מימוש equals() בלבד (השארת hashCode מקורי)

אם נעשה override רק על equals כדי להשוות אובייקטים לפי התוכן העסקי שלהם (למשל לפי ID או אימייל), אבל נשאיר את ה hashCode המקורי של Java:

מה שקורה מאחורי הקלעים: ה hashCode המקורי מבוסס על כתובת הזיכרון (Memory Address) של האובייקט ב Heap. המשמעות היא ששני אובייקטים שונים לחלוטין בזיכרון, גם אם הם מכילים בדיוק את אותם נתונים בדיוק, יקבלו ערכי Hash שונים לחלוטין.

התוצאה ב HashMap:

מימוש hashCode() בלבד (השארת equals מקורי)

אם נעשה override רק על hashCode (כדי ששני אובייקטים זהים יחזירו את אותו מספר גיבוב), אבל נשאיר את ה equals המקורי:

מה שקורה מאחורי הקלעים: מתודת ה equals המקורית של Java משווה אך ורק את כתובת הזיכרון באמצעות האופרטור ==.

התוצאה ב HashMap:

⚠️ כלל האצבע!

הכלל במערכות Java קובע חד משמעית: הן עובדות כצמד או לא עובדות בכלל. אם נדרש להשתמש במחלקה מותאמת אישית בתור key ב HashMap, חובה לכתוב override תמיד על שתי המתודות יחד, ולהבטיח שהן משתמשות באותם שדות בדיוק לצורך חישוב ה Hash והשוואת השוויון.

דוגמא למימוש

import java.util.Objects;
import java.util.HashMap;
import java.util.Map;

class Employee {
    private int id;
    private String email;

    public Employee(int id, String email) {
        this.id = id;
        this.email = email;
    }

    // 1. Implementing equals to compare business equality based on id and email
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Employee employee = (Employee) o;
        return id == employee.id && Objects.equals(email, employee.email);
    }

    // 2. Implementing hashCode consistently using the exact same fields to maintain the contract
    @Override
    public int hashCode() {
        return Objects.hash(id, email);
    }
}

public class HashCodeEqualsDemo {
    public static void main(String[] args) {
        Map employeeRoles = new HashMap<>();

        Employee emp1 = new Employee(101, "alice@company.com");
        Employee emp2 = new Employee(101, "alice@company.com"); // Different object instance, but logically equal

        // Putting data into the map using emp1
        employeeRoles.put(emp1, "Software Engineer");

        // Retrieving data using emp2 (which is logically equal to emp1)
        // Because equals() and hashCode() are properly aligned, this will successfully return "Software Engineer"
        String role = employeeRoles.get(emp2);

        System.out.println("Employee Role: " + role); // Output: Software Engineer
    }
}
🏠 Back to Orly's Code Corner