Java Core · جاوا پایه پایهBeginner ~61 دقیقه مطالعه~51 min read

فریم‌ورک Collections و درونیات HashMapCollections Framework & HashMap Internals

در این درس از صفر یاد می‌گیری فریم‌ورک Collections جاوا چطور چیده شده و درون HashMap و LinkedHashMap و TreeMap و ConcurrentHashMap دقیقاً چه می‌گذرد — با تشبیه‌های ساده، جدول‌های Big-O، کد واقعی و سؤال‌های حساس مصاحبه.A from-scratch, story-driven walk through the Java Collections Framework and the exact inner machinery of HashMap, LinkedHashMap, TreeMap, and ConcurrentHashMap — with plain analogies, Big-O tables, real code, and the tricky interview questions.


هر برنامه‌ای که می‌نویسی، در نهایت باید یک مشت داده را یک‌جا نگه دارد: فهرست کاربرها، جدولِ «شناسه به نام»، صفِ کارهای در انتظار. جاوا برای این کار یک جعبه‌ابزار کامل دارد به اسم فریم‌ورک Collections. مشکل اکثر آدم‌ها این نیست که این ابزارها را بلد نیستند، بلکه این است که نمی‌دانند پشت پرده‌ی هرکدام چه می‌گذرد — و دقیقاً همین‌جا مصاحبه‌های سنیور آدم را گیر می‌اندازند. این درس همان پشت‌پرده است.

نقشه‌ی راه این درس
  • قسمت ۰: چند واژه‌ای که باید قبل از هر چیز حس‌شان کنی (اینترفیس، Big-O، bucket، hash).
  • ساختار خانواده: List / Set / Queue / Deque و اینکه چرا Map عضو خانواده نیست.
  • جدول Big-O: کدام عملیات روی کدام ساختار چند هزینه دارد.
  • ArrayList در برابر LinkedList: چرا پیش‌فرض همیشه ArrayList است.
  • قلب مصاحبه — درونیات HashMap: hash، bucket، treeify، resize، باگ افسانه‌ای جاوا ۷.
  • LinkedHashMap و کش LRU، TreeMap و پرس‌وجوی مرتب، ConcurrentHashMap بدون segment.
  • نقشه‌های تخصصی، دام‌های رایج، و در پایان ۱۵ سؤال مصاحبه با جواب کامل.

قسمت ۰ — واژه‌هایی که باید اول حس کنی

قبل از اینکه وارد جزئیات شویم، بگذار چهار کلمه‌ای را که مدام تکرار می‌شوند از پایه بسازیم؛ اگر این‌ها را حس کنی بقیه‌ی درس مثل آب خوردن است.

اینترفیس یعنی «شکل پریز»

به پریز برق دیوار فکر کن. یخچال، تلویزیون و شارژر موبایل هر سه دوشاخه دارند و به همان پریز می‌خورند. تو با «شکل پریز» کار می‌کنی، نه با اینکه پشتش چه دستگاهی وصل است. در جاوا هم List یک «شکل پریز» است؛ ArrayList و LinkedList دو دستگاه متفاوت‌اند که به همان پریز می‌خورند. اگر کد تو با پریز (List) کار کند نه با دستگاه (ArrayList)، هر وقت خواستی می‌توانی دستگاه را عوض کنی بی‌آنکه بقیه‌ی خانه را سیم‌کشی مجدد کنی.

همین ایده اسم رسمی دارد: برنامه‌نویسی رو به اینترفیس (program to the interface). یعنی فیلدها، پارامترها و نوع بازگشتی‌ات را List / Set / Map اعلام کن و فقط جایی که new می‌زنی اسم کلاس concrete (واقعی) را بیاور.

Big-O یعنی «هزینه با بزرگ‌شدن کار»

تصور کن یک آبدارچی داری. اگر برای آوردن یک چای همیشه ۳۰ ثانیه وقت بگذارد، فرقی نمی‌کند ۵ نفر باشی یا ۵۰۰۰ نفر: زمانِ هر چای ثابت است. این می‌شود O(1). اما اگر برای پیدا کردن پرونده‌ی یک نفر مجبور باشد کل بایگانی را از اول ورق بزند، هرچه بایگانی بزرگ‌تر شود کارش کندتر می‌شود — این می‌شود O(n). و اگر بتواند بایگانی مرتب را نصف‌نصف حذف کند (مثل پیدا کردن کلمه در دیکشنری)، می‌شود O(log n) که خیلی سریع‌تر از O(n) رشد می‌کند.

دو کلمه‌ای که کل فصل حول آن‌هاست

هر تصمیمی درباره‌ی collectionها در نهایت به دو چیز برمی‌گردد: (۱) ترتیب پیمایش — آیا وقتی روی آن حلقه می‌زنی عناصر به ترتیب درج می‌آیند، مرتب‌شده می‌آیند، یا بی‌نظم؟ (۲) پیچیدگی عملیات اصلی — get / add / contains / remove هرکدام چند هزینه دارند؟ تقریباً هر اشتباهِ مصاحبه، سردرگمی درباره‌ی یکی از همین دو است.

کلمه‌ی bucket (سطل) و hash را هم فعلاً همین‌قدر بدان: hash یعنی از یک شیء یک عدد صحیح می‌سازیم، و bucket یعنی یک خانه از یک آرایه که آن شیء را طبق عددش در آن می‌گذاریم. جزئیاتش را در بخش HashMap کامل باز می‌کنیم.

ساختار خانواده‌ی Collections

فریم‌ورک Collections مجموعه‌ای از اینترفیس‌ها (Collection، List، Set، Queue، Deque، Map) به‌همراه پیاده‌سازی‌های واقعی است که همگی زیر پکیج java.util قرار دارند. حالا یک نکته‌ی مهم که خیلی‌ها را گیج می‌کند:

چرا Map عضو خانواده‌ی Collection نیست؟

یک Collection مثل یک ردیف صندلیِ سینماست: یک دنباله‌ی واحد از عناصر. اما Map مثل یک دفترچه‌تلفن است: هر ردیف یک زوج «اسم → شماره» است، نه یک عنصر تنها. چون ماهیتش «نگاشتِ کلید به مقدار» است نه «دنباله‌ای از عناصر»، طراحان جاوا عمداً آن را زیرِ Collection نگذاشتند؛ Map سلسله‌مراتب جداگانه‌ی خودش را دارد.

این تصویر کل درخت خانواده را نشان می‌دهد:

            Iterable
               │
          Collection ───────────────┐
          /    |     \               │
       List   Set    Queue ── Deque  │   Map  (سلسله‌مراتب جدا)
        │      │        │       │        │
   ArrayList  HashSet  PriorityQ ArrayDeque  HashMap
   LinkedList LinkedHashSet      LinkedList  LinkedHashMap
   Vector     TreeSet(NavigableSet)          TreeMap(NavigableMap)
   CopyOnWriteArrayList                       ConcurrentHashMap
                                              EnumMap / WeakHashMap / IdentityHashMap

حالا هر اینترفیس را با یک جمله‌ی حسی معرفی کنیم:

  • List — یک ردیفِ شماره‌دار: مرتب بر اساس اندیس، تکرار مجاز، و می‌توانی بگویی «عنصر شماره‌ی ۵ را بده». پیاده‌سازی‌ها: ArrayList، LinkedList، Vector، CopyOnWriteArrayList.
  • Set — یک کیسه که تکراری قبول نمی‌کند (تکراری‌بودن را با equals می‌سنجد). HashSet (بدون ترتیب)، LinkedHashSet (ترتیب درج)، TreeSet (مرتب‌شده، از نوع NavigableSet).
  • Queue — یک صفِ نانوایی، معمولاً «اول‌آمده اول‌می‌رود» (FIFO)؛ متدهایش offer/poll/peek. PriorityQueue (که با ساختار heap مرتب می‌ماند)، ArrayDeque، LinkedList.
  • Deque — صفِ دوسر (هم از سر و هم از ته می‌شود اضافه/کم کرد)؛ جایگزین مدرن کلاس قدیمی Stack. ArrayDeque هم به‌عنوان پشته (stack) و هم صف (queue) توصیه‌شده است.
  • Map — دفترچه‌ی کلید→مقدار. HashMap، LinkedHashMap، TreeMap، ConcurrentHashMap، EnumMap، WeakHashMap، IdentityHashMap.

جدول مرجع Big-O

این جدول را مثل یک نقشه‌ی گنج نگه دار؛ نصف سؤال‌های collections از دلِ همین چند خانه بیرون می‌آید.

عملیات ArrayList LinkedList ArrayDeque HashMap/HashSet TreeMap/TreeSet LinkedHashMap
get(i) / دسترسی تصادفی O(1) O(n)
get(key) / contains O(n) O(n) O(1) متوسط، O(log n) بدترین O(log n) O(1) متوسط
افزودن به انتها O(1) سرشکن O(1) O(1) سرشکن O(1) متوسط O(log n) O(1) متوسط
افزودن/حذف از ابتدا O(n) O(1) O(1)
درج/حذف در اندیس O(n) O(n) یافتن + O(1)
پیمایش مرتب درج درج درج هیچ مرتب‌شده درج/دسترسی

کلمه‌ی «سرشکن» (amortized) را برایت باز کنم، چون مهم است.

«سرشکن» یعنی هزینه‌ی گران را پخش کن

فرض کن یک کمد کوچک داری و هر بار لباس اضافه می‌کنی. بیشترِ وقت‌ها فقط یک لباس را آویزان می‌کنی: ارزان و آنی. اما هرازگاهی کمد پر می‌شود و مجبوری یک کمدِ دوبرابر بخری و همه‌ی لباس‌ها را جابه‌جا کنی: یک هزینه‌ی سنگین. اگر آن هزینه‌ی سنگین گاه‌به‌گاه را روی همه‌ی افزودن‌های ارزان پخش کنی، به‌طور میانگین هر افزودن ارزان می‌ماند. به این می‌گویند O(1) سرشکن: نه اینکه هیچ‌وقت گران نشود، بلکه میانگینش ثابت می‌ماند.

تله‌ی بزرگ LinkedList

add(index, e) و remove(index) روی LinkedList را به‌عنوان «عملیات لیست» تبلیغ می‌کنند، اما رسیدن به آن اندیس O(n) است چون باید گره‌به‌گره از سر لیست راه بروی. LinkedList فقط وقتی واقعاً به‌درد می‌خورد که یا یک Iterator/ListIterator را دقیقاً روی نقطه‌ی تغییر نگه داشته باشی، یا صرفاً به‌عنوان Deque استفاده‌اش کنی. برای تقریباً همه‌ی کارهای واقعی، ArrayList هم سریع‌تر است و هم به‌خاطر «محلی‌بودن حافظه‌ی نهان» بهتر. پیش‌فرض تو باید ArrayList باشد؛ پیش‌فرضِ deque باید ArrayDeque باشد.

ArrayList در برابر LinkedList — با جزئیات

ArrayList در باطن یک آرایه‌ی معمولی Object[] را می‌پیچد. افزودن به انتها به‌صورت سرشکن O(1) است چون هنگام پرشدن، ظرفیت را حدود ۱٫۵ برابر می‌کند (newCap = old + (old >> 1)). دسترسی تصادفی فقط یک اندیس‌گذاری آرایه است، پس آنی. حذف از میانه، همه‌ی عناصر بعدی را یک خانه به عقب شیفت می‌دهد (با System.arraycopy) که O(n) است — ولی به‌ازای هر عنصر فوق‌العاده سریع، چون یک جابه‌جایی حافظه‌ی پیوسته است.

LinkedList یک لیست پیوندی دوطرفه از اشیای Node است. هر گره یک تخصیص جداگانه در heap با دو اشاره‌گر (به قبلی و بعدی) به‌علاوه‌ی سربارِ هدرِ شیء است.

چرا LinkedList حافظه‌ی نهان را نابود می‌کند؟

آرایه مثل خانه‌های یک کوچه‌ی پشت‌سرهم است: وقتی به خانه‌ی ۱۰ می‌رسی، خانه‌های ۱۱ و ۱۲ همان نزدیکی‌اند و CPU از قبل حدس می‌زند و برشان می‌دارد (این همان cache locality / محلی‌بودن حافظه‌ی نهان است). اما LinkedList مثل یک شکارِ گنج است: هر گره فقط یک کاغذ است که نشانی گره بعدی روی آن نوشته، و آن گره ممکن است در هر گوشه‌ای از heap باشد. این «اشاره‌گر-دنبال‌کردن (pointer chasing)» CPU را مجبور می‌کند مدام به نقاط پراکنده‌ی حافظه بپرد و کارایی cache را نابود می‌کند. روی JVM ۶۴ بیتی هر گره حدود ۲۴ بایت سربار دارد، در مقابل فقط ۴ تا ۸ بایت برای یک خانه‌ی ArrayList.

LinkedList هم List و هم Deque را پیاده می‌کند. اما برای پشته و صف، انتخاب درست چیز دیگری است:

// ArrayDeque را به Stack (که synchronized است و از Vector ارث می‌برد) ترجیح دهید
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2);      // ۲ در بالاست
int top = stack.pop();              // ۲

Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1); queue.offer(2);
int head = queue.poll();            // ۱ (FIFO)
چرا نه Stack؟

کلاس قدیمی Stack از Vector ارث می‌برد و هر متدش synchronized است — یعنی حتی وقتی تک‌ترد کار می‌کنی، هزینه‌ی قفل‌گذاری را می‌پردازی. ArrayDeque نه قفل بی‌جا دارد و نه محدودیت Stack را، و هم پشته و هم صف را عالی انجام می‌دهد. قاعده: از امروز Stack را فراموش کن.

درونیات HashMap — قلب مصاحبه

رسیدیم به مهم‌ترین بخش. اگر فقط یک ساختار داده را برای مصاحبه‌ی سنیور عمیق یاد بگیری، باید HashMap باشد.

HashMap مثل کمدِ کفشِ استخر است

به کمدهای شماره‌دارِ رختکنِ استخر فکر کن. وقتی می‌رسی، متصدی از روی کدِ ملی‌ات یک عدد می‌سازد و می‌گوید «کمد شماره‌ی ۷». دفعه‌ی بعد که می‌آیی، دوباره همان محاسبه انجام می‌شود، مستقیم می‌روی سراغ کمد ۷ و کفشت آنجاست — بدون اینکه لازم باشد همه‌ی کمدها را باز کنی. این «از روی خودِ چیز، شماره‌ی خانه‌اش را حساب کن» جوهره‌ی HashMap است. حالا مشکل: اگر کدِ ملیِ دو نفر به کمد ۷ برسد چه؟ اسمش تصادم (collision) است و در ادامه می‌بینیم HashMap چطور حلش می‌کند.

پس در باطن، HashMap یک آرایه از سطل‌ها (bucket) است: Node<K,V>[] table. هر سطل یکی از این سه حالت را دارد: خالی (null)، یا یک لیست پیوندی کوتاه از گره‌ها (وقتی چند کلید به یک سطل رسیده‌اند)، یا — وقتی یک سطل خیلی شلوغ شد — یک درخت قرمز-سیاه (red-black tree) که یک درخت جستجوی خودمتوازن است.

ثابت‌های کلیدی

این چند عدد را در HashMap کدگذاری کرده‌اند و باید حفظشان باشی:

ثابت مقدار معنی
DEFAULT_INITIAL_CAPACITY 16 طول table هنگام اولین پرشدن
DEFAULT_LOAD_FACTOR 0.75 resize وقتی size > capacity × loadFactor
TREEIFY_THRESHOLD 8 سطلی با ≥۸ گره ممکن است درخت شود
UNTREEIFY_THRESHOLD 6 درختی که به ≤۶ کوچک شود به لیست برمی‌گردد
MIN_TREEIFY_CAPACITY 64 اما فقط اگر خود table ≥۶۴ باشد؛ وگرنه resize
ستون فقرات کل طراحی

