Java Core · جاوا پایه متوسطIntermediate ~68 دقیقه مطالعه~57 min read

قرارداد equals/hashCode، رشته، تغییرناپذیری و Boxingequals/hashCode, String, Immutability & Boxing

در این درس یاد می‌گیری چرا `==` با `.equals` فرق دارد، قرارداد equals/hashCode چطور درستی HashMap را تعیین می‌کند، رشته چرا تغییرناپذیر است و استخرش چه دامی می‌سازد، و autoboxing و کش Integer چطور بی‌صدا کدت را می‌شکنند.In this lesson you will learn why `==` is not `.equals`, how the equals/hashCode contract decides whether your HashMap tells the truth, why String is immutable and what trap its pool sets, and how autoboxing plus the Integer cache silently break your code.


سه گوشهٔ به‌ظاهر ساده در جاوا وجود دارد که کدِ واقعیِ بیشتری را از هر باگِ همزمانی (concurrency) نابود کرده‌اند — دقیقاً چون ساده به نظر می‌رسند. آن‌قدر بی‌آزار به چشم می‌آیند که کسی مکث نمی‌کند تا درست یادشان بگیرد، و بعد یک روز map.get(key) برای کلیدی که همین سه خط بالاتر درجش کردی، با خونسردی null برمی‌گرداند. این درس هر سه گوشه را از صفر می‌سازد تا دیگر هیچ‌وقت غافلگیرت نکنند.

نقشهٔ راه این درس

چهار چیز را کامل یاد می‌گیری: ۱. تفاوت هویت (==) و برابری مقدار (.equals) — چرا یکی گرفتنِ این دو، منبعِ نصف باگ‌های ظریف جاواست. ۲. قرارداد equals/hashCode — قانونی الزام‌آور که درستیِ HashMap، HashSet و هر ساختار هش‌محور به آن بند است. ۳. String — چرا تغییرناپذیر (immutable) است، استخر رشته (string pool) چیست، و چرا == روی رشته یک تلهٔ کلاسیک است. ۴. Autoboxing و کش Integer — چطور جاوا بی‌سروصدا مقادیر را بین دنیای primitive و دنیای شیء جابه‌جا می‌کند و کجا این جادو به کدت خنجر می‌زند.

اگر فقط یک جمله را قرار است با خودت ببری، این باشد: == مرجع‌ها را مقایسه می‌کند (یا primitiveهای خام را)؛ هر چیزی که به برابریِ مقدار مربوط است از قرارداد equals/hashCode می‌گذرد؛ و boxing بی‌سروصدا مقادیر را بین این دو دنیا حمل می‌کند.

بخش ۰ — واژه‌هایی که باید بشناسی

قبل از اینکه جلو برویم، چهار واژه را با تشبیه بشکافیم تا بعداً هیچ‌کدام سرد و بی‌توضیح روی سرت خراب نشود.

  • مرجع (reference): آدرسِ یک شیء در حافظه، نه خودِ شیء. مثل تکه‌کاغذی که رویش آدرسِ یک خانه نوشته شده. دو کاغذ می‌توانند آدرسِ یک خانهٔ واحد را داشته باشند، یا آدرسِ دو خانهٔ متفاوت که تصادفاً دکوراسیون یکسانی دارند.
  • primitive: انواع پایه‌ای مثل int، long، double، boolean. این‌ها شیء نیستند؛ خودِ مقدار را مستقیم نگه می‌دارند، نه آدرسی به جایی. int x = 5 یعنی خودِ ۵ آنجاست، نه اشاره‌ای به ۵.
  • wrapper (نوع پوششی): نسخهٔ شیءگونهٔ همان primitiveها: Integer برای int، Long برای long، Double برای double و... . یک جعبه (box) که یک primitive را داخلش می‌گذارد تا بتوانی جایی که فقط شیء قبول می‌شود (مثل List<Integer>) از عدد استفاده کنی.
  • سطل (bucket) و هش (hash): یک HashMap را مثل کمدِ چمدان‌های ایستگاه قطار تصور کن. به‌جای اینکه همهٔ چمدان‌ها را در یک صف بگذارد، هر چمدان را با یک شمارهٔ کوتاه (هش) به یک قفسه (سطل) نگاشت می‌کند. پیداکردنِ چمدان یعنی اول شمارهٔ قفسه را حساب کن، سراغ همان قفسه برو، بعد فقط چمدان‌های همان قفسه را وارسی کن — نه کلِ ایستگاه را.
چرا اصلاً هش وجود دارد

هدفِ هش این است که جست‌وجو را از «همه را یکی‌یکی چک کن» (کند، از مرتبهٔ O(n)) به «مستقیم برو سطل درست» (سریع، تقریباً O(1)) تبدیل کند. تمام قرارداد equals/hashCode در واقع قراردادی‌ست که این میان‌بُر را سالم نگه می‌دارد. اگر قرارداد را بشکنی، میان‌بُر تو را به قفسهٔ اشتباه می‌فرستد.

سه دنیای برابری: == در برابر .equals

دو کاغذ آدرس

دو تکه‌کاغذ در دست داری. == می‌پرسد: «آیا این دو کاغذ دقیقاً همان آدرس را نشان می‌دهند؟» — یعنی آیا به یک خانهٔ واحد اشاره می‌کنند. .equals می‌پرسد: «برو داخلِ هر دو خانه؛ آیا محتوایشان یکی است؟» دو خانهٔ متفاوت می‌توانند مبلمانِ کاملاً یکسان داشته باشند: == می‌گوید نه (آدرس فرق دارد)، .equals می‌گوید بله (محتوا یکی است).

در جاوا:

  • برای primitiveها (int، long، ...) عملگرِ == خودِ مقدار را مقایسه می‌کند: 5 == 5 یعنی true. اینجا اصلاً آدرسی در کار نیست.
  • برای شیءها (هر چیزی که با new ساخته می‌شود یا مرجع است)، == آدرس‌ها را مقایسه می‌کند: آیا این دو مرجع به یک شیء واحد اشاره می‌کنند؟ برای مقایسهٔ محتوا باید .equals را صدا بزنی.

پیش‌فرضِ Object.equals که کلاست از آن ارث می‌برد، خودش دقیقاً همان == است (مقایسهٔ هویت). پس تا وقتی equals را override نکنی، «برابری مقدار» برایت وجود ندارد؛ فقط «همان شیء بودن» را داری.

قرارداد equals/hashCode

Object.equals و Object.hashCode یک جفت‌اند با قراردادی الزام‌آور — نه یک پیشنهاد مؤدبانه، بلکه قانونی که کلِ کتابخانهٔ استاندارد بر آن حساب می‌کند. HashMap، HashSet، LinkedHashMap، ConcurrentHashMap، حذفِ تکراری‌ها (deduplication)، کشینگ — همه بر پایبندیِ تو به این قرارداد بنا شده‌اند. اگر آن را بشکنی، این کلاس‌ها خراب نمی‌شوند؛ فقط بی‌صدا نتیجهٔ غلط می‌دهند، که بدتر است.

کتابدار و شمارهٔ قفسه

یک HashMap مثل کتابداری‌ست که کتاب‌ها را با یک فرمول (hashCode) به قفسه‌ها می‌چیند. وقتی کتاب می‌آوری، اول فرمول را روی عنوانش اجرا می‌کند تا شمارهٔ قفسه دربیاید، بعد کتاب را همان‌جا می‌گذارد. وقتی همان کتاب را می‌خواهی، دوباره همان فرمول را اجرا می‌کند تا بداند کدام قفسه را بگردد. حالا تصور کن دو نسخهٔ یکسان از یک کتاب، به‌خاطر یک اشکال در فرمول، شمارهٔ قفسهٔ متفاوت بگیرند. کتابدار سراغ قفسهٔ اشتباه می‌رود، کتاب را نمی‌بیند، و با اطمینان می‌گوید «نداریم» — درحالی‌که نسخهٔ دیگرش همان تو با دست خودت گذاشتی. این دقیقاً همان چیزی‌ست که وقتی equals را بدون hashCode بنویسی اتفاق می‌افتد.

equals — پنج قانون

برای مرجع‌های غیرِ null یعنی x, y, z:

ویژگی معنا
بازتابی (reflexive) x.equals(x) برابر true است
متقارن (symmetric) x.equals(y)y.equals(x)
متعدی (transitive) x.equals(y) و y.equals(z)x.equals(z)
سازگار (consistent) فراخوانی‌های مکرر همان نتیجه را می‌دهند، اگر حالتِ دخیل در equals تغییر نکند
غیر null x.equals(null) برابر false است (هرگز exception پرت نمی‌کند)

این پنج قانون در نگاه اول بدیهی‌اند، ولی هرکدام یک اشتباهِ واقعی را می‌بندند. متقارن جلوی این را می‌گیرد که a خودش را با b برابر بداند ولی b نه — که با انواع مختلط رخ می‌دهد. متعدی یک زنجیرهٔ برابری را سالم نگه می‌دارد. سازگار یعنی اگر روی داده‌ای که در equals استفاده نمی‌شود تکیه کنی (مثلاً وضعیت شبکه)، نتیجه‌ات بی‌ثبات می‌شود. و غیر null یعنی هرگز نباید equals تو با ورودی null بترکد؛ باید آرام false بدهد.

hashCode — سه قانون

۱. سازگار: یک شیء، در طولِ یک اجرا همان مقدارِ هش را برمی‌گرداند (مگر حالتِ مرتبط با equals تغییر کند). ۲. قانونی که اهمیت دارد: اگر a.equals(b) آنگاه باید a.hashCode() == b.hashCode(). الزامی. ۳. اشیای نابرابر می‌توانند هشِ یکسان داشته باشند (برخورد یا collision مجاز است)، ولی هش‌های متمایز کارایی را بهتر می‌کنند.

نامتقارنیِ طلایی

تمامِ بازی در همین عدمِ‌تقارن است: اشیای برابر باید هشِ برابر داشته باشند؛ اما اشیای نابرابر مجازند هشِ یکسان داشته باشند. یعنی جهت یک‌طرفه است. اگر این را جابه‌جا بفهمی همه‌چیز می‌شکند. قانون ۲ را بشکن، آنگاه دو شیءِ برابر در دو سطلِ متفاوت می‌افتند — یک HashSet با خوشحالی دو عنصرِ «برابر» را کنار هم نگه می‌دارد، و map.get(key) برای کلیدی که همین الان درج کردی null برمی‌گرداند.

چرا برخورد مجاز است؟ چون فضای هش (یک int، حدود چهار میلیارد حالت) محدود است ولی تعداد اشیای ممکن نامحدود. پس ناگزیر گاهی دو شیءِ متفاوت هشِ یکسان می‌گیرند. HashMap این را می‌پذیرد: در یک سطل ممکن است چند ورودی باشند و آنجا با equals تفکیکشان می‌کند. برخورد فقط کندی می‌آورد، نه غلطی. اما نقضِ قانون ۲ — برابرها با هشِ متفاوت — غلطی می‌آورد.

چطور شکستنِ آن یک HashMap را خراب می‌کند

// خرابکاری: equals را override می‌کند ولی hashCode را نه
class Point {
    final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }

    @Override public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }
    // hashCode() از Object به ارث می‌رسد → مبتنی بر هویت
}

Map<Point, String> map = new HashMap<>();
map.put(new Point(1, 2), "A");
System.out.println(map.get(new Point(1, 2))); // null چاپ می‌کند!

بیا خط‌به‌خط ببینیم چرا فاجعه رخ می‌دهد. دو نمونهٔ Point(1,2) از نظر .equals برابرند (چون x و y یکی‌اند). ولی چون hashCode را override نکردی، هرکدام هشِ هویتی پیش‌فرض می‌گیرند که از آدرسِ حافظه می‌آید — و دو new جدا، دو آدرسِ جدا، پس دو هشِ متفاوت. کتابدار (HashMap) نمونهٔ اول را در قفسهٔ ۷ (مثلاً) گذاشت. حالا که با نمونهٔ دوم سراغش می‌آیی، فرمول را روی نمونهٔ دوم اجرا می‌کند و می‌رسد به قفسهٔ ۳۱. قفسهٔ ۳۱ خالی است. get هرگز حتی به مقایسهٔ equals نمی‌رسد؛ همان اول در قفسهٔ اشتباه دست‌خالی برمی‌گردد. این رایج‌ترین باگِ دادهٔ جاواست.

خرابیِ دوگانه هم وجود دارد

حالتِ برعکس — hashCode را override کنی ولی equals را نه — ظریف‌تر است. حالا هر دو نمونه به قفسهٔ درست می‌روند (هش یکی است)، ولی وقتی HashMap داخلِ قفسه با equals می‌خواهد تطبیقشان دهد، equalsِ ارث‌رسیده دوباره به هویت برمی‌گردد (آدرس‌ها فرق دارند) و می‌گوید «نه، اینها یکی نیستند». پس باز هم کلیدت را با مقدار پیدا نمی‌کنی. درس: این دو همیشه باید با هم و روی همان فیلدها override شوند.

پیاده‌سازیِ متعارف (canonical)

حالا نسخهٔ درست را ببین و هر تصمیمِ مهندسی را بشکافیم:

public final class Money {
    private final long amountMinor;   // سنت را ذخیره کن، هرگز double
    private final String currency;

    public Money(long amountMinor, String currency) {
        this.amountMinor = amountMinor;
        this.currency = Objects.requireNonNull(currency);
    }

    @Override public boolean equals(Object o) {
        if (this == o) return true;                    // مسیر سریع
        if (!(o instanceof Money m)) return false;     // بررسی نوع و null
        return amountMinor == m.amountMinor
            && currency.equals(m.currency);
    }

    @Override public int hashCode() {
        return Objects.hash(amountMinor, currency);    // فیلدها را ترکیب می‌کند
    }
}

چند نکتهٔ کاربردی که این کد را «سنیور» می‌کند:

  • if (this == o) return true; یک مسیرِ سریع (fast path) است: اگر همان شیء را با خودش مقایسه می‌کنی، دیگر لازم نیست فیلدها را وارسی کنی. در حلقه‌های داغ صرفه‌جویی می‌کند.
  • if (!(o instanceof Money m)) return false; هم‌زمان دو کار می‌کند: هم چک می‌کند نوع درست باشد، هم چک می‌کند o برابر null نباشد (چون null instanceof هرچیز همیشه false است). این همان الگوی «pattern matching for instanceof» است که از جاوا ۱۶ به بعد به‌طور رسمی داری؛ متغیرِ m مستقیم از cast درمی‌آید.
`instanceof` در برابر `getClass()`

اینکه در equals نوع را با instanceof چک کنی یا با getClass()، یک انتخابِ ظریف با تبعات جدی است. instanceof پیش‌فرضِ عملی است (و اگر می‌خواهی زیرکلاس‌ها با نمونه‌های ابرکلاس برابر باشند، الزامی است)، ولی اگر یک زیرکلاس فیلدِ تازه‌ای اضافه کند که در برابری دخیل باشد، instanceof تقارن را می‌شکند (پدر می‌گوید با پسر برابرم، پسر می‌گوید نه). getClass() تقارن را حفظ می‌کند ولی جایگزینیِ لیسکوف (Liskov) را با proxyها و زیرکلاس‌ها می‌شکند — مثلاً Hibernate یک proxy می‌سازد که کلاسش فرق دارد و دیگر با entity واقعی برابر نیست. توصیهٔ Effective Java: ترکیب به‌جای وراثت (composition over inheritance) را ترجیح بده تا کلاً از این معما رها شوی؛ برای حالتِ رایجِ final/برگ از instanceof استفاده کن.

دو نکتهٔ دیگر:

  • در equals و hashCode همان فیلدها را استفاده کن. فیلدی که در یکی هست و در دیگری نیست، مستقیماً قانونِ طلایی را نقض می‌کند (ممکن است دو شیء equals باشند ولی چون فیلدِ حاضر در equals در hashCode نیست، هشِ متفاوت بگیرند).
  • Objects.hash(...) راحت است ولی هر آرگومان را box می‌کند و یک آرایهٔ varargs تخصیص می‌دهد. برای یک POJO معمولی عالی است؛ ولی در hashCodeِ داغِ درونِ یک حلقهٔ تنگ، این تخصیص‌ها جمع می‌شوند و آنجا بهتر است دستی بنویسی (که همین الان می‌بینیم). و برای فیلدهای nullable از Objects.equals(a, b) استفاده کن که خودش nullها را مدیریت می‌کند و NPE نمی‌دهد.

اصطلاحِ 31 * result

@Override public int hashCode() {
    int result = Long.hashCode(amountMinor);
    result = 31 * result + currency.hashCode();
    return result;
}

این الگوی دستیِ کلاسیک است: با هشِ فیلد اول شروع کن، بعد برای هر فیلد بعدی نتیجه را در ۳۱ ضرب کن و هشِ فیلدِ تازه را جمع کن. چرا دقیقاً ۳۱؟

۳۱ عددی اول (prime) و فرد (odd) است. اول‌بودن توزیعِ خوبِ هش را کمک می‌کند (هش‌ها یکنواخت‌تر روی سطل‌ها پخش می‌شوند و برخورد کمتر می‌شود). فردبودن یک ترفندِ عملکردی دارد: 31 * i دقیقاً برابرِ (i << 5) - i است (یعنی «۳۲ برابر منهای یک برابر»)، پس JVM آن را به یک shift و یک تفریق تبدیل می‌کند که از ضربِ واقعی ارزان‌تر است.

