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 درمیآید.
اینکه در 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 فقط مقدارِ عددی را میبیند و میگوید برابرند.
اینجا نکتهٔ سنیوریست: 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 همزمانسازیشده (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..127)، Character (بازهٔ 0..127)، Integer (بازهٔ -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 است.
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 صفر تخصیص دارد.
وقتی 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ها یک 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 است، پسdunbox میشود؛ این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سازگار نگه دار، مگر آنکه عمداً خلافش را مستند کنی.
سوالات مصاحبه
اینجا همان سوالهاییست که در مصاحبهٔ سنیور میآید. هرکدام را با پاسخِ کامل بخوان و بلند برای خودت بگو.
اشیای برابر باید هشِ برابر بدهند؛ نابرابرها ممکن است برخورد کنند؛ و نتایج باید در طولِ یک اجرا سازگار باشند. اگر دو شیءِ equals هشهای متفاوت داشته باشند، HashMap آنها را به سطلهای متفاوت مسیریابی میکند، پس get هرگز حتی به مقایسهٔ equals نمیرسد و برای کلیدی که موجود است null برمیگرداند — و یک HashSet میتواند دو عنصرِ «برابر» را همزمان نگه دارد.
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 بستگی دارد نه به الحاقِ زمانِ اجرا.
آرایهٔ پشتیبانش final است و هرگز mutate نمیشود. فوایدش: (۱) thread-safety ذاتی — میتوانی آزادانه بینِ نخها به اشتراکش بگذاری چون هیچکس تغییرش نمیدهد؛ (۲) امن بهعنوانِ کلیدِ HashMap — چون محتوا ثابت است هشش هم پایدار میماند و کلید یتیم نمیشود؛ (۳) امکانِ pooling/interning برای صرفهجویی حافظه. فایدهٔ اضافه: امنیت — یک مسیرِ فایل یا URLِ اعتبارسنجیشده نمیتواند بعد از بررسی زیرِ دستت عوض شود.
تقریباً هرگز. یک شیءِ heapِ متمایز را اجباری میکند، استخر را خنثی و حافظه را هدر میدهد. موردِ خاصِ مشروع: اجبارِ یک هویتِ تازه برای یک شیءِ قفل، یا جداکردنِ آرایهٔ پشتیبانِ بزرگِ یک substring در JDKهای قدیمی (new String(sub) پیش از تغییرِ کپیِ substring در Java 7u6، که قبل از آن substring آرایهٔ بزرگِ رشتهٔ اصلی را به اشتراک میگذاشت و مانعِ آزادسازیِ حافظه میشد). در غیرِ این صورت بوی بدِ کد است.
HashSet اندازهٔ ۲ میدهد (از equals استفاده میکند که scale را در نظر میگیرد، پس 1.0 و 1.00 نابرابرند)، ولی TreeSet اندازهٔ ۱ میدهد (از compareTo استفاده میکند که فقط عددیست و scale را نادیده میگیرد، پس آنها را برابر و تکراری میبیند). این ناسازگاریِ compareTo/equals را نشان میدهد: مجموعههای مرتب عضویت را با comparator تعریف میکنند نه با 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 این باگ را در زمانِ کامپایل میگرفت.
۳۱ عددی اول و فرد است که توزیعِ خوبی میدهد، و 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() تقارن را تضمین میکند ولی مانعِ برابریِ یک نمونهٔ زیرکلاس با نمونهٔ ابرکلاس میشود (که با 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ها کمعمق (shallow) تغییرناپذیرند — فیلدها final هستند، ولی مؤلفهای که به یک شیءِ تغییرپذیر (یک List، یک آرایه) اشاره میکند، از طریقِ همان مرجع همچنان تغییرپذیر است. راهحل: در سازندهٔ فشرده کپی کن (List.copyOf(...)). همچنین یک مؤلفهٔ byte[] باعث میشود equals/hashCodeِ تولیدشده از هویتِ آرایه استفاده کنند نه محتوایش، که بیصدا برابریِ مقدار را میشکند (دو رکورد با آرایههای هممحتوا نابرابر تلقی میشوند).
چیزی یک فیلدِ مرتبط با 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: اگر با حذف، تعداد به ۶ برسد دوباره به لیست برمیگردد.
وقتی سطل درخت میشود، 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. HashMapnull-کلید و null-مقدار را میپذیرد، ولیConcurrentHashMapهیچکدام را — چون در محیطِ همروندmap.get(k) == nullدوپهلو میشد («نبود» یا «مقدارش null است؟»).
اگر کلیدهای 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 باید در طولِ اجرا ثابت بماند» میشکست. با کشکردن در هدر، حتی اگر شیء جابهجا شود، هش ثابت میماند.
چون hash و وضعیتِ biased locking هر دو در همان mark word ذخیره میشوند، اولین باری که System.identityHashCode(obj) (یا Object.hashCode()ِ پیشفرض) را روی یک شیء صدا بزنی، آن شیء برای همیشه از biased locking خارج میشود و اگر bias داشته باشد باطل میشود. در کدِ حساسبهکارایی که هم قفل میگیری هم هش، این یک اثرِ نامرئی روی throughput دارد. (در جاوا ۱۵+ biased locking بهصورتِ پیشفرض غیرفعال و در حالِ حذف است، پس این نکته بیشتر تاریخی/برای JVMهای قدیمی است.)
گاهی واقعاً میخواهی کلیدها با == (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 که الگوی بیتیِ متفاوت دارند، جدا شمرده میشوند.
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(); // ثابت! هرگز عوض نمیشود
}
}
۱. hashCodeِ ثابت برگردان (مثلاً getClass().hashCode() یا یک عددِ ثابت). چون hashCode باید در طولِ عمرِ عضویت در Set ثابت بماند، ثابتبودن آن را از دامِ تغییر نجات میدهد — بله، همه در یک سطل میافتند، ولی تعدادِ entityهای همزمان در یک Set معمولاً کوچک است، پس عملاً بیضرر.
۲. برای equals از یک کلیدِ کسبوکاریِ (business/natural key) پایدار و یکتا استفاده کن (isbn، uuidِ برنامهساخته، شمارهٔ سفارش) — نه از idِ دیتابیس. اگر کلیدِ طبیعی نداری، یک UUID را در سازنده تولید کن (نه دیتابیس).
@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 (با -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ها هنگامِ deserialize بهجای دورزدنِ سازنده (حفرهٔ امنیتیِ سریالسازیِ کلاسیک)، سازندهٔ متعارف را صدا میزنند، پس اعتبارسنجیها و نرمالسازیهای compact constructor دوباره اجرا و invariantها حفظ میشوند — امنتر از یک کلاسِ معمولی برای value objectهای سریالشونده.
سوالات مصاحبهٔ سنیور (پیشرفته)
وقتی همزمان تعدادِ عناصرِ یک سطل به TREEIFY_THRESHOLD = 8 برسد و اندازهٔ کلِ جدول حداقل MIN_TREEIFY_CAPACITY = 64 باشد؛ اگر جدول کوچکتر باشد، بهجای درختشدن اول resize میکند. درختشدن جستوجوی درونِ سطل را از O(n) به O(log n) میبرد و یک تورِ ایمنی در برابرِ hashCodeِ بد و حملهٔ collision است، ولی آن را «درست نمیکند»: اگر hashCode تو فاجعهبار باشد، همه هنوز در یک سطلاند و بهترین حالت O(log n) است، نه O(1). ضمناً برای مرتبسازیِ گرهها، اگر هشها برابر و کلید Comparable باشد از compareTo استفاده میشود. یک hashCodeِ خوب هنوز واجب است.
a - b روی int سرریز میکند: اگر a = Integer.MAX_VALUE و b = -1، نتیجهٔ ریاضی مثبت است ولی بهخاطرِ سرریز منفی میشود، پس ترتیب برعکس و قرارداد نقض میشود. با دادههای کوچک هرگز دیده نمیشود؛ با اعدادِ بزرگ یا منفی ناگهان ترتیبِ غلط یا کرشِ TimSort میدهد. راهِحل: Integer.compare(a, b) که سرریز ندارد. قاعدهٔ کلی: هرگز برای Comparator تفریق نکن، از X.compare استفاده کن.
الگوریتمِ مرتبسازیِ جاوا (TimSort) در حین کار صحتِ قراردادِ Comparator را چک میکند و اگر ناسازگاری ببیند این IllegalArgumentException را میدهد. علل: سرریزِ تفریق، NaN در فیلدِ اعشاری (که با هیچچیز </> نیست و ترتیبِ کلی را میشکند)، مقایسهٔ فیلدهای null بدونِ nullsFirst/Last، یا Comparatorِ وابسته به وضعیتِ متغیر (غیرمتعدی/ناپایدار). «گاهبهگاه» است چون فقط چیدمانِ خاصی از دادهها TimSort را به نقطهای میرساند که تناقض را ببیند — برای همین با دادههای کوچک سبز و با دادههای بزرگِ خاص قرمز میشود. راهِحل: Comparator را با comparingInt/thenComparing/nullsFirst/Double.compare بساز.
نه. در HotSpotِ مدرن، identity hash code بارِ اولِ نیاز بهصورتِ شبهتصادفی (بر پایهٔ وضعیتِ thread، نه آدرس) تولید و در mark wordِ هدرِ شیء کش میشود. نمیتواند آدرس باشد چون GC اشیا را جابهجا میکند و آنوقت هش عوض میشد و قراردادِ ثبات را میشکست؛ با کش در هدر، هش حتی پس از جابهجایی ثابت میماند. اثرِ جانبیِ جالب: اولین محاسبهٔ identity hash روی یک شیء، آن را از biased locking خارج میکند، چون هر دو در همان mark word جا میگیرند.
اولی true، دومی false — دقیقاً برعکسِ عملگرِ ==. چون equalsِ تولیدشدهٔ record برای double/float از == استفاده نمیکند بلکه از معناشناسیِ بیتی (Double.compare/doubleToLongBits) استفاده میکند تا بازتابی بماند: NaN باید با NaN برابر باشد (وگرنه در HashMap گم میشود)، و +0.0 و -0.0 که الگوی بیتیِ متفاوت دارند جدا شمرده میشوند. اگر میخواهی ±0.0 یکی باشند، در compact constructor صفر را نرمال کن.
از idِ تولیدیِ دیتابیس استفاده نکن، چون قبل از persist نال است و بعد از persist عوض میشود؛ پس اگر entity را قبل از ذخیره در یک Set بگذاری، تغییرِ id باعثِ تغییرِ hashCode و گمشدنِ آن در سطلِ قدیمی میشود. راهِ درست: equals را روی یک کلیدِ کسبوکاریِ پایدارِ یکتا (isbn، uuidِ برنامهساخته) با instanceof (نه getClass، تا با پروکسیِ Hibernate کار کند) بساز، و hashCode را ثابت برگردان (getClass().hashCode()) تا هرگز عوض نشود. اگر کلیدِ طبیعی نداری، یک UUID در سازنده تولید کن.
intern از جاوا ۷ استخر را در heap نگه میدارد؛ رشتههای internشده تا وقتی reference دارند GC نمیشوند، پس internکردنِ میلیونها رشتهٔ یکتا یک نشتِ حافظهٔ آهسته و فشارِ CPU است. فقط برای تعدادِ کمِ مقادیرِ پرتکرار میارزد. تفاوت: intern کارِ برنامهنویس است تا == روی رشتهها کار کند؛ اما string deduplication (-XX:+UseStringDeduplication روی G1) کارِ GC است که پشتِ صحنه آرایهٔ پشتِ رشتههای هممحتوا را یکی میکند تا حافظه صرفه شود، بدونِ تغییرِ == و بدونِ دخالتِ کد. برای صرفهجوییِ حافظه، اول فلگِ GC را امتحان کن، نه intern دستی.
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.
You will fully learn four things:
- The difference between identity (
==) and value equality (.equals) — confusing the two is the source of half of Java's subtle bugs. - The equals/hashCode contract — a legally binding rule that the correctness of
HashMap,HashSet, and every hash-based structure depends on. - String — why it is immutable, what the string pool is, and why
==on strings is a classic trap. - 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 = 5means the 5 is right there, not a pointer to a 5. - wrapper: the object version of each primitive:
Integerforint,Longforlong,Doublefordouble, and so on. A box that holds a primitive inside so you can use a number where only objects are accepted (likeList<Integer>). - bucket and hash: picture a
HashMapas 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.
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
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 == 5istrue. 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.
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
- Consistent: the same object returns the same hash value across calls within one run (unless equals-relevant state changes).
- The one that matters: if
a.equals(b)thena.hashCode() == b.hashCode(). Mandatory. - Unequal objects may share a hash code (collisions are legal), but distinct hashes improve performance.
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 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 checksoisn'tnull(becausenull instanceof anythingis alwaysfalse). This is the "pattern matching for instanceof" idiom, official since Java 16; the variablemcomes straight out of the cast.
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
equalsandhashCode. A field in one but not the other directly violates the golden rule (two objects could beequalsyet, because a field present inequalsis absent fromhashCode, get different hashes). Objects.hash(...)is convenient but boxes every argument and allocates a varargs array. Fine for an ordinary POJO; but in a hothashCodeinside a tight loop, those allocations add up, and there you should hand-roll it (which we see next). For nullable fields, useObjects.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.
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.
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."
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.
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 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.
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.
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 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
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 cache — Double d1 = 1.0; Double d2 = 1.0; d1 == d2 is always false.
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!
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.
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.
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:
- Make the class
final(or use a private constructor + factory methods) so nobody can subclass it and mutate it through that. - Make all fields
private final. - Provide no mutators (no setters or other state-changing methods).
- 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()); }
}
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.
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 mutableListoutward. For true immutability you must copy in the compact constructor:items = List.copyOf(items). - The generated
equalsuses all components. But if a component is an array,equalsuses the array's identity, not its contents (because arrays don't overrideequals). Sorecord+ array is a subtle bug: two records with identical array contents will haveequalsreturnfalse. - 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))returnsnullbecause you overrodeequalsbut nothashCode.- Mutating a key after putting it in a
HashSet/HashMaporphans it. Integer == Integeristrueonly 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— the1.0literal is a primitive, sodunboxes; this istrue. ButDouble == Doubleisfalse.BigDecimalin aHashSetvsTreeSetgives different sizes for1.0and1.00.Objects.hash()allocates — don't call it in a hot loop'shashCode.- Overriding
equalswith a wrong parameter type:public boolean equals(Point p)overloads rather than overridesObject.equals(Object)— collections still call theObjectversion. Always@Overrideand takeObject.
Best practices
- Always override
equalsandhashCodetogether, 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
Comparatorconsistent withequalsunless 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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).
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.
- Inside HashMap: hash spreading, bucket treeification, load factor and rehash, and the hash-flooding attack.
- The Comparator trap:
a - bsubtraction overflow, and the notorious "Comparison method violates its general contract!" crash from TimSort. - The address-based hashCode myth: the mark-word reality and its effect on biased locking.
- Floating-point equality: why
NaN.equals(NaN)is true but0.0 == -0.0, and how records behave withdouble. - equals for JPA/Hibernate entities: the null-id-before-persist problem, the constant-hashCode trick, and the Lombok trap.
- Strings at scale: hashCode caching, intern's memory cost, and how it differs from G1 string deduplication.
- 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.
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. HashMapaccepts a null key and null values, butConcurrentHashMapaccepts neither — because concurrently,map.get(k) == nullwould be ambiguous ("absent" vs "value is null?").
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!
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.
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.)
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'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
}
}
- 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. - For
equals, use a stable, unique business/natural key (isbn, an application-generated uuid, an order number) — not the databaseid. If you have no natural key, generate aUUIDin the constructor (not from the DB).
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).
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-hostile — List.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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
- 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.ofare null-hostile, duplicate-hostile, and not order-stable.
==vs.equals:==compares identity (address) or a raw primitive;.equalscompares value. The defaultequalsis==until you override it.- The contract: equal objects must have equal hashes; unequal ones may collide. Always write
equalsandhashCodetogether, 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
==. UseStringBuilderfor loop concatenation. - Boxing: the
Integercache is-128..127(alsoLong,Byte,Short,Character,Boolean; butFloat/Doublehave no cache). Never compare wrappers with==; unboxing anullmeans 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.