ظرفیت HashMap همیشه توانی از ۲ است (۱۶، ۳۲، ۶۴، ...). این یک تصادف نیست؛ کلیدِ همه‌ی حقه‌های هوشمندانه‌ی بعدی است — هم محاسبه‌ی اندیس، هم resize. هر جا گیر کردی، این جمله را یادت باشد.

هش‌کردن و محاسبه‌ی اندیس

نکته‌ی ظریف: HashMap مستقیماً از key.hashCode() به‌عنوان شماره‌ی سطل استفاده نمی‌کند. اول هش را کمی «هم می‌زند» تا کیفیتش بهتر شود:

static final int hash(Object key) {
    int h;
    // XOR گرفتن ۱۶ بیت بالا با ۱۶ بیت پایین
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

بعد شماره‌ی سطل این‌طور محاسبه می‌شود: (n - 1) & hash که در آن n طولِ (توانِ دو) table است. حالا چرا این مرحله‌ی XOR لازم است؟

چرا بیت‌های بالا را با پایین XOR می‌کنیم؟

تصور کن شماره‌ی کمد را فقط از روی دو رقم آخرِ کد ملی می‌سازی. اگر همه‌ی مشتری‌هایت کد ملی‌ای دارند که فقط در ارقام اولش فرق می‌کند و دو رقم آخرشان یکسان است، همه به یک کمد می‌رسند — فاجعه. عملیات (n-1) & دقیقاً همین است: چون n توانی از ۲ است، (n-1) یک ماسک است که فقط بیت‌های پایینِ هش را نگه می‌دارد و بیت‌های بالا را دور می‌ریزد. برای همین اول h ^ (h >>> 16) می‌زنیم: این ۱۶ بیتِ بالا را با ۱۶ بیت پایین XOR می‌کند («پخش / spreading») تا اطلاعاتِ بیت‌های بالا هم در بیت‌های پایین اثر بگذارد و کلیدهایی که فقط در بیت‌های بالا فرق دارند باز هم در سطل‌های مختلف بیفتند. همه‌ی این‌ها ارزان است — یک XOR و یک shift، نه یک هشِ ضربیِ گران.

کلید null کجا می‌رود؟

در تابع بالا اگر کلید null باشد هشش صفر می‌شود، پس همیشه در سطل ۰ می‌نشیند. به همین دلیل HashMap دقیقاً یک کلید null را مجاز می‌داند (چون فقط یک سطل ۰ داریم و درون آن با equals تشخیص داده می‌شود).

مرور گام‌به‌گامِ عملیات put

بیا دقیقاً ببینیم وقتی map.put(key, value) می‌زنی چه اتفاقی می‌افتد:

۱. hash(key) را حساب کن و از رویش اندیس i = (n-1) & hash را دربیاور. ۲. اگر table[i] خالی است، گره‌ی جدید را همان‌جا بگذار. تمام. ۳. وگرنه سطل را بپیما. اگر گره‌ای با کلید برابر یافتی، مقدارش را بازنویسی کن. «برابر» یعنی: hash برابر و (key == k || key.equals(k)). ۴. اگر نیافتی، گره را به انتهای سطل اضافه کن. حالا اگر این سطل ≥ TREEIFY_THRESHOLD (۸) گره دارد و طول table ≥ ۶۴ است، سطل را به درخت قرمز-سیاه treeify کن. اما اگر طول table < ۶۴ است، به‌جای درخت‌کردن، کل table را resize کن (که تصادم‌ها را پخش می‌کند). ۵. size را یک‌واحد افزایش بده؛ اگر حالا size > threshold (یعنی capacity × loadFactor)، resize کن.

چرا اول hash را مقایسه می‌کنیم بعد equals؟

در گام ۳ اول hash را مقایسه می‌کنیم و فقط اگر برابر بود سراغ equals می‌رویم. چرا؟ چون مقایسه‌ی دو عدد صحیح تقریباً رایگان است، ولی equals می‌تواند گران باشد (تصور کن مقایسه‌ی دو رشته‌ی طولانی، کاراکتربه‌کاراکتر). دو شیء با هش‌های متفاوت قطعاً برابر نیستند، پس با یک مقایسه‌ی عدد ارزان، equalsِ گران را رد می‌کنیم. به این می‌گویند «خروج زودهنگام (early-out)».

Treeification — چرا دقیقاً عددِ ۸؟

گفتیم سطلِ شلوغ در آستانه‌ی ۸ به درخت تبدیل می‌شود. اما چرا ۸؟

توزیع پواسون و «چرا ۸»

اگر یک تابع هشِ خوب داشته باشی و کلیدها تصادفی باشند، پخش‌شدن کلیدها بین سطل‌ها از یک قانون آماری به اسم توزیع پواسون (Poisson) پیروی می‌کند — همان قانونی که می‌گوید احتمال «چند مشتری هم‌زمان به یک باجه‌ی بانک برسند» چقدر است. طبق همین قانون، Javadoc خودِ HashMap صراحتاً می‌نویسد احتمال اینکه یک سطل به ۸ عنصر برسد حدوداً ۰٫۰۰۰۰۰۰۰۶ است — یعنی عملاً هرگز، مگر اینکه تابع هش بد باشد یا کسی عمداً کلیدهای خصمانه (adversarial) بسازد.

پس چرا اصلاً به درخت‌شدن اهمیت می‌دهیم؟ چون treeify آن سطلِ بدبخت را از پیمایش خطیِ O(n) به O(log n) تبدیل می‌کند و این جلوی حملاتِ محرومیت‌از‌سرویس مبتنی بر تصادم هش (hash-collision DoS) را می‌گیرد: حمله‌ای که در آن مهاجم عمداً هزاران کلید با هشِ یکسان می‌فرستد تا همه در یک سطل تلنبار شوند و سرور کند شود. درخت آن آسیب را محدود می‌کند. ترتیب درون درخت اول بر اساس hash است، بعد اگر کلیدها Comparable باشند بر پایه‌ی آن، وگرنه با یک «شکننده‌ی تساویِ» مبتنی بر هویت شیء.

Resize — و زیبایی توانِ دو

وقتی size از آستانه فراتر رفت، ظرفیت دو برابر می‌شود. اینجاست که آن جمله‌ی «ظرفیت همیشه توانِ دو است» طلا می‌شود:

حقه‌ی تک‌بیتی resize

وقتی table از oldCap به 2*oldCap رشد می‌کند، هر کلیدِ موجود فقط دو سرنوشت دارد: یا سرِ جای قبلی‌اش در اندیس j می‌ماند، یا به اندیس j + oldCap می‌رود. و کدامش؟ فقط با نگاه به یک بیت تعیین می‌شود: hash & oldCap. یعنی لازم نیست هشِ هیچ کلیدی را از نو حساب کنی؛ فقط هر سطل قدیمی را تمیز به دو تکه می‌کنی — یک لیست «پایین» و یک لیست «بالا». این همان صرفه‌جویی بزرگی است که توانِ دو بودن به تو هدیه می‌دهد.

oldCap = 16 (10000b). کلیدی که بیت-۴ (ارزش ۱۶) هشش:
   0  -> در اندیس j می‌ماند
   1  -> به اندیس j + 16 می‌رود

حالا یک داستان تاریخیِ معروف که تقریباً در هر مصاحبه‌ی سنیور می‌پرسند:

باگ افسانه‌ای حلقه‌ی بی‌نهایتِ جاوا ۷

در جاوا ۷، هنگام resize، انتقال هر سطل با «prepend» (افزودن به ابتدا) انجام می‌شد که ترتیب لیست را معکوس می‌کرد. حالا اگر دو ترد هم‌زمان همان HashMap را resize می‌کردند، این معکوس‌سازی می‌توانست گره‌ها را در یک چرخه (cycle) به هم گره بزند — یعنی گره A به B اشاره کند و B دوباره به A. نتیجه؟ اولین get() بعدی که وارد آن سطل می‌شد، برای همیشه دور می‌زد و CPU را روی ۱۰۰٪ قفل می‌کرد. جاوا ۸ این را با شکستِ مرتب (همان لیست پایین/بالا با hash & oldCap) درست کرد که ترتیب را حفظ می‌کند و آن چرخه دیگر ممکن نیست. اما هشدار مهم: این فقط آن نشانه‌ی خاص را درمان کرد. HashMap هنوز thread-safe نیست؛ نوشتن هم‌زمان چند ترد همچنان می‌تواند داده گم کند یا حالت را خراب کند. برای همزمانی باید ConcurrentHashMap بزنی.

پیمایشگرهای fail-fast

فرض کن داری روی یک لیست حلقه می‌زنی و وسط کار خودِ لیست را عوض می‌کنی. جاوا این را زود می‌گیرد:

مکانیزم fail-fast مثل شماره‌ی نسخه‌ی سند است

تصور کن دو نفر روی یک سند مشترک کار می‌کنند. سند یک «شماره‌ی نسخه» دارد. تو نسخه را یادداشت می‌کنی (مثلاً نسخه‌ی ۵) و شروع به خواندن می‌کنی. اگر وسط کار کسی سند را عوض کند، شماره‌ی نسخه می‌رود روی ۶؛ دفعه‌ی بعد که سرت را بلند می‌کنی و می‌بینی نسخه دیگر ۵ نیست، فوراً داد می‌زنی «این سند زیر دستم عوض شد!». در جاوا آن «شماره‌ی نسخه» اسمش modCount است.

هر تغییر ساختاری (add/remove که size را عوض می‌کند، یا treeify) یک modCount داخلی را افزایش می‌دهد. پیمایشگر هنگام ساخته‌شدن، modCount را در expectedModCount عکس می‌گیرد؛ در هر next() این دو را مقایسه می‌کند و اگر فرق داشتند ConcurrentModificationException پرتاب می‌کند.

fail-fast یک تضمین نیست، یک «تلاش برای گرفتن باگ» است

این مکانیزم best-effort است — ساخته شده تا باگ‌هایت را زود لو بدهد، نه تا درستی را تضمین کند. تنها راهِ امنِ حذف حین پیمایش، از طریق خودِ پیمایشگر است:

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (shouldRemove(it.next())) it.remove();  // امن
}
// list.remove(x) داخل for-each -> ConcurrentModificationException

و یک تله‌ی خیلی موذی: اگر عنصرِ ماقبلِ‌آخر را با list.remove() در for-each حذف کنی، ممکن است اصلاً پرتاب نکند! چون بعد از حذف، hasNext پیمایشگر مقدار false برمی‌گرداند و next دیگر صدا زده نمی‌شود، پس آن مقایسه‌ی modCount هرگز اجرا نمی‌شود. این یک «باگ خاموش» کلاسیک است.

چرا کلیدها باید تغییرناپذیر (immutable) باشند

این یکی از پرتکرارترین باگ‌های محیط تولید است، پس خوب حسش کن:

کلید تغییرپذیر مثل جابه‌جایی مخفیانه‌ی خانه است

یادت هست هویتِ هر چیز در HashMap از روی هشش شماره‌ی سطلش تعیین می‌شود؟ فرض کن یک بسته را در کمد شماره‌ی ۷ (که از روی نشانی‌ات حساب شده) گذاشته‌ای. حالا مخفیانه نشانی‌ات را عوض می‌کنی. دفعه‌ی بعد که متصدی از روی نشانیِ جدیدت شماره‌ی کمد را حساب می‌کند، می‌رود سراغ کمد ۱۲ و می‌گوید «چیزی اینجا نیست» — درحالی‌که بسته‌ات هنوز فیزیکی در کمد ۷ نشسته. حالا نه پیدایش می‌کنی، نه حافظه‌اش آزاد می‌شود: گم‌شده اما همچنان اشغال‌کننده‌ی حافظه.

به زبان دقیق: وقتی put می‌کنی، نقشه بر اساس هشِ آن لحظه تصمیم می‌گیرد کلید به کدام سطل برود. اگر بعداً فیلدی را که در hashCode/equals شرکت دارد تغییر دهی، کلید حالا به سطلِ دیگری هش می‌شود ولی فیزیکی هنوز در سطل قدیمی است. get(sameKey) اندیس جدید را حساب می‌کند، در سطل جدید می‌گردد و چیزی نمی‌یابد.

// خراب: کلید تغییرپذیر
Map<Point, String> m = new HashMap<>();
Point p = new Point(1, 1);
m.put(p, "a");
p.setX(99);                 // فیلدهای به‌کاررفته در hashCode/equals را عوض می‌کند
m.get(p);                   // null! رکورد در سطل قدیمی یتیم شده
قانون طلاییِ equals و hashCode

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

LinkedHashMap و کش‌های LRU

LinkedHashMap از HashMap ارث می‌برد و علاوه بر آن، هر رکورد را روی یک لیست پیوندی دوطرفه هم «نخ» می‌کند. نتیجه: پیمایش با ترتیب درجِ قابل‌پیش‌بینی (برخلاف HashMap که ترتیبش نامشخص است).

اما ویژگیِ کُشنده‌اش چیز دیگری است: ترتیب دسترسی (access order).

کش LRU مثل قفسه‌ی روزنامه است

یک روزنامه‌فروشی را تصور کن که فضای محدودی دارد. هر بار یک روزنامه را کسی برمی‌دارد و می‌خواند، فروشنده آن را دوباره جلوی قفسه (نزدیک‌ترین جا) می‌گذارد. نتیجه؟ روزنامه‌هایی که تازه خوانده شده‌اند جلواند و آن‌هایی که مدت‌هاست کسی سراغشان نرفته می‌روند ته قفسه. وقتی جا کم می‌آید، فروشنده تهِ قفسه — یعنی کم‌استفاده‌ترین (least-recently-used / LRU) — را دور می‌ریزد. این دقیقاً کاری است که LinkedHashMap با accessOrder = true می‌کند: هر get/put رکورد لمس‌شده را به دُم می‌برد، پس سرِ لیست همیشه کم‌استفاده‌ترین رکورد است.

با بازنویسی متد removeEldestEntry می‌شود در چند خط یک کشِ LRU با ظرفیت محدود ساخت:

class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    LRUCache(int capacity) {
        super(16, 0.75f, true);      // true = ترتیب دسترسی
        this.capacity = capacity;
    }
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;    // حذف LRU وقتی از ظرفیت گذشت
    }
}

آرگومان سومِ سازنده (true) همان چیزی است که ترتیب دسترسی را روشن می‌کند؛ و هر بار که یک عنصر جدید put می‌شود، جاوا removeEldestEntry را صدا می‌زند و اگر true برگرداند، سرِ لیست (کم‌استفاده‌ترین) را حذف می‌کند.

TreeMap / NavigableMap

تا اینجا همه‌چیز حول hash بود. TreeMap بازیِ متفاوتی است: یک درخت قرمز-سیاه (نوعی درخت جستجوی دودوییِ خودمتوازن). همه‌ی عملیاتش O(log n) است و — مهم‌ترین تفاوت — پیمایشش با ترتیب کلید مرتب‌شده انجام می‌شود: یا بر پایه‌ی ترتیب طبیعیِ کلید (Comparable) یا یک Comparator که خودت می‌دهی.

TreeMap مثل یک بایگانیِ همیشه‌مرتب است

HashMap مثل یک انبار است که هر چیز را در یک کمدِ تصادفی می‌اندازد: پیدا کردنِ یک چیزِ مشخص سریع است، ولی نمی‌توانی بپرسی «نزدیک‌ترین چیز به فلان». TreeMap مثل قفسه‌ای است که کتاب‌ها همیشه به ترتیب الفبا چیده شده‌اند. کمی کندتر است (O(log n) به‌جای O(1))، اما در عوض می‌توانی بپرسی «نزدیک‌ترین کتاب قبل از حرف M کدام است؟» — کاری که هیچ hash map از پسش برنمی‌آید.

همین توانایی، مجموعه‌ای از متدهای «ناوبری» به تو می‌دهد که NavigableMap نامشان است: floorKey، ceilingKey، higherKey، lowerKey، firstKey، lastKey، headMap، tailMap، subMap.