چرا ضریب باید فرد باشد

اگر ضریب زوج باشد، هر ضرب مثل «ضرب در ۲ به توانِ چیزی» عمل می‌کند، یعنی بیت‌ها را به چپ می‌راند. چون int فقط ۳۲ بیت دارد، بیت‌هایی که از لبهٔ بالا بیرون می‌روند برای همیشه گم می‌شوند. با یک ضریبِ زوج، بعد از چند فیلد بخشی از اطلاعاتِ فیلدهای اولیه دود می‌شود و هش‌ها خوشه‌ای (clustered) می‌شوند. ضریبِ فرد این نشتِ اطلاعات را ندارد.

کلیدهای تغییرپذیر (mutable) — قاتلِ خاموش

حتی یک equals/hashCode کاملاً درست هم اگر کلید را بعد از درج تغییر دهی شکست می‌خورد:

Set<List<Integer>> set = new HashSet<>();
List<Integer> key = new ArrayList<>(List.of(1, 2));
set.add(key);
key.add(3);                          // hashCode کلید همین الان عوض شد
System.out.println(set.contains(key)); // false — حتی همان شیء دقیق «گم» شده

اینجا حتی یک شیءِ جدید هم نساختی — همان key را می‌پرسی و باز false می‌گیری! چرا؟ چون List.hashCode از محتوای لیست حساب می‌شود. وقتی add(3) کردی، محتوا از [1,2] به [1,2,3] رفت و هش عوض شد. ولی عنصر هنوز فیزیکی در سطلِ هشِ قدیمی نشسته. حالا contains هشِ جدید را حساب می‌کند و سطلِ متفاوتی را می‌گردد و آنجا چیزی نمی‌بیند.

کلیدهای هش باید عملاً تغییرناپذیر باشند

هر چیزی که به‌عنوان کلید در HashMap/HashSet می‌گذاری باید بعد از درج ثابت بماند (حداقل فیلدهایی که در equals/hashCode دخیل‌اند). این یکی از قوی‌ترین دلایلِ ترجیحِ کلاس‌های immutable — String، recordها، wrapperهای عددی — برای کلیدهاست. اگر کلیدت را نمی‌توانی تغییر بدهی، هرگز یتیم نمی‌شود.

Comparable، Comparator و سازگاری با equals

تا اینجا از برابری گفتیم. حالا برویم سراغ ترتیب. اینجا هم یک جفت مفهوم داری که مثل equals/hashCode باید هماهنگ بمانند.

Comparable<T> یک ترتیبِ طبیعی (natural ordering) را از طریقِ متد compareTo تعریف می‌کند — یعنی «راهِ پیش‌فرضِ مرتب‌کردنِ خودم» که داخلِ خودِ کلاس نوشته می‌شود. Comparator<T> در مقابل یک ترتیبِ بیرونی و قابل‌تعویض است: بدونِ دست‌زدن به کلاس، از بیرون می‌گویی «این‌بار این‌طور مرتب کن».

کتاب‌های کتابخانه

Comparable مثل این است که هر کتاب یک شمارهٔ رده روی عطفش دارد — ترتیبِ ذاتیِ خودش. Comparator مثل کتابداری‌ست که می‌گوید «امروز نه بر اساس شماره، بلکه بر اساس رنگِ جلد بچین». کتاب عوض نمی‌شود؛ فقط یک قاعدهٔ مرتب‌سازیِ بیرونی به آن اعمال می‌شود، و فردا می‌توانی قاعدهٔ دیگری بدهی.

record Person(String name, int age) {}

// Comparator: مرتب‌سازی بر اساس سن سپس نام — بدون دست‌زدن به کلاس
Comparator<Person> byAgeThenName =
    Comparator.comparingInt(Person::age)
              .thenComparing(Person::name);

compareTo عددِ منفی/صفر/مثبت برمی‌گرداند: منفی یعنی «من کوچک‌ترم»، صفر یعنی «برابریم»، مثبت یعنی «من بزرگ‌ترم». قراردادش آینهٔ equals است: باید یک ترتیبِ کلی (total order) بسازد (پادمتقارن و متعدی) و رابطهٔ signum(x.compareTo(y)) == -signum(y.compareTo(x)) را رعایت کند — یعنی اگر x از y بزرگ‌تر است، حتماً y از x کوچک‌تر باشد. (تابع signum فقط علامت را برمی‌گرداند: ۱-، ۰ یا ۱+.)

سازگاری با equals — دامِ BigDecimal

مستندات به‌شدت توصیه می‌کنند ولی الزام نمی‌کنند که (x.compareTo(y) == 0) == x.equals(y) — یعنی «اگر ترتیب می‌گوید برابرند، برابری هم باید بگوید برابرند». ناقضِ معروفِ این توصیه، BigDecimal است:

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b));       // false — scale متفاوت است (۱ در برابر ۲)
System.out.println(a.compareTo(b));    // 0 — از نظر عددی برابر

Set<BigDecimal> hashSet = new HashSet<>(List.of(a, b));
Set<BigDecimal> treeSet = new TreeSet<>(List.of(a, b));
System.out.println(hashSet.size());    // 2 — از equals استفاده می‌کند
System.out.println(treeSet.size());    // 1 — از compareTo استفاده می‌کند!

"1.0" و "1.00" از نظرِ عددی یک عددند، ولی BigDecimal علاوه بر مقدار، scale (تعداد رقم بعد از اعشار) را هم نگه می‌دارد: اولی scale=۱، دومی scale=۲. متدِ equals هم مقدار و هم scale را چک می‌کند، پس آن‌ها را نابرابر می‌داند. اما compareTo فقط مقدارِ عددی را می‌بیند و می‌گوید برابرند.

سطل‌ها با equals، درخت‌ها با compareTo

اینجا نکتهٔ سنیوری‌ست: HashSet/HashMap عضویت را با equals (و hashCode) تعیین می‌کنند، ولی TreeSet/TreeMap عضویت را با compareTo تعیین می‌کنند. برای همین hashSet.size() می‌شود ۲ (هر دو نگه داشته می‌شوند) ولی treeSet.size() می‌شود ۱ (دومی چون compareToاش با اولی صفر است، «تکراری» تلقی و انداخته می‌شود). درسِ کلیدی: وقتی یک Comparator به یک TreeMap می‌دهی، در واقع داری برابری را برای آن ساختار بازتعریف می‌کنی. اگر comparator تو با equals ناسازگار باشد، مجموعه‌های مرتب بی‌صدا عناصری را «گم» می‌کنند که یک HashSet نگه می‌داشت.

String: تغییرناپذیری، استخر و هویت

String تغییرناپذیر (immutable) است — یعنی وقتی یک رشته ساخته شد، محتوایش هرگز عوض نمی‌شود. دادهٔ پشتیبانش (byte[] value از جاوا ۹ به بعد با «رشته‌های فشرده» یا compact strings؛ پیش از آن char[]) هم final است و هم هرگز mutate نمی‌شود. وقتی s.toUpperCase() می‌زنی، رشتهٔ قبلی دست‌نخورده می‌ماند و یک رشتهٔ جدید برمی‌گردد.

کارت شناساییِ لمینت‌شده

یک رشتهٔ جاوا مثل کارتِ شناساییِ لمینت‌شده است: هر وقت بخواهی چیزی رویش تغییر دهی، نمی‌توانی خطش بزنی؛ باید کارتِ کاملاً تازه‌ای چاپ کنی. کارتِ قدیمی همان‌طور که بود می‌ماند. همین «چاپِ تازه به‌جای خط‌زدن» است که به رشته چند ابرقدرت می‌دهد: چون هیچ‌کس نمی‌تواند زیر پای دیگری محتوایش را عوض کند، می‌توانی بی‌ترس بینِ نخ‌ها (thread) به اشتراکش بگذاری، بی‌خیال به‌عنوان کلیدِ HashMap استفاده‌اش کنی (هشش هرگز عوض نمی‌شود پس یتیم نمی‌شود)، و استخر بتواند نسخه‌ها را به اشتراک بگذارد.

استخر رشته و ==

مقادیرِ ثابتِ رشته (string literal) — یعنی هر رشته‌ای که مستقیم در کد می‌نویسی مثل "hello" — به‌طور خودکار در یک استخر (pool) در heap اینترن (intern) می‌شوند. «اینترن» یعنی جاوا یک نسخهٔ متعارفِ واحد از هر رشتهٔ متمایز نگه می‌دارد، و همهٔ literalهای یکسان همان یک شیء را به اشتراک می‌گذارند:

String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);          // true — همان شیء از استخر

String s3 = new String("hello");
System.out.println(s1 == s3);          // false — new() یک شیء تازهٔ heap می‌سازد
System.out.println(s1.equals(s3));     // true — همان مقدار
System.out.println(s1 == s3.intern()); // true — intern() مرجع استخرشده را می‌دهد

s1 و s2 هر دو به همان شیءِ داخلِ استخر اشاره می‌کنند، پس == می‌شود true. ولی new String("hello") صریحاً می‌گوید «برایم یک شیءِ تازه بساز» و از استخر عبور نمی‌کند، پس s3 آدرسِ متفاوتی دارد و s1 == s3 می‌شود false — هرچند محتوایشان یکی‌ست و .equals این را تأیید می‌کند. متدِ intern() هم می‌گوید «مرجعِ متعارفِ استخرشدهٔ این رشته را به من بده»، که دوباره همان s1 است.

قانون: هرگز رشته‌ها را با `==` مقایسه نکن

همیشه از .equals (یا equalsIgnoreCase) استفاده کن. هر باری که == روی رشته‌ها تصادفاً true می‌شود، صرفاً یک اتفاقِ ناشی از interning است، نه یک تضمین. کدی که در تستِ واحد با literalها «کار می‌کند»، در پروداکشن روی رشته‌های ساخته‌شده در زمانِ اجرا (از فایل، شبکه، ورودی کاربر) بی‌صدا می‌شکند.

تاشدنِ ثابت در زمانِ کامپایل (constant folding)

اینجا رفتار یک لایه ظریف‌تر می‌شود:

String a = "hel" + "lo";               // در زمان کامپایل تا می‌شود → literal "hello"
System.out.println(a == "hello");      // true

String part = "hel";
String b = part + "lo";                // الحاق در زمان اجرا → شیء جدید
System.out.println(b == "hello");      // false

final String cpart = "hel";            // ثابتِ زمان کامپایل
String c = cpart + "lo";               // تا می‌شود → true
System.out.println(c == "hello");      // true

نکته این است: یک + که روی ثابت‌های زمانِ کامپایل انجام شود — یعنی literalها، متغیرهای final که با یک ثابت مقداردهی شده‌اند، و static finalها — توسطِ کامپایلر (javac) پیش از اجرا محاسبه و در یک literalِ واحد تا می‌شود (folded) که بعد اینترن می‌شود. برای همین "hel" + "lo" عملاً همان "hello"ِ داخلِ استخر است.

ولی یک + که شاملِ یک مقدارِ زمانِ اجرا باشد (مثل متغیرِ غیر-final یعنی part)، در زمانِ اجرا یک شیءِ کاملاً تازه می‌سازد که در استخر نیست. جادوی خطِ سوم اینجاست: cpart را final کردی و با یک ثابت مقداردهی کردی، پس کامپایلر می‌داند مقدارش قطعی است و دوباره تا می‌کند.

الحاق رشته از جاوا ۹ به بعد

از جاوا ۹، الحاقِ رشته در زمانِ اجرا دیگر لزوماً به زنجیره‌های صریحِ StringBuilder کامپایل نمی‌شود؛ به‌جایش به یک دستورِ invokedynamic که به StringConcatFactory می‌رسد کامپایل می‌شود. یعنی خودِ JVM در زمانِ اجرا تصمیم می‌گیرد بهترین استراتژیِ الحاق چیست. این جزئیاتِ پیاده‌سازی است، ولی خوب است بدانی چرا bytecode امروزِ یک a + b با bytecode جاوا ۸ فرق دارد.

intern() — با احتیاط استفاده کن

intern() نمونهٔ متعارفِ استخرشده را برمی‌گرداند و امکانِ ==ِ بعدی را می‌دهد. وقتی تعدادِ عظیمی رشتهٔ تکراری داری، می‌تواند حافظه صرفه‌جویی کند (چون همه به یک نسخه اشاره می‌کنند به‌جای هزاران کپی). ولی استخر یک جدولِ هشِ اندازه‌ثابت است (قابل‌تنظیم با فلگِ -XX:StringTableSize)، و اینترن‌کردنِ مجموعه‌های عظیمِ متمایز فقط فشار و CPU اضافه می‌کند بدون سود. پیش‌فرضِ مدرن: اینترن نکن؛ بگذار استخر خودش literalها را مدیریت کند، و برای مقایسه از equals استفاده کن.

StringBuilder — چرا و چطور

چون رشته‌ها تغییرناپذیرند، s = s + x در یک حلقه از مرتبهٔ O(n²) است: هر تکرار یک رشتهٔ کاملاً جدید تخصیص می‌دهد و همهٔ کاراکترهای قبلی را در آن کپی می‌کند. پس در تکرارِ صدم، صد کاراکترِ قبلی دوباره کپی می‌شوند، در تکرارِ هزارم، هزار کاراکتر — و مجموع می‌شود مربعی.

// بد: مرتبه دو، n رشتهٔ میانی تخصیص می‌دهد
String r = "";
for (String w : words) r += w;

// خوب: یک بافر، مستهلک O(n)
StringBuilder sb = new StringBuilder(words.size() * 8); // پیش‌اندازه برای پرهیز از resize
for (String w : words) sb.append(w);
String r = sb.toString();

StringBuilder یک بافرِ تغییرپذیر است: به‌جای ساختنِ رشتهٔ تازه در هر مرحله، در همان بافر append می‌کند و فقط گاهی که پر شد آن را بزرگ می‌کند. با new StringBuilder(words.size() * 8) بافر را از اول به اندازهٔ حدسی بزرگ می‌سازی تا از resizeهای مکرر پرهیز شود.

StringBuilder یا StringBuffer؟

StringBuilder هم‌زمان‌سازی‌شده (synchronized) نیست — سریع و برای کارِ تک‌نخ. StringBuffer نسخهٔ synchronizedِ همان است — قدیمی و به‌ندرت لازم (فقط اگر واقعاً یک بافر را بینِ چند نخ به اشتراک می‌گذاری، که کارِ نادری‌ست). و یادت باشد: یک عبارتِ واحدِ a + b + c اصلاً مشکلی ندارد؛ کامپایلر خودش آن را در یک عملیات ادغام می‌کند. فقط حلقه‌های دستی به builderِ صریح نیاز دارند.

Autoboxing، Unboxing و کش Integer

Autoboxing یعنی جاوا خودکار یک int را به Integer تبدیل می‌کند (پشتِ صحنه با Integer.valueOf). Unboxing عکسِ آن است: Integer به int (با intValue). این خودکاربودن خیلی راحت است — می‌توانی int را مستقیم در List<Integer> بریزی — ولی یک میدانِ مین هم هست.

کش: بازهٔ -128..127

اینجا نکتهٔ کلیدی: Integer.valueOf برای مقادیرِ -128 تا 127 (شاملِ هر دو سر) نمونه‌ها را کش می‌کند. یعنی boxهای هم‌مقدار در این بازه همان شیءِ واحداند؛ ولی بیرونِ این بازه، هر box یک شیءِ تازه است.

Integer a = 127, b = 127;
System.out.println(a == b);   // true — هر دو از کش

Integer c = 128, d = 128;
System.out.println(c == d);   // false — بیرون از کش، اشیای متمایز
System.out.println(c.equals(d)); // true — برابری مقدار
انبارِ اعدادِ کوچک

تصور کن جاوا یک قفسهٔ کوچک دارد که در آن یک نسخهٔ آمادهٔ هر عددِ «پرکاربرد» از ۱۲۸- تا ۱۲۷ را نگه می‌دارد. وقتی Integer برای یکی از این اعدادِ کوچک می‌خواهی، همان نسخهٔ آماده را به تو می‌دهد — پس دو بار درخواستِ ۱۲۷ همان یک شیء را می‌دهد. ولی ۱۲۸ در قفسه نیست، پس هر بار مجبور است یکی تازه بسازد. برای همین 127 == 127 می‌شود true ولی 128 == 128 می‌شود false. این چیزی نیست که بخواهی به آن تکیه کنی؛ فقط باید بدانی هست.

