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 (واقعی) را بیاور.
تصور کن یک آبدارچی داری. اگر برای آوردن یک چای همیشه ۳۰ ثانیه وقت بگذارد، فرقی نمیکند ۵ نفر باشی یا ۵۰۰۰ نفر: زمانِ هر چای ثابت است. این میشود 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 قرار دارند. حالا یک نکتهی مهم که خیلیها را گیج میکند:
یک 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) سرشکن: نه اینکه هیچوقت گران نشود، بلکه میانگینش ثابت میماند.
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 با دو اشارهگر (به قبلی و بعدی) بهعلاوهی سربارِ هدرِ شیء است.
آرایه مثل خانههای یک کوچهی پشتسرهم است: وقتی به خانهی ۱۰ میرسی، خانههای ۱۱ و ۱۲ همان نزدیکیاند و 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 از Vector ارث میبرد و هر متدش synchronized است — یعنی حتی وقتی تکترد کار میکنی، هزینهی قفلگذاری را میپردازی. ArrayDeque نه قفل بیجا دارد و نه محدودیت Stack را، و هم پشته و هم صف را عالی انجام میدهد. قاعده: از امروز Stack را فراموش کن.
درونیات 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 لازم است؟
تصور کن شمارهی کمد را فقط از روی دو رقم آخرِ کد ملی میسازی. اگر همهی مشتریهایت کد ملیای دارند که فقط در ارقام اولش فرق میکند و دو رقم آخرشان یکسان است، همه به یک کمد میرسند — فاجعه. عملیات (n-1) & دقیقاً همین است: چون n توانی از ۲ است، (n-1) یک ماسک است که فقط بیتهای پایینِ هش را نگه میدارد و بیتهای بالا را دور میریزد. برای همین اول h ^ (h >>> 16) میزنیم: این ۱۶ بیتِ بالا را با ۱۶ بیت پایین XOR میکند («پخش / spreading») تا اطلاعاتِ بیتهای بالا هم در بیتهای پایین اثر بگذارد و کلیدهایی که فقط در بیتهای بالا فرق دارند باز هم در سطلهای مختلف بیفتند. همهی اینها ارزان است — یک XOR و یک shift، نه یک هشِ ضربیِ گران.
در تابع بالا اگر کلید 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 میرویم. چرا؟ چون مقایسهی دو عدد صحیح تقریباً رایگان است، ولی equals میتواند گران باشد (تصور کن مقایسهی دو رشتهی طولانی، کاراکتربهکاراکتر). دو شیء با هشهای متفاوت قطعاً برابر نیستند، پس با یک مقایسهی عدد ارزان، equalsِ گران را رد میکنیم. به این میگویند «خروج زودهنگام (early-out)».
Treeification — چرا دقیقاً عددِ ۸؟
گفتیم سطلِ شلوغ در آستانهی ۸ به درخت تبدیل میشود. اما چرا ۸؟
اگر یک تابع هشِ خوب داشته باشی و کلیدها تصادفی باشند، پخششدن کلیدها بین سطلها از یک قانون آماری به اسم توزیع پواسون (Poisson) پیروی میکند — همان قانونی که میگوید احتمال «چند مشتری همزمان به یک باجهی بانک برسند» چقدر است. طبق همین قانون، Javadoc خودِ HashMap صراحتاً مینویسد احتمال اینکه یک سطل به ۸ عنصر برسد حدوداً ۰٫۰۰۰۰۰۰۰۶ است — یعنی عملاً هرگز، مگر اینکه تابع هش بد باشد یا کسی عمداً کلیدهای خصمانه (adversarial) بسازد.
پس چرا اصلاً به درختشدن اهمیت میدهیم؟ چون treeify آن سطلِ بدبخت را از پیمایش خطیِ O(n) به O(log n) تبدیل میکند و این جلوی حملاتِ محرومیتازسرویس مبتنی بر تصادم هش (hash-collision DoS) را میگیرد: حملهای که در آن مهاجم عمداً هزاران کلید با هشِ یکسان میفرستد تا همه در یک سطل تلنبار شوند و سرور کند شود. درخت آن آسیب را محدود میکند. ترتیب درون درخت اول بر اساس hash است، بعد اگر کلیدها Comparable باشند بر پایهی آن، وگرنه با یک «شکنندهی تساویِ» مبتنی بر هویت شیء.
Resize — و زیبایی توانِ دو
وقتی size از آستانه فراتر رفت، ظرفیت دو برابر میشود. اینجاست که آن جملهی «ظرفیت همیشه توانِ دو است» طلا میشود:
وقتی 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
فرض کن داری روی یک لیست حلقه میزنی و وسط کار خودِ لیست را عوض میکنی. جاوا این را زود میگیرد:
تصور کن دو نفر روی یک سند مشترک کار میکنند. سند یک «شمارهی نسخه» دارد. تو نسخه را یادداشت میکنی (مثلاً نسخهی ۵) و شروع به خواندن میکنی. اگر وسط کار کسی سند را عوض کند، شمارهی نسخه میرود روی ۶؛ دفعهی بعد که سرت را بلند میکنی و میبینی نسخه دیگر ۵ نیست، فوراً داد میزنی «این سند زیر دستم عوض شد!». در جاوا آن «شمارهی نسخه» اسمش modCount است.
هر تغییر ساختاری (add/remove که size را عوض میکند، یا treeify) یک modCount داخلی را افزایش میدهد. پیمایشگر هنگام ساختهشدن، modCount را در expectedModCount عکس میگیرد؛ در هر next() این دو را مقایسه میکند و اگر فرق داشتند ConcurrentModificationException پرتاب میکند.
این مکانیزم 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! رکورد در سطل قدیمی یتیم شده
hashCode و equals باید سازگار باشند: اگر دو شیء equals باشند، باید hashCode یکسان داشته باشند، وگرنه HashMap میشکند (چون ممکن است در سطلهای مختلف بیفتند و همدیگر را پیدا نکنند). بازنویسی equals بدون بازنویسی hashCode یکی از رایجترین باگهای واقعیِ محیط تولید است. برای همین String، عددهای جعبهای (Integer و...) و انواعِ تغییرناپذیر، کلیدهای ایدهآلی هستند.
LinkedHashMap و کشهای LRU
LinkedHashMap از HashMap ارث میبرد و علاوه بر آن، هر رکورد را روی یک لیست پیوندی دوطرفه هم «نخ» میکند. نتیجه: پیمایش با ترتیب درجِ قابلپیشبینی (برخلاف HashMap که ترتیبش نامشخص است).
اما ویژگیِ کُشندهاش چیز دیگری است: ترتیب دسترسی (access order).
یک روزنامهفروشی را تصور کن که فضای محدودی دارد. هر بار یک روزنامه را کسی برمیدارد و میخواند، فروشنده آن را دوباره جلوی قفسه (نزدیکترین جا) میگذارد. نتیجه؟ روزنامههایی که تازه خوانده شدهاند جلواند و آنهایی که مدتهاست کسی سراغشان نرفته میروند ته قفسه. وقتی جا کم میآید، فروشنده تهِ قفسه — یعنی کماستفادهترین (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 که خودت میدهی.
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 کلید 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 یعنی «مقایسه-و-جابهجایی (Compare-And-Swap)»، یک دستور سطحسختافزار که بهصورت اتمیک میگوید «اگر مقدار این خانه هنوز X است، آن را به Y عوض کن؛ وگرنه دست نزن و بگو نشد». چون سختافزار تضمین میکند این کل عمل یکتکه انجام شود، دیگر لازم نیست قفلِ سنگین بگیری. این پایهی همهی برنامهنویسیِ «بدون قفل (lock-free)» است.
size() هم داستان جالبی دارد:
تصور کن ۸ صندوقدار فروشگاه هرکدام تعداد فروش خودشان را روی یک تختهی جدا مینویسند، و مجموع را هروقت لازم شد جمع میزنی. اگر همه مجبور بودند روی یک تختهی مشترک بنویسند، مدام سرِ آن تخته صف و دعوا میشد (رقابت / contention). بهجایش، ConcurrentHashMap شمارش را «راهراه (striped)» میکند: baseCount بهعلاوهی یک آرایه CounterCell[] — همان ایدهی LongAdder. عیبش؟ چون چند تخته را جمع میزنی، عددی که size() میدهد یک برآوردِ سازگار-در-یک-لحظه است، نه یک عددِ تضمینیِ دقیق. برای شمارشِ long هم mappingCount() را داری که همانقدر مشورتی است.
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) نگه میدارد.
یک ارجاعِ قوی (معمولی) به 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درست، سازگار و تغییرناپذیر بده.
سؤالات مصاحبه
حالا همهی آنچه خواندی را در قالب سؤالهای واقعیِ مصاحبه تمرین کنیم. هر کدام را اول خودت جواب بده، بعد نگاه کن.
اندیس (n-1) & hash(key) است. چون طول table یعنی n توانی از ۲ است، (n-1) یک ماسک است که فقط بیتهای پایین را نگه میدارد و بیتهای بالای hashCode() را دور میریزد. XORِ ۱۶ بیتِ بالا با ۱۶ بیت پایین («پخش / spreading») تضمین میکند که تفاوتهای بیتبالا هم روی سطل اثر بگذارند، پس تصادم را ارزان کاهش میدهد بدون نیاز به یک هشِ ضربیِ کامل و گران.
نه. سطل فقط زمانی درخت میشود که به TREEIFY_THRESHOLD (۸) برسد و طول table ≥ MIN_TREEIFY_CAPACITY (۶۴) باشد. اگر table کوچکتر است، HashMap بهجای درختکردن، table را resize میکند — چون تصادم در tableِ کوچک معمولاً مشکلِ اندازه است نه مشکلِ هش. درختها هم وقتی به ≤۶ (UNTREEIFY_THRESHOLD) کوچک شوند دوباره به لیست برمیگردند.
resizeِ جاوا ۷ هر سطل را با prepend منتقل میکرد که ترتیب لیست را معکوس میکرد. تحت resizeِ همزمان توسط دو ترد، این معکوسسازی میتوانست گرهها را در یک چرخه به هم پیوند دهد، پس getِ بعدی برای همیشه با ۱۰۰٪ CPU میچرخید. جاوا ۸ هر سطل را با hash & oldCap به لیستهای مرتبِ پایین/بالا میشکند، ترتیب را حفظ میکند و آن چرخه را ناممکن میکند. اما HashMap هنوز برای نوشتنِ همزمان thread-safe نیست — این فقط یک نشانه را رفع کرد، نه آن دسته از باگ را.
نقشه کلید را بر اساس هشِ زمانِ درج جای میدهد. تغییر فیلدی که در hashCode شرکت دارد، سطلِ منطقیِ کلید را جابهجا میکند بدونِ جابهجاییِ فیزیکی، پس جستجوهای بعدی اندیسِ متفاوتی حساب میکنند و به خطا میخورند — رکورد یتیم میشود، حافظه نشت میکند، و از طریقِ کلیدِ خودش دیگر دستنیافتنی است.
Hashtable قدیمی است: روی هر متد کاملاً synchronized (قفلِ درشت)، بدون null، کند. HashMap غیرهمگام است و یک کلید null و مقادیر null را میپذیرد. ConcurrentHashMap با قفل/CAS بهازای هر سطل thread-safe است، هیچ nullی نمیپذیرد، پیمایشگرش ضعیف-سازگار است و خواندنش بدون قفل. تکتردی HashMap بزن، همزمان ConcurrentHashMap؛ Hashtable را هرگز.
در یک نقشهی همزمان، بازگشتِ null از get مبهم است: «غایب» یا «موجود اما نگاشته به null»؟ کد تکتردی با یک containsKey رفع ابهام میکند، اما همزمان حالت میتواند بین آن دو فراخوانی عوض شود (یک مسابقه / race). ممنوعیت null این ابهام را کاملاً حذف میکند — nullِ برگشتی از get بیابهام یعنی غایب.
نه — آن مالِ جاوا ۷ بود. جاوا ۸ segmentها را با یک آرایهی سطلِ سبکِ HashMap جایگزین کرد که برای نصبِ اولین گره در سطلِ خالی از CAS و برای نوشتنهای بعدی از synchronized روی سرِ سطل استفاده میکند. همزمانی حالا بهازای هر سطل است نه هر segment، و resize مشارکتی است (تردها در انتقال به هم کمک میکنند).
نه بهطور قابلاتکا زیرِ تغییرِ همزمان. با یک شمارندهی راهراهِ سبکِ LongAdder (baseCount + CounterCell[]) ردیابی میشود تا از رقابت پرهیز شود، پس مقدارِ برگشتی یک برآوردِ سازگار-در-یک-لحظه است. برای شمارشِ نوعِ long و مشابهاً تقریبی، mappingCount() را داری.
وقتی پیمایشِ مرتب یا ناوبری لازم داری: پرسوجوی نزدیکترین کلید (floor/ceiling/higher/lower)، نماهای بازه (subMap/headMap/tailMap)، یا min/max. HashMap هیچکدام را نمیتواند. همچنین وقتی کیفیتِ هش ضعیف است و تضمینِ بدترینحالت از سرعتِ متوسط مهمتر است.
بازگشتِ ۰ از 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 کن.
آن O(1) فقط وقتی صادق است که از قبل گره/پیمایشگر را در دست داری. دسترسیِ مبتنی بر اندیس و indexOf هردو O(n) هستند، هر گره یک تخصیصِ heap جداگانه (~۲۴ بایت سربار) با محلیبودنِ حافظهی نهانِ افتضاح است، و System.arraycopyِ ArrayList اغلب حتی برای درجِ میانی هم از pointer chasing بهتر است. پیشفرض ArrayList، برای stack/queue ArrayDeque.
LinkedHashMap را با accessOrder = true (آرگومانِ سومِ سازنده) گسترش بده و removeEldestEntry را طوری بازنویسی کن که size() > capacity برگرداند. ترتیبِ دسترسی، رکوردهای لمسشده را به دُم میبرد تا سر همیشه کماستفادهترین باشد؛ آن بازنویسی هم در putِ بعدی حذفش میکند. برای نیازهای همزمان از Caffeine یا ConcurrentHashMap + حذفِ صریح استفاده کن.
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(int numMappings) اضافه شد که آرگومانش تعدادِ ورودی است، نه ظرفیت — و خودش تقسیم بر ۰٫۷۵ را انجام میدهد. همان برای HashSet.newHashSet، LinkedHashMap.newLinkedHashMap و WeakHashMap.newWeakHashMap هم هست. از این به بعد این را بنویس تا اصلاً درگیرِ افسانهی ظرفیت نشوی:
Map<K,V> m = HashMap.newHashMap(1000); // JDK 19+, تضمینِ بدون-resize
تلههای computeIfAbsent و merge
این خانوادهی متدهای اتمیک، جواهرِ کدنویسیِ مدرناند — اما دو تلهی مرگبار دارند که تقریباً همیشه در مصاحبهی سنیور پرسیده میشوند.
اگر داخلِ تابعِ 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 را از پیش پر کن.
در ConcurrentHashMap، متدِ computeIfAbsent روی سرِ همان bucket قفلِ synchronized میگیرد و تا پایانِ تابع نگه میدارد. اگر داخلِ آن تابع، کلیدِ دیگری را روی همان map بنویسی که تصادفاً در همان bucket بیفتد، به بنبست (deadlock) یا در بهترین حالت خرابیِ شمارنده میخوری — نه به CME. این با HashMap فرق دارد و باگِ واقعیِ سنگینی است. قاعده: داخلِ تابعِ computeIfAbsentِ یک CHM هرگز روی همان map ننویس و کارِ بلوکهکننده/کند نکن (چون کلِ آن bucket برای بقیهی نخها قفل است).
اگر تابعِ نگاشت 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) اگر تشخیص دهد Comparatorت ناسازگار است — یعنی خاصیتهای «اگر a<b و b<c پس a<c» یا «اگر a>b پس b<a» را نقض میکند — وسطِ کار IllegalArgumentException با همین پیام پرتاب میکند. علتِ رایج: مقایسهای که برای بعضی جفتها 0 و برای بعضی نامتقارن جواب میدهد، یا nullها را بیقاعده هندل میکند، یا از تفریقِ سرریزشونده استفاده کرده. این استثنا یعنی «دادهات خراب نیست، Comparatorت ریاضیاتاً غلط است». برای nullها از Comparator.nullsFirst/nullsLast استفاده کن.
کلیدهای اعشاری: NaN و صفرِ منفی
اینجا equals و == عمداً برعکسِ همدیگر رفتار میکنند و همین دو تله میسازد:
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 عمداً ترتیبِ پیمایش را با یک SALTِ تصادفی که یکبار در هر اجرای JVM (از روی زمان) ساخته میشود، بههم میریزند. یعنی خروجیِ حلقهات از یک اجرا به اجرای بعد فرق میکند. این عمدی است تا تو را از تکیهکردن به ترتیب بترساند. اگر ترتیبِ پایدار میخواهی، LinkedHashMap بساز.
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]
هر نوشتنِ واحد (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(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 باشند.
سؤالهای سختِ مصاحبهی سنیور
چون آرگومان «ظرفیت» است نه «تعدادِ ورودی»، و resize سرِ size > capacity × 0.75 رخ میدهد. 1000 میشود ظرفیتِ ۱۰۲۴ که سرِ عنصرِ ۷۶۹اُم دوبرابر میشود. برای صفرکردنِ resize باید ظرفیت ≥ تعداد / 0.75 باشد (اینجا ≥ ۱۳۳۴ → ۲۰۴۸). راهِ تمیز از جاوا ۱۹: HashMap.newHashMap(1000) که خودش تقسیم بر ۰٫۷۵ را انجام میدهد. ضمناً جدول تا اولین put اصلاً ساخته نمیشود (تخصیصِ تنبل).
در جاوا ۸ ممکن است بیسروصدا خراب کند: تابع میتواند باعثِ resize شود و ورودیای بسازد که get بعداً پیدایش نمیکند (JDK-8071667). در جاوا ۹ به بعد، همان چکِ modCount به computeIfAbsent اضافه شد و حالا ConcurrentModificationException پرتاب میکند چون تابعِ نگاشت، map را وسطِ محاسبه تغییر داده. درسِ درست: memoizationِ بازگشتی را با get+putِ دو مرحلهای بنویس، نه با computeIfAbsent.
چون CHM حینِ اجرای تابع، روی سرِ همان bucket قفلِ synchronized نگه میدارد. اگر تابع کلیدِ دومی بنویسد که در همان bucket بیفتد (یا بهشکلی به همان قفل نیاز پیدا کند)، به بنبست یا خرابیِ داخلی میخوری — نه به یک CMEِ تمیز. پس داخلِ computeIfAbsentِ یک CHM هرگز روی همان map ننویس و هیچ کارِ کند/بلوکهکننده نکن، چون تمامِ آن bucket برای بقیهی نخها قفل است.
تفریقِ دو int سرریز میکند: اگر a.age مثبتِ بزرگ و b.age منفیِ بزرگ باشد، حاصلِ تفریق سرریز کرده و علامت برعکس میشود، پس ترتیب نقض میشود. راهِ درست Integer.compare. اگر Comparatorت ناسازگار باشد (تعدی/transitivity یا تقارن را نقض کند، یا nullها را بیقاعده هندل کند)، TimSort وسطِ merge تشخیص میدهد و IllegalArgumentException: Comparison method violates its general contract! میدهد — یعنی مرتبسازی نتوانست به یک ترتیبِ سازگار برسد.
بله، contains(NaN) جوابِ true میدهد و میتوانی حذفش کنی، چون کالکشنها با equals کار میکنند و Double.equals برای NaN==NaN برابرِ true است — دقیقاً برعکسِ عملگرِ ==. اما 0.0 و -0.0 برعکساند: == آنها را برابر میبیند ولی Double.equals نه، پس HashSet این دو را دو عنصرِ جدا میشمارد. نتیجه: نوعِ اعشاری را بینرمالسازی کلید نکن.
IllegalStateException: Duplicate key پرتاب میشود — بیسروصدا overwrite نمیکند. باید نسخهی سهآرگومانی با تابعِ ادغام بدهی، مثلِ toMap(k, v, (a, b) -> b) برای «آخری برنده». اگر ترتیب یا نوعِ خاصِ map میخواهی، آرگومانِ چهارم LinkedHashMap::new را بده. و بدان مقدارِ null را هم نمیپذیرد چون داخلاً merge صدا میزند.
وقتی خواندن بسیار پرتکرارتر از نوشتن است (مثلاً فهرستِ listenerها). هزینهی پنهان: هر نوشتنِ واحد کلِ آرایه را کپی میکند، پس نوشتن O(n) و پرمصرف است و برای بارِ نوشتنِ زیاد فاجعه است. iterator().remove() روی آن UnsupportedOperationException میدهد، چون iterator یک عکسِ لحظهایِ فقطخواندنی است؛ همین باعث میشود CME هرگز پرتاب نشود اما تغییراتِ بعد از ساختِ iterator هم دیده نشوند.
دو دلیل: (۱) عملیاتِ مرکب مثلِ «check-then-act» (if (!m.containsKey(k)) m.put(k, v)) اتمیک نیست؛ بینِ دو فراخوانیِ synchronized نخِ دیگر میتواند وضع را عوض کند (race و lost update). (۲) پیمایش خودبهخود قفل نمیگیرد؛ باید دستی synchronized(map) دورِ کلِ حلقه بگذاری وگرنه اگر نخِ دیگری وسطِ پیمایش بنویسد ConcurrentModificationException میگیری. ConcurrentHashMap با عملیاتِ اتمیکِ merge/compute و iteratorِ ضعیفسازگار هر دو مشکل را حل میکند.
کالکشنهای تغییرناپذیرِ 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.
- Part 0: a few words you must feel first (interface, Big-O, bucket, hash).
- Family layout:
List/Set/Queue/Deque— and whyMapisn'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.
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.
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).
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:
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 byequals).HashSet(unordered),LinkedHashSet(insertion order),TreeSet(sorted, aNavigableSet).Queue— a bakery line, usually "first-in first-out" (FIFO); methodsoffer/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 oldStack.ArrayDequeis 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.
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.
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.
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)
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.
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 |
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?
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.
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):
- Compute
hash(key), then derive indexi = (n-1) & hash. - If
table[i]is empty, drop the new node there. Done. - Otherwise walk the bin. If you find a node with an equal key, overwrite its value. "Equal" means:
hashequal and (key == k || key.equals(k)). - 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). - Increment
size; if nowsize > threshold(i.e. capacity × loadFactor), resize.
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?
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:
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:
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:
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.
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:
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
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.
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.
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}
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.
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.
gettraversesvolatilenodes and always sees a consistent (though possibly slightly stale) view. - During resize, threads help each other: a special
ForwardingNodemarks bins that have migrated, and multiple threads cooperatively transfer ranges of the table (transferIndex,stride).
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:
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 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 plainObject[]indexed byordinal()(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 overHashMap<MyEnum, V>.WeakHashMap— holds its keys via weak references.
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==andSystem.identityHashCode, notequals/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")andnew String("a")are two entirely distinct keys.
Common pitfalls & gotchas
These are the places where seemingly-correct code breaks silently:
Arrays.asList()returns a fixed-size list backed by the array —add/removeon it throwUnsupportedOperationException. AndList.of(...)/Map.of(...)return fully immutable collections that additionally reject nulls.HashMapiteration order is unspecified and can change on resize. Never rely on it; if you need order, useLinkedHashMaporTreeMap.- Autoboxing cache:
Integervalues −128..127 are cached, so==on small boxed ints accidentally "works", then suddenly breaks at 128. Always.equals()or compare primitives. subList,keySet,values,entrySetare views, not copies — mutating them mutates the backing collection, and a structural change to the backing collection invalidates the view.remove(int)vsremove(Object)onList<Integer>:list.remove(2)removes the element at index 2, butlist.remove(Integer.valueOf(2))removes the value 2.- Set/Map correctness depends on the immutable
hashCodeof contained elements — putting a mutable object in aHashSetthen mutating it orphans it (the same mutable-key story).
Best practices
- Program to interfaces; for known-large maps, size up front to avoid repeated resizes:
new HashMap<>(expectedSize / 0.75 + 1). - Default to
ArrayListandArrayDeque; reach forLinkedListalmost never. - For concurrent counters and caches, use
ConcurrentHashMapwith atomiccompute/merge/computeIfAbsent— never get-then-put. - Use
EnumMap/EnumSetfor enum keys;TreeMapfor range queries;LinkedHashMapfor LRU and stable output order. - Give value classes a correct, consistent, immutable
equals/hashCodebefore 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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
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.
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).
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 false — get returns the same cached box for small ints but distinct boxes for large ones, and == compares references, not values. Always .equals() or unbox.
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.
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.
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.
- Sizing HashMap right — why
new HashMap<>(1000)does not avoid resizing, and the JDK 19 fix. computeIfAbsent/mergetraps — the recursion bug and the same-bucket lock in ConcurrentHashMap.- Comparator correctness — subtraction overflow and TimSort's infamous exception.
- Floating-point keys —
NaNand-0.0silently hiding bugs. EnumSetas a bit vector, andMap.of's randomized order.- The concurrent-collection zoo beyond CHM, and
synchronizedMapstill 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.
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.
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.
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.
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).
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.
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(...).
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: in float mathDouble.NaN == Double.NaNisfalse, butDouble.valueOf(NaN).equals(...)istrue. Because collections useequals, if you putNaNin aHashSet,contains(NaN)returnstrueand you can remove it — the opposite of what==would suggest.-0.0:0.0 == -0.0istrue, butDouble.valueOf(0.0).equals(Double.valueOf(-0.0))isfalse. So aHashSet<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 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.
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]
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.
ConcurrentSkipListMap/...Set: the concurrent version ofTreeMap— a sortedNavigableMapwith no global lock and O(log n) operations. When you need both concurrency and sorted/navigation, reach for this, notsynchronizedSortedMap.- The
BlockingQueuefamily: the backbone of the producer/consumer pattern.put/takeblock until there's room/an element, whereasoffer/polldon't.ArrayBlockingQueueis bounded andLinkedBlockingQueueis optionally bounded — in production always bound it so backpressure doesn't blow up memory. ConcurrentHashMap.newKeySet(): the correct way to get a powerful concurrentSet(backed by CHM).
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
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
Collections.unmodifiableList(x)creates a read-only view; if you mutate the underlyingx, 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 rejectnulland throw on it.- "Shallow immutability": even
List.of(mutableUser)locks the list butmutableUser.setName(...)still works. True immutability means the elements are immutable too.
Hard senior interview questions
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).
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.
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.
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.
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.
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.
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.
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.
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.
- Sizing: the constructor arg is capacity, not count; for no-resize use capacity ≥
n/0.75orHashMap.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:
NaNis equal under equals but not ==;-0.0is the reverse — so normalize float keys. - Concurrency:
ConcurrentSkipListMapfor sorted,CopyOnWriteArrayListfor read-heavy (O(n) writes),BlockingQueuefor producer/consumer;synchronizedMapmakes neither compound ops nor iteration safe.
- Program to interfaces;
Mapis deliberately not aCollectionbecause it's key→value, not a sequence. - Two axes drive every decision: iteration order and operation complexity. Defaults:
ArrayListandArrayDeque;LinkedListalmost 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 bithash & oldCap. Keys must be immutable in theirhashCodefields, 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 bycompareTo==0, notequals. - ConcurrentHashMap (Java 8): no segments, CAS on an empty bin +
synchronizedon the bin head, lock-free reads, no nulls, weakly-consistent iterators, approximatesize(). Count with atomicmerge/compute, never get-then-put. - Specialist maps:
EnumMap(array by ordinal),WeakHashMap(weakly-referenced keys),IdentityHashMap(==, notequals).
Source: the official Java documentation and Javadocs (java.util.HashMap, ConcurrentHashMap, TreeMap, LinkedHashMap).