NavigableMap<Integer, String> tm = new TreeMap<>();
tm.put(10, "a"); tm.put(20, "b"); tm.put(30, "c");
tm.floorKey(25);     // ۲۰  (بزرگ‌ترین کلید <= ۲۵)
tm.ceilingKey(25);   // ۳۰  (کوچک‌ترین کلید >= ۲۵)
tm.subMap(10, true, 25, true);   // {10=a, 20=b}
نکته‌ی حساسِ برابری در TreeMap

TreeMap کلید null را رد می‌کند (چون باید کلیدها را مقایسه کند و null قابل‌مقایسه نیست)، و اگر کلیدها متقابلاً قابل‌مقایسه نباشند و Comparator هم نداده باشی ClassCastException می‌دهد. اما تله‌ی اصلی این است: TreeMap برابریِ دو کلید را با بازگشتِ ۰ از comparator/compareTo تصمیم می‌گیرد، نه با equals! پس اگر یک comparator بدهی که فقط یک فیلد را مقایسه کند، دو شیء که در آن فیلد برابرند «همان کلید» شمرده می‌شوند و روی هم put بازنویسی می‌کنند — حتی اگر equalsشان بگوید فرق دارند. این عمداً از قرارداد Map منحرف می‌شود و آدم‌ها را حسابی گیر می‌اندازد.

درونیات ConcurrentHashMap (جاوا ۸ به بعد)

وقتی چند ترد هم‌زمان می‌خواهند یک نقشه‌ی مشترک را بخوانند و بنویسند، HashMap خطرناک است و Hashtableِ قدیمی کند. جوابِ درست ConcurrentHashMap است — اما طراحی‌اش در جاوا ۸ عوض شد و همین «عوض‌شدن» یک سؤال کلاسیک مصاحبه است.

از یک قفلِ بزرگ به قفل روی هر کشو

پیش از جاوا ۸، ConcurrentHashMap به حدود ۱۶ قطعه (Segment) تقسیم می‌شد که هرکدام قفل خودش را داشت — مثل یک بایگانی که به ۱۶ کابینت تقسیم شده و برای کار با هر کابینت باید کلِ آن کابینت را قفل کنی. جاوا ۸ segmentها را کاملاً حذف کرد و رفت سراغ چیزی ریزدانه‌تر: همان آرایه‌ی سطلِ HashMap، ولی حالا قفل روی تک‌تک سطل‌ها می‌افتد، نه روی یک کابینت بزرگ. مثل اینکه به‌جای قفل‌کردن کل کابینت، فقط همان یک کشویی را که دستت تویش است قفل کنی.

طراحی مدرن این‌طور کار می‌کند:

  • سطل خالی: برای نصبِ اولین گره از یک CAS بدون قفل (compareAndSwapObject) استفاده می‌شود. در حالت رایج و بی‌رقابت، اصلاً قفلی گرفته نمی‌شود.
  • سطل غیرخالی: ترد فقط روی گره‌ی سرِ همان سطل synchronized می‌شود — پس نویسندگانی که به سطل‌های متفاوت می‌نویسند هرگز همدیگر را بلاک نمی‌کنند. دانه‌بندیِ قفل «یک سطل» است، نه کل نقشه.
  • سطل‌ها باز هم در ۸ (با همان قانونِ table ≥۶۴) treeify می‌شوند، دقیقاً مثل HashMap.
  • خواندن هرگز قفل نمی‌کند. get گره‌های volatile را می‌پیماید و همیشه یک دیدِ سازگار (هرچند شاید کمی کهنه) می‌بیند.
  • حین resize تردها به هم کمک می‌کنند: یک ForwardingNode ویژه سطل‌هایی را که مهاجرت کرده‌اند علامت می‌زند، و چند ترد به‌صورت مشارکتی بازه‌هایی از table را با هم منتقل می‌کنند (transferIndex، stride).
CAS چیست؟

CAS یعنی «مقایسه-و-جابه‌جایی (Compare-And-Swap)»، یک دستور سطح‌سخت‌افزار که به‌صورت اتمیک می‌گوید «اگر مقدار این خانه هنوز X است، آن را به Y عوض کن؛ وگرنه دست نزن و بگو نشد». چون سخت‌افزار تضمین می‌کند این کل عمل یک‌تکه انجام شود، دیگر لازم نیست قفلِ سنگین بگیری. این پایه‌ی همه‌ی برنامه‌نویسیِ «بدون قفل (lock-free)» است.

size() هم داستان جالبی دارد:

چرا size() تقریبی است؟

تصور کن ۸ صندوق‌دار فروشگاه هرکدام تعداد فروش خودشان را روی یک تخته‌ی جدا می‌نویسند، و مجموع را هروقت لازم شد جمع می‌زنی. اگر همه مجبور بودند روی یک تخته‌ی مشترک بنویسند، مدام سرِ آن تخته صف و دعوا می‌شد (رقابت / contention). به‌جایش، ConcurrentHashMap شمارش را «راه‌راه (striped)» می‌کند: baseCount به‌علاوه‌ی یک آرایه CounterCell[] — همان ایده‌ی LongAdder. عیبش؟ چون چند تخته را جمع می‌زنی، عددی که size() می‌دهد یک برآوردِ سازگار-در-یک-لحظه است، نه یک عددِ تضمینیِ دقیق. برای شمارشِ long هم mappingCount() را داری که همان‌قدر مشورتی است.

دو قاعده‌ی سختِ ConcurrentHashMap

ConcurrentHashMap نه کلید null و نه مقدار null را می‌پذیرد (برخلاف HashMap که یک کلید null و مقادیر null را قبول می‌کند). چرا؟ چون در یک نقشه‌ی همزمان، اگر get مقدار null برگرداند نمی‌توانی بفهمی یعنی «کلید غایب است» یا «کلید هست ولی به null نگاشته». در کد تک‌تردی با یک containsKey رفع ابهام می‌کنی، اما همزمان حالت می‌تواند بین آن دو فراخوانی عوض شود (یک مسابقه / race). ممنوعیت null این ابهام را از ریشه می‌زند. ضمناً پیمایشگرهایش ضعیف-سازگار (weakly consistent) هستند نه fail-fast: هرگز ConcurrentModificationException نمی‌دهند، بدون قفل می‌پیمایند، و ممکن است نوشتن‌های حین پیمایش را ببینند یا نبینند.

راهِ درستِ شمارشِ همزمان از عملیات اتمیکِ خودِ نقشه می‌گذرد، نه get-سپس-put:

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
// اتمیک، قفل‌راه‌راه read-modify-write — راه درست شمارش همزمان
counts.merge("apples", 1, Integer::sum);
counts.compute("apples", (k, v) -> (v == null) ? 1 : v + 1);
counts.computeIfAbsent("bananas", k -> 0);
// نادرست تحت همزمانی: get سپس put یک مسابقه‌ی lost-update است
// counts.put("x", counts.getOrDefault("x", 0) + 1);

نقشه‌های تخصصی

سه نقشه‌ی خاص که هرکدام برای یک شغلِ مشخص ساخته شده‌اند:

  • EnumMap — وقتی کلیدها ثابت‌های یک enum هستند. در باطن فقط یک Object[] ساده است که با ordinal() (شماره‌ی ترتیبیِ ثابتِ enum) اندیس‌گذاری می‌شود، پس فوق‌العاده فشرده و سریع است — دسترسی مستقیمِ آرایه‌ای، بدون هیچ هش‌کردنی. با ترتیبِ اعلانِ enum پیمایش می‌کند. همیشه به HashMap<MyEnum, V> ترجیحش بده.
  • WeakHashMap — کلیدها را با ارجاعِ ضعیف (weak reference) نگه می‌دارد.
weak reference مثل یادداشتی است که مانع خانه‌تکانی نمی‌شود

یک ارجاعِ قوی (معمولی) به GC می‌گوید «تا من هستم این شیء را دور نریز». اما ارجاعِ ضعیف می‌گوید «اگر هیچ‌کسِ دیگری این شیء را نمی‌خواهد، راحت دورش بریز، من مانع نمی‌شوم». پس در WeakHashMap، وقتی یک کلید دیگر هیچ‌جای دیگری strongly reachable نباشد، GC آن را جمع می‌کند و رکوردش هم (تنبل، از طریق یک ReferenceQueue که هنگام دسترسی تخلیه می‌شود) حذف می‌شود. برای کش‌های متادیتا/canonicalizing که نباید مانع جمع‌آوریِ کلیدها شوند عالی است. خطر: اگر خودِ مقدار به کلید ارجاع دهد، کلید strongly reachable می‌ماند و هرگز پاک نمی‌شود.

  • IdentityHashMap — از == و System.identityHashCode استفاده می‌کند، نه equals/hashCode. یعنی برایش «همان شیء بودن» مهم است نه «برابر بودن». در باطن از آدرس‌دهیِ باز (open addressing / linear probing) با یک آرایه‌ی تختِ واحد که کلیدها و مقادیر یک‌درمیان در آن‌اند استفاده می‌کند، نه زنجیره‌سازی. کاربردش: پیمایشِ گرافِ حفظ‌کننده‌ی توپولوژی، سریالایزرها، و مجموعه‌های «بازدیدشده» در کپیِ عمیق — جاهایی که به هویتِ ارجاعی نیاز داری نه برابریِ منطقی. اینجا new String("a") و new String("a") دو کلید کاملاً متمایزند.

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

این‌ها همان جاهایی هستند که کد ظاهراً درست، بی‌صدا می‌شکند:

شش دامِ کلاسیک که باید بشناسی
  • Arrays.asList() یک لیست با اندازه‌ی ثابت برمی‌گرداند که پشتِ آرایه است — add/remove روی آن UnsupportedOperationException می‌دهند. و List.of(...) / Map.of(...) مجموعه‌های کاملاً immutable برمی‌گردانند که ضمناً null را هم رد می‌کنند.
  • ترتیب پیمایشِ HashMap نامشخص است و می‌تواند هنگام resize عوض شود. هرگز به آن تکیه نکن؛ اگر ترتیب می‌خواهی LinkedHashMap یا TreeMap بزن.
  • کش autoboxing: مقادیر Integer بین −۱۲۸ تا ۱۲۷ کش می‌شوند، پس == روی int‌های جعبه‌ایِ کوچک تصادفاً «کار می‌کند» و بعد در ۱۲۸ ناگهان می‌شکند. همیشه .equals() یا مقایسه‌ی primitive.
  • subList، keySet، values، entrySet نما (view) هستند، نه کپی — تغییرشان مجموعه‌ی پشتیبان را تغییر می‌دهد، و تغییرِ ساختاریِ مجموعه‌ی پشتیبان، نما را نامعتبر می‌کند.
  • remove(int) در برابر remove(Object) روی List<Integer>: list.remove(2) عنصرِ اندیسِ ۲ را حذف می‌کند، ولی list.remove(Integer.valueOf(2)) مقدارِ ۲ را.
  • درستیِ Set/Map به hashCode تغییرناپذیرِ عناصر بستگی دارد — گذاشتن یک شیء تغییرپذیر در HashSet و بعد تغییرش، آن را یتیم می‌کند (همان داستانِ کلید تغییرپذیر).

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

چک‌لیستِ روزمره
  • رو به اینترفیس برنامه‌نویسی کن؛ برای نقشه‌های بزرگِ معلوم، از اول اندازه‌بندی کن تا از resizeهای پی‌درپی جلوگیری شود: new HashMap<>(expectedSize / 0.75 + 1).
  • پیش‌فرض ArrayList و ArrayDeque باشد؛ تقریباً هرگز سراغ LinkedList نرو.
  • برای شمارنده‌ها و کش‌های همزمان، ConcurrentHashMap با compute/merge/computeIfAbsent اتمیک — هرگز get سپس put.
  • برای کلیدهای enum از EnumMap/EnumSet؛ برای پرس‌وجوی بازه از TreeMap؛ برای LRU و ترتیب خروجیِ پایدار از LinkedHashMap.
  • به کلاس‌های مقدار، پیش از استفاده به‌عنوان کلید یا عنصرِ set، یک equals/hashCode درست، سازگار و تغییرناپذیر بده.

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

حالا همه‌ی آنچه خواندی را در قالب سؤال‌های واقعیِ مصاحبه تمرین کنیم. هر کدام را اول خودت جواب بده، بعد نگاه کن.

۱. HashMap اندیس سطل را چطور حساب می‌کند و چرا مرحله‌ی `h ^ (h >>> 16)`؟

اندیس (n-1) & hash(key) است. چون طول table یعنی n توانی از ۲ است، (n-1) یک ماسک است که فقط بیت‌های پایین را نگه می‌دارد و بیت‌های بالای hashCode() را دور می‌ریزد. XORِ ۱۶ بیتِ بالا با ۱۶ بیت پایین («پخش / spreading») تضمین می‌کند که تفاوت‌های بیت‌بالا هم روی سطل اثر بگذارند، پس تصادم را ارزان کاهش می‌دهد بدون نیاز به یک هشِ ضربیِ کامل و گران.

۲. چه چیزی treeification را فعال می‌کند و آیا سطل همیشه در ۸ گره درخت می‌شود؟ (حساس)

نه. سطل فقط زمانی درخت می‌شود که به TREEIFY_THRESHOLD (۸) برسد و طول table ≥ MIN_TREEIFY_CAPACITY (۶۴) باشد. اگر table کوچک‌تر است، HashMap به‌جای درخت‌کردن، table را resize می‌کند — چون تصادم در tableِ کوچک معمولاً مشکلِ اندازه است نه مشکلِ هش. درخت‌ها هم وقتی به ≤۶ (UNTREEIFY_THRESHOLD) کوچک شوند دوباره به لیست برمی‌گردند.

۳. باگ حلقه‌ی بی‌نهایتِ HashMap جاوا ۷ را توضیح بده و جاوا ۸ چه چیزی را عوض کرد. (سخت)

resizeِ جاوا ۷ هر سطل را با prepend منتقل می‌کرد که ترتیب لیست را معکوس می‌کرد. تحت resizeِ همزمان توسط دو ترد، این معکوس‌سازی می‌توانست گره‌ها را در یک چرخه به هم پیوند دهد، پس getِ بعدی برای همیشه با ۱۰۰٪ CPU می‌چرخید. جاوا ۸ هر سطل را با hash & oldCap به لیست‌های مرتبِ پایین/بالا می‌شکند، ترتیب را حفظ می‌کند و آن چرخه را ناممکن می‌کند. اما HashMap هنوز برای نوشتنِ همزمان thread-safe نیست — این فقط یک نشانه را رفع کرد، نه آن دسته از باگ را.

۴. چرا کلیدهای نقشه باید در فیلدهای hashCode عملاً تغییرناپذیر باشند؟

نقشه کلید را بر اساس هشِ زمانِ درج جای می‌دهد. تغییر فیلدی که در hashCode شرکت دارد، سطلِ منطقیِ کلید را جابه‌جا می‌کند بدونِ جابه‌جاییِ فیزیکی، پس جستجوهای بعدی اندیسِ متفاوتی حساب می‌کنند و به خطا می‌خورند — رکورد یتیم می‌شود، حافظه نشت می‌کند، و از طریقِ کلیدِ خودش دیگر دست‌نیافتنی است.

۵. HashMap در برابر Hashtable در برابر ConcurrentHashMap؟

Hashtable قدیمی است: روی هر متد کاملاً synchronized (قفلِ درشت)، بدون null، کند. HashMap غیرهمگام است و یک کلید null و مقادیر null را می‌پذیرد. ConcurrentHashMap با قفل/CAS به‌ازای هر سطل thread-safe است، هیچ nullی نمی‌پذیرد، پیمایشگرش ضعیف-سازگار است و خواندنش بدون قفل. تک‌تردی HashMap بزن، همزمان ConcurrentHashMap؛ Hashtable را هرگز.

۶. چرا ConcurrentHashMap کلید و مقدار null را ممنوع می‌کند؟ (حساس)