کش برای چه نوع‌هایی وجود دارد؟ Boolean، Byte، Short (بازهٔ -128..127Character (بازهٔ 0..127Integer (بازهٔ -128..127، که کرانِ بالایش را می‌توانی با فلگِ -XX:AutoBoxCacheMax=<n> یا خصوصیتِ java.lang.Integer.IntegerCache.high بالا ببری) و Long (بازهٔ -128..127، ولی این یکی غیرقابلِ تنظیم). و مهم: Float و Double هیچ کشی ندارندDouble d1 = 1.0; Double d2 = 1.0; d1 == d2 همیشه false است.

هرگز wrapperها را با `==` مقایسه نکن

Integer == Integer مقایسهٔ مرجع است، پس نتیجه‌اش به کش وابسته می‌شود و همان‌طور که دیدی برای اعدادِ بزرگ‌تر از ۱۲۷ بی‌صدا false می‌دهد حتی اگر مقدارها یکی باشند. همیشه از .equals استفاده کن یا هر دو را به primitive تبدیل کن. این یکی از بدترین باگ‌هاست چون تستِ تو با IDهای کوچک پاس می‌شود و پروداکشن در ۱۲۸ می‌ترکد.

==ِ ترکیبی: یک primitive باعثِ unboxing می‌شود

اگر یکی از دو عملوندِ == یک primitive باشد، دیگری هم unbox می‌شود و مقایسه دیگر مقایسهٔ مرجع نیست، بلکه عددی است — و کش اصلاً درگیر نمی‌شود:

Integer i = 1000;
int j = 1000;
System.out.println(i == j);   // true — i به int تبدیل (unbox) و مقایسه عددی می‌شود

اینجا j یک intِ خام است. جاوا نمی‌تواند یک int و یک Integer را مستقیم با == مرجعی مقایسه کند، پس مجبور است i را unbox کند تا هر دو int شوند، بعد مقدارها را مقایسه کند. ۱۰۰۰ برابرِ ۱۰۰۰ است، پس true — با اینکه ۱۰۰۰ کاملاً بیرونِ بازهٔ کش است!

کدِ هم‌شکل، معنای متضاد

دقت کن چه دامِ زیبایی‌ست: Integer == Integer مقایسهٔ مرجع است (وابسته به کش)، ولی Integer == int مقایسهٔ مقدار است (همیشه درست). دو خطِ تقریباً یکسان، دو معنای کاملاً متفاوت. تنها فرقشان این است که یک طرفشان primitive شده یا نه.

NullPointerException از unboxing

Map<String, Integer> counts = new HashMap<>();
int n = counts.get("missing");  // get مقدار null برمی‌گرداند → unbox خودکار → NPE

counts.get("missing") چون کلید نیست null برمی‌گرداند — و null از نوعِ Integer است. حالا می‌خواهی آن را در یک int (primitive) بریزی، پس جاوا سعی می‌کند unbox کند، یعنی روی null متدِ intValue() را صدا بزند — و همان‌جا NullPointerException پرت می‌شود، در خطی که هیچ nullی در آن نوشته نشده! این یکی از غافلگیرکننده‌ترین کرش‌های پروداکشن است.

چطور محافظت کنی

از getOrDefault("missing", 0) استفاده کن که اگر کلید نبود صفر می‌دهد، یا از Optional، یا اصلاً نوعِ متغیر را Integer (مرجع) نگه‌دار تا اگر null شد لااقل کرش در خطِ درست و واضح رخ دهد نه در یک unboxِ پنهان.

کارایی: boxing در مسیرهای داغ

// کند: Long تقریباً هر تکرار box می‌شود → میلیون‌ها تخصیص
Long sum = 0L;
for (long i = 0; i < 100_000_000L; i++) sum += i; // هر بار unbox، جمع، box دوباره

// سریع: primitive، صفر تخصیص
long sum2 = 0L;
for (long i = 0; i < 100_000_000L; i++) sum2 += i;

فرقِ این دو حلقه فقط یک حرفِ بزرگ است: Long در برابرِ long. ولی در نسخهٔ کند، هر بار که sum += i اجرا می‌شود، جاوا باید sum را unbox کند، جمع را انجام دهد، و نتیجه را دوباره در یک Longِ تازه box کند. صد میلیون تکرار یعنی صد میلیون تخصیصِ شیء و فشارِ عظیم روی garbage collector. نسخهٔ primitive صفر تخصیص دارد.

در کارِ عددیِ حجیم primitive بمان

وقتی boxing غالب می‌شود، از مجموعه‌ها و streamهای تخصصیِ primitive استفاده کن: IntStream، LongStream، یا کتابخانه‌هایی مثل Eclipse Collections و fastutil. جنریک‌ها همیشه box می‌کنند (List<Integer> واقعاً لیستی از شیءهای Integer است، نه intها)، برای همین int[] در کارِ عددیِ حجیم به‌مراتب از List<Integer> سریع‌تر و کم‌حافظه‌تر است.

طراحیِ کلاس‌های تغییرناپذیر (immutable)

دیدیم که تغییرناپذیری چه ابرقدرت‌هایی به String داد. حالا بیاموزیم چطور خودمان یک کلاسِ immutable بسازیم. تغییرناپذیری، thread-safety، هشِ امن، اشتراکِ امن و سازگاری با کش را می‌خرد. دستورِ پخت (از Effective Java) چهار قدم دارد:

۱. کلاس را final کن (یا سازندهٔ private + متدهای factory) تا کسی نتواند زیرکلاس بسازد و از آن راه تغییرپذیرش کند. ۲. همهٔ فیلدها را private final کن. ۳. هیچ mutator (متدِ تغییردهنده، مثل setter) نگذار. ۴. ورودی‌های تغییرپذیر را هنگامِ ورود و حالتِ تغییرپذیر را هنگامِ خروج کپیِ دفاعی (defensive copy) کن.

public final class Period {
    private final Date start;   // Date تغییرپذیر است — خطر
    private final Date end;

    public Period(Date start, Date end) {
        // کپی ورودی: فراخواننده بعداً نمی‌تواند درون ما را تغییر دهد
        this.start = new Date(start.getTime());
        this.end   = new Date(end.getTime());
        if (this.start.after(this.end))
            throw new IllegalArgumentException("start after end");
        // کپی‌ها را اعتبارسنجی کن، نه آرگومان‌ها (ایمنی در برابر TOCTOU)
    }

    public Date start() { return new Date(start.getTime()); } // کپی خروجی
    public Date end()   { return new Date(end.getTime());   }
}
کلیدِ خانه‌ات را کپی بده، نه اصلش را

Date در جاوا تغییرپذیر است — یعنی هر کسی که یک Date در دست دارد می‌تواند تاریخش را عوض کند. اگر مستقیم مرجعِ Dateِ فراخواننده را نگه داری، مثل این است که کلیدِ اصلیِ خانه‌ات را به مهمان بدهی: بعداً می‌تواند برگردد و بی‌اجازه چیدمانِ داخل را عوض کند. به‌جایش هنگامِ ورود یک کپی می‌گیری (new Date(...)) و هنگامِ خروج هم کپی می‌دهی، نه اصل. حالا هرچه بیرون با کپی‌ها بکنند، درونِ تو دست‌نخورده و invariantها (مثلاً «start قبل از end است») سالم می‌مانند.

نکتهٔ ظریفِ داخلِ سازنده: اعتبارسنجی را روی کپی‌ها انجام می‌دهی نه روی آرگومان‌های اصلی. این جلوی حملهٔ TOCTOU (time-of-check to time-of-use — «بینِ لحظهٔ بررسی و لحظهٔ استفاده») را می‌گیرد: اگر آرگومانِ اصل را چک می‌کردی، یک نخِ دیگر می‌توانست دقیقاً بینِ چکِ تو و کپیِ تو مقدارش را عوض کند. با چک‌کردنِ کپی، چیزی که چک می‌کنی همان چیزی‌ست که نگه می‌داری. و بهترین کار: از اول نوع‌های فیلدِ تغییرناپذیر انتخاب کن — java.time.Instant، LocalDate، List.copyOf(...) — تا اصلاً نیازی به کپی نباشد.

Record — حاملِ داده به‌درستی

recordهای جاوا ۱۶ به بعد، تجمیع‌های دادهٔ شفاف و تغییرناپذیراند. تو فقط مؤلفه‌ها را اعلام می‌کنی و کامپایلر خودش سازندهٔ متعارف، فیلدهای private final، accessorها، و — مهم‌تر از همه — equals، hashCode و toStringِ درست‌ازنظرِ قرارداد را از رویِ همهٔ مؤلفه‌ها تولید می‌کند.

public record Money(long amountMinor, String currency) {
    // سازندهٔ فشرده (compact): اعتبارسنجی/نرمال‌سازی، بدون نیاز به انتساب فیلد
    public Money {
        Objects.requireNonNull(currency);
        if (amountMinor < 0) throw new IllegalArgumentException("negative");
        currency = currency.toUpperCase(); // انتساب دوباره به پارامتر، فیلد را نرمال می‌کند
    }
}

توجه کن سازندهٔ فشرده (compact) چقدر تمیز است: نه لیستِ پارامتر می‌نویسی نه this.currency = currency؛ فقط اعتبارسنجی و نرمال‌سازی می‌کنی و کامپایلر بقیه را می‌بندد. حتی وقتی به پارامترِ currency دوباره مقدار می‌دهی (toUpperCase)، همان مقدارِ نرمال‌شده در فیلدِ نهایی می‌نشیند.

بزرگ‌ترین دلیلِ محبوبیتِ record

recordها یک equals/hashCodeِ درست را رایگان می‌دهند — دقیقاً همان قراردادی که تمامِ این درس دربارهٔ سختیِ رعایتش بود. برای همین record بهترین انتخاب برای کلیدهای map، DTOها و value objectهاست: دیگر خطر «equals را نوشتم ولی hashCode را فراموش کردم» وجود ندارد.

اما چند هشدار که سنیورها می‌دانند:

  • recordها کم‌عمق (shallow) تغییرناپذیرند: یک record Holder(List<String> items) هنوز آن Listِ تغییرپذیر را به بیرون افشا می‌کند. برای تغییرناپذیریِ واقعی باید در سازندهٔ فشرده کپی کنی: items = List.copyOf(items).
  • equalsِ تولیدشده از همهٔ مؤلفه‌ها استفاده می‌کند. ولی اگر مؤلفه‌ای یک آرایه باشد، equals از هویتِ آرایه استفاده می‌کند نه محتوایش (چون آرایه‌ها خودشان equals را override نمی‌کنند). پس record + آرایه یک باگِ ظریف است: دو رکورد با محتوای آرایه‌ایِ یکسان، equalsشان false می‌شود.
  • recordها به‌طورِ ضمنی finalاند، نمی‌توانند کلاسی را extend کنند، ولی می‌توانند interface پیاده کنند. مناسبِ دادهاند، نه سلسله‌مراتب‌های پُررفتار.

دام‌ها و نکاتِ رایج

یک فهرستِ فشرده برای مرور — هرکدام را در درس بازکردیم:

  • map.get(new Point(1,2)) مقدار null برمی‌گرداند چون equals را override کردی ولی hashCode را نه.
  • تغییرِ کلید بعد از قراردادن در HashSet/HashMap آن را یتیم می‌کند.
  • Integer == Integer فقط در بازهٔ -128..127 مقدار true است؛ تست‌های یکپارچگی با IDهای کوچک پاس می‌شوند و پروداکشن در ۱۲۸ می‌شکند.
  • int x = map.get(k) وقتی کلید غایب است NPE می‌دهد.
  • str1 == str2 در تست‌های واحد (literalهای اینترن‌شده) «کار می‌کند» و روی رشته‌های ساخته‌شده در زمانِ اجرا شکست می‌خورد.
  • Double d = 1.0; d == 1.0 — literalِ 1.0 یک primitive است، پس d unbox می‌شود؛ این true است. ولی Double == Double مقدار false است.
  • BigDecimal در HashSet در برابرِ TreeSet برای 1.0 و 1.00 اندازه‌های متفاوت می‌دهد.
  • Objects.hash() تخصیص می‌دهد — در hashCodeِ یک حلقهٔ داغ فراخوانی‌اش نکن.
  • override کردنِ equals با نوعِ پارامترِ اشتباه: public boolean equals(Point p) به‌جای override، Object.equals(Object) را overload می‌کند — مجموعه‌ها همچنان نسخهٔ Object را صدا می‌زنند. همیشه @Override بگذار و Object بگیر.

بهترین شیوه‌ها

  • همیشه equals و hashCode را با هم، روی همان فیلدها override کن و همیشه @Override بگذار.
  • برای value objectها و کلیدهای map از record استفاده کن — قراردادِ رایگان و درست.
  • هرگز wrapperهای box‌شده یا رشته‌ها را با == مقایسه نکن؛ از .equals استفاده کن.
  • کلیدهای هش را immutable نگه دار.
  • در مسیرهای داغِ عددی از نوع‌های primitive و streamهای primitive استفاده کن.
  • ورودی/خروجی‌های تغییرپذیر را کپیِ دفاعی کن، یا از ابتدا از نوع‌های immutable استفاده کن.
  • Comparator را با equals سازگار نگه دار، مگر آنکه عمداً خلافش را مستند کنی.

سوالات مصاحبه

اینجا همان سوال‌هایی‌ست که در مصاحبهٔ سنیور می‌آید. هرکدام را با پاسخِ کامل بخوان و بلند برای خودت بگو.

۱. قرارداد hashCode را بیان کن و دقیقاً توضیح بده نقض آن چطور یک HashMap را می‌شکند.

اشیای برابر باید هشِ برابر بدهند؛ نابرابرها ممکن است برخورد کنند؛ و نتایج باید در طولِ یک اجرا سازگار باشند. اگر دو شیءِ equals هش‌های متفاوت داشته باشند، HashMap آن‌ها را به سطل‌های متفاوت مسیریابی می‌کند، پس get هرگز حتی به مقایسهٔ equals نمی‌رسد و برای کلیدی که موجود است null برمی‌گرداند — و یک HashSet می‌تواند دو عنصرِ «برابر» را هم‌زمان نگه دارد.

۲. (دام) `Integer a = 127, b = 127, c = 128, d = 128;` — `a==b` و `c==d` چه چاپ می‌کنند و چرا؟

true و false. متدِ Integer.valueOf بازهٔ -128..127 را کش می‌کند، پس a و b هر دو همان شیءِ کش‌شده‌اند و ==شان true می‌شود. ولی 128 بیرونِ کش است، پس c و d دو شیءِ متمایزند و ==شان false. برای مقایسهٔ مقدار از .equals استفاده کن.

۳. (دام) چه چاپ می‌کند؟
Integer i = 1000; int j = 1000;
System.out.println(i == j);

true. چون j یک primitive است، جاوا i را unbox می‌کند و مقایسه عددی می‌شود — کش کاملاً بی‌ربط است. این را با Integer == Integer مقایسه کن که مقایسهٔ مرجع است و به کش وابسته. تنها فرق، حضورِ یک طرفِ primitive است.

۴. (سخت، چه چاپ می‌کند) هر خط را توضیح بده:
System.out.println("a" + "b" == "ab");           // (۱)
String x = "a"; System.out.println(x + "b" == "ab"); // (۲)

(۱) true: عبارتِ "a"+"b" یک ثابتِ زمانِ کامپایل است که کامپایلر آن را به literalِ "ab" تا می‌کند (constant folding)، و آن literal اینترن است، پس همان شیءِ "ab" می‌شود. (۲) false: x یک مقدارِ زمانِ اجراست (متغیرِ غیر-final)، پس x + "b" در زمانِ اجرا یک Stringِ جدید در heap می‌سازد که در استخر نیست. این سؤال سخت است چون به constant folding بستگی دارد نه به الحاقِ زمانِ اجرا.

۵. چرا `String` تغییرناپذیر است و سه فایدهٔ مشخص نام ببر.

آرایهٔ پشتیبانش final است و هرگز mutate نمی‌شود. فوایدش: (۱) thread-safety ذاتی — می‌توانی آزادانه بینِ نخ‌ها به اشتراکش بگذاری چون هیچ‌کس تغییرش نمی‌دهد؛ (۲) امن به‌عنوانِ کلیدِ HashMap — چون محتوا ثابت است هشش هم پایدار می‌ماند و کلید یتیم نمی‌شود؛ (۳) امکانِ pooling/interning برای صرفه‌جویی حافظه. فایدهٔ اضافه: امنیت — یک مسیرِ فایل یا URLِ اعتبارسنجی‌شده نمی‌تواند بعد از بررسی زیرِ دستت عوض شود.

۶. کِی `new String("x")` نسبت به literalِ `"x"` توجیه دارد؟

تقریباً هرگز. یک شیءِ heapِ متمایز را اجباری می‌کند، استخر را خنثی و حافظه را هدر می‌دهد. موردِ خاصِ مشروع: اجبارِ یک هویتِ تازه برای یک شیءِ قفل، یا جداکردنِ آرایهٔ پشتیبانِ بزرگِ یک substring در JDKهای قدیمی (new String(sub) پیش از تغییرِ کپیِ substring در Java 7u6، که قبل از آن substring آرایهٔ بزرگِ رشتهٔ اصلی را به اشتراک می‌گذاشت و مانعِ آزادسازیِ حافظه می‌شد). در غیرِ این صورت بوی بدِ کد است.

۷. (دام) BigDecimal `1.0` در برابر `1.00` در HashSet در برابر TreeSet — اندازه‌ها؟

HashSet اندازهٔ ۲ می‌دهد (از equals استفاده می‌کند که scale را در نظر می‌گیرد، پس 1.0 و 1.00 نابرابرند)، ولی TreeSet اندازهٔ ۱ می‌دهد (از compareTo استفاده می‌کند که فقط عددی‌ست و scale را نادیده می‌گیرد، پس آن‌ها را برابر و تکراری می‌بیند). این ناسازگاریِ compareTo/equals را نشان می‌دهد: مجموعه‌های مرتب عضویت را با comparator تعریف می‌کنند نه با equals.

۸. آیا Comparable باید همیشه با equals سازگار باشد؟ یک ناقض بگو.

به‌شدت توصیه‌شده، ولی الزامی نیست. BigDecimal نقضش می‌کند (compareToاش برای 1.0 و 1.00 صفر است ولی equalsاش false). خطر این است که TreeMap/TreeSet از compareTo برای تعیینِ عضویت استفاده می‌کنند، پس یک ترتیبِ ناسازگار بی‌صدا عناصری را حذف یا ادغام می‌کند که یک HashSet نگه می‌داشت.

۹. (باگ را پیدا کن)
class Id {
    final String v; Id(String v){this.v=v;}
    public boolean equals(Id o){ return v.equals(o.v); } // باگ
    public int hashCode(){ return v.hashCode(); }
}

equals(Id) به‌جای اینکه Object.equals(Object) را override کند، آن را overload می‌کند — یعنی یک متدِ جدید با امضای متفاوت می‌سازد، نه اینکه متدِ والد را بازنویسی کند. مجموعه‌ها همیشه equals(Object) را صدا می‌زنند، که همان نسخهٔ هویتیِ ارث‌رسیده از Object است — پس این کلاس طوری رفتار می‌کند که انگار equals هرگز override نشده. رفع: امضا را public boolean equals(Object o) کن و @Override بگذار؛ همان @Override این باگ را در زمانِ کامپایل می‌گرفت.

۱۰. چرا ۳۱ در hashCode، و چرا ضریبِ دستی باید فرد باشد؟

۳۱ عددی اول و فرد است که توزیعِ خوبی می‌دهد، و 31*i توسطِ JVM به (i<<5)-i بهینه می‌شود (یک shift و یک تفریق به‌جای ضربِ کامل). یک ضریبِ زوج هنگامِ سرریزِ int بیت‌ها را از لبهٔ بالا بیرون می‌راند، اطلاعاتِ فیلدهای اولیه را از دست می‌دهد و هش‌ها را خوشه‌ای (clustered) می‌کند؛ ضریبِ فرد این نشت را ندارد.

۱۱. (دام، چه چاپ می‌کند)
Map<String,Integer> m = new HashMap<>();
System.out.println(m.get("x") + 1);

NullPointerException پرت می‌کند. m.get("x") مقدارِ null (از نوعِ Integer) برمی‌گرداند، و عملیاتِ + 1 مجبورش می‌کند unbox شود — یعنی intValue() روی null صدا زده می‌شود → NPE، در خطی که هرگز کلمهٔ null در آن نیست. راه‌حل: getOrDefault("x", 0).

۱۲. `getClass()` در برابر `instanceof` در equals — مصالحه چیست؟

getClass() تقارن را تضمین می‌کند ولی مانعِ برابریِ یک نمونهٔ زیرکلاس با نمونهٔ ابرکلاس می‌شود (که با proxyهای Hibernate و زیرکلاس‌سازی می‌شکند، چون proxy کلاسِ متفاوتی دارد). instanceof برابریِ بین‌نوعی را اجازه می‌دهد ولی اگر یک زیرکلاس حالتِ مرتبط با equals اضافه کند می‌تواند تقارن را نقض کند. توصیهٔ Effective Java: ترکیب به‌جای وراثت را ترجیح بده تا کلاً معما ناپدید شود؛ برای حالتِ رایجِ final/برگ از instanceof استفاده کن.

۱۳. (سخت، چه چاپ می‌کند)
Object o = true ? Integer.valueOf(1) : Double.valueOf(2.0);
System.out.println(o);

1.0. در یک عبارتِ شرطی (? :) که یک شاخه‌اش Integer و شاخهٔ دیگرش Double است، قانونِ ارتقای عددیِ دودویی (binary numeric promotion) فعال می‌شود: جاوا هر دو را unbox می‌کند و به double ارتقا می‌دهد، سپس نتیجه را به Double دوباره box می‌کند — پس با اینکه شرط true است و ظاهراً باید 1 بگیری، مقدار به 1.0 تبدیل می‌شود. این یک دامِ بدنامِ عبارتِ شرطی در JLS (مشخصاتِ زبانِ جاوا) است.

۱۴. آیا recordها کاملاً تغییرناپذیرند؟ دامِ آرایه چیست؟

recordها کم‌عمق (shallow) تغییرناپذیرند — فیلدها final هستند، ولی مؤلفه‌ای که به یک شیءِ تغییرپذیر (یک List، یک آرایه) اشاره می‌کند، از طریقِ همان مرجع همچنان تغییرپذیر است. راه‌حل: در سازندهٔ فشرده کپی کن (List.copyOf(...)). همچنین یک مؤلفهٔ byte[] باعث می‌شود equals/hashCodeِ تولیدشده از هویتِ آرایه استفاده کنند نه محتوایش، که بی‌صدا برابریِ مقدار را می‌شکند (دو رکورد با آرایه‌های هم‌محتوا نابرابر تلقی می‌شوند).

۱۵. (سنیور) از یک شیءِ تغییرپذیر به‌عنوانِ کلیدِ HashMap استفاده می‌کنی و جست‌وجوها به‌صورتِ متناوب شکست می‌خورند. تشخیص بده.

چیزی یک فیلدِ مرتبط با equals/hashCode را بعد از درج تغییر داده. حالا آن ورودی در سطلِ متناظرِ هشِ قدیمیاش نشسته؛ جست‌وجو هشِ جدید را حساب می‌کند و سطلِ متفاوتی را می‌گردد و null برمی‌گرداند. «متناوب» بودنش هم چون فقط کلیدهایی که mutate شده‌اند مشکل دارند. رفع: از کلیدهای immutable استفاده کن (recordها، String، wrapperهای box‌شده)، یا هرگز کلیدها را بعد از درج تغییر نده.

نکاتِ سنیور و موارد پیشرفته

تا اینجا قرارداد را بلدی. حالا برویم سراغ چیزهایی که یک سنیور را از یک میدلِ خوب جدا می‌کند: این‌که درونِ HashMap واقعاً چه می‌گذرد، چطور یک Comparatorِ به‌ظاهر بی‌گناه در پروداکشن سرویس را با استثنا از پا درمی‌آورد، این‌که hashCodeِ پیش‌فرض اصلاً آدرسِ حافظه نیست، و این‌که equals/hashCode برای یک Entityِ Hibernate یکی از سخت‌ترین تصمیم‌های طراحی است که ۹۰٪ تیم‌ها غلط انجامش می‌دهند.

نقشهٔ راهِ این بخش

۱. درونِ HashMap: پخشِ هش (spread)، درخت‌شدنِ سطل (treeification)، load factor و rehash، و حملهٔ hash-flooding. ۲. دامِ Comparator: تفریقِ a - b و سرریز، و کرشِ معروفِ «Comparison method violates its general contract!» از TimSort. ۳. افسانهٔ hashCodeِ آدرس‌محور: واقعیتِ mark word و اثرش روی biased locking. ۴. برابریِ اعدادِ اعشاری: چرا NaN.equals(NaN) درست است ولی 0.0 == -0.0، و رفتارِ record با double. ۵. equals برای Entityهای JPA/Hibernate: مشکلِ idِ null قبل از persist، ترفندِ hashCodeِ ثابت، و دامِ Lombok. ۶. رشته در مقیاس: کشِ hashCode، هزینهٔ حافظهٔ intern، و تفاوتش با string deduplication در G1. ۷. کالکشن‌های immutableِ مدرن و چند دامِ ریز.

بخش ۱ — درونِ HashMap: چیزی که در مصاحبهٔ سنیور می‌پرسند

قرارداد را که رعایت کردی، سوالِ بعدی این است: «وقتی put می‌زنی دقیقاً چه اتفاقی می‌افتد؟» جوابِ سطحی («هش را حساب می‌کند و در سطل می‌گذارد») تو را میدل نشان می‌دهد. جوابِ سنیور سه لایه دارد.

لایهٔ اول: پخشِ هش (hash spreading)

hashCode()ِ تو یک int ۳۲ بیتی می‌دهد، ولی HashMap فقط از چند بیتِ پایینیِ آن برای انتخابِ سطل استفاده می‌کند (چون تعدادِ سطل‌ها همیشه توانی از ۲ است و ایندکس = hash & (n-1)). اگر hashCodeهای تو فقط در بیت‌های بالا فرق کنند، همه در یک سطل می‌ریزند. برای همین HashMap قبل از استفاده، هش را «هم می‌زند»:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

این h ^ (h >>> 16) بیت‌های بالا را با پایین XOR می‌کند تا به ایندکس اثر بگذارند. درسِ سنیور: HashMap تا حدی hashCodeهای بدِ تو را جبران می‌کند ولی معجزه نمی‌کند — اگر hashCode ثابت return 42; برگرداند همه در یک سطل می‌مانند و کارایی به O(n) می‌افتد.

لایهٔ دوم: درخت‌شدنِ سطل (treeification)

از جاوا ۸ به بعد، اگر یک سطل خیلی شلوغ شود، HashMap آن سطل را از یک لیستِ پیوندی به یک درختِ قرمز-سیاه تبدیل می‌کند تا جست‌وجوی درونِ سطل از O(n) به O(log n) برسد. دو آستانه دخیل‌اند:

  • TREEIFY_THRESHOLD = 8: وقتی یک سطل به ۸ عنصر برسد کاندیدِ درخت‌شدن است.
  • MIN_TREEIFY_CAPACITY = 64: ولی فقط اگر کلِ جدول حداقل ۶۴ خانه داشته باشد؛ وگرنه به‌جای درخت‌شدن، اول resize می‌کند.
  • UNTREEIFY_THRESHOLD = 6: اگر با حذف، تعداد به ۶ برسد دوباره به لیست برمی‌گردد.
درخت‌شدن به Comparable وابسته است

وقتی سطل درخت می‌شود، HashMap برای مرتب‌کردنِ گره‌ها اول با hashCode مقایسه می‌کند؛ اگر هش‌ها برابر بودند و کلید Comparable بود، از compareTo کمک می‌گیرد. یعنی اگر کلیدهایت hashCodeِ فاجعه‌بار دارند و Comparable نیستند، درخت به یک تفکیکِ ضعیفِ مبتنی بر identity hash می‌افتد. درسِ عملی: درخت‌شدن یک تورِ ایمنیِ جاوا در برابر hashCodeِ بد و حملهٔ collision است، نه مجوزی برای بی‌دقتی. یک hashCode خوب هنوز واجب است.

نمودارِ چرخهٔ عمرِ یک سطل — bucket lifecycle in a HashMap:

stateDiagram-v2
    [*] --> Empty
    Empty --> LinkedList: put (collision)
    LinkedList --> Tree: size >= 8 AND table >= 64
    LinkedList --> Resize: size >= 8 AND table < 64
    Resize --> LinkedList: rehash spreads entries
    Tree --> LinkedList: size <= 6 (untreeify)
    Tree --> [*]: clear

لایهٔ سوم: load factor و rehash

وقتی تعدادِ عناصر از capacity * loadFactor (پیش‌فرض 0.75) بگذرد، HashMap ظرفیت را دو برابر و همه‌چیز را rehash می‌کند. سه پیامدِ سنیور:

  • اگر اندازهٔ نهایی را می‌دانی، با new HashMap<>(expectedSize / 0.75f + 1) از پیش ظرفیت بده تا rehashهای پیاپی حذف شوند.
  • ترتیبِ پیمایشِ HashMap تضمین‌شده نیست و بعد از resize عوض می‌شود؛ هرگز به آن تکیه نکن — ترتیبِ درج LinkedHashMap، ترتیبِ مرتب TreeMap.
  • HashMap null-کلید و null-مقدار را می‌پذیرد، ولی ConcurrentHashMap هیچ‌کدام را — چون در محیطِ هم‌روند map.get(k) == null دوپهلو می‌شد («نبود» یا «مقدارش null است؟»).
حملهٔ hash-flooding (algorithmic complexity attack)

اگر کلیدهای Mapِ تو از ورودیِ کاربر بیایند (مثلاً پارامترهای یک درخواستِ HTTP در یک Map)، یک مهاجم می‌تواند عمداً هزاران کلید با hashCodeِ یکسان بسازد تا همه در یک سطل بریزند و هر lookup به O(n) و کلِ درخواست به O(n²) برسد — یک DoS با CPU. درخت‌شدنِ جاوا ۸ این را از O(n²) به O(n log n) تخفیف می‌دهد ولی حذفش نمی‌کند. دفاعِ واقعی: روی داده‌های نامعتمد، تعدادِ کلیدها را محدود کن یا کلید را با یک تابعِ مقاومِ randomized (مثل SipHash در برخی زبان‌ها) هش کن.

بخش ۲ — دامِ Comparator که سرویس را می‌خواباند

این یکی از خطرناک‌ترین باگ‌های «کدِ تمیز به‌نظر می‌رسد» است.

تفریق به‌جای مقایسه = سرریزِ خاموش

// باگ: سرریزِ int
Comparator<Integer> byValue = (a, b) -> a - b;
byValue.compare(Integer.MAX_VALUE, -1); // انتظار: مثبت. واقعیت: سرریز → منفی!

a - b وقتی a بزرگ و b منفیِ بزرگ باشد سرریز می‌کند و علامتش برعکس می‌شود. نتیجه: ترتیبِ اشتباه، یا بدتر، نقضِ قراردادِ Comparator. همیشه از Integer.compare(a, b) (یا Long.compare, Double.compare) استفاده کن — نه تفریق.

کرشِ معروفِ TimSort

الگوریتمِ مرتب‌سازیِ جاوا (Arrays.sort/Collections.sort روی اشیا) TimSort است و در حینِ اجرا صحتِ قراردادِ Comparator را کنترل می‌کند. اگر Comparatorِ تو ناسازگار/غیرمتعدی باشد (مثلاً به‌خاطرِ همان سرریز، یا مقایسهٔ فیلدهایی که گاهی null یا NaN‌اند)، در وسطِ مرتب‌سازی این استثنا را پرتاب می‌کند:

java.lang.IllegalArgumentException: Comparison method violates its general contract!
چرا این کرش «گاه‌به‌گاه» است و دیباگش جهنم است

این استثنا فقط وقتی رخ می‌دهد که چیدمانِ خاصی از داده‌ها TimSort را به ناحیه‌ای برساند که تناقض را ببیند — پس با ۱۰ رکورد سبز است و با ۱۰٬۰۰۰ رکوردِ خاص می‌ترکد. علت‌های رایج: (۱) تفریقِ سرریزکننده، (۲) NaN در یک فیلدِ double (چون NaN با هیچ‌چیز، حتی خودش، < یا > نیست، ترتیب کلی را می‌شکند)، (۳) Comparatorی که به وضعیتِ متغیر یا زمان وابسته است. راهِ حل: از سازنده‌های Comparator.comparingInt(...).thenComparing(...) استفاده کن، برای فیلدهای null از Comparator.nullsFirst(...) و برای اعشاری از Double.compare که NaN را هم مرتب می‌کند.

// درست، امن در برابرِ null و NaN و سرریز
Comparator<Person> safe =
    Comparator.comparing(Person::name, Comparator.nullsFirst(Comparator.naturalOrder()))
              .thenComparingInt(Person::age);

بخش ۳ — hashCodeِ پیش‌فرض آدرسِ حافظه نیست

یک باورِ رایج (که در خیلی از منابع، حتی همین فصل، ساده‌سازی شده) این است که hashCodeِ پیش‌فرضِ Object «آدرسِ حافظه» است. این در JVMهای مدرن غلط است.

در HotSpot، identity hash code یک عدد است که بارِ اول که لازم شود تولید می‌شود و در mark wordِ هدرِ خودِ شیء کش می‌شود. در JDK 8 به بعد به‌صورتِ پیش‌فرض از یک الگوریتمِ شبه‌تصادفیِ مبتنی بر وضعیتِ thread (نه آدرس) می‌آید. چرا نمی‌تواند آدرس باشد؟ چون GC (به‌ویژه compacting GCها) اشیا را در حافظه جابه‌جا می‌کند؛ اگر hash از آدرس می‌آمد، بعد از هر GC عوض می‌شد و قراردادِ «hashCode باید در طولِ اجرا ثابت بماند» می‌شکست. با کش‌کردن در هدر، حتی اگر شیء جابه‌جا شود، هش ثابت می‌ماند.

عارضهٔ جانبیِ عجیب: identityHashCode قفلِ سوگیرانه را باطل می‌کند

چون hash و وضعیتِ biased locking هر دو در همان mark word ذخیره می‌شوند، اولین باری که System.identityHashCode(obj) (یا Object.hashCode()ِ پیش‌فرض) را روی یک شیء صدا بزنی، آن شیء برای همیشه از biased locking خارج می‌شود و اگر bias داشته باشد باطل می‌شود. در کدِ حساس‌به‌کارایی که هم قفل می‌گیری هم هش، این یک اثرِ نامرئی روی throughput دارد. (در جاوا ۱۵+ biased locking به‌صورتِ پیش‌فرض غیرفعال و در حالِ حذف است، پس این نکته بیشتر تاریخی/برای JVMهای قدیمی است.)

IdentityHashMap کِی درست است

گاهی واقعاً می‌خواهی کلیدها با == (identity) مقایسه شوند نه equals — مثلاً یک گرافِ اشیا برای سریال‌سازی، یا ردیابیِ «این دقیقاً همان شیء را قبلاً دیده‌ام؟» برای تشخیصِ چرخه. IdentityHashMap دقیقاً این کار را می‌کند و از System.identityHashCode استفاده می‌کند. این تنها جایی است که تکیه بر identity عمدی و درست است.

بخش ۴ — برابریِ اعدادِ اعشاری: NaN و صفرِ منفی

سه راهِ مقایسهٔ double سه نتیجهٔ متفاوت می‌دهند و سنیور باید بداند کدام کجاست:

double n = Double.NaN;
System.out.println(n == n);                 // false  — NaN با هیچ‌چیز == نیست، حتی خودش
System.out.println(Double.valueOf(n).equals(n)); // true — Double.equals روی بیت‌ها کار می‌کند

System.out.println(0.0 == -0.0);            // true   — عملگرِ == این‌ها را برابر می‌بیند
System.out.println(Double.valueOf(0.0).equals(-0.0)); // false — equals تفکیکشان می‌کند
System.out.println(Double.compare(0.0, -0.0)); // 1   — compare هم -0.0 را کوچک‌تر می‌بیند

چرا؟ Double.equals و Double.compare مقدار را به بیت (doubleToLongBits) تبدیل می‌کنند تا بازتابی (reflexive) بمانند: NaN باید با NaN برابر باشد وگرنه یک NaN در HashSet دو بار درج می‌شود و هرگز پیدا نمی‌شود؛ و +0.0 و -0.0 که الگوی بیتیِ متفاوت دارند، جدا شمرده می‌شوند.

record با فیلدِ double همین معناشناسی را می‌گیرد

equalsِ تولیدشدهٔ یک record برای فیلدهای double/float از == استفاده نمی‌کند؛ از معناشناسیِ Double.compare/بیتی استفاده می‌کند. پس:

record P(double x) {}
new P(Double.NaN).equals(new P(Double.NaN)); // true!  (برخلافِ == که false می‌داد)
new P(0.0).equals(new P(-0.0));              // false! (برخلافِ == که true می‌داد)

اگر برای کلیدِ Map از یک record با فیلدِ اعشاری استفاده می‌کنی و انتظار داری +0.0 و -0.0 یکی باشند، غافلگیر می‌شوی. راهِ حل: هنگامِ ساخت، صفر را نرمال کن (x == 0.0 ? 0.0 : x) یا اصلاً از double به‌عنوانِ کلید پرهیز کن.

بخش ۵ — equals/hashCode برای Entityهای JPA/Hibernate (تلهٔ واقعیِ پروداکشن)

این جایی است که تئوریِ کتاب با واقعیتِ ORM برخورد می‌کند و بیشترِ تیم‌ها اشتباه می‌کنند. یک @Entity را در نظر بگیر که idِ آن را دیتابیس هنگامِ persist تولید می‌کند (@GeneratedValue).

مشکل ۱ — id قبل از persist نال است. اگر equals/hashCode را روی id بسازی: یک entityِ جدید (id = null) را در یک HashSet می‌گذاری، بعد ذخیره‌اش می‌کنی، حالا id مقدار می‌گیرد و hashCode عوض می‌شود → همان دامِ «کلیدِ تغییرپذیر» که در متنِ اصلی دیدی: entity در سطلِ هشِ قدیمی گم می‌شود و set.contains(entity) نادرست می‌شود.

مشکل ۲ — پروکسیِ lazy. Hibernate برای رابطه‌های lazy یک پروکسیِ زیرکلاس می‌سازد که getClass()ِ آن با کلاسِ واقعی فرق دارد؛ پس getClass() در equals (که در متنِ اصلی دیدی) entityِ واقعی را با پروکسیِ همان ردیف نابرابر می‌کند.

راهِ‌حلِ سنیور (توصیهٔ Vlad Mihalcea):

@Entity
public class Book {
    @Id @GeneratedValue Long id;
    @Column(unique = true) String isbn; // کلیدِ کسب‌وکاری، پایدار و یکتا

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Book)) return false; // instanceof با پروکسی کار می‌کند
        return isbn != null && isbn.equals(((Book) o).isbn);
    }
    @Override public int hashCode() {
        return getClass().hashCode(); // ثابت! هرگز عوض نمی‌شود
    }
}
دو ترفندِ کلیدی برای equals در Entity

۱. hashCodeِ ثابت برگردان (مثلاً getClass().hashCode() یا یک عددِ ثابت). چون hashCode باید در طولِ عمرِ عضویت در Set ثابت بماند، ثابت‌بودن آن را از دامِ تغییر نجات می‌دهد — بله، همه در یک سطل می‌افتند، ولی تعدادِ entityهای هم‌زمان در یک Set معمولاً کوچک است، پس عملاً بی‌ضرر. ۲. برای equals از یک کلیدِ کسب‌وکاریِ (business/natural key) پایدار و یکتا استفاده کن (isbn، uuidِ برنامه‌ساخته، شمارهٔ سفارش) — نه از idِ دیتابیس. اگر کلیدِ طبیعی نداری، یک UUID را در سازنده تولید کن (نه دیتابیس).

دامِ Lombok: @EqualsAndHashCode روی Entity و رابطهٔ دوطرفه

@EqualsAndHashCode و @Dataِ Lombok به‌صورتِ پیش‌فرض همهٔ فیلدها را در equals/hashCode می‌گنجانند. روی یک Entity این فاجعه است: (۱) فیلدهای رابطه‌ای را هم می‌گیرد، پس در یک رابطهٔ دوطرفه (Order ↔ OrderLine) فراخوانیِ hashCode بی‌نهایت بازگشتی می‌شود و StackOverflowError می‌دهد؛ (۲) دسترسی به فیلدهای lazy را trigger می‌کند و کوئریِ ناخواسته می‌زند. اگر مجبوری Lombok بزنی: @EqualsAndHashCode(onlyExplicitlyIncluded = true) و فقط کلیدِ کسب‌وکاری را با @EqualsAndHashCode.Include علامت بزن.