در یک نقشه‌ی همزمان، بازگشتِ null از get مبهم است: «غایب» یا «موجود اما نگاشته به null»؟ کد تک‌تردی با یک containsKey رفع ابهام می‌کند، اما همزمان حالت می‌تواند بین آن دو فراخوانی عوض شود (یک مسابقه / race). ممنوعیت null این ابهام را کاملاً حذف می‌کند — nullِ برگشتی از get بی‌ابهام یعنی غایب.

۷. آیا ConcurrentHashMap هنوز از Segment استفاده می‌کند؟ (حساس)

نه — آن مالِ جاوا ۷ بود. جاوا ۸ segmentها را با یک آرایه‌ی سطلِ سبکِ HashMap جایگزین کرد که برای نصبِ اولین گره در سطلِ خالی از CAS و برای نوشتن‌های بعدی از synchronized روی سرِ سطل استفاده می‌کند. همزمانی حالا به‌ازای هر سطل است نه هر segment، و resize مشارکتی است (تردها در انتقال به هم کمک می‌کنند).

۸. آیا `size()` روی ConcurrentHashMap دقیق است؟

نه به‌طور قابل‌اتکا زیرِ تغییرِ همزمان. با یک شمارنده‌ی راه‌راهِ سبکِ LongAdder (baseCount + CounterCell[]) ردیابی می‌شود تا از رقابت پرهیز شود، پس مقدارِ برگشتی یک برآوردِ سازگار-در-یک-لحظه است. برای شمارشِ نوعِ long و مشابهاً تقریبی، mappingCount() را داری.

۹. چه زمانی با وجود O(log n) به‌جای HashMap سراغ TreeMap می‌روی؟

وقتی پیمایشِ مرتب یا ناوبری لازم داری: پرس‌وجوی نزدیک‌ترین کلید (floor/ceiling/higher/lower)، نماهای بازه (subMap/headMap/tailMap)، یا min/max. HashMap هیچ‌کدام را نمی‌تواند. همچنین وقتی کیفیتِ هش ضعیف است و تضمینِ بدترین‌حالت از سرعتِ متوسط مهم‌تر است.

۱۰. نکته‌ی حساسِ برابری TreeMap — چه چیزی یکتاییِ کلید را تعیین می‌کند؟ (سخت)

بازگشتِ ۰ از comparator (یا compareToنه equals. یک TreeMap با comparator روی یک فیلد، دو شیءِ برابر در آن فیلد را «همان کلید» می‌پندارد و در put بازنویسی می‌کند، حتی اگر equals مقدار false برگرداند. این عمداً از قراردادِ Map/equals برای موردِ مرتب‌شده انحراف دارد.

۱۱. این چه چاپ می‌کند؟ (باگ را پیدا کن)
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4));
for (Integer x : list) {
    if (x == 2) list.remove(x);   // remove(Object)
}
System.out.println(list);

یک ConcurrentModificationException پرتاب می‌کند. list.remove(x) مقدارِ modCount را افزایش می‌دهد؛ hasNext/nextِ بعدی در for-each تشخیص می‌دهد modCount != expectedModCount. (و توجه: remove(x) اینجا remove(Object) را صدا می‌زند نه remove(int)، چون x یک Integer است — نه یک int خام.) با Iterator.remove()ِ صریح یا list.removeIf(x -> x == 2) رفعش کن.

۱۲. این چه چاپ می‌کند؟ (حساس)
Map<String, Integer> m = new HashMap<>();
m.put("a", 1);
System.out.println(m.get("a") == m.get("a"));

اینجا true (چون ۱ در کشِ Integer بین −۱۲۸ تا ۱۲۷ است و همان جعبه‌ی کش‌شده برمی‌گردد)، اما اگر مقدار را به 1000 تغییر دهی false چاپ می‌کند — get برای int‌های کوچک همان جعبه‌ی کش‌شده را برمی‌گرداند اما برای بزرگ‌ها جعبه‌های متمایز، و == ارجاع‌ها را مقایسه می‌کند نه مقدارها را. همیشه .equals() یا unbox کن.

۱۳. چرا LinkedList با وجودِ درجِ O(1) پیش‌فرضِ بدی است؟

آن O(1) فقط وقتی صادق است که از قبل گره/پیمایشگر را در دست داری. دسترسیِ مبتنی بر اندیس و indexOf هردو O(n) هستند، هر گره یک تخصیصِ heap جداگانه (~۲۴ بایت سربار) با محلی‌بودنِ حافظه‌ی نهانِ افتضاح است، و System.arraycopyِ ArrayList اغلب حتی برای درجِ میانی هم از pointer chasing بهتر است. پیش‌فرض ArrayList، برای stack/queue ArrayDeque.

۱۴. با JDK چطور یک کش LRU می‌سازی؟

LinkedHashMap را با accessOrder = true (آرگومانِ سومِ سازنده) گسترش بده و removeEldestEntry را طوری بازنویسی کن که size() > capacity برگرداند. ترتیبِ دسترسی، رکوردهای لمس‌شده را به دُم می‌برد تا سر همیشه کم‌استفاده‌ترین باشد؛ آن بازنویسی هم در putِ بعدی حذفش می‌کند. برای نیازهای همزمان از Caffeine یا ConcurrentHashMap + حذفِ صریح استفاده کن.

۱۵. تفاوتِ پیمایشگرِ fail-fast و ضعیف-سازگار؟ (سنیور)

fail-fast (HashMap، ArrayList) مقدارِ modCount را عکس می‌گیرند و روی تغییرِ ساختاریِ همزمانِ تشخیص‌داده‌شده ConcurrentModificationException می‌دهند — یک آشکارسازِ باگِ best-effort، نه یک تضمین، و فقط برای تغییراتِ درونِ همان ترد مرئی. ضعیف-سازگار (ConcurrentHashMap، CopyOnWriteArrayList) هرگز پرتاب نمی‌کنند، یک دیدِ تقریباً-عکس‌برداری‌شده را بدون قفل می‌پیمایند، و ممکن است نوشتن‌های همزمان را ببینند یا نبینند — برای پیمایشِ همزمانِ امن طراحی شده‌اند.

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

تا اینجا درونیات را خوب فهمیدی. حالا می‌رویم سراغ چیزهایی که کتاب‌ها نمی‌نویسند ولی سرِ همان‌ها در پروداکشن آدم‌ها را می‌سوزانند و در مصاحبه‌ی سنیور فرق آدمِ «بلد» با آدمِ «واقعاً بلد» را نشان می‌دهند. هیچ‌کدام از این‌ها تکرار مطالب بالا نیست؛ همه شکافِ باقی‌مانده است.

نقشه‌ی راهِ این بخش
  • سایزکردنِ درستِ HashMap — چرا new HashMap<>(1000) تعداد resize را صفر نمی‌کند، و متد جدید JDK 19.
  • تله‌های computeIfAbsent / merge — باگِ بازگشتی (recursion) و قفلِ همان bucket در ConcurrentHashMap.
  • درستیِ Comparator — سرریزِ تفریق و استثنای معروفِ TimSort.
  • کلیدهای اعشاریNaN و -0.0 که سکوت‌کردنِ یک باگ‌اند.
  • EnumSet به‌عنوان بردارِ بیتی، و ترتیبِ تصادفیِ Map.of.
  • باغ‌وحشِ کالکشن‌های همزمان فراتر از CHM، و synchronizedMap که با وجودِ synchronized بازهم خراب می‌شود.
  • ردِّ پای حافظه در مقیاس، لایه‌های تغییرناپذیری، و ۹ سؤالِ سختِ سنیور.

سایزکردنِ HashMap: افسانه‌ی ظرفیتِ اولیه

خیلی‌ها فکر می‌کنند اگر بدانند قرار است هزار عنصر بریزند، با new HashMap<>(1000) جلوی همه‌ی resizeها را می‌گیرند. این اشتباه است و دلیلش همان load factor است.

آرگومانِ سازنده، «ظرفیت» است نه «تعداد ورودیِ موردانتظار». HashMap آن عدد را به نزدیک‌ترین توانِ ۲ به بالا گرد می‌کند (tableSizeFor)، پس 1000 می‌شود ظرفیتِ 1024. اما resize وقتی رخ می‌دهد که size > capacity × 0.75 باشد، یعنی سرِ عنصرِ ۷۶۹اُم جدول دوبرابر می‌شود — پس هزار عنصر یک resize می‌خورد، درست همان چیزی که می‌خواستی جلویش را بگیری.

فرمولِ درستِ سایزکردن

برای اینکه هیچ resize رخ ندهد، باید ظرفیت ≥ تعداد / 0.75 باشد:

// می‌خواهی ۱۰۰۰ عنصر بدون resize جا بگیرد؟
int n = 1000;
Map<K,V> m = new HashMap<>((int) Math.ceil(n / 0.75));   // 1334 -> ظرفیت 2048

نکته‌ی ظریف: تا اولین put، آرایه‌ی table اصلاً ساخته نمی‌شود (تخصیصِ تنبل/lazy)؛ سازنده فقط threshold را ست می‌کند. پس new HashMap<>()های خالیِ زیاد ارزان‌اند و حافظه‌ی جدول نمی‌گیرند.

به‌روزرسانیِ مدرن: `HashMap.newHashMap` در JDK 19

از جاوا ۱۹ به بعد متدِ کارخانه‌ایِ HashMap.newHashMap(int numMappings) اضافه شد که آرگومانش تعدادِ ورودی است، نه ظرفیت — و خودش تقسیم بر ۰٫۷۵ را انجام می‌دهد. همان برای HashSet.newHashSet، LinkedHashMap.newLinkedHashMap و WeakHashMap.newWeakHashMap هم هست. از این به بعد این را بنویس تا اصلاً درگیرِ افسانه‌ی ظرفیت نشوی:

Map<K,V> m = HashMap.newHashMap(1000);   // JDK 19+, تضمینِ بدون-resize

تله‌های computeIfAbsent و merge

این خانواده‌ی متدهای اتمیک، جواهرِ کدنویسیِ مدرن‌اند — اما دو تله‌ی مرگبار دارند که تقریباً همیشه در مصاحبه‌ی سنیور پرسیده می‌شوند.

تله‌ی اول: تغییرِ بازگشتیِ همان map

اگر داخلِ تابعِ computeIfAbsent روی همان map چیزِ دیگری بنویسی (مثلاً memoizationِ بازگشتیِ فیبوناچی)، از جاوا ۹ به بعد استثنای ConcurrentModificationException می‌گیری. چرا؟ چون در جاوا ۸ این حالت باگِ خاموش داشت (JDK-8071667): تابع می‌توانست باعثِ resize شود و ورودی‌ای اضافه کند که بعداً خودِ get پیدایش نمی‌کرد. جاوا ۹ همان چکِ modCount را اینجا هم گذاشت تا باگ را داد بزند.

Map<Integer,Long> memo = new HashMap<>();
Function<Integer,Long> fib = new Function<>() {
    public Long apply(Integer n) {
        return n < 2 ? n
            : memo.computeIfAbsent(n-1, this) + memo.computeIfAbsent(n-2, this);
    }               // ← در جاوا ۹+ اینجا CME پرتاب می‌شود
};

راهِ درست: memoization را دو-مرحله‌ای کن (get سپس put)، یا map را از پیش پر کن.

تله‌ی دوم: `computeIfAbsent` روی ConcurrentHashMap و همان bucket

در ConcurrentHashMap، متدِ computeIfAbsent روی سرِ همان bucket قفلِ synchronized می‌گیرد و تا پایانِ تابع نگه می‌دارد. اگر داخلِ آن تابع، کلیدِ دیگری را روی همان map بنویسی که تصادفاً در همان bucket بیفتد، به بن‌بست (deadlock) یا در بهترین حالت خرابیِ شمارنده می‌خوری — نه به CME. این با HashMap فرق دارد و باگِ واقعیِ سنگینی است. قاعده: داخلِ تابعِ computeIfAbsentِ یک CHM هرگز روی همان map ننویس و کارِ بلوکه‌کننده/کند نکن (چون کلِ آن bucket برای بقیه‌ی نخ‌ها قفل است).

یک ظرافتِ `computeIfAbsent` که کم‌کسی می‌داند

اگر تابعِ نگاشت null برگرداند، computeIfAbsent هیچ ورودی‌ای ثبت نمی‌کند و null برمی‌گرداند — یعنی دفعه‌ی بعد دوباره تابع اجرا می‌شود. پس برای کش‌کردنِ «نبودِ نتیجه» باید یک مقدارِ نگهبان (sentinel) ذخیره کنی، نه null.

درستیِ Comparator: دو باگی که سکوت می‌کنند

مرتب‌سازی و TreeMap روی Comparator سوارند، و دو اشتباهِ کلاسیک اینجا کمین کرده.

هرگز با تفریق مقایسه نکن

الگوی وسوسه‌انگیزِ (a, b) -> a.value - b.value برای intها سرریز (overflow) می‌کند: اگر a.value مثبتِ بزرگ و b.value منفیِ بزرگ باشد، تفریق سرریز کرده و علامتش برعکس می‌شود — ترتیب به‌هم می‌ریزد و گاهی مرتب‌سازی کاملاً غلط درمی‌آید. همیشه از Integer.compare(a, b) یا Comparator.comparingInt(...) استفاده کن.

استثنای معروفِ TimSort: «Comparison method violates its general contract!»

الگوریتمِ مرتب‌سازیِ جاوا (TimSort) اگر تشخیص دهد Comparatorت ناسازگار است — یعنی خاصیت‌های «اگر a<b و b<c پس a<c» یا «اگر a>b پس b<a» را نقض می‌کند — وسطِ کار IllegalArgumentException با همین پیام پرتاب می‌کند. علتِ رایج: مقایسه‌ای که برای بعضی جفت‌ها 0 و برای بعضی نامتقارن جواب می‌دهد، یا nullها را بی‌قاعده هندل می‌کند، یا از تفریقِ سرریزشونده استفاده کرده. این استثنا یعنی «داده‌ات خراب نیست، Comparatorت ریاضیاتاً غلط است». برای nullها از Comparator.nullsFirst/nullsLast استفاده کن.

کلیدهای اعشاری: NaN و صفرِ منفی

اینجا equals و == عمداً برعکسِ همدیگر رفتار می‌کنند و همین دو تله می‌سازد:

`NaN` و `-0.0` در کالکشن‌ها
  • NaN: در ریاضیِ اعشاری Double.NaN == Double.NaN برابرِ false است، اما Double.valueOf(NaN).equals(...) برابرِ true است. چون کالکشن‌ها با equals کار می‌کنند، اگر NaN را در HashSet بگذاری، contains(NaN) جوابِ true می‌دهد و می‌توانی حذفش کنی — یعنی برعکسِ چیزی که با == انتظار داری.
  • -0.0: 0.0 == -0.0 برابرِ true است، اما Double.valueOf(0.0).equals(Double.valueOf(-0.0)) برابرِ false. پس یک HashSet<Double> این دو را دو کلیدِ جدا می‌بیند. اگر داده‌ی مالی یا مختصات را کلید کنی، این می‌تواند دو رکوردِ ظاهراً یکسان بسازد.

درسِ کلی: نوعِ اعشاری را مستقیم کلید نکن؛ اگر مجبوری، اول نرمال‌سازی کن (d == 0.0 ? 0.0 : d, و NaN را جداگانه هندل کن).

EnumSet: همتای فراموش‌شده‌ی EnumMap

اگر مجموعه‌ای از ثابت‌های یک enum داری، HashSet<MyEnum> تقریباً همیشه اشتباه است. EnumSet داخلاً یک بردارِ بیتی (bit vector) است: هر ثابت با ordinal()ش یک بیت در یک long می‌شود. تا ۶۴ ثابت، کلِ مجموعه یک long است (RegularEnumSet)؛ بیشتر که شد، آرایه‌ای از long (JumboEnumSet). نتیجه: add/contains/removeAll عملاً چند دستورِ بیتیِ CPU است، مصرفِ حافظه ناچیز، و ترتیبِ پیمایش همان ترتیبِ تعریفِ enum.