بخش ۶ — رشته در مقیاسِ بزرگ

hashCode رشته کش می‌شود

String مقدارِ hashCode را در یک فیلدِ int hash کش می‌کند (چون immutable است و هرگز عوض نمی‌شود). فرمولش چندجمله‌ای است: s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]. دو نکتهٔ ریز: (۱) "".hashCode() برابرِ 0 است، و چون مقدارِ کش‌شدهٔ اولیه هم 0 است، رشتهٔ خالی و رشته‌هایی که تصادفاً هش صفر دارند، هر بار دوباره محاسبه می‌شوند (در JDKهای جدید یک فلگِ hashIsZero این را حل کرده). (۲) چون فرمول عمومی و شناخته‌شده است، مهاجم می‌تواند رشته‌هایی با هشِ برابر بسازد — دلیلِ دیگرِ hash-flooding.

intern هزینهٔ حافظه دارد

String.intern() از جاوا ۷ به بعد استخر را در heap نگه می‌دارد (قبلاً در PermGen بود که OOMِ معروف می‌داد). ولی رشته‌های intern‌شده مثل هر شیءِ heap تا وقتی reference داشته باشند GC نمی‌شوند؛ intern‌کردنِ میلیون‌ها رشتهٔ یکتا یک نشتِ حافظهٔ آهسته است. intern فقط وقتی می‌ارزد که تعدادِ کمی مقدارِ پرتکرار داری (مثلاً نامِ ستون‌ها هنگامِ پارسِ یک فایلِ بزرگ).

intern در برابر string deduplication در G1

اشتباه نگیر: intern یک کارِ برنامه‌نویس است که رشته‌ها را در استخر یکی می‌کند تا == کار کند. String deduplication (با -XX:+UseStringDeduplication روی G1) یک کارِ GC است که پشتِ صحنه، char[]/byte[]ِ پشتِ رشته‌های با محتوای یکسان را یکی می‌کند تا حافظه صرفه‌جویی شود — بدونِ این‌که == تغییر کند یا کدِ تو دخالت کند. اگر برنامه‌ات پر از رشته‌های تکراری است، به‌جای intern دستی، اول این فلگِ GC را امتحان کن؛ ریسکِ نشت ندارد.

بخش ۷ — کالکشن‌های immutableِ مدرن، چند دامِ ریز

از جاوا ۹، List.of, Set.of, Map.of کالکشن‌های immutableِ سبک می‌سازند، ولی رفتارشان با ArrayList/HashMap فرق دارد و سه‌تایش سرِ سنیور را می‌گیرد: (۱) null-ستیزندList.of(null) یا Map.of("k", null) استثنا می‌دهند؛ (۲) کلید/عضوِ تکراری مثل Set.of("a","a") در زمانِ ساخت IllegalArgumentException می‌دهد، برخلافِ HashSet که بی‌صدا نادیده می‌گیرد؛ (۳) ترتیبِ پیمایش تصادفی و ناپایدار است — یک نمکِ (SALT) تصادفی در هر بار اجرای JVM دارند، پس ترتیب بینِ اجراها فرق می‌کند و تستِ حساس‌به‌ترتیب می‌شکند.

record و سریال‌سازی امن‌تر

recordها هنگامِ deserialize به‌جای دورزدنِ سازنده (حفرهٔ امنیتیِ سریال‌سازیِ کلاسیک)، سازندهٔ متعارف را صدا می‌زنند، پس اعتبارسنجی‌ها و نرمال‌سازی‌های compact constructor دوباره اجرا و invariantها حفظ می‌شوند — امن‌تر از یک کلاسِ معمولی برای value objectهای سریال‌شونده.

سوالات مصاحبهٔ سنیور (پیشرفته)

۱. دقیقاً چه زمانی یک سطلِ HashMap به درخت تبدیل می‌شود و آیا این یک hashCodeِ بد را «درست» می‌کند؟

وقتی هم‌زمان تعدادِ عناصرِ یک سطل به TREEIFY_THRESHOLD = 8 برسد و اندازهٔ کلِ جدول حداقل MIN_TREEIFY_CAPACITY = 64 باشد؛ اگر جدول کوچک‌تر باشد، به‌جای درخت‌شدن اول resize می‌کند. درخت‌شدن جست‌وجوی درونِ سطل را از O(n) به O(log n) می‌برد و یک تورِ ایمنی در برابرِ hashCodeِ بد و حملهٔ collision است، ولی آن را «درست نمی‌کند»: اگر hashCode تو فاجعه‌بار باشد، همه هنوز در یک سطل‌اند و بهترین حالت O(log n) است، نه O(1). ضمناً برای مرتب‌سازیِ گره‌ها، اگر هش‌ها برابر و کلید Comparable باشد از compareTo استفاده می‌شود. یک hashCodeِ خوب هنوز واجب است.

۲. این Comparator چه اشکالی دارد و کِی می‌ترکد؟ `Comparator<Integer> c = (a, b) -> a - b;`

a - b روی int سرریز می‌کند: اگر a = Integer.MAX_VALUE و b = -1، نتیجهٔ ریاضی مثبت است ولی به‌خاطرِ سرریز منفی می‌شود، پس ترتیب برعکس و قرارداد نقض می‌شود. با داده‌های کوچک هرگز دیده نمی‌شود؛ با اعدادِ بزرگ یا منفی ناگهان ترتیبِ غلط یا کرشِ TimSort می‌دهد. راهِ‌حل: Integer.compare(a, b) که سرریز ندارد. قاعدهٔ کلی: هرگز برای Comparator تفریق نکن، از X.compare استفاده کن.

۳. در پروداکشن استثنای «Comparison method violates its general contract!» می‌گیری. علت چیست و چرا گاه‌به‌گاه است؟

الگوریتمِ مرتب‌سازیِ جاوا (TimSort) در حین کار صحتِ قراردادِ Comparator را چک می‌کند و اگر ناسازگاری ببیند این IllegalArgumentException را می‌دهد. علل: سرریزِ تفریق، NaN در فیلدِ اعشاری (که با هیچ‌چیز </> نیست و ترتیبِ کلی را می‌شکند)، مقایسهٔ فیلدهای null بدونِ nullsFirst/Last، یا Comparatorِ وابسته به وضعیتِ متغیر (غیرمتعدی/ناپایدار). «گاه‌به‌گاه» است چون فقط چیدمانِ خاصی از داده‌ها TimSort را به نقطه‌ای می‌رساند که تناقض را ببیند — برای همین با داده‌های کوچک سبز و با داده‌های بزرگِ خاص قرمز می‌شود. راهِ‌حل: Comparator را با comparingInt/thenComparing/nullsFirst/Double.compare بساز.

۴. آیا hashCodeِ پیش‌فرضِ Object همان آدرسِ حافظه است؟

نه. در HotSpotِ مدرن، identity hash code بارِ اولِ نیاز به‌صورتِ شبه‌تصادفی (بر پایهٔ وضعیتِ thread، نه آدرس) تولید و در mark wordِ هدرِ شیء کش می‌شود. نمی‌تواند آدرس باشد چون GC اشیا را جابه‌جا می‌کند و آن‌وقت هش عوض می‌شد و قراردادِ ثبات را می‌شکست؛ با کش در هدر، هش حتی پس از جابه‌جایی ثابت می‌ماند. اثرِ جانبیِ جالب: اولین محاسبهٔ identity hash روی یک شیء، آن را از biased locking خارج می‌کند، چون هر دو در همان mark word جا می‌گیرند.

۵. برای `record P(double x)`، آیا `new P(Double.NaN).equals(new P(Double.NaN))` درست است؟ و `new P(0.0).equals(new P(-0.0))`؟

اولی true، دومی false — دقیقاً برعکسِ عملگرِ ==. چون equalsِ تولیدشدهٔ record برای double/float از == استفاده نمی‌کند بلکه از معناشناسیِ بیتی (Double.compare/doubleToLongBits) استفاده می‌کند تا بازتابی بماند: NaN باید با NaN برابر باشد (وگرنه در HashMap گم می‌شود)، و +0.0 و -0.0 که الگوی بیتیِ متفاوت دارند جدا شمرده می‌شوند. اگر می‌خواهی ±0.0 یکی باشند، در compact constructor صفر را نرمال کن.

۶. equals/hashCode را برای یک @Entityِ JPA چطور می‌نویسی و چرا استفاده از idِ دیتابیس خطرناک است؟

از idِ تولیدیِ دیتابیس استفاده نکن، چون قبل از persist نال است و بعد از persist عوض می‌شود؛ پس اگر entity را قبل از ذخیره در یک Set بگذاری، تغییرِ id باعثِ تغییرِ hashCode و گم‌شدنِ آن در سطلِ قدیمی می‌شود. راهِ درست: equals را روی یک کلیدِ کسب‌وکاریِ پایدارِ یکتا (isbn، uuidِ برنامه‌ساخته) با instanceof (نه getClass، تا با پروکسیِ Hibernate کار کند) بساز، و hashCode را ثابت برگردان (getClass().hashCode()) تا هرگز عوض نشود. اگر کلیدِ طبیعی نداری، یک UUID در سازنده تولید کن.

۷. `String.intern()` امروز چه هزینه‌ای دارد و با string deduplicationِ G1 چه فرقی دارد؟

intern از جاوا ۷ استخر را در heap نگه می‌دارد؛ رشته‌های intern‌شده تا وقتی reference دارند GC نمی‌شوند، پس intern‌کردنِ میلیون‌ها رشتهٔ یکتا یک نشتِ حافظهٔ آهسته و فشارِ CPU است. فقط برای تعدادِ کمِ مقادیرِ پرتکرار می‌ارزد. تفاوت: intern کارِ برنامه‌نویس است تا == روی رشته‌ها کار کند؛ اما string deduplication (-XX:+UseStringDeduplication روی G1) کارِ GC است که پشتِ صحنه آرایهٔ پشتِ رشته‌های هم‌محتوا را یکی می‌کند تا حافظه صرفه شود، بدونِ تغییرِ == و بدونِ دخالتِ کد. برای صرفه‌جوییِ حافظه، اول فلگِ GC را امتحان کن، نه intern دستی.

۸. `Set.of("a", "a")` چه می‌کند و آیا ترتیبِ پیمایشِ `Set.of` بینِ اجراها پایدار است؟

Set.of("a","a") در زمانِ ساخت IllegalArgumentException پرتاب می‌کند (برخلافِ HashSet که کلیدِ تکراری را بی‌صدا نادیده می‌گیرد). و نه — ترتیبِ پیمایشِ Set.of/Map.of پایدار نیست: این کالکشن‌ها یک نمکِ (SALT) تصادفی در هر بار اجرای JVM دارند، پس ترتیب بینِ اجراهای مختلف عوض می‌شود. این عمدی است تا هیچ‌کس به ترتیب تکیه نکند؛ اگر تستی به ترتیبِ خروجی حساس باشد، به‌شکلِ متناوب می‌شکند. ضمناً این کالکشن‌ها null-ستیزند و Set.of((Object)null) هم استثنا می‌دهد.

جمع‌بندیِ سنیور
  • درونِ HashMap: spread هش، درخت‌شدن در آستانهٔ ۸ و جدولِ ۶۴، rehash در load factor ۰٫۷۵؛ درخت تورِ ایمنی است نه جانشینِ hashCodeِ خوب.
  • Comparator: هرگز تفریق نکن (X.compare)؛ nullsFirst و Double.compare برای null و NaN؛ وگرنه کرشِ TimSort.
  • hashCodeِ پیش‌فرض آدرس نیست؛ عددِ کش‌شده در mark word است و biased locking را باطل می‌کند.
  • اعشاری: NaN.equals(NaN) درست، 0.0.equals(-0.0) غلط؛ record هم همین را می‌گیرد.
  • Entity: کلیدِ کسب‌وکاری برای equals، hashCodeِ ثابت، instanceof نه getClass؛ مراقبِ Lombok و رابطهٔ دوطرفه باش.
  • رشته و کالکشن: intern نشت می‌دهد (به‌جایش G1 dedup)؛ Set.of/Map.of نال‌ستیز، تکراری‌ستیز و بی‌ترتیبِ پایدارند.