EnumSet<Day> weekend = EnumSet.of(Day.SAT, Day.SUN);   // یک long
EnumSet<Day> workdays = EnumSet.complementOf(weekend);  // مکملِ بیتی، فوری
ترتیبِ تصادفیِ `Map.of` و `Set.of`

کالکشن‌های تغییرناپذیرِ Map.of/Set.of عمداً ترتیبِ پیمایش را با یک SALTِ تصادفی که یک‌بار در هر اجرای JVM (از روی زمان) ساخته می‌شود، به‌هم می‌ریزند. یعنی خروجیِ حلقه‌ات از یک اجرا به اجرای بعد فرق می‌کند. این عمدی است تا تو را از تکیه‌کردن به ترتیب بترساند. اگر ترتیبِ پایدار می‌خواهی، LinkedHashMap بساز.

`Collectors.toMap` روی کلیدِ تکراری منفجر می‌شود

stream.collect(Collectors.toMap(keyFn, valFn)) اگر دو عنصر کلیدِ یکسان بسازند، IllegalStateException: Duplicate key پرتاب می‌کند — نه اینکه بی‌سروصدا overwrite کند. باید نسخه‌ی سه‌آرگومانی با تابعِ ادغام بدهی: toMap(keyFn, valFn, (a, b) -> b). ضمناً نوعِ برگشتی HashMap است و تضمین نمی‌شود؛ برای ترتیب یا نوعِ خاص، آرگومانِ چهارم (LinkedHashMap::new) را بده. توجه: toMap مقدارِ null را هم تحمل نمی‌کند (داخلاً merge صدا می‌زند).

باغ‌وحشِ کالکشن‌های همزمان (فراتر از ConcurrentHashMap)

ConcurrentHashMap تنها بازیگرِ صحنه‌ی همزمانی نیست. سنیور باید ابزارِ درست را برای هر شکلِ همزمانی بشناسد.

قبل از انتخاب، این درختِ تصمیم را نگه دار — کپشن: «کدام کالکشنِ همزمان برای کدام کار».

flowchart TD
  Start[Need thread-safe collection?] --> Kind{What shape?}
  Kind -->|Key to value| Sorted{Need sorted keys?}
  Sorted -->|No| CHM[ConcurrentHashMap]
  Sorted -->|Yes| CSLM[ConcurrentSkipListMap]
  Kind -->|List, read-heavy| COW[CopyOnWriteArrayList]
  Kind -->|Producer / consumer| BQ[BlockingQueue family]
  Kind -->|Set| Set[ConcurrentHashMap.newKeySet]
`CopyOnWriteArrayList`: عالی برای خواندن، فاجعه برای نوشتن

هر نوشتنِ واحد (add/set/remove) کلِ آرایه‌ی زیرین را کپی می‌کند — یعنی هر نوشتن O(n) و پرمصرف است. پس فقط وقتی مناسب است که تعدادِ خواندن بسیار بیشتر از نوشتن باشد (کلاسیک: فهرستِ listenerها/observerها). دو نکته‌ی سنیور: (۱) iteratorش یک عکسِ لحظه‌ای (snapshot) از زمانِ ساختِ iterator است، پس CME هرگز پرتاب نمی‌شود ولی تغییراتِ بعدی را نمی‌بیند؛ (۲) iterator().remove() روی آن UnsupportedOperationException می‌دهد، چون snapshot فقط-خواندنی است.

بقیه‌ی اعضای مهم
  • ConcurrentSkipListMap / ...Set: نسخه‌ی همزمانِ TreeMap — یک NavigableMapِ مرتب و بدون-قفلِ سراسری با عملیاتِ O(log n). وقتی هم همزمانی و هم ترتیب/navigation می‌خواهی، این را بردار، نه synchronizedSortedMap.
  • خانواده‌ی BlockingQueue: ستونِ الگوی producer/consumer. put/take تا خالی/پرشدن بلوکه می‌شوند، اما offer/poll نه. ArrayBlockingQueue کران‌دار (bounded) و LinkedBlockingQueue اختیاراً کران‌دار — در پروداکشن همیشه کران بگذار تا در فشار، حافظه ترکیدن نگیرد.
  • ConcurrentHashMap.newKeySet(): راهِ درستِ ساختِ یک Setِ همزمان و پرقدرت (پشتِ صحنه همان CHM).
`Collections.synchronizedMap` با وجودِ synchronized بازهم غلط جواب می‌دهد

Collections.synchronizedMap(new HashMap<>()) هر متدِ تکی را روی یک قفل می‌بندد، اما دو دام باقی می‌ماند: (۱) عملیاتِ مرکب («اگر نبود بگذار» به‌صورتِ get سپس put) اتمیک نیست و بینِ دو فراخوانی race رخ می‌دهد — برای همین computeIfAbsentِ اتمیک را باید صریح صدا بزنی یا خودت بلوکِ synchronized(map) بگذاری. (۲) هنگامِ پیمایش (iteration) باید دستی روی خودِ map قفل بگیری، وگرنه ConcurrentModificationException می‌گیری:

Map<K,V> m = Collections.synchronizedMap(new HashMap<>());
synchronized (m) {                 // اجباری هنگام پیمایش
    for (var e : m.entrySet()) { ... }
}

در عملِ واقعی، ConcurrentHashMap تقریباً همیشه انتخابِ بهتری است.

ردِّ پای حافظه در مقیاس

چند عنصر واقعاً چقدر می‌خورد

روی JVMِ ۶۴ بیتی با compressed oops، هر HashMap.Node حدودِ ۳۲ بایت است (هدرِ ۱۲ + هشِ int ۴ + سه رفرنسِ key/value/next، با padding) — جدای از آرایه‌ی bucket و خودِ آبجکت‌های key/value. یعنی یک HashMapِ ده‌میلیونی حدودِ نیم گیگ فقط برای Nodeها و جدول می‌خورد. جمع‌بندیِ سنیور: در مقیاس به HashMap<Long, ...> مشکوک باش (هر boxed Long یک آبجکتِ جدا است)؛ گاهی کتابخانه‌ی primitive-collection مثلِ Eclipse Collections/fastutil ده‌ها برابر حافظه صرفه‌جویی می‌کند.

لایه‌های تغییرناپذیری: view در برابر copy در برابر واقعاً immutable

سه چیز که به‌اشتباه یکی گرفته می‌شوند
  • Collections.unmodifiableList(x) یک viewِ فقط‌خواندنی می‌سازد؛ اگر لیستِ اصلیِ x را عوض کنی، تغییر از پشتِ view دیده می‌شود. امنیت نمی‌دهد، فقط خواندن-از-این-مرجع را می‌بندد.
  • List.copyOf(x) / List.of(...) یک کپیِ واقعاً تغییرناپذیر می‌سازند که از تغییرِ منبع مصون است — اما null را قبول نمی‌کنند و سرِ null استثنا می‌دهند.
  • «تغییرناپذیریِ کم‌عمق (shallow)»: حتی List.of(mutableUser) خودِ لیست را قفل می‌کند ولی mutableUser.setName(...) هنوز کار می‌کند. تغییرناپذیریِ واقعی یعنی عناصر هم immutable باشند.

سؤال‌های سختِ مصاحبه‌ی سنیور

۱. چرا `new HashMap<>(1000)` جلوی resize را نمی‌گیرد و راهِ درست چیست؟

چون آرگومان «ظرفیت» است نه «تعدادِ ورودی»، و resize سرِ size > capacity × 0.75 رخ می‌دهد. 1000 می‌شود ظرفیتِ ۱۰۲۴ که سرِ عنصرِ ۷۶۹اُم دوبرابر می‌شود. برای صفرکردنِ resize باید ظرفیت ≥ تعداد / 0.75 باشد (اینجا ≥ ۱۳۳۴ → ۲۰۴۸). راهِ تمیز از جاوا ۱۹: HashMap.newHashMap(1000) که خودش تقسیم بر ۰٫۷۵ را انجام می‌دهد. ضمناً جدول تا اولین put اصلاً ساخته نمی‌شود (تخصیصِ تنبل).

۲. یک تابعِ بازگشتیِ فیبوناچی داخلِ `computeIfAbsent` روی HashMap می‌نویسی؛ در جاوا ۸ و جاوا ۹ چه فرقی می‌کند؟ (سخت)

در جاوا ۸ ممکن است بی‌سروصدا خراب کند: تابع می‌تواند باعثِ resize شود و ورودی‌ای بسازد که get بعداً پیدایش نمی‌کند (JDK-8071667). در جاوا ۹ به بعد، همان چکِ modCount به computeIfAbsent اضافه شد و حالا ConcurrentModificationException پرتاب می‌کند چون تابعِ نگاشت، map را وسطِ محاسبه تغییر داده. درسِ درست: memoizationِ بازگشتی را با get+putِ دو مرحله‌ای بنویس، نه با computeIfAbsent.

۳. چرا `computeIfAbsent` روی `ConcurrentHashMap` که کلیدِ دیگری را روی همان map می‌نویسد خطرناک‌تر از HashMap است؟ (سخت)

چون CHM حینِ اجرای تابع، روی سرِ همان bucket قفلِ synchronized نگه می‌دارد. اگر تابع کلیدِ دومی بنویسد که در همان bucket بیفتد (یا به‌شکلی به همان قفل نیاز پیدا کند)، به بن‌بست یا خرابیِ داخلی می‌خوری — نه به یک CMEِ تمیز. پس داخلِ computeIfAbsentِ یک CHM هرگز روی همان map ننویس و هیچ کارِ کند/بلوکه‌کننده نکن، چون تمامِ آن bucket برای بقیه‌ی نخ‌ها قفل است.

۴. چرا `(a, b) -> a.age - b.age` به‌عنوان Comparator باگ دارد و TimSort کِی «violates its general contract» پرتاب می‌کند؟

تفریقِ دو int سرریز می‌کند: اگر a.age مثبتِ بزرگ و b.age منفیِ بزرگ باشد، حاصلِ تفریق سرریز کرده و علامت برعکس می‌شود، پس ترتیب نقض می‌شود. راهِ درست Integer.compare. اگر Comparatorت ناسازگار باشد (تعدی/transitivity یا تقارن را نقض کند، یا nullها را بی‌قاعده هندل کند)، TimSort وسطِ merge تشخیص می‌دهد و IllegalArgumentException: Comparison method violates its general contract! می‌دهد — یعنی مرتب‌سازی نتوانست به یک ترتیبِ سازگار برسد.

۵. `NaN` را در یک `HashSet<Double>` می‌گذاری؛ آیا `contains(Double.NaN)` جوابِ true می‌دهد؟ و `0.0` با `-0.0`؟ (تله)

بله، contains(NaN) جوابِ true می‌دهد و می‌توانی حذفش کنی، چون کالکشن‌ها با equals کار می‌کنند و Double.equals برای NaN==NaN برابرِ true است — دقیقاً برعکسِ عملگرِ ==. اما 0.0 و -0.0 برعکس‌اند: == آن‌ها را برابر می‌بیند ولی Double.equals نه، پس HashSet این دو را دو عنصرِ جدا می‌شمارد. نتیجه: نوعِ اعشاری را بی‌نرمال‌سازی کلید نکن.

۶. `Collectors.toMap` را روی استریمی با کلیدهای تکراری اجرا می‌کنی؛ چه می‌شود و چطور درستش می‌کنی؟

IllegalStateException: Duplicate key پرتاب می‌شود — بی‌سروصدا overwrite نمی‌کند. باید نسخه‌ی سه‌آرگومانی با تابعِ ادغام بدهی، مثلِ toMap(k, v, (a, b) -> b) برای «آخری برنده». اگر ترتیب یا نوعِ خاصِ map می‌خواهی، آرگومانِ چهارم LinkedHashMap::new را بده. و بدان مقدارِ null را هم نمی‌پذیرد چون داخلاً merge صدا می‌زند.

۷. کِی `CopyOnWriteArrayList` انتخابِ درست است و هزینه‌ی پنهانش چیست؟ آیا می‌توانی حینِ پیمایش با `iterator().remove()` عنصر حذف کنی؟

وقتی خواندن بسیار پرتکرارتر از نوشتن است (مثلاً فهرستِ listenerها). هزینه‌ی پنهان: هر نوشتنِ واحد کلِ آرایه را کپی می‌کند، پس نوشتن O(n) و پرمصرف است و برای بارِ نوشتنِ زیاد فاجعه است. iterator().remove() روی آن UnsupportedOperationException می‌دهد، چون iterator یک عکسِ لحظه‌ایِ فقط‌خواندنی است؛ همین باعث می‌شود CME هرگز پرتاب نشود اما تغییراتِ بعد از ساختِ iterator هم دیده نشوند.

۸. با اینکه هر متدِ `Collections.synchronizedMap` قفل دارد، چطور بازهم می‌تواند نتیجه‌ی غلط یا استثنا بدهد؟ (سنیور)

دو دلیل: (۱) عملیاتِ مرکب مثلِ «check-then-act» (if (!m.containsKey(k)) m.put(k, v)) اتمیک نیست؛ بینِ دو فراخوانیِ synchronized نخِ دیگر می‌تواند وضع را عوض کند (race و lost update). (۲) پیمایش خودبه‌خود قفل نمی‌گیرد؛ باید دستی synchronized(map) دورِ کلِ حلقه بگذاری وگرنه اگر نخِ دیگری وسطِ پیمایش بنویسد ConcurrentModificationException می‌گیری. ConcurrentHashMap با عملیاتِ اتمیکِ merge/compute و iteratorِ ضعیف‌سازگار هر دو مشکل را حل می‌کند.

۹. چرا ترتیبِ پیمایشِ `Map.of(...)` از یک اجرای برنامه به اجرای بعد فرق می‌کند، ولی `HashMap` معمولاً در یک اجرا پایدار است؟ (تله)

کالکشن‌های تغییرناپذیرِ Map.of/Set.of عمداً از یک SALTِ تصادفی که یک‌بار در هر اجرای JVM ساخته می‌شود برای جای‌گذاری استفاده می‌کنند، پس ترتیبِ خروجی بین اجراها randomize می‌شود تا برنامه‌نویس را از تکیه به ترتیب بازدارد. HashMap چنین randomizationِ بین‌اجرایی ندارد و ترتیبش تابعِ hash و ظرفیت است (که بین اجراها یکسان می‌ماند)، ولی همان هم رسماً «نامشخص» است و با resize عوض می‌شود. اگر ترتیب مهم است، LinkedHashMap/TreeMap.

در یک نگاه (نکاتِ سنیور)
  • سایز: آرگومانِ سازنده ظرفیت است نه تعداد؛ برای بدون-resize، ظرفیت ≥ n/0.75 یا HashMap.newHashMap(n) در JDK 19+.
  • computeIfAbsent: تغییرِ بازگشتیِ همان map در جاوا ۹+ CME می‌دهد؛ روی CHM قفلِ همان bucket را می‌گیرد و می‌تواند deadlock کند.
  • Comparator: تفریق سرریز می‌کند (Integer.compare)؛ Comparatorِ ناسازگار در TimSort «violates its general contract» می‌دهد.
  • اعشاری: NaN با equals برابر است ولی با == نه؛ -0.0 برعکس — پس کلیدِ اعشاری را نرمال‌سازی کن.
  • همزمانی: ConcurrentSkipListMap برای مرتب، CopyOnWriteArrayList برای خواندن‌محور (نوشتن O(n))، BlockingQueue برای producer/consumer؛ synchronizedMap عملیاتِ مرکب و پیمایش را امن نمی‌کند.