جمع‌بندی
  • == در برابر .equals: == هویت (آدرس) یا primitiveِ خام را مقایسه می‌کند؛ .equals مقدار را. پیش‌فرضِ equals همان == است تا وقتی override کنی.
  • قرارداد: اشیای برابر باید هشِ برابر داشته باشند؛ نابرابرها مجازند برخورد کنند. equals و hashCode را همیشه با هم و روی همان فیلدها بنویس، و @Override بگذار (تا دامِ overload گیر بیفتد).
  • String: immutable است، literalها در استخر اینترن می‌شوند، constant folding عبارت‌های ثابت را تا می‌کند. هرگز رشته را با == مقایسه نکن. برای الحاق در حلقه StringBuilder.
  • Boxing: کش Integer بازهٔ -128..127 است (و Long، Byte، Short، Character، Boolean؛ ولی Float/Double هیچ کشی ندارند). هرگز wrapperها را با == مقایسه نکن؛ unboxingِ null یعنی NPE؛ در مسیرهای داغ primitive بمان.
  • immutable و record: کلاس‌های immutable با کپیِ دفاعی بساز، یا بهتر، از record استفاده کن که equals/hashCodeِ درست را رایگان می‌دهد — بهترین انتخاب برای کلیدها و value objectها (فقط مراقبِ کم‌عمق‌بودن و دامِ آرایه باش).

بر هر سه — قرارداد، رشته، boxing — مسلط شو، وگرنه HashMap تو بی‌صدا دروغ می‌گوید.

There are three deceptively simple corners of Java that have sunk more real code than any concurrency bug — precisely because they look trivial. They seem so harmless that nobody pauses to learn them properly, and then one day map.get(key) calmly returns null for a key you inserted three lines earlier. This lesson builds all three corners from scratch so they never ambush you again.

Roadmap for this lesson

You will fully learn four things:

  1. The difference between identity (==) and value equality (.equals) — confusing the two is the source of half of Java's subtle bugs.
  2. The equals/hashCode contract — a legally binding rule that the correctness of HashMap, HashSet, and every hash-based structure depends on.
  3. String — why it is immutable, what the string pool is, and why == on strings is a classic trap.
  4. Autoboxing and the Integer cache — how Java silently moves values between the primitive world and the object world, and where that magic stabs your code.

If you carry away one sentence, make it this: == compares references (or raw primitives); everything about value equality flows through the equals/hashCode contract; and boxing quietly carries values between those two worlds.

Part 0 — words you must know

Before we move on, let's crack open four words with analogies so none of them ever lands on you cold later.

  • reference: the address of an object in memory, not the object itself. Like a slip of paper with a house's address written on it. Two slips can hold the same address (one house) or two different addresses that happen to have identical furniture.
  • primitive: the basic types like int, long, double, boolean. These aren't objects; they hold the value directly, not an address to it. int x = 5 means the 5 is right there, not a pointer to a 5.
  • wrapper: the object version of each primitive: Integer for int, Long for long, Double for double, and so on. A box that holds a primitive inside so you can use a number where only objects are accepted (like List<Integer>).
  • bucket and hash: picture a HashMap as a train-station luggage locker. Instead of lining up all bags in one row, it maps each bag via a short number (a hash) to a shelf (a bucket). Finding a bag means: compute the shelf number, go to that shelf, then only inspect the bags on that one shelf — not the whole station.
Why hashing exists at all

The point of hashing is to turn lookup from "check everything one by one" (slow, O(n)) into "go straight to the right bucket" (fast, nearly O(1)). The entire equals/hashCode contract is really the contract that keeps this shortcut honest. Break the contract and the shortcut sends you to the wrong shelf.

Three worlds of equality: == vs .equals

Two address slips

You hold two slips of paper. == asks: "do these two slips show exactly the same address?" — i.e., do they point to one and the same house. .equals asks: "go inside both houses; is the content the same?" Two different houses can have identical furniture: == says no (different address), .equals says yes (same content).

In Java:

  • For primitives (int, long, ...) the == operator compares the value itself: 5 == 5 is true. No address is involved.
  • For objects (anything made with new, anything that is a reference), == compares addresses: do these two references point to one single object? To compare content you must call .equals.

The default Object.equals your class inherits is exactly == (identity comparison). So until you override equals, you have no "value equality" at all — only "same-object-ness."

The equals/hashCode contract

Object.equals and Object.hashCode come as a pair with a legally binding contract — not a polite suggestion, but a rule the entire standard library relies on. HashMap, HashSet, LinkedHashMap, ConcurrentHashMap, deduplication, caching — all of it rests on you upholding it. Break it and these classes don't crash; they just silently return wrong answers, which is worse.

The librarian and the shelf number

A HashMap is like a librarian who files books onto shelves using a formula (hashCode). When you bring a book in, she first runs the formula on its title to get a shelf number, then puts it there. When you want that book back, she runs the same formula again to know which shelf to search. Now imagine two identical copies of a book, due to a flaw in the formula, get different shelf numbers. The librarian goes to the wrong shelf, doesn't see the book, and confidently says "we don't have it" — even though its twin is right there where you filed it. That is exactly what happens when you write equals without hashCode.

equals — five rules

For non-null references x, y, z:

Property Meaning
Reflexive x.equals(x) is true
Symmetric x.equals(y)y.equals(x)
Transitive x.equals(y) && y.equals(z)x.equals(z)
Consistent Repeated calls return the same result if no state used in equals changes
Non-null x.equals(null) is false (never throws)

These five look obvious, but each closes a real mistake. Symmetric stops a from claiming it equals b while b denies it — which happens with mixed types. Transitive keeps a chain of equality honest. Consistent means if you lean on data not used in equals (say, network state), your results become unstable. And Non-null means your equals must never blow up on a null argument; it should quietly return false.

hashCode — three rules

  1. Consistent: the same object returns the same hash value across calls within one run (unless equals-relevant state changes).
  2. The one that matters: if a.equals(b) then a.hashCode() == b.hashCode(). Mandatory.
  3. Unequal objects may share a hash code (collisions are legal), but distinct hashes improve performance.
The golden asymmetry

The whole game is in this asymmetry: equal objects MUST have equal hashes; but unequal objects MAY have the same hash. It's a one-way street. Get the direction backwards and everything breaks. Break rule 2 and two equal objects land in two different buckets — a HashSet will happily keep two "equal" elements side by side, and map.get(key) returns null for a key you just inserted.

Why are collisions allowed? Because the hash space (an int, about four billion states) is finite while the number of possible objects is infinite. So inevitably two different objects sometimes get the same hash. HashMap accepts this: a bucket may hold several entries, and it distinguishes them there using equals. A collision only costs speed, not correctness. But violating rule 2 — equals with unequal hashes — costs correctness.

How breaking it corrupts a HashMap

// BROKEN: overrides equals but NOT hashCode
class Point {
    final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }

    @Override public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }
    // hashCode() inherited from Object → identity-based
}

Map<Point, String> map = new HashMap<>();
map.put(new Point(1, 2), "A");
System.out.println(map.get(new Point(1, 2))); // prints null!

Let's walk through why disaster strikes. Two Point(1,2) instances are .equals (their x and y match). But since you didn't override hashCode, each gets the default identity hash derived from its memory address — and two separate news mean two separate addresses, so two different hashes. The librarian (HashMap) put the first instance on shelf 7 (say). Now you come back with the second instance; she runs the formula on it and arrives at shelf 31. Shelf 31 is empty. get never even reaches the equals comparison; it returns empty-handed from the wrong shelf. This is the single most common Java data bug.

The dual failure exists too

The reverse — override hashCode but not equals — is subtler. Now both instances go to the right bucket (the hash matches), but when HashMap tries to match them inside the bucket with equals, the inherited equals falls back to identity (addresses differ) and says "no, these aren't the same." So you still can't find your key by value. Lesson: these two must always be overridden together, on the same fields.

Canonical implementation

Now look at the correct version and dissect every engineering decision:

public final class Money {
    private final long amountMinor;   // store cents, never double
    private final String currency;

    public Money(long amountMinor, String currency) {
        this.amountMinor = amountMinor;
        this.currency = Objects.requireNonNull(currency);
    }

    @Override public boolean equals(Object o) {
        if (this == o) return true;                    // fast path
        if (!(o instanceof Money m)) return false;     // type + null check
        return amountMinor == m.amountMinor
            && currency.equals(m.currency);
    }

    @Override public int hashCode() {
        return Objects.hash(amountMinor, currency);    // combines fields
    }
}

A few practical points that make this "senior":

  • if (this == o) return true; is a fast path: if you're comparing an object to itself, there's no need to inspect fields. It saves time in hot loops.
  • if (!(o instanceof Money m)) return false; does two things at once: it checks the type is right, and it checks o isn't null (because null instanceof anything is always false). This is the "pattern matching for instanceof" idiom, official since Java 16; the variable m comes straight out of the cast.
`instanceof` vs `getClass()`

Whether you check the type in equals with instanceof or with getClass() is a subtle choice with serious consequences. instanceof is the pragmatic default (and required if you want subclasses to be equal to superclass instances), but if a subclass adds a new equals-relevant field, instanceof breaks symmetry (parent says "I equal the child," child says "no"). getClass() preserves symmetry but breaks Liskov substitution with proxies and subclasses — e.g. Hibernate makes a proxy whose class differs, so it no longer equals the real entity. Effective Java's advice: prefer composition over inheritance to sidestep the whole dilemma; use instanceof for the common final/leaf case.

Two more points:

  • Use the same fields in equals and hashCode. A field in one but not the other directly violates the golden rule (two objects could be equals yet, because a field present in equals is absent from hashCode, get different hashes).
  • Objects.hash(...) is convenient but boxes every argument and allocates a varargs array. Fine for an ordinary POJO; but in a hot hashCode inside a tight loop, those allocations add up, and there you should hand-roll it (which we see next). For nullable fields, use Objects.equals(a, b), which handles nulls and won't NPE.

The 31 * result idiom

@Override public int hashCode() {
    int result = Long.hashCode(amountMinor);
    result = 31 * result + currency.hashCode();
    return result;
}

This is the classic hand-rolled pattern: start with the hash of the first field, then for each subsequent field multiply the running result by 31 and add the new field's hash. Why exactly 31?

31 is a prime and odd number. Being prime helps hash distribution (hashes spread more evenly across buckets, fewer collisions). Being odd has a performance trick: 31 * i is exactly (i << 5) - i ("32 times minus one time"), so the JVM turns it into a shift and a subtract, cheaper than a real multiply.

Why the multiplier must be odd

If the multiplier were even, every multiply acts like "times a power of 2," i.e. it shifts bits leftward. Since an int has only 32 bits, bits that fall off the top edge are lost forever. With an even multiplier, after a few fields some of the early fields' information evaporates and hashes cluster. An odd multiplier has no such information leak.

Mutable keys — the silent killer

Even a perfectly correct equals/hashCode is defeated if you mutate a key after insertion:

Set<List<Integer>> set = new HashSet<>();
List<Integer> key = new ArrayList<>(List.of(1, 2));
set.add(key);
key.add(3);                          // hashCode of key just changed
System.out.println(set.contains(key)); // false — even the exact object is "gone"

Here you didn't even make a new object — you ask about the very same key and still get false! Why? Because List.hashCode is computed from the list's contents. When you did add(3), the content went from [1,2] to [1,2,3] and the hash changed. But the element still physically sits in the bucket for its old hash. Now contains computes the new hash and searches a different bucket, where it finds nothing.

Hash keys must be effectively immutable

Anything you put as a key in a HashMap/HashSet must stay constant after insertion (at least the fields involved in equals/hashCode). This is one of the strongest reasons to prefer immutable classes — String, records, numeric wrappers — for keys. If you can't mutate your key, it never gets orphaned.

Comparable, Comparator, and consistency with equals

So far we've discussed equality. Now let's turn to ordering. Here too you have a pair of concepts that, like equals/hashCode, must stay in sync.

Comparable<T> defines a natural ordering via the compareTo method — "my default way of sorting myself," written inside the class itself. Comparator<T>, by contrast, is an external, swappable ordering: without touching the class, you say from the outside "sort it this way this time."

Library books

Comparable is like every book having a call number on its spine — its intrinsic order. Comparator is like a librarian who says "today sort not by number but by cover color." The book doesn't change; only an external sorting rule is applied to it, and tomorrow you can supply a different rule.

record Person(String name, int age) {}

// Comparator: sort by age, then name — no need to touch the class
Comparator<Person> byAgeThenName =
    Comparator.comparingInt(Person::age)
              .thenComparing(Person::name);

compareTo returns a negative/zero/positive number: negative means "I am smaller," zero means "we're equal," positive means "I am larger." Its contract mirrors equals: it must form a total order (antisymmetric, transitive) and satisfy signum(x.compareTo(y)) == -signum(y.compareTo(x)) — i.e. if x is greater than y, then y must be less than x. (signum returns just the sign: -1, 0, or +1.)

Consistency with equals — the BigDecimal trap

The docs strongly recommend but do not require that (x.compareTo(y) == 0) == x.equals(y) — "if ordering says they're equal, equality should agree." The famous violator is BigDecimal:

BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");

System.out.println(a.equals(b));       // false — scale differs (1 vs 2)
System.out.println(a.compareTo(b));    // 0 — numerically equal

Set<BigDecimal> hashSet = new HashSet<>(List.of(a, b));
Set<BigDecimal> treeSet = new TreeSet<>(List.of(a, b));
System.out.println(hashSet.size());    // 2 — uses equals
System.out.println(treeSet.size());    // 1 — uses compareTo!

"1.0" and "1.00" are numerically one number, but BigDecimal stores, besides the value, the scale (number of digits after the decimal point): the first has scale 1, the second scale 2. equals checks both value and scale, so it calls them unequal. But compareTo sees only the numeric value and calls them equal.

Buckets go by equals, trees go by compareTo

Here's the senior point: HashSet/HashMap determine membership by equals (and hashCode), but TreeSet/TreeMap determine membership by compareTo. That's why hashSet.size() is 2 (both kept) while treeSet.size() is 1 (the second is treated as a duplicate because its compareTo with the first is zero, and dropped). Key takeaway: when you hand a Comparator to a TreeMap, you are effectively redefining equality for that structure. If your comparator is inconsistent with equals, sorted collections silently "lose" elements a HashSet would keep.

String: immutability, the pool, and identity

String is immutable — meaning once a string is created, its content never changes. Its backing data (byte[] value since Java 9's "compact strings"; char[] before) is both final and never mutated. When you call s.toUpperCase(), the old string stays untouched and a new string is returned.

A laminated ID card

A Java string is like a laminated ID card: whenever you want to change something on it, you can't cross it out; you have to print a completely fresh card. The old card stays as it was. This "print fresh instead of cross out" is what gives strings several superpowers: because nobody can change its content out from under someone else, you can share it fearlessly across threads, use it carelessly as a HashMap key (its hash never changes, so it never gets orphaned), and let the pool share copies.

The string pool and ==

String literals — any string you write directly in code, like "hello" — are automatically interned into a pool in the heap. "Intern" means Java keeps one canonical copy of each distinct string, and all identical literals share that one object:

String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);          // true — same pooled object

String s3 = new String("hello");
System.out.println(s1 == s3);          // false — new() forces a fresh heap object
System.out.println(s1.equals(s3));     // true — same value
System.out.println(s1 == s3.intern()); // true — intern() returns the pooled ref

s1 and s2 both point to the same object inside the pool, so == is true. But new String("hello") explicitly says "make me a fresh object" and skips the pool, so s3 has a different address and s1 == s3 is false — even though their contents are identical, which .equals confirms. And intern() says "give me the canonical pooled reference for this string," which is s1 again.

Rule: never compare strings with `==`

Always use .equals (or equalsIgnoreCase). Every time == on strings happens to be true, it's merely an accident of interning, not a guarantee. Code that "works" in a unit test with literals silently breaks in production on strings built at runtime (from a file, the network, user input).

Compile-time constant folding

Here the behavior gets one layer subtler:

String a = "hel" + "lo";               // folded at compile time → literal "hello"
System.out.println(a == "hello");      // true

String part = "hel";
String b = part + "lo";                // runtime concatenation → new object
System.out.println(b == "hello");      // false

final String cpart = "hel";            // compile-time constant
String c = cpart + "lo";               // folded → true
System.out.println(c == "hello");      // true

The point is: a + performed on compile-time constants — literals, final variables initialized with a constant, and static finals — is computed by the compiler (javac) before execution and folded into a single literal, which is then interned. So "hel" + "lo" is effectively the very same "hello" inside the pool.

But a + that involves a runtime value (like the non-final variable part) builds a brand-new object at runtime that is not in the pool. The magic of the third line is here: you made cpart final and initialized it with a constant, so the compiler knows its value is fixed and folds again.

String concatenation since Java 9

Since Java 9, runtime string concatenation no longer necessarily compiles to explicit StringBuilder chains; instead it compiles to an invokedynamic instruction that reaches StringConcatFactory. That means the JVM itself decides at runtime what the best concatenation strategy is. This is an implementation detail, but it's good to know why today's bytecode for a + b differs from Java 8's.

intern() — use with care

intern() returns the canonical pooled instance, enabling a later ==. When you have a huge number of duplicate strings, it can save memory (all pointing to one copy instead of thousands). But the pool is a fixed-size hash table (tunable via the -XX:StringTableSize flag), and interning huge sets of distinct strings just adds pressure and CPU with no benefit. Modern default: don't intern; let the pool handle literals, and use equals to compare.

StringBuilder — why and how

Because strings are immutable, s = s + x in a loop is O(n²): each iteration allocates a brand-new string and copies all the previous characters into it. So at the 100th iteration, 100 prior characters are copied again; at the 1000th, 1000 characters — and the total is quadratic.

// BAD: quadratic, allocates n intermediate Strings
String r = "";
for (String w : words) r += w;

// GOOD: one buffer, amortized O(n)
StringBuilder sb = new StringBuilder(words.size() * 8); // pre-size to avoid resizes
for (String w : words) sb.append(w);
String r = sb.toString();

StringBuilder is a mutable buffer: instead of building a fresh string each step, it appends into the same buffer and only occasionally grows it when full. With new StringBuilder(words.size() * 8) you make the buffer big from the start with a rough guess, to avoid repeated resizes.

StringBuilder or StringBuffer?

StringBuilder is not synchronized — fast, for single-threaded work. StringBuffer is the synchronized version of it — legacy, rarely needed (only if you truly share one buffer across threads, which is unusual). And remember: a single a + b + c expression is fine; the compiler fuses it into one operation itself. Only manual loops need the explicit builder.

Autoboxing, unboxing, and the Integer cache

Autoboxing means Java automatically converts an int to an Integer (behind the scenes via Integer.valueOf). Unboxing is the reverse: Integer to int (via intValue). This automation is very convenient — you can drop an int straight into a List<Integer> — but it's also a minefield.

The cache: -128..127

Here's the key point: Integer.valueOf caches instances for values -128 to 127 inclusive. So same-valued boxes in that range are the very same object; but outside that range, each box is a fresh object.

Integer a = 127, b = 127;
System.out.println(a == b);   // true  — both from the cache

Integer c = 128, d = 128;
System.out.println(c == d);   // false — outside cache, distinct objects
System.out.println(c.equals(d)); // true — value equality
A shelf of small numbers

Imagine Java keeps a small shelf holding one ready-made copy of each "common" number from -128 to 127. When you want an Integer for one of these small numbers, it hands you the ready-made copy — so asking twice for 127 gives the same object. But 128 isn't on the shelf, so each time it must build a fresh one. That's why 127 == 127 is true but 128 == 128 is false. It's not something to rely on; you just need to know it's there.

Which types have a cache? Boolean, Byte, Short (range -128..127), Character (range 0..127), Integer (range -128..127, whose upper bound you can raise via the -XX:AutoBoxCacheMax=<n> flag or the java.lang.Integer.IntegerCache.high property), and Long (range -128..127, but this one not tunable). And importantly: Float and Double have NO cacheDouble d1 = 1.0; Double d2 = 1.0; d1 == d2 is always false.

Never compare wrappers with `==`

Integer == Integer is a reference comparison, so its result depends on the cache and, as you saw, silently returns false for numbers above 127 even when the values match. Always use .equals or unbox both to primitives. This is one of the nastiest bugs because your test passes with small IDs and production blows up at 128.

Mixed ==: one primitive forces unboxing

If either operand of == is a primitive, the other is unboxed too and the comparison is no longer a reference comparison but a numeric one — and the cache is not involved at all:

Integer i = 1000;
int j = 1000;
System.out.println(i == j);   // true — i is unboxed to int, numeric compare

Here j is a raw int. Java can't directly compare an int and an Integer with reference ==, so it must unbox i to make both ints, then compare values. 1000 equals 1000, so true — even though 1000 is well outside the cache range!

Same-looking code, opposite meaning

Notice the beautiful trap: Integer == Integer is a reference comparison (cache-dependent), but Integer == int is a value comparison (always correct). Two nearly identical lines, two completely different meanings. Their only difference is whether one side became a primitive.

NullPointerException from unboxing

Map<String, Integer> counts = new HashMap<>();
int n = counts.get("missing");  // get returns null → auto-unbox → NPE

counts.get("missing") returns null because the key is absent — and that null is of type Integer. Now you want to put it into an int (a primitive), so Java tries to unbox it, i.e. call intValue() on null — and right there a NullPointerException is thrown, on a line with no null written in it! This is one of the most surprising production crashes.

How to guard

Use getOrDefault("missing", 0), which yields zero if the key is absent, or Optional, or simply keep the variable's type as Integer (a reference) so that if it's null the crash at least happens on the right, obvious line rather than in a hidden unbox.

Performance: boxing in hot paths

// SLOW: Long autoboxes ~ every iteration → millions of allocations
Long sum = 0L;
for (long i = 0; i < 100_000_000L; i++) sum += i; // unbox, add, re-box each time

// FAST: primitive, zero allocation
long sum2 = 0L;
for (long i = 0; i < 100_000_000L; i++) sum2 += i;

The only difference between these two loops is one capital letter: Long vs long. But in the slow version, every time sum += i runs, Java must unbox sum, do the addition, and re-box the result into a fresh Long. A hundred million iterations means a hundred million object allocations and enormous pressure on the garbage collector. The primitive version allocates nothing.

Stay primitive in numeric heavy lifting

When boxing dominates, use primitive-specialized collections and streams: IntStream, LongStream, or libraries like Eclipse Collections and fastutil. Generics always box (List<Integer> really is a list of Integer objects, not ints), which is why int[] is far faster and leaner than List<Integer> for bulk numeric work.

Designing immutable classes

We saw what superpowers immutability gave String. Now let's learn to build our own immutable class. Immutability buys thread-safety, safe hashing, safe sharing, and cache-friendliness. The recipe (from Effective Java) has four steps:

  1. Make the class final (or use a private constructor + factory methods) so nobody can subclass it and mutate it through that.
  2. Make all fields private final.
  3. Provide no mutators (no setters or other state-changing methods).
  4. Defensively copy mutable inputs on the way in and mutable state on the way out.
public final class Period {
    private final Date start;   // Date is mutable — danger
    private final Date end;

    public Period(Date start, Date end) {
        // copy IN: caller can't mutate our internals afterward
        this.start = new Date(start.getTime());
        this.end   = new Date(end.getTime());
        if (this.start.after(this.end))
            throw new IllegalArgumentException("start after end");
        // validate the COPIES, not the args (TOCTOU safety)
    }

    public Date start() { return new Date(start.getTime()); } // copy OUT
    public Date end()   { return new Date(end.getTime());   }
}
Hand out a copy of your key, not the original

Date in Java is mutable — anyone holding a Date can change its time. If you keep the caller's Date reference directly, it's like handing your house key to a guest: they can come back later and rearrange your interior without permission. Instead you take a copy on the way in (new Date(...)) and hand out a copy on the way out, never the original. Now whatever the outside does to the copies, your interior stays untouched and your invariants (e.g. "start is before end") stay intact.

A subtle point inside the constructor: you validate the copies, not the original arguments. This guards against a TOCTOU attack (time-of-check to time-of-use): if you checked the original argument, another thread could change its value exactly between your check and your copy. By checking the copy, what you check is what you keep. And the best move: choose immutable field types from the start — java.time.Instant, LocalDate, List.copyOf(...) — so no copying is needed at all.

Records — data carriers done right

Java 16+ records are transparent, immutable data aggregates. You just declare the components, and the compiler generates the canonical constructor, the private final fields, the accessors, and — most importantly — contract-correct equals, hashCode, and toString derived from all the components.

public record Money(long amountMinor, String currency) {
    // compact constructor: validate/normalize, no field assignment needed
    public Money {
        Objects.requireNonNull(currency);
        if (amountMinor < 0) throw new IllegalArgumentException("negative");
        currency = currency.toUpperCase(); // reassigning the parameter normalizes the field
    }
}

Notice how clean the compact constructor is: no parameter list, no this.currency = currency; you just validate and normalize, and the compiler wires up the rest. Even when you reassign the currency parameter (toUpperCase), that normalized value lands in the final field.

The biggest reason records are loved

Records give you a correct equals/hashCode for free — exactly the contract this whole lesson has been about the difficulty of upholding. That's why a record is the best choice for map keys, DTOs, and value objects: the "I wrote equals but forgot hashCode" danger simply doesn't exist.

But a few caveats seniors know:

  • Records are shallowly immutable: a record Holder(List<String> items) still exposes that mutable List outward. For true immutability you must copy in the compact constructor: items = List.copyOf(items).
  • The generated equals uses all components. But if a component is an array, equals uses the array's identity, not its contents (because arrays don't override equals). So record + array is a subtle bug: two records with identical array contents will have equals return false.
  • Records are implicitly final, can't extend a class, but can implement interfaces. They're good for data, not for behavior-rich hierarchies.

Common pitfalls & gotchas

A compact list to review — each unpacked in the lesson above:

  • map.get(new Point(1,2)) returns null because you overrode equals but not hashCode.
  • Mutating a key after putting it in a HashSet/HashMap orphans it.
  • Integer == Integer is true only within -128..127; your integration tests pass with small IDs and prod breaks at 128.
  • int x = map.get(k) NPEs when the key is absent.
  • str1 == str2 "works" in unit tests (interned literals) and fails on runtime-built strings.
  • Double d = 1.0; d == 1.0 — the 1.0 literal is a primitive, so d unboxes; this is true. But Double == Double is false.
  • BigDecimal in a HashSet vs TreeSet gives different sizes for 1.0 and 1.00.
  • Objects.hash() allocates — don't call it in a hot loop's hashCode.
  • Overriding equals with a wrong parameter type: public boolean equals(Point p) overloads rather than overrides Object.equals(Object) — collections still call the Object version. Always @Override and take Object.

Best practices

  • Always override equals and hashCode together, on the same fields, and always add @Override.
  • Prefer records for value objects and map keys — free, correct contract.
  • Never compare boxed wrappers or strings with ==; use .equals.
  • Keep hash keys immutable.
  • Use primitive types and primitive streams in numeric hot paths.
  • Defensively copy mutable inputs/outputs, or use immutable types to begin with.
  • Keep Comparator consistent with equals unless you deliberately document otherwise.

Interview Questions

These are the questions that come up in a senior interview. Read each with its full answer and say it out loud to yourself.

1. State the hashCode contract and explain exactly how violating it breaks a HashMap.

Equal objects must return equal hash codes; unequal ones may collide; and results must be consistent within a run. If two equals objects have different hashes, HashMap routes them to different buckets, so get never even reaches the equals comparison and returns null for a key that is present — and a HashSet can hold two "equal" elements at once.

2. (Gotcha) `Integer a = 127, b = 127, c = 128, d = 128;` — what do `a==b` and `c==d` print, and why?

true and false. Integer.valueOf caches the range -128..127, so a and b are both the same cached object and their == is true. But 128 is outside the cache, so c and d are two distinct objects and their == is false. Use .equals for value comparison.

3. (Gotcha) What prints?
Integer i = 1000; int j = 1000;
System.out.println(i == j);

true. Because j is a primitive, Java unboxes i and the comparison becomes numeric — the cache is entirely irrelevant. Contrast this with Integer == Integer, which is a reference comparison that depends on the cache. The only difference is the presence of a primitive on one side.

4. (Hard, what prints) Explain each line:
System.out.println("a" + "b" == "ab");           // (1)
String x = "a"; System.out.println(x + "b" == "ab"); // (2)

(1) true: the expression "a"+"b" is a compile-time constant that the compiler folds into the literal "ab" (constant folding), and that literal is interned, so it's the same "ab" object. (2) false: x is a runtime value (a non-final variable), so x + "b" builds a new String on the heap at runtime that isn't in the pool. This question is hard because it hinges on constant folding, not runtime concatenation.

5. Why is `String` immutable, and name three concrete benefits.

Its backing array is final and never mutated. Benefits: (1) inherent thread-safety — you can share it freely across threads because nobody mutates it; (2) safe as a HashMap key — since the content is fixed, its hash stays stable and the key never gets orphaned; (3) pooling/interning to save memory. Bonus: security — a validated file path or URL can't be changed out from under you after the check.

6. When is `new String("x")` justified over the literal `"x"`?

Almost never. It forces a distinct heap object, defeating the pool and wasting memory. Legitimate niche: forcing a fresh identity for a lock object, or detaching a substring's large backing array in old JDKs (new String(sub) before Java 7u6's substring-copy change, where before that a substring shared the original string's big array and prevented it from being freed). Otherwise it's a code smell.

7. (Gotcha) BigDecimal `1.0` vs `1.00` in HashSet vs TreeSet — sizes?

HashSet gives size 2 (it uses equals, which considers scale, so 1.0 and 1.00 are unequal), while TreeSet gives size 1 (it uses compareTo, which is purely numeric and ignores scale, so it sees them as equal duplicates). This illustrates compareTo/equals inconsistency: sorted collections define membership by the comparator, not by equals.

8. Should Comparable always be consistent with equals? Give a violator.

Strongly recommended, not required. BigDecimal violates it (its compareTo is zero for 1.0 and 1.00, but its equals is false). The danger is that TreeMap/TreeSet use compareTo to decide membership, so an inconsistent ordering silently drops or merges elements a HashSet would keep.

9. (Find the bug)
class Id {
    final String v; Id(String v){this.v=v;}
    public boolean equals(Id o){ return v.equals(o.v); } // bug
    public int hashCode(){ return v.hashCode(); }
}

equals(Id) overloads rather than overrides Object.equals(Object) — it creates a new method with a different signature, not a rewrite of the parent method. Collections always call equals(Object), which is the inherited identity version from Object — so this class behaves as if equals was never overridden. Fix: make the signature public boolean equals(Object o) and add @Override; that @Override would have caught the bug at compile time.

10. Why 31 in hashCode, and why must a manual multiplier be odd?

31 is an odd prime giving good distribution, and the JVM optimizes 31*i into (i<<5)-i (a shift and a subtract instead of a full multiply). An even multiplier would shift bits off the top edge on int overflow, losing the early fields' information and clustering hashes; an odd multiplier has no such leak.

11. (Gotcha, what prints)
Map<String,Integer> m = new HashMap<>();
System.out.println(m.get("x") + 1);

Throws NullPointerException. m.get("x") returns null (of type Integer), and the + 1 operation forces it to unbox — i.e. intValue() is called on null → NPE, on a line that never mentions the word null. Fix: getOrDefault("x", 0).

12. `getClass()` vs `instanceof` in equals — what's the trade-off?

getClass() guarantees symmetry but forbids a subclass instance from equaling a superclass one (breaks with Hibernate proxies and subclassing, since a proxy has a different class). instanceof allows cross-type equality but can violate symmetry if a subclass adds equals-relevant state. Effective Java's advice: prefer composition over inheritance so the dilemma disappears entirely; use instanceof for the common final/leaf case.

13. (Hard, what prints)
Object o = true ? Integer.valueOf(1) : Double.valueOf(2.0);
System.out.println(o);

1.0. In a conditional expression (? :) where one branch is Integer and the other is Double, the rule of binary numeric promotion kicks in: Java unboxes both and promotes to double, then re-boxes the result to Double — so even though the condition is true and you'd expect 1, the value becomes 1.0. This is a notorious conditional-expression trap in the JLS (Java Language Specification).

14. Are records fully immutable? What's the array gotcha?

Records are shallowly immutable — the fields are final, but a component that references a mutable object (a List, an array) is still mutable through that reference. Fix: copy in the compact constructor (List.copyOf(...)). Also, a byte[] component makes the generated equals/hashCode use the array's identity, not its contents, silently breaking value equality (two records with equal-content arrays are treated as unequal).

15. (Senior) You use a mutable object as a HashMap key and lookups start failing intermittently. Diagnose.

Something mutated an equals/hashCode-relevant field after insertion. The entry now lives in the bucket for its old hash; a lookup computes the new hash and searches a different bucket, returning null. It's "intermittent" because only the keys that got mutated misbehave. Fix: use immutable keys (records, String, boxed wrappers), or never mutate keys after insertion.

Senior notes & advanced edge cases

You know the contract now. Let's go where a senior pulls ahead of a strong mid-level: what actually happens inside a HashMap, how an innocent-looking Comparator takes a production service down with an exception, why the default hashCode is not the memory address at all, and why equals/hashCode for a Hibernate @Entity is one of the hardest design calls — one that 90% of teams get wrong.

Roadmap for this section
  1. Inside HashMap: hash spreading, bucket treeification, load factor and rehash, and the hash-flooding attack.
  2. The Comparator trap: a - b subtraction overflow, and the notorious "Comparison method violates its general contract!" crash from TimSort.
  3. The address-based hashCode myth: the mark-word reality and its effect on biased locking.
  4. Floating-point equality: why NaN.equals(NaN) is true but 0.0 == -0.0, and how records behave with double.
  5. equals for JPA/Hibernate entities: the null-id-before-persist problem, the constant-hashCode trick, and the Lombok trap.
  6. Strings at scale: hashCode caching, intern's memory cost, and how it differs from G1 string deduplication.
  7. Modern immutable collections and a few sharp edges.