جمع‌بندی
  • رو به اینترفیس بنویس؛ Map عمداً Collection نیست چون کلید→مقدار است نه یک دنباله.
  • دو محورِ همه‌ی تصمیم‌ها: ترتیب پیمایش و پیچیدگیِ عملیات. پیش‌فرض‌ها: ArrayList و ArrayDeque؛ LinkedList تقریباً هرگز.
  • HashMap: آرایه‌ی سطل، ظرفیتْ توانِ دو، اندیس (n-1) & (h ^ h>>>16). سطلِ شلوغ در ۸ گره و table ≥۶۴ به درختِ قرمز-سیاه treeify می‌شود؛ resize با تک‌بیتِ hash & oldCap سطل را به پایین/بالا می‌شکند. کلیدها باید در فیلدهای hashCode تغییرناپذیر باشند، وگرنه رکورد یتیم می‌شود.
  • LinkedHashMap ترتیبِ درج/دسترسی می‌دهد و با accessOrder=true + removeEldestEntry می‌شود کش LRU. TreeMap درختِ قرمز-سیاهِ O(log n) با ناوبریِ مرتب است و برابری را با compareTo==0 می‌سنجد نه equals.
  • ConcurrentHashMap (جاوا ۸): بدونِ segment، CAS روی سطلِ خالی + synchronized روی سرِ سطل، خواندنِ بدونِ قفل، بدونِ null، پیمایشگرِ ضعیف-سازگار، size() تقریبی. برای شمارش از merge/compute اتمیک استفاده کن، نه get-سپس-put.
  • نقشه‌های تخصصی: EnumMap (آرایه با ordinal)، WeakHashMap (کلیدِ ارجاع‌ضعیف)، IdentityHashMap (== نه equals).

منبع: مستندات و Javadocهای رسمی جاوا (java.util.HashMap, ConcurrentHashMap, TreeMap, LinkedHashMap).

Every program you write eventually has to hold a pile of data somewhere: a list of users, a "id → name" table, a queue of pending jobs. Java ships a whole toolbox for this called the Collections Framework. Most people's problem isn't that they can't use the tools — it's that they don't know what happens behind the curtain of each one, and that's exactly where senior interviews trip people up. This lesson is that behind-the-curtain tour.

Roadmap for this lesson
  • Part 0: a few words you must feel first (interface, Big-O, bucket, hash).
  • Family layout: List / Set / Queue / Deque — and why Map isn't in the family.
  • Big-O table: which operation costs what, on which structure.
  • ArrayList vs LinkedList: why the default is always ArrayList.
  • Heart of the interview — HashMap internals: hash, bucket, treeify, resize, the legendary Java 7 bug.
  • LinkedHashMap & LRU caches, TreeMap & sorted queries, the segment-free ConcurrentHashMap.
  • Specialist maps, common gotchas, and finally 15 interview questions with full answers.

Part 0 — words you must feel first

Before the details, let's build the four words that keep recurring, from the ground up. Feel these and the rest is easy.

An interface is "the shape of the wall socket"

Think of a wall socket. A fridge, a TV, and a phone charger all have plugs that fit the same socket. You deal with the socket shape, not with what device is behind it. In Java, List is a socket shape; ArrayList and LinkedList are two different devices that fit it. If your code talks to the socket (List) rather than the device (ArrayList), you can swap the device any time without rewiring the whole house.

That idea has a formal name: program to the interface. Declare your fields, parameters, and return types as List / Set / Map, and let only the new expression name the concrete class.

Big-O is "cost as the job grows"

Imagine an office tea-server. If fetching one cup always takes 30 seconds whether there are 5 of you or 5,000, the time per cup is constant — that's O(1). But if finding someone's file means flipping through the whole archive from the start, the bigger the archive the slower it gets — that's O(n). And if the archive is sorted so they can keep halving it (like finding a word in a dictionary), it's O(log n), which grows far more gently than O(n).

The two words the whole chapter orbits

Every collections decision comes down to two things: (1) iteration order — when you loop, do elements come out in insertion order, sorted order, or no order? (2) complexity of core operations — what does get / add / contains / remove each cost? Almost every interview mistake is confusion about one of these two.

For now, know bucket and hash at this level: hashing means turning an object into an integer, and a bucket is a slot in an array where we place that object according to its number. We'll fully unpack the details in the HashMap section.

The Collections family layout

The Collections Framework is a set of interfaces (Collection, List, Set, Queue, Deque, Map) plus concrete implementations, all under the java.util package. Now one point that confuses many people:

Why isn't Map part of the Collection family?

A Collection is like a row of cinema seats: a single sequence of elements. But a Map is like a phone book: each row is a "name → number" pair, not a lone element. Because its nature is "mapping keys to values" rather than "a sequence of elements", Java's designers deliberately kept it out from under Collection; Map has its own separate hierarchy.

Here's the whole family tree:

            Iterable
               │
          Collection ───────────────┐
          /    |     \               │
       List   Set    Queue ── Deque  │   Map  (separate hierarchy)
        │      │        │       │        │
   ArrayList  HashSet  PriorityQ ArrayDeque  HashMap
   LinkedList LinkedHashSet      LinkedList  LinkedHashMap
   Vector     TreeSet(NavigableSet)          TreeMap(NavigableMap)
   CopyOnWriteArrayList                       ConcurrentHashMap
                                              EnumMap / WeakHashMap / IdentityHashMap

Each interface, in one feel-it sentence:

  • List — a numbered row: ordered by index, duplicates allowed, and you can say "give me element #5". Implementations: ArrayList, LinkedList, Vector, CopyOnWriteArrayList.
  • Set — a bag that refuses duplicates (duplicate-ness judged by equals). HashSet (unordered), LinkedHashSet (insertion order), TreeSet (sorted, a NavigableSet).
  • Queue — a bakery line, usually "first-in first-out" (FIFO); methods offer/poll/peek. PriorityQueue (kept ordered by a heap), ArrayDeque, LinkedList.
  • Deque — a double-ended queue (add/remove at both ends); the modern replacement for the old Stack. ArrayDeque is the recommended stack and queue.
  • Map — the key→value phone book. HashMap, LinkedHashMap, TreeMap, ConcurrentHashMap, EnumMap, WeakHashMap, IdentityHashMap.

The Big-O cheat sheet

Keep this table like a treasure map; half of collections questions come straight out of these cells.

Operation ArrayList LinkedList ArrayDeque HashMap/HashSet TreeMap/TreeSet LinkedHashMap
get(i) / random access O(1) O(n)
get(key) / contains O(n) O(n) O(1) avg, O(log n) worst O(log n) O(1) avg
add at end O(1) amortized O(1) O(1) amortized O(1) avg O(log n) O(1) avg
add/remove at head O(n) O(1) O(1)
insert/remove at index O(n) O(n) to find + O(1)
ordered iteration insertion insertion insertion none sorted insertion/access

Let me unpack "amortized", because it matters.

"Amortized" means spread the rare expensive cost

Picture a small wardrobe you keep adding clothes to. Most of the time you just hang one item: cheap and instant. But occasionally the wardrobe fills up and you must buy one twice the size and move all the clothes over: one heavy cost. If you spread that occasional heavy cost across all the cheap adds, each add on average stays cheap. That's amortized O(1): not that it's never expensive, but that the average stays constant.

The big LinkedList trap

add(index, e) and remove(index) on LinkedList are advertised as "list operations", but reaching that index is O(n) because you must walk node by node from the head. LinkedList is only genuinely useful when you either hold an Iterator/ListIterator right at the mutation point, or use it purely as a Deque. For almost all real workloads, ArrayList is both faster and better because of cache locality. Your default should be ArrayList; your default deque should be ArrayDeque.

ArrayList vs LinkedList — in detail

ArrayList internally wraps a plain Object[]. Appends are amortized O(1) because when it fills, capacity grows by ~1.5x (newCap = old + (old >> 1)). Random access is a single array index — instant. Removing from the middle shifts every following element back one slot (via System.arraycopy), which is O(n) — but extremely fast per element, because it's one contiguous memory move.

LinkedList is a doubly-linked list of Node objects. Each node is a separate heap allocation with two pointers (to previous and next) plus the object-header overhead.

Why LinkedList wrecks the CPU cache

An array is like houses on one straight street: when you reach house 10, houses 11 and 12 are right there and the CPU pre-fetches them (that's cache locality). But a LinkedList is like a treasure hunt: each node is just a slip of paper with the address of the next node written on it, and that next node could be anywhere in the heap. This "pointer chasing" forces the CPU to keep jumping to scattered memory spots and destroys cache performance. On a 64-bit JVM each node carries ~24 bytes of overhead, versus just 4–8 bytes for an ArrayList slot.

LinkedList implements both List and Deque. But for stacks and queues, the right choice is something else:

// Prefer ArrayDeque over Stack (which is synchronized and extends Vector)
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2);      // 2 is on top
int top = stack.pop();              // 2

Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1); queue.offer(2);
int head = queue.poll();            // 1 (FIFO)
Why not Stack?

The old Stack class extends Vector and every method is synchronized — so even single-threaded you pay the locking cost. ArrayDeque has neither the needless lock nor Stack's baggage, and it does both stack and queue beautifully. Rule of thumb: from today, forget Stack.

HashMap internals — the heart of the interview

Now the most important section. If you deeply learn just one data structure for a senior interview, make it HashMap.

HashMap is like the numbered lockers at a pool

Think of the numbered lockers in a pool changing room. When you arrive, the attendant computes a number from your ID and says "locker 7". Next time you come, the same computation runs, you go straight to locker 7, and your shoes are there — no need to open every locker. This "compute the slot number from the thing itself" is the essence of HashMap. Now the problem: what if two people's IDs both map to locker 7? That's a collision, and we'll see exactly how HashMap resolves it.

So internally, a HashMap is an array of buckets: Node<K,V>[] table. Each bucket is in one of three states: empty (null), a short linked list of nodes (when several keys landed in one bucket), or — once a bucket gets very crowded — a red-black tree, which is a self-balancing binary search tree.

The key constants

These few numbers are hard-coded into HashMap and worth memorizing:

Constant Value Meaning
DEFAULT_INITIAL_CAPACITY 16 table length when first populated
DEFAULT_LOAD_FACTOR 0.75 resize when size > capacity × loadFactor
TREEIFY_THRESHOLD 8 a bin with ≥8 nodes may become a tree
UNTREEIFY_THRESHOLD 6 a tree shrinking to ≤6 reverts to a list
MIN_TREEIFY_CAPACITY 64 but only if the table itself is ≥64; otherwise resize instead
The backbone of the whole design

HashMap capacity is always a power of two (16, 32, 64, ...). That is no accident; it's the linchpin of every clever trick that follows — both index computation and resize. Whenever you get stuck, remember this sentence.

Hashing and index computation

The subtle bit: HashMap does not use key.hashCode() directly as the bucket number. It first "stirs" the hash a little to improve its quality:

static final int hash(Object key) {
    int h;
    // XOR the top 16 bits into the bottom 16 bits
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

Then the bucket index is computed as (n - 1) & hash, where n is the (power-of-two) table length. Why is that XOR step needed?

Why XOR the high bits into the low bits?

Imagine you build the locker number from only the last two digits of the national ID. If all your customers have IDs that differ only in their leading digits but share the last two, they all map to one locker — disaster. The (n-1) & operation is exactly this: because n is a power of two, (n-1) is a mask that keeps only the low bits of the hash and throws the high bits away. That's why we first do h ^ (h >>> 16): it XORs the top 16 bits into the bottom 16 ("spreading") so the high-bit information influences the low bits, and keys that differ only in high bits still land in different buckets. All of this is cheap — one XOR and one shift, not an expensive multiplicative hash.

Where does the null key go?

In the function above, if the key is null its hash is 0, so it always sits in bucket 0. That's why HashMap allows exactly one null key (there's only one bucket 0, and inside it null is matched by equals).

The put walk-through, step by step

Let's see exactly what happens when you call map.put(key, value):

  1. Compute hash(key), then derive index i = (n-1) & hash.
  2. If table[i] is empty, drop the new node there. Done.
  3. Otherwise walk the bin. If you find a node with an equal key, overwrite its value. "Equal" means: hash equal and (key == k || key.equals(k)).
  4. If not found, append the node to the bin. Now if this bin has ≥ TREEIFY_THRESHOLD (8) nodes and table length ≥ 64, treeify the bin into a red-black tree. But if table length < 64, instead of treeifying, resize the whole table (which spreads the collisions out).
  5. Increment size; if now size > threshold (i.e. capacity × loadFactor), resize.
Why compare hash before equals?

In step 3 we compare hash first, and only reach for equals if the hashes match. Why? Comparing two integers is nearly free, but equals can be expensive (imagine comparing two long strings character by character). Two objects with different hashes are definitely not equal, so one cheap integer compare lets us skip the expensive equals. This is called an "early-out".

Treeification — why exactly 8?

We said a crowded bin turns into a tree at the threshold of 8. But why 8?

The Poisson distribution and "why 8"

With a good hash function and random keys, how keys spread across buckets follows a statistical law called the Poisson distribution — the same law that governs "how likely is it that several customers hit one bank teller at once". By that law, HashMap's own Javadoc explicitly states the probability of a bin reaching 8 elements is about 0.00000006 — essentially never, unless the hash function is bad or someone deliberately crafts adversarial keys.

So why do we care about treeifying at all? Because treeify converts that unlucky bin from an O(n) linear scan to O(log n), and this caps hash-collision denial-of-service (DoS) attacks: an attack where the adversary deliberately sends thousands of keys with the same hash so they all pile into one bucket and slow the server to a crawl. The tree bounds that damage. Order inside the tree is first by hash, then by Comparable if the keys implement it, else by an identity-based tiebreak.

Resize — and the beauty of powers of two

When size exceeds the threshold, capacity doubles. This is where "capacity is always a power of two" turns to gold:

The single-bit resize trick

When the table grows from oldCap to 2*oldCap, each existing key has only two fates: it either stays put at index j, or moves to index j + oldCap. And which one? Decided by looking at a single bit: hash & oldCap. So you never re-hash any key; you just split each old bin cleanly into two pieces — a "low" list and a "high" list. That's the big saving that power-of-two sizing hands you.

oldCap = 16 (10000b). A key with hash whose bit-4 (value 16) is:
   0  -> stays at index j
   1  -> moves to index j + 16

Now a famous piece of history, asked in almost every senior interview:

The legendary Java 7 infinite-loop bug

In Java 7, during resize, each bin was transferred by "prepending" (adding to the front), which reversed the list order. Now if two threads resized the same HashMap at once, this reversal could knot nodes into a cycle — node A pointing to B and B pointing back to A. The result? The next get() that entered that bin spun forever, pinning the CPU at 100%. Java 8 fixed this with the ordered split (the low/high lists via hash & oldCap) which preserves order, so that cycle is no longer possible. But an important warning: this only cured that specific symptom. HashMap is still not thread-safe; concurrent writes from multiple threads can still lose data or corrupt state. For concurrency you must use ConcurrentHashMap.

Fail-fast iterators

Suppose you're looping over a list and, mid-loop, you change the list itself. Java catches this early:

The fail-fast mechanism is like a document version number

Imagine two people working on one shared document. The document has a "version number". You note the version (say, v5) and start reading. If someone edits the document mid-read, the version bumps to v6; the next time you look up and see it's no longer v5, you immediately shout "this document changed under me!". In Java that "version number" is called modCount.

Every structural modification (an add/remove that changes size, or a treeify) bumps an internal modCount. When an iterator is created it snapshots modCount into expectedModCount; on each next() it compares the two and throws ConcurrentModificationException if they differ.

Fail-fast is not a guarantee — it's a "best-effort bug catcher"

This mechanism is best-effort — built to surface your bugs early, not to enforce correctness. The only safe way to remove during iteration is via the iterator itself:

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (shouldRemove(it.next())) it.remove();  // safe
}
// list.remove(x) inside a for-each -> ConcurrentModificationException

And a very sneaky trap: if you remove the second-to-last element via list.remove() in a for-each, it may not throw at all! Because after the removal the iterator's hasNext returns false and next is never called again, so the modCount check never runs. This is a classic "silent bug".

Why keys must be immutable

This is one of the most frequent production bugs, so feel it well:

A mutable key is like secretly moving house

Remember that each thing's identity in HashMap decides its bucket number from its hash? Say you've put a package in locker 7 (computed from your address). Now you secretly change your address. Next time the attendant computes the locker number from your new address, they go to locker 12 and say "nothing here" — while your package still physically sits in locker 7. Now you can neither find it nor free its memory: lost but still occupying memory.

Precisely: when you put, the map decides which bucket a key goes to based on its hash at that moment. If you later mutate a field that participates in hashCode/equals, the key now hashes to a different bucket but still physically sits in the old one. get(sameKey) computes the new index, looks in the new bucket, and finds nothing.

// Broken: mutable key
Map<Point, String> m = new HashMap<>();
Point p = new Point(1, 1);
m.put(p, "a");
p.setX(99);                 // mutates fields used by hashCode/equals
m.get(p);                   // null! entry is orphaned in the old bucket
The golden rule of equals and hashCode

hashCode and equals must be consistent: if two objects are equals, they must have the same hashCode, or HashMap breaks (they might land in different buckets and never find each other). Overriding equals without overriding hashCode is one of the most common real-world production bugs. That's why String, boxed primitives (Integer, etc.), and other immutable types make ideal keys.

LinkedHashMap and LRU caches

LinkedHashMap extends HashMap and additionally threads every entry onto a doubly-linked list. The result: predictable insertion-order iteration (unlike HashMap, whose order is unspecified).

But its killer feature is something else: access order.

An LRU cache is like a newsstand shelf

Picture a newsstand with limited space. Each time someone picks up and reads a paper, the vendor puts it back at the front of the shelf (the most accessible spot). The result? Recently-read papers are up front and the ones nobody has touched in ages drift to the back. When space runs short, the vendor discards the back of the shelf — the least-recently-used (LRU) item. That's exactly what LinkedHashMap with accessOrder = true does: every get/put moves the touched entry to the tail, so the head is always the least-recently-used entry.

By overriding removeEldestEntry, you can build a bounded LRU cache in a handful of lines:

class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    LRUCache(int capacity) {
        super(16, 0.75f, true);      // true = access-order
        this.capacity = capacity;
    }
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;    // evict LRU when over capacity
    }
}

The constructor's third argument (true) is what turns on access order; and every time a new element is put, Java calls removeEldestEntry, and if it returns true, the head (the least-recently-used) is evicted.

TreeMap / NavigableMap

So far everything revolved around hashing. TreeMap plays a different game: a red-black tree (a kind of self-balancing binary search tree). All its operations are O(log n) and — the crucial difference — iteration happens in sorted key order: either by the key's natural ordering (Comparable) or a Comparator you supply.

TreeMap is like an always-sorted archive

HashMap is like a warehouse that tosses each thing into a random locker: finding one specific thing is fast, but you can't ask "what's nearest to X". TreeMap is like a shelf where books are always kept in alphabetical order. It's a bit slower (O(log n) instead of O(1)), but in return you can ask "what's the nearest book before the letter M?" — something no hash map can do.

That very ability gives you a set of "navigation" methods, collectively called NavigableMap: floorKey, ceilingKey, higherKey, lowerKey, firstKey, lastKey, headMap, tailMap, subMap.

NavigableMap<Integer, String> tm = new TreeMap<>();
tm.put(10, "a"); tm.put(20, "b"); tm.put(30, "c");
tm.floorKey(25);     // 20  (greatest key <= 25)
tm.ceilingKey(25);   // 30  (least key >= 25)
tm.subMap(10, true, 25, true);   // {10=a, 20=b}
The TreeMap equality gotcha

TreeMap rejects null keys (it must compare keys, and null isn't comparable), and throws ClassCastException if keys aren't mutually comparable and you gave no Comparator. But here's the real trap: TreeMap decides whether two keys are equal by the comparator/compareTo returning 0, not by equals! So if you give a comparator that compares only one field, two objects equal on that field count as "the same key" and put overwrites — even if their equals says they differ. This deliberately deviates from the Map contract and bites people hard.

ConcurrentHashMap internals (Java 8+)

When multiple threads want to read and write one shared map at once, HashMap is dangerous and the old Hashtable is slow. The right answer is ConcurrentHashMap — but its design changed in Java 8, and that very "change" is a classic interview question.

From one big lock to a lock on each drawer

Before Java 8, ConcurrentHashMap was split into ~16 pieces (Segments), each with its own lock — like an archive divided into 16 cabinets, where to work with any cabinet you had to lock the whole cabinet. Java 8 removed segments entirely and went finer-grained: the same HashMap-style bucket array, but now the lock lands on each individual bucket, not on a big cabinet. Like locking only the one drawer your hand is in, instead of the entire cabinet.

The modern design works like this:

  • Empty bin: installing the first node uses a lock-free CAS (compareAndSwapObject). In the common, uncontended case, no lock is taken at all.
  • Non-empty bin: the thread synchronizeds only on the bin's head node — so writers to different buckets never block each other. Lock granularity is one bucket, not the whole map.
  • Bins still treeify at 8 (with the same table ≥64 rule), exactly like HashMap.
  • Reads never lock. get traverses volatile nodes and always sees a consistent (though possibly slightly stale) view.
  • During resize, threads help each other: a special ForwardingNode marks bins that have migrated, and multiple threads cooperatively transfer ranges of the table (transferIndex, stride).
What is CAS?

CAS means "Compare-And-Swap", a hardware-level instruction that atomically says "if this slot still holds X, swap it to Y; otherwise don't touch it and report failure". Because the hardware guarantees the whole thing happens as one indivisible step, you no longer need a heavy lock. This is the foundation of all "lock-free" programming.

size() has an interesting story too:

Why size() is approximate

Imagine 8 store cashiers each writing their own sales tally on a separate board, and you sum them whenever you need the total. If they all had to write on one shared board, there'd be a constant fight over that board (contention). Instead, ConcurrentHashMap "stripes" the count: baseCount plus a CounterCell[] array — the same idea as LongAdder. The downside? Because you sum several boards, the number size() returns is a consistent-at-some-instant estimate, not a guaranteed-exact figure. For a long-typed count you also have mappingCount(), which is equally advisory.

ConcurrentHashMap's two hard rules

ConcurrentHashMap permits no null keys and no null values (unlike HashMap, which allows one null key and null values). Why? Because in a concurrent map, if get returns null you can't tell whether that means "key absent" or "key present but mapped to null". Single-threaded code disambiguates with a containsKey, but concurrently the state can change between the two calls (a race). Banning null removes the ambiguity at the root. Also, its iterators are weakly consistent, not fail-fast: they never throw ConcurrentModificationException, they traverse without locking, and they may or may not reflect writes that happen during iteration.

The right way to count concurrently goes through the map's own atomic operations, not get-then-put:

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
// atomic, lock-striped read-modify-write — the correct way to count concurrently
counts.merge("apples", 1, Integer::sum);
counts.compute("apples", (k, v) -> (v == null) ? 1 : v + 1);
counts.computeIfAbsent("bananas", k -> 0);
// WRONG under concurrency: get-then-put is a lost-update race
// counts.put("x", counts.getOrDefault("x", 0) + 1);

The specialist maps

Three special maps, each built for one specific job:

  • EnumMap — when keys are the constants of an enum. Internally it's just a plain Object[] indexed by ordinal() (the enum constant's ordinal number), so it's extremely compact and fast — direct array access, no hashing at all. It iterates in enum declaration order. Always prefer it over HashMap<MyEnum, V>.
  • WeakHashMap — holds its keys via weak references.
A weak reference is like a note that doesn't stop spring cleaning

A strong (normal) reference tells the GC "as long as I exist, don't collect this object". But a weak reference says "if nobody else wants this object, feel free to collect it — I won't stop you". So in a WeakHashMap, once a key is no longer strongly reachable anywhere else, the GC reclaims it and its entry is removed too (lazily, via a ReferenceQueue drained on access). Great for metadata/canonicalizing caches that must not prevent keys from being collected. Danger: if the value itself references the key, the key stays strongly reachable and never clears.

  • IdentityHashMap — uses == and System.identityHashCode, not equals/hashCode. That is, it cares about "being the same object", not "being equal". Internally it uses open addressing (linear probing) with a single flat array of alternating keys and values, not chaining. Uses: topology-preserving graph traversal, serializers, and deep-copy "visited" sets — places where you need reference identity, not logical equality. Here, new String("a") and new String("a") are two entirely distinct keys.

Common pitfalls & gotchas

These are the places where seemingly-correct code breaks silently:

Six classic traps you must know
  • Arrays.asList() returns a fixed-size list backed by the array — add/remove on it throw UnsupportedOperationException. And List.of(...) / Map.of(...) return fully immutable collections that additionally reject nulls.
  • HashMap iteration order is unspecified and can change on resize. Never rely on it; if you need order, use LinkedHashMap or TreeMap.
  • Autoboxing cache: Integer values −128..127 are cached, so == on small boxed ints accidentally "works", then suddenly breaks at 128. Always .equals() or compare primitives.
  • subList, keySet, values, entrySet are views, not copies — mutating them mutates the backing collection, and a structural change to the backing collection invalidates the view.
  • remove(int) vs remove(Object) on List<Integer>: list.remove(2) removes the element at index 2, but list.remove(Integer.valueOf(2)) removes the value 2.
  • Set/Map correctness depends on the immutable hashCode of contained elements — putting a mutable object in a HashSet then mutating it orphans it (the same mutable-key story).

Best practices

Everyday checklist
  • Program to interfaces; for known-large maps, size up front to avoid repeated resizes: new HashMap<>(expectedSize / 0.75 + 1).
  • Default to ArrayList and ArrayDeque; reach for LinkedList almost never.
  • For concurrent counters and caches, use ConcurrentHashMap with atomic compute/merge/computeIfAbsentnever get-then-put.
  • Use EnumMap/EnumSet for enum keys; TreeMap for range queries; LinkedHashMap for LRU and stable output order.
  • Give value classes a correct, consistent, immutable equals/hashCode before using them as keys or set elements.

Interview Questions

Now let's practice everything you read as real interview questions. Try each yourself first, then look.

1. How does HashMap compute the bucket index, and why the `h ^ (h >>> 16)` step?

The index is (n-1) & hash(key). Because table length n is a power of two, (n-1) is a mask that keeps only the low bits and throws the high bits of hashCode() away. XORing the top 16 bits into the bottom 16 ("spreading") ensures high-bit differences still affect the bucket, so it cheaply reduces collisions without needing a full, expensive multiplicative hash.

2. What triggers treeification, and does a bin always treeify at 8 nodes? (gotcha)

No. A bin treeifies only when it reaches TREEIFY_THRESHOLD (8) and the table length is ≥ MIN_TREEIFY_CAPACITY (64). If the table is smaller, HashMap resizes instead of treeifying — because collisions in a small table are usually a sizing problem, not a hash problem. Trees also revert to lists when they shrink to ≤6 (UNTREEIFY_THRESHOLD).

3. Explain the Java 7 HashMap infinite-loop bug and what Java 8 changed. (hard)

Java 7's resize transferred each bin by prepending, which reversed the list order. Under concurrent resize by two threads, that reversal could link nodes into a cycle, so a later get spun forever at 100% CPU. Java 8 splits each bin into ordered low/high lists using hash & oldCap, preserving order and making that cycle impossible. But HashMap is still not thread-safe for concurrent writes — this fixed one symptom, not the class of bug.

4. Why must map keys be effectively immutable in their hashCode fields?

The map places a key based on its hash at insertion time. Mutating a hashCode-participating field moves the key's logical bucket without moving it physically, so subsequent lookups compute a different index and miss — the entry is orphaned, leaks memory, and is unreachable via its own key.

5. HashMap vs Hashtable vs ConcurrentHashMap?

Hashtable is legacy: fully synchronized on every method (coarse lock), no nulls, slow. HashMap is unsynchronized and allows one null key and null values. ConcurrentHashMap is thread-safe with per-bin locking/CAS, allows no nulls, has weakly-consistent iterators, and does lock-free reads. Use HashMap single-threaded, ConcurrentHashMap concurrent; never Hashtable.

6. Why does ConcurrentHashMap forbid null keys and values? (gotcha)

In a concurrent map, get returning null is ambiguous: "absent", or "present-but-mapped-to-null"? Single-threaded code disambiguates with containsKey, but concurrently the state can change between the two calls (a race). Banning null removes the ambiguity entirely — null from get unambiguously means absent.

7. Does ConcurrentHashMap still use Segments? (gotcha)

No — that was Java 7. Java 8 replaced segments with a single HashMap-style bucket array, using CAS to install the first node in an empty bin and synchronized on the bin head for subsequent writes. Concurrency is now per-bucket, not per-segment, and resizing is cooperative (threads help transfer).

8. Is `size()` on ConcurrentHashMap exact?

Not reliably under concurrent mutation. It's tracked with a striped, LongAdder-style counter (baseCount + CounterCell[]) to avoid contention, so the returned value is a consistent-at-some-instant estimate. Use mappingCount() for the long-typed, similarly-approximate count.

9. When would you pick TreeMap over HashMap despite O(log n)?

When you need sorted iteration or navigation: nearest-key queries (floor/ceiling/higher/lower), range views (subMap/headMap/tailMap), or a min/max. HashMap can't do any of those. Also when hash quality is poor and worst-case guarantees matter more than average speed.

10. TreeMap equality gotcha — what determines key uniqueness? (hard)

The comparator (or compareTo) returning 0, not equals. A TreeMap with a comparator on one field treats two objects equal on that field as the same key, overwriting on put, even if equals returns false. This intentionally deviates from the Map/equals contract for the sorted case.

11. What does this print? (find the bug)
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4));
for (Integer x : list) {
    if (x == 2) list.remove(x);   // remove(Object)
}
System.out.println(list);

It throws ConcurrentModificationException. list.remove(x) bumps modCount; the next hasNext/next in the for-each detects modCount != expectedModCount. (And note remove(x) here calls remove(Object), not remove(int), because x is an Integer — not a raw int.) Fix with an explicit Iterator.remove() or list.removeIf(x -> x == 2).

12. What does this print? (gotcha)
Map<String, Integer> m = new HashMap<>();
m.put("a", 1);
System.out.println(m.get("a") == m.get("a"));

true here (1 is in the Integer cache −128..127, so the same cached box is returned), but change the value to 1000 and it prints falseget returns the same cached box for small ints but distinct boxes for large ones, and == compares references, not values. Always .equals() or unbox.

13. Why is LinkedList a poor default despite O(1) inserts?

That O(1) only holds when you already hold the node/iterator. Index-based access and indexOf are both O(n), each node is a separate heap allocation (~24 bytes overhead) with terrible cache locality, and ArrayList's System.arraycopy often beats pointer-chasing even for middle inserts. Use ArrayList by default, ArrayDeque for stack/queue.

14. How do you build an LRU cache with the JDK?

Extend LinkedHashMap with accessOrder = true (the third constructor arg) and override removeEldestEntry to return size() > capacity. Access order moves touched entries to the tail so the head is always least-recently-used; that override evicts it on the next put. For concurrent needs, use Caffeine or a ConcurrentHashMap + explicit eviction.

15. Difference between fail-fast and weakly-consistent iterators? (senior)

Fail-fast (HashMap, ArrayList) snapshot modCount and throw ConcurrentModificationException on a detected concurrent structural change — a best-effort bug detector, not a guarantee, and visible only for changes within the same thread. Weakly-consistent (ConcurrentHashMap, CopyOnWriteArrayList) never throw, traverse a snapshot-ish view without locking, and may or may not reflect concurrent writes — designed for safe concurrent iteration.

Senior notes & advanced edge cases

You already understand the internals. Now let's get into the things books don't write down but that burn people in production and, in a senior interview, separate someone who "knows" from someone who really knows. None of this repeats the material above — it's all the remaining gap.

Roadmap for this section
  • Sizing HashMap right — why new HashMap<>(1000) does not avoid resizing, and the JDK 19 fix.
  • computeIfAbsent / merge traps — the recursion bug and the same-bucket lock in ConcurrentHashMap.
  • Comparator correctness — subtraction overflow and TimSort's infamous exception.
  • Floating-point keysNaN and -0.0 silently hiding bugs.
  • EnumSet as a bit vector, and Map.of's randomized order.
  • The concurrent-collection zoo beyond CHM, and synchronizedMap still going wrong despite being synchronized.
  • Memory footprint at scale, layers of immutability, and 9 hard senior questions.