Part 1 — Inside HashMap: what the senior interview really asks

Once you respect the contract, the next question is: "what exactly happens on put?" The shallow answer ("it computes the hash and drops it in a bucket") marks you as mid-level. The senior answer has three layers.

Layer one: hash spreading

Your hashCode() gives a 32-bit int, but HashMap only uses the low bits to pick a bucket (because the table size is always a power of two, and index = hash & (n-1)). If your hashCodes differ only in their high bits, everything lands in one bucket. So HashMap "stirs" the hash before using it:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

That h ^ (h >>> 16) XORs the high bits down so they influence the index. Senior lesson: HashMap partially compensates for bad hashCodes but performs no miracles — a constant return 42; piles everything into one bucket and collapses performance to O(n).

Layer two: bucket treeification

Since Java 8, if a single bucket gets very crowded, HashMap converts that bucket from a linked list to a red-black tree, so intra-bucket lookup goes from O(n) to O(log n). Two thresholds are involved:

  • TREEIFY_THRESHOLD = 8: a bucket that reaches 8 entries becomes a treeify candidate.
  • MIN_TREEIFY_CAPACITY = 64: but only if the whole table has at least 64 slots; otherwise it resizes first instead of treeifying.
  • UNTREEIFY_THRESHOLD = 6: if removals drop it back to 6, it reverts to a list.
Treeification leans on Comparable

When a bucket becomes a tree, HashMap orders the nodes first by hashCode; if hashes tie and the key is Comparable, it uses compareTo to break the tie. So if your keys have catastrophic hashCodes and aren't Comparable, the tree degrades to a weak identity-hash tie-break. Practical lesson: treeification is Java's safety net against a bad hashCode and collision attacks — not a license to be sloppy. A good hashCode is still mandatory.

Bucket lifecycle inside a HashMap — چرخهٔ عمرِ یک سطل در HashMap:

stateDiagram-v2
    [*] --> Empty
    Empty --> LinkedList: put (collision)
    LinkedList --> Tree: size >= 8 AND table >= 64
    LinkedList --> Resize: size >= 8 AND table < 64
    Resize --> LinkedList: rehash spreads entries
    Tree --> LinkedList: size <= 6 (untreeify)
    Tree --> [*]: clear

Layer three: load factor and rehash

When the element count exceeds capacity * loadFactor (default 0.75), HashMap doubles capacity and rehashes everything. Three senior consequences:

  • If you know the final size, pre-size with new HashMap<>(expectedSize / 0.75f + 1) to eliminate repeated rehashes.
  • HashMap iteration order is not guaranteed and can change after a resize; never rely on it — insertion order → LinkedHashMap, sorted order → TreeMap.
  • HashMap accepts a null key and null values, but ConcurrentHashMap accepts neither — because concurrently, map.get(k) == null would be ambiguous ("absent" vs "value is null?").
Hash-flooding (algorithmic complexity attack)

If your map's keys come from user input (say, HTTP request parameters stuffed into a Map), an attacker can deliberately craft thousands of keys with identical hashCodes so they all land in one bucket, turning every lookup into O(n) and the whole request into O(n²) — a CPU-based DoS. Java 8 treeification softens this from O(n²) to O(n log n) but does not remove it. Real defense: on untrusted data, cap the number of keys or hash the key with a randomized, attack-resistant function (like SipHash in some languages).

Part 2 — the Comparator trap that takes a service down

This is one of the most dangerous "looks like clean code" bugs.

Subtraction instead of comparison = silent overflow

// BUG: int overflow
Comparator<Integer> byValue = (a, b) -> a - b;
byValue.compare(Integer.MAX_VALUE, -1); // expected: positive. reality: overflow → negative!

a - b overflows when a is large and b is a large negative, and the sign flips. The result is wrong ordering, or worse, a violated Comparator contract. Always use Integer.compare(a, b) (or Long.compare, Double.compare) — never subtraction.

The infamous TimSort crash

Java's sort for objects (Arrays.sort/Collections.sort) is TimSort, and it validates the Comparator contract at runtime. If your Comparator is inconsistent/non-transitive (from that overflow, or from comparing fields that are sometimes null or NaN), it throws this mid-sort:

java.lang.IllegalArgumentException: Comparison method violates its general contract!
Why this crash is "intermittent" and hell to debug

The exception only fires when a particular arrangement of data drives TimSort into a region where it observes the contradiction — so it's green with 10 records and explodes with a specific 10,000. Common causes: (1) overflowing subtraction, (2) NaN in a double field (since NaN is neither < nor > than anything, even itself, breaking total order), (3) a Comparator that depends on mutable state or time. Fix: build with Comparator.comparingInt(...).thenComparing(...), use Comparator.nullsFirst(...) for null fields and Double.compare for floats (which also orders NaN).

// correct: null-, NaN-, and overflow-safe
Comparator<Person> safe =
    Comparator.comparing(Person::name, Comparator.nullsFirst(Comparator.naturalOrder()))
              .thenComparingInt(Person::age);

Part 3 — the default hashCode is not the memory address

A widespread belief (simplified even in this very chapter) is that Object's default hashCode is the "memory address." On modern JVMs this is wrong.

In HotSpot, the identity hash code is a number generated the first time it's needed and cached in the object's own header mark word. Since JDK 8 the default comes from a pseudo-random algorithm based on thread state (not the address). Why can't it be the address? Because GC (especially compacting collectors) moves objects in memory; if the hash came from the address, it would change after each GC and break the "hashCode must be consistent during a run" contract. By caching it in the header, the hash stays fixed even if the object relocates.

A strange side effect: identityHashCode revokes biased locking

Because the hash and the biased-locking state both live in that same mark word, the first time you call System.identityHashCode(obj) (or the default Object.hashCode()) on an object, that object is permanently taken out of biased locking, and any existing bias is revoked. In performance-sensitive code that both locks and hashes an object, this has an invisible throughput effect. (In Java 15+ biased locking is disabled by default and being removed, so this is now more historical / for old JVMs.)

When IdentityHashMap is right

Sometimes you genuinely want keys compared by == (identity), not equals — e.g. an object graph for serialization, or tracking "have I seen this exact object before?" for cycle detection. IdentityHashMap does exactly that, using System.identityHashCode. It's the one place where relying on identity is deliberate and correct.

Part 4 — floating-point equality: NaN and negative zero

Three ways to compare a double give three different answers, and a senior must know which is which:

double n = Double.NaN;
System.out.println(n == n);                      // false — NaN != anything, even itself
System.out.println(Double.valueOf(n).equals(n)); // true  — Double.equals works on bits

System.out.println(0.0 == -0.0);                    // true  — the == operator sees them equal
System.out.println(Double.valueOf(0.0).equals(-0.0)); // false — equals distinguishes them
System.out.println(Double.compare(0.0, -0.0));      // 1     — compare puts -0.0 below +0.0

Why? Double.equals and Double.compare convert the value to bits (doubleToLongBits) to stay reflexive: NaN must equal NaN, otherwise one NaN gets inserted into a HashSet twice and is never found; and +0.0 and -0.0, which have different bit patterns, are counted separately.

A record with a double field inherits these semantics

A record's generated equals does not use == for double/float fields; it uses Double.compare/bitwise semantics. So:

record P(double x) {}
new P(Double.NaN).equals(new P(Double.NaN)); // true!  (unlike ==, which was false)
new P(0.0).equals(new P(-0.0));              // false! (unlike ==, which was true)

If you use a record with a floating-point field as a map key and expect +0.0 and -0.0 to be one, you'll be surprised. Fix: normalize zero at construction (x == 0.0 ? 0.0 : x) or avoid double keys altogether.

Part 5 — equals/hashCode for JPA/Hibernate entities (the real production trap)

This is where textbook theory collides with ORM reality, and most teams get it wrong. Take an @Entity whose id is generated by the database on persist (@GeneratedValue).

Problem 1 — the id is null before persist. If you base equals/hashCode on id: you put a new entity (id = null) into a HashSet, then save it, now the id gets a value and hashCode changes → the exact "mutable key" trap from the main chapter: the entity is lost in its old hash bucket and set.contains(entity) returns false.

Problem 2 — the lazy proxy. For lazy relations Hibernate creates a subclass proxy whose getClass() differs from the real class; so getClass() in equals (the one shown in the main chapter) makes the real entity unequal to the proxy of the very same row.

The senior fix (Vlad Mihalcea's recommendation):

@Entity
public class Book {
    @Id @GeneratedValue Long id;
    @Column(unique = true) String isbn; // stable, unique business key

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Book)) return false; // instanceof works with the proxy
        return isbn != null && isbn.equals(((Book) o).isbn);
    }
    @Override public int hashCode() {
        return getClass().hashCode(); // constant! never changes
    }
}
Two key tricks for entity equals
  1. Return a constant hashCode (e.g. getClass().hashCode() or a fixed number). Because hashCode must stay constant while the object is a Set member, being constant immunizes it against the mutation trap — yes, everyone lands in one bucket, but the number of entities co-resident in a single Set is usually small, so it's harmless in practice.
  2. For equals, use a stable, unique business/natural key (isbn, an application-generated uuid, an order number) — not the database id. If you have no natural key, generate a UUID in the constructor (not from the DB).
The Lombok trap: @EqualsAndHashCode on an entity with a bidirectional relation

Lombok's @EqualsAndHashCode and @Data include all fields in equals/hashCode by default. On an entity this is a disaster: (1) it includes relation fields, so in a bidirectional relation (Order ↔ OrderLine) the hashCode call recurses infinitely and throws StackOverflowError; (2) it triggers access to lazy fields, firing unintended queries. If you must use Lombok: @EqualsAndHashCode(onlyExplicitlyIncluded = true) and mark only the business key with @EqualsAndHashCode.Include.

Part 6 — strings at scale

String hashCode is cached

String caches its hashCode in an int hash field (it can, because it's immutable and never changes). The formula is a polynomial: s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]. Two fine points: (1) "".hashCode() is 0, and since the cache's initial value is also 0, the empty string and any string that happens to hash to zero get recomputed every time (newer JDKs added a hashIsZero flag to fix this). (2) Because the formula is public and well known, an attacker can craft strings with equal hashes — another vector for hash-flooding.

intern has a memory cost

String.intern() since Java 7 keeps the pool on the heap (it used to live in PermGen, the source of a famous OOM). But interned strings, like any heap object, aren't GC'd while referenced; interning millions of distinct strings is a slow memory leak. Interning only pays off when you have a small number of high-frequency values (e.g. column names while parsing a large file).

intern vs G1 string deduplication

Don't conflate them: intern is a programmer action that unifies strings in the pool so == works. String deduplication (-XX:+UseStringDeduplication on G1) is a GC action that, behind the scenes, merges the backing char[]/byte[] of strings with identical content to save memory — without changing == or involving your code. If your app is full of duplicate strings, try that GC flag before hand-rolling intern; it carries no leak risk.

Part 7 — modern immutable collections, a few sharp edges

Since Java 9, List.of, Set.of, Map.of build lightweight immutable collections, but they behave differently from ArrayList/HashMap, and three of those differences bite seniors: (1) they're null-hostileList.of(null) or Map.of("k", null) throw; (2) a duplicate key/element like Set.of("a","a") throws IllegalArgumentException at construction, unlike HashSet, which silently ignores it; (3) iteration order is randomized and unstable — they use a random SALT per JVM run, so order changes between runs and an order-sensitive test breaks.

Records serialize more safely

On deserialize, records invoke the canonical constructor instead of bypassing it (the classic-serialization security hole), so the compact-constructor validation and normalization run again and invariants are preserved — safer than an ordinary class for serializable value objects.

Senior interview questions (advanced)

1. Exactly when does a HashMap bucket become a tree, and does that "fix" a bad hashCode?

When a bucket's entry count reaches TREEIFY_THRESHOLD = 8 and simultaneously the whole table has at least MIN_TREEIFY_CAPACITY = 64 slots; if the table is smaller, it resizes first instead of treeifying. Treeification takes intra-bucket lookup from O(n) to O(log n) and is a safety net against a bad hashCode and collision attacks — but it does not "fix" it: if your hashCode is catastrophic, everyone is still in one bucket and the best case is O(log n), not O(1). Also, to order the nodes, when hashes tie and the key is Comparable, compareTo is used. A good hashCode is still mandatory.

2. What's wrong with this Comparator, and when does it blow up? `Comparator<Integer> c = (a, b) -> a - b;`

a - b overflows on int: if a = Integer.MAX_VALUE and b = -1, the mathematical result is positive but overflow makes it negative, so the order flips and the contract is violated. It's never seen with small data; with large or negative numbers it suddenly gives wrong ordering or a TimSort crash. Fix: Integer.compare(a, b), which doesn't overflow. Rule of thumb: never subtract in a Comparator; use X.compare.

3. In production you get "Comparison method violates its general contract!". What causes it, and why is it intermittent?

Java's sort (TimSort) validates the Comparator contract as it runs and throws this IllegalArgumentException if it detects an inconsistency. Causes: subtraction overflow, NaN in a floating field (neither < nor > than anything, breaking total order), comparing null fields without nullsFirst/Last, or a Comparator that depends on mutable state (non-transitive/unstable). It's "intermittent" because only a particular arrangement of data drives TimSort to the point where it sees the contradiction — hence green with small data, red with a specific large input. Fix: build the Comparator with comparingInt/thenComparing/nullsFirst/Double.compare.

4. Is Object's default hashCode the memory address?

No. On modern HotSpot, the identity hash code is generated pseudo-randomly (from thread state, not the address) the first time it's needed and cached in the object header's mark word. It can't be the address because GC relocates objects, which would then change the hash and break the consistency contract; caching in the header keeps the hash fixed even after a move. Fun side effect: the first identity-hash computation on an object takes it out of biased locking, because both live in the same mark word.

5. For `record P(double x)`, is `new P(Double.NaN).equals(new P(Double.NaN))` true? And `new P(0.0).equals(new P(-0.0))`?

The first is true, the second is false — exactly the opposite of the == operator. Because a record's generated equals doesn't use == for double/float; it uses bitwise semantics (Double.compare/doubleToLongBits) to stay reflexive: NaN must equal NaN (or it gets lost in a HashMap), and +0.0 and -0.0, having different bit patterns, are counted separately. If you want ±0.0 to be one, normalize zero in the compact constructor.

6. How do you write equals/hashCode for a JPA @Entity, and why is using the database id dangerous?

Do not use the DB-generated id, because it's null before persist and changes after; so if you put the entity in a Set before saving, the id change alters hashCode and the entity gets lost in its old bucket. Correct: base equals on a stable, unique business key (isbn, an app-generated uuid) using instanceof (not getClass, so it works with the Hibernate proxy), and return a constant hashCode (getClass().hashCode()) so it never changes. If you have no natural key, generate a UUID in the constructor.

7. What does `String.intern()` cost in memory today, and how does it differ from G1 string deduplication?

Since Java 7, intern keeps the pool on the heap; interned strings aren't GC'd while referenced, so interning millions of distinct strings is a slow memory leak plus CPU pressure. It only pays for a small number of high-frequency values. The difference: intern is a programmer action so == works on strings; string deduplication (-XX:+UseStringDeduplication on G1) is a GC action that merges the backing arrays of equal-content strings behind the scenes to save memory, without changing == or touching your code. For memory savings, try the GC flag first, not manual intern.

8. What does `Set.of("a", "a")` do, and is `Set.of` iteration order stable across runs?

Set.of("a","a") throws IllegalArgumentException at construction (unlike HashSet, which silently ignores a duplicate). And no — Set.of/Map.of iteration order is not stable: these collections use a random SALT per JVM run, so order changes between runs. It's deliberate, so nobody relies on order; an order-sensitive test breaks intermittently. Also, these collections are null-hostile, so Set.of((Object)null) throws too.

Senior capsule
  • Inside HashMap: hash spread, treeify at 8 with a 64-slot table, rehash at 0.75 load factor; the tree is a safety net, not a substitute for a good hashCode.
  • Comparator: never subtract (X.compare); nullsFirst and Double.compare for null and NaN; otherwise the TimSort crash.
  • Default hashCode is not the address; it's a cached number in the mark word, and it revokes biased locking.
  • Floating point: NaN.equals(NaN) true, 0.0.equals(-0.0) false; records inherit this.
  • Entities: business key for equals, constant hashCode, instanceof not getClass; beware Lombok and bidirectional relations.
  • Strings & collections: intern leaks (prefer G1 dedup); Set.of/Map.of are null-hostile, duplicate-hostile, and not order-stable.
In a nutshell
  • == vs .equals: == compares identity (address) or a raw primitive; .equals compares value. The default equals is == until you override it.
  • The contract: equal objects must have equal hashes; unequal ones may collide. Always write equals and hashCode together, on the same fields, with @Override (so the overload trap gets caught).
  • String: it's immutable, literals are interned in the pool, constant folding collapses constant expressions. Never compare strings with ==. Use StringBuilder for loop concatenation.
  • Boxing: the Integer cache is -128..127 (also Long, Byte, Short, Character, Boolean; but Float/Double have no cache). Never compare wrappers with ==; unboxing a null means NPE; stay primitive in hot paths.
  • Immutable & records: build immutable classes with defensive copies, or better, use a record, which gives you a correct equals/hashCode for free — the best choice for keys and value objects (just watch the shallow-immutability and array gotchas).

Master all three — the contract, String, boxing — or your HashMap will silently lie to you.