Sizing a HashMap: the initial-capacity myth

Many people believe that if they know 1000 entries are coming, new HashMap<>(1000) prevents all resizes. That is wrong, and the culprit is the load factor.

The constructor argument is capacity, not expected number of entries. HashMap rounds it up to the next power of two (tableSizeFor), so 1000 becomes capacity 1024. But a resize fires when size > capacity × 0.75 — i.e. the table doubles as you insert the 769th element. So 1000 entries still incur one resize, the very thing you tried to avoid.

The correct sizing formula

For zero resizes, capacity must be ≥ count / 0.75:

// want 1000 entries with no resize?
int n = 1000;
Map<K,V> m = new HashMap<>((int) Math.ceil(n / 0.75));   // 1334 -> capacity 2048

Subtle point: the table array isn't allocated until the first put (lazy allocation); the constructor only sets threshold. So lots of empty new HashMap<>()s are cheap and hold no table memory.

Modern update: `HashMap.newHashMap` in JDK 19

Since Java 19 there's a factory method HashMap.newHashMap(int numMappings) whose argument is the number of entries, not capacity — it does the / 0.75 for you. The same exists for HashSet.newHashSet, LinkedHashMap.newLinkedHashMap, and WeakHashMap.newWeakHashMap. Prefer it so you never fight the capacity myth again:

Map<K,V> m = HashMap.newHashMap(1000);   // JDK 19+, guaranteed no-resize

computeIfAbsent and merge traps

This family of atomic methods is a jewel of modern coding — but it has two deadly traps that are almost always asked in a senior interview.

Trap 1: recursively modifying the same map

If inside a computeIfAbsent mapping function you write something else to the same map (e.g. recursive Fibonacci memoization), then from Java 9 onward you get a ConcurrentModificationException. Why? In Java 8 this case was a silent bug (JDK-8071667): the function could trigger a resize and add an entry that get later couldn't find. Java 9 added the same modCount check here to surface the bug loudly.

Map<Integer,Long> memo = new HashMap<>();
Function<Integer,Long> fib = new Function<>() {
    public Long apply(Integer n) {
        return n < 2 ? n
            : memo.computeIfAbsent(n-1, this) + memo.computeIfAbsent(n-2, this);
    }               // ← throws CME on Java 9+
};

The right fix: make memoization two-step (get then put), or pre-populate the map.

Trap 2: `computeIfAbsent` on ConcurrentHashMap and the same bucket

In ConcurrentHashMap, computeIfAbsent takes a synchronized lock on that bucket's head node and holds it for the whole mapping function. If inside that function you write another key to the same map that happens to land in the same bucket, you get a deadlock — or at best counter corruption — not a CME. This differs from HashMap and is a serious real-world bug. Rule: inside a CHM's computeIfAbsent function, never write to the same map and never do blocking/slow work (the whole bucket is locked out for other threads).

A `computeIfAbsent` subtlety few know

If the mapping function returns null, computeIfAbsent records no entry and returns null — meaning next time the function runs again. So to cache "no result", store a sentinel value, not null.

Comparator correctness: two bugs that go silent

Sorting and TreeMap ride on Comparators, and two classic mistakes lurk here.

Never compare by subtraction

The tempting (a, b) -> a.value - b.value for ints overflows: if a.value is a large positive and b.value a large negative, the subtraction overflows and its sign flips — the order is corrupted and the sort can come out completely wrong. Always use Integer.compare(a, b) or Comparator.comparingInt(...).

TimSort's infamous "Comparison method violates its general contract!"

Java's sort algorithm (TimSort) throws an IllegalArgumentException with exactly this message mid-run if it detects your Comparator is inconsistent — i.e. it violates transitivity ("if a<b and b<c then a<c") or antisymmetry ("if a>b then b<a"). Common causes: a comparison that returns 0 for some pairs and asymmetric results for others, unruly null handling, or overflow-prone subtraction. This exception means "your data is fine; your Comparator is mathematically wrong." For nulls, use Comparator.nullsFirst/nullsLast.

Floating-point keys: NaN and negative zero

Here equals and == deliberately behave opposite to each other, creating two traps:

`NaN` and `-0.0` in collections
  • NaN: in float math Double.NaN == Double.NaN is false, but Double.valueOf(NaN).equals(...) is true. Because collections use equals, if you put NaN in a HashSet, contains(NaN) returns true and you can remove it — the opposite of what == would suggest.
  • -0.0: 0.0 == -0.0 is true, but Double.valueOf(0.0).equals(Double.valueOf(-0.0)) is false. So a HashSet<Double> sees these as two distinct keys. If you key financial data or coordinates, this can produce two seemingly-identical records.

Lesson: don't key a floating-point type directly; if you must, normalize first (d == 0.0 ? 0.0 : d, and handle NaN separately).

EnumSet: the forgotten twin of EnumMap

If you have a set of an enum's constants, HashSet<MyEnum> is almost always wrong. EnumSet is internally a bit vector: each constant's ordinal() becomes one bit in a long. Up to 64 constants the entire set is a single long (RegularEnumSet); beyond that, a long[] (JumboEnumSet). Result: add/contains/removeAll are literally a few CPU bit ops, memory is negligible, and iteration is in enum declaration order.

EnumSet<Day> weekend = EnumSet.of(Day.SAT, Day.SUN);    // one long
EnumSet<Day> workdays = EnumSet.complementOf(weekend);  // bitwise complement, instant
The randomized order of `Map.of` and `Set.of`

The immutable Map.of/Set.of collections deliberately scramble iteration order using a random SALT computed once per JVM run (from the clock). So your loop's output differs from one run to the next. This is intentional — meant to scare you off relying on order. If you want stable order, build a LinkedHashMap.

`Collectors.toMap` blows up on duplicate keys

stream.collect(Collectors.toMap(keyFn, valFn)) throws IllegalStateException: Duplicate key if two elements produce the same key — it does not silently overwrite. You must pass the three-arg version with a merge function: toMap(keyFn, valFn, (a, b) -> b). Also the returned type is a HashMap and is not guaranteed; for order or a specific type, pass the fourth argument (LinkedHashMap::new). Note: toMap doesn't tolerate null values either (it calls merge internally).

The concurrent-collection zoo (beyond ConcurrentHashMap)

ConcurrentHashMap isn't the only player on the concurrency stage. A senior should know the right tool for each shape of concurrency.

Keep this decision tree — caption: "which concurrent collection for which job".

flowchart TD
  Start[Need thread-safe collection?] --> Kind{What shape?}
  Kind -->|Key to value| Sorted{Need sorted keys?}
  Sorted -->|No| CHM[ConcurrentHashMap]
  Sorted -->|Yes| CSLM[ConcurrentSkipListMap]
  Kind -->|List, read-heavy| COW[CopyOnWriteArrayList]
  Kind -->|Producer / consumer| BQ[BlockingQueue family]
  Kind -->|Set| Set[ConcurrentHashMap.newKeySet]
`CopyOnWriteArrayList`: great for reads, disaster for writes

Every single write (add/set/remove) copies the entire backing array — so each write is O(n) and expensive. It only fits when reads vastly outnumber writes (the classic: a list of listeners/observers). Two senior notes: (1) its iterator is a snapshot taken when the iterator was created, so CME never fires but later changes aren't seen; (2) iterator().remove() throws UnsupportedOperationException, because the snapshot is read-only.

The other important members
  • ConcurrentSkipListMap / ...Set: the concurrent version of TreeMap — a sorted NavigableMap with no global lock and O(log n) operations. When you need both concurrency and sorted/navigation, reach for this, not synchronizedSortedMap.
  • The BlockingQueue family: the backbone of the producer/consumer pattern. put/take block until there's room/an element, whereas offer/poll don't. ArrayBlockingQueue is bounded and LinkedBlockingQueue is optionally bounded — in production always bound it so backpressure doesn't blow up memory.
  • ConcurrentHashMap.newKeySet(): the correct way to get a powerful concurrent Set (backed by CHM).
`Collections.synchronizedMap` still goes wrong despite being synchronized

Collections.synchronizedMap(new HashMap<>()) locks each individual method, but two traps remain: (1) a compound operation ("put if absent" as get then put) is not atomic and a race happens between the two calls — that's why you must call the atomic computeIfAbsent explicitly or wrap it in your own synchronized(map) block; (2) during iteration you must lock on the map yourself, or you'll get a ConcurrentModificationException:

Map<K,V> m = Collections.synchronizedMap(new HashMap<>());
synchronized (m) {                 // mandatory while iterating
    for (var e : m.entrySet()) { ... }
}

In real practice, ConcurrentHashMap is almost always the better choice.

Memory footprint at scale

What N entries actually cost

On a 64-bit JVM with compressed oops, each HashMap.Node is about 32 bytes (12-byte header + 4-byte int hash + three key/value/next references, padded) — plus the bucket array and the key/value objects themselves. So a 10-million-entry HashMap costs roughly half a gig just for nodes and table, separate from the data. Senior takeaway: at scale be suspicious of HashMap<Long, ...> (each boxed Long is a separate object); a primitive-collection library like Eclipse Collections/fastutil can save tens of times the memory.

Layers of immutability: view vs copy vs truly immutable

Three things wrongly treated as one
  • Collections.unmodifiableList(x) creates a read-only view; if you mutate the underlying x, the change shows through the view. It gives no safety, only "you can't write through this reference."
  • List.copyOf(x) / List.of(...) create a truly immutable copy that is immune to source mutation — but they reject null and throw on it.
  • "Shallow immutability": even List.of(mutableUser) locks the list but mutableUser.setName(...) still works. True immutability means the elements are immutable too.

Hard senior interview questions

1. Why doesn't `new HashMap<>(1000)` avoid resizing, and what's the right way?

Because the argument is capacity, not entry count, and a resize fires when size > capacity × 0.75. 1000 becomes capacity 1024, which doubles as you insert the 769th element. To get zero resizes, capacity must be ≥ count / 0.75 (here ≥ 1334 → 2048). The clean way since Java 19: HashMap.newHashMap(1000), which does the / 0.75 for you. Also, the table isn't allocated until the first put (lazy allocation).

2. You write a recursive Fibonacci inside `computeIfAbsent` on a HashMap; what differs between Java 8 and Java 9? (hard)

In Java 8 it may corrupt silently: the function can trigger a resize and create an entry that get later can't find (JDK-8071667). From Java 9 the same modCount check was added to computeIfAbsent, so it now throws ConcurrentModificationException because the mapping function modified the map mid-computation. The correct approach: write recursive memoization with a two-step get+put, not with computeIfAbsent.

3. Why is `computeIfAbsent` on a `ConcurrentHashMap` that writes another key to the same map more dangerous than on a HashMap? (hard)

Because CHM holds a synchronized lock on that bucket's head while running the function. If the function writes a second key that lands in the same bucket (or otherwise needs that lock), you get a deadlock or internal corruption — not a clean CME. So inside a CHM's computeIfAbsent, never write to the same map and do no slow/blocking work, because that entire bucket is locked out for other threads.

4. Why is `(a, b) -> a.age - b.age` a buggy Comparator, and when does TimSort throw "violates its general contract"?

Subtracting two ints overflows: if a.age is a large positive and b.age a large negative, the subtraction overflows and the sign flips, so ordering is violated. The fix is Integer.compare. If your Comparator is inconsistent (breaks transitivity or antisymmetry, or handles nulls unruly), TimSort detects it mid-merge and throws IllegalArgumentException: Comparison method violates its general contract! — meaning it couldn't reach a consistent ordering.

5. You put `NaN` in a `HashSet<Double>`; does `contains(Double.NaN)` return true? And `0.0` vs `-0.0`? (gotcha)

Yes, contains(NaN) returns true and you can remove it, because collections use equals and Double.equals treats NaN==NaN as true — the exact opposite of the == operator. But 0.0 and -0.0 are the reverse: == sees them equal while Double.equals doesn't, so a HashSet counts them as two distinct elements. Conclusion: don't key a floating-point type without normalizing.

6. You run `Collectors.toMap` over a stream with duplicate keys; what happens and how do you fix it?

It throws IllegalStateException: Duplicate key — it does not silently overwrite. You must pass the three-arg version with a merge function, e.g. toMap(k, v, (a, b) -> b) for "last wins". If you want order or a specific map type, pass the fourth argument LinkedHashMap::new. And note it doesn't accept null values because it calls merge internally.

7. When is `CopyOnWriteArrayList` the right choice and what's its hidden cost? Can you remove during iteration via `iterator().remove()`?

When reads vastly outnumber writes (e.g. a listener list). Hidden cost: every single write copies the whole array, so writes are O(n) and expensive — disastrous under a heavy write load. iterator().remove() throws UnsupportedOperationException, because the iterator is a read-only snapshot; that's also why CME never fires but changes made after the iterator was created aren't seen.

8. Even though every `Collections.synchronizedMap` method is locked, how can it still give wrong results or throw? (senior)

Two reasons: (1) compound operations like check-then-act (if (!m.containsKey(k)) m.put(k, v)) aren't atomic; between the two synchronized calls another thread can change state (race and lost update). (2) Iteration doesn't lock automatically; you must manually wrap the whole loop in synchronized(map) or you'll get a ConcurrentModificationException if another thread writes mid-iteration. ConcurrentHashMap solves both with atomic merge/compute and a weakly-consistent iterator.

9. Why does the iteration order of `Map.of(...)` differ from one program run to the next, while `HashMap` is usually stable within a run? (gotcha)

The immutable Map.of/Set.of collections deliberately use a random SALT computed once per JVM run for placement, so iteration order is randomized across runs to stop programmers from relying on it. HashMap has no such cross-run randomization; its order is a function of hash and capacity (identical across runs) — but it's still officially "unspecified" and changes on resize. If order matters, use LinkedHashMap/TreeMap.

At a glance (senior notes)
  • Sizing: the constructor arg is capacity, not count; for no-resize use capacity ≥ n/0.75 or HashMap.newHashMap(n) on JDK 19+.
  • computeIfAbsent: recursively modifying the same map throws CME on Java 9+; on CHM it locks the same bucket and can deadlock.
  • Comparator: subtraction overflows (use Integer.compare); an inconsistent Comparator makes TimSort throw "violates its general contract".
  • Floating-point: NaN is equal under equals but not ==; -0.0 is the reverse — so normalize float keys.
  • Concurrency: ConcurrentSkipListMap for sorted, CopyOnWriteArrayList for read-heavy (O(n) writes), BlockingQueue for producer/consumer; synchronizedMap makes neither compound ops nor iteration safe.
In a nutshell
  • Program to interfaces; Map is deliberately not a Collection because it's key→value, not a sequence.
  • Two axes drive every decision: iteration order and operation complexity. Defaults: ArrayList and ArrayDeque; LinkedList almost never.
  • HashMap: bucket array, power-of-two capacity, index (n-1) & (h ^ h>>>16). A crowded bin treeifies into a red-black tree at 8 nodes and table ≥64; resize splits each bin low/high via the single bit hash & oldCap. Keys must be immutable in their hashCode fields, or the entry is orphaned.
  • LinkedHashMap gives insertion/access order and, with accessOrder=true + removeEldestEntry, an LRU cache. TreeMap is an O(log n) red-black tree with sorted navigation and judges equality by compareTo==0, not equals.
  • ConcurrentHashMap (Java 8): no segments, CAS on an empty bin + synchronized on the bin head, lock-free reads, no nulls, weakly-consistent iterators, approximate size(). Count with atomic merge/compute, never get-then-put.
  • Specialist maps: EnumMap (array by ordinal), WeakHashMap (weakly-referenced keys), IdentityHashMap (==, not equals).

Source: the official Java documentation and Javadocs (java.util.HashMap, ConcurrentHashMap, TreeMap, LinkedHashMap).