Interview Bank · بانک سوالات متوسطIntermediate ~54 دقیقه مطالعه~46 min read
پرسشوپاسخ سریع: زبان جاوا، Collections و JVMRapid-Fire Q&A: Java Language, Collections & JVM
یک دورهٔ فشردهٔ «صفر تا صد» برای مصاحبهٔ جاوا که ۵۰ سؤال واقعی — از equals/hashCode و پاکسازی جنریکها تا درونکارِ HashMap، الگوریتمهای GC و pinning در virtual thread — را با تشبیه، سازوکار و کد بهروشنی به تو یاد میدهد.A guided zero-to-hero cram course for Java interviews that teaches all 50 real questions — from equals/hashCode and generics erasure to HashMap internals, GC algorithms, and virtual-thread pinning — with analogies, mechanisms, and runnable code.
خب، بیا رک باشیم: این فصل یک «بانک سؤالِ مصاحبه» است، اما قرار نیست مثل بانکهای خشکِ معمول باشد که فقط جواب را جلویت میگذارند و میروند. من قرار است کنارت بنشینم و هر سؤال را طوری برایت باز کنم که وقتی مصاحبهگر بپرسد «چرا؟» — که همیشه میپرسد — تو نه فقط جوابِ درست، بلکه سازوکارِ پشتش را بلد باشی. چون تفاوتِ یک مهندسِ ارشد با بقیه دقیقاً همینجاست: او نتیجه را حفظ نکرده، او میداند نتیجه از کجا میآید.
این پنجاه سؤال همانهاییاند که واقعاً در مصاحبههای میانرده تا ارشدِ جاوا پرسیده میشوند. بر اساس موضوع دستهبندی شدهاند و از گرمکردن تا سؤالهای بیرحمانه تشدید میشوند. هرجا واقعیتی وابسته به نسخه باشد، به یک نسخهٔ مشخص از JDK گره خورده — چون در مصاحبه گفتنِ «فکر کنم از یه نسخهای به بعد» امتیاز منفی است، اما گفتنِ «از جاوا ۹ به بعد» امتیاز مثبت.
ما هشت ایستگاه را طی میکنیم:
- مبانی زبان و OOP — برابری، هش، رشته،
final، overload/override. - جنریکها — type erasure و همهٔ محدودیتهایی که از دلش بیرون میآید.
- Collections — درونِ
HashMap، انتخابِ ساختارِ داده، iteratorها. - استثناها — checked/unchecked و رفتارِ عجیبِ
finally. - JVM و مدلِ حافظه — stack/heap، بارگذاریِ کلاس،
volatile. - جمعآوریِ زباله (GC) — GCِ نسلی، collectorها، انواعِ مرجع، نشتیِ حافظه.
- همروندی و JVMِ مدرن — virtual threadها و تلهٔ pinning.
- تلههای سریع — «خروجی این کد چیست؟».
هر سؤال در یک جعبهٔ 🎯 مصاحبه آمده تا بتوانی مثل فلشکارت مرورش کنی، اما داخلِ هر جعبه، من سازوکار را هم برایت باز کردهام.
اول سؤال را بخوان و ذهنی جواب بده، بعد جعبه را باز کن و توضیح را بخوان. جایی که کد دارد «خروجیِ این چیست؟»، قبل از دیدنِ کامنتِ جواب، خودت روی کاغذ حدس بزن. مصاحبه یک عضلهٔ حافظه است؛ اینطور آن عضله را میسازی.
بخش ۰ — چند واژه که باید از همین اول بلد باشی
قبل از شروع، سه واژه هست که در کلِ فصل برمیگردند. بیا همین حالا با تشبیه جا بیندازیمشان تا بعداً سرد و بیتوضیح رهایت نکنم.
تصور کن یک برگهٔ کاغذ داری که رویش آدرسِ یک خانه نوشتهای. برگه «مرجع» است؛ خانه «شیء (object)» است. اگر دو برگه آدرسِ یک خانه را داشته باشند، دو مرجعِ متفاوت به یک شیءِ واحداند. حالا اگر عددِ خالصِ «۷» را روی برگه بنویسی، آن دیگر آدرس نیست، خودِ مقدار است — این «primitive» است: int، double، boolean و امثالشان که خودِ مقدار را در دست داری، نه آدرسش.
هیپ را یک انبارِ بزرگ در نظر بگیر که همهٔ اجناسِ واقعی (شیءها) آنجا انبار میشوند. پشته دفترچهٔ یادداشتِ کوچکِ هر کارگر (هر نخ/thread) است که فقط آدرسها و یادداشتهای موقتش را مینویسد. کارگر جنس را در دفترچهاش نمیگذارد؛ آدرسِ قفسهاش در انبار را مینویسد. این تصویر را نگه دار؛ در بخش JVM دوباره سراغش میرویم.
با این دو تصویر، بقیهٔ فصل خیلی راحتتر میشود. برویم.
۱. مبانی زبان و شیءگرایی (OOP)
دو اسکناسِ ۱۰ هزار تومانی را در نظر بگیر. آیا «یکی» هستند؟ بستگی دارد چه بپرسی. اگر بپرسی «آیا این دقیقاً همان اسکناسِ فیزیکیِ واحد است؟» جواب نه است — دو تکه کاغذِ جدا با شمارهسریالِ متفاوتاند. این «identity» است. اما اگر بپرسی «آیا ارزششان برابر است؟» جواب بله است. این «برابریِ معنایی» است. در جاوا، == سؤالِ اول را میپرسد و equals() سؤالِ دوم را.
با تصویرِ اسکناسها: == برای اشیاء، مرجعها (identity) را مقایسه میکند — «آیا این دو برگه آدرسِ یک خانهاند؟». برای primitiveها همان مقدارِ خام را مقایسه میکند (چون primitive آدرس نیست، خودِ عدد است).
equals() مقایسهٔ معنایی/مقداری است که هر کلاس خودش تعریفش میکند. نکتهٔ مهم: پیشفرضِ Object.equals هیچ جادویی ندارد — دقیقاً همان == است! تا وقتی خودت override نکنی، «برابری» یعنی «همان شیءِ فیزیکی».
Objects.equals(a, b) نسخهٔ امن در برابر null است. منطقش ساده است: اگر هر دو null باشند true؛ اگر دقیقاً یکی null باشد false؛ وگرنه a.equals(b) را صدا میزند. چرا خوب است؟ چون اگر a خودش null باشد و تو a.equals(b) بنویسی، یک NullPointerException میخوری؛ Objects.equals این تله را برایت خنثی میکند.
این قرارداد قلبِ کارکردِ HashMap و HashSet است، پس خوب جا بینداز: اگر a.equals(b) باشد، آنگاه حتماً a.hashCode() == b.hashCode(). این یکطرفه و الزامی است.
عکسش لازم نیست: دو شیءِ نابرابر میتوانند hashCodeِ یکسان داشته باشند. به این «برخورد» یا collision میگویند و کاملاً مجاز است (بعداً در HashMap میبینی چرا اجتنابناپذیر است).
اگر این قانون را بشکنی، collectionهای مبتنی بر hash بیسروصدا شیءهایت را گم میکنند: put میکنی، بعد get میکنی و null تحویل میگیری — بدونِ هیچ خطایی. این نوع باگها ساعتها وقت میگیرند. علاوه بر این، equals باید چهار خاصیت داشته باشد: بازتابی (a.equals(a) همیشه true)، متقارن (اگر a.equals(b) پس b.equals(a))، متعدی (اگر a=b و b=c پس a=c) و سازگار (تا وقتی شیء عوض نشده، جوابش عوض نشود).
تصور کن یک انبارِ کتابخانه با هزار قفسه داری و کتابها را بر اساس حرفِ اولِ عنوان قفسهبندی میکنی (این «hashCode» است). حالا اگر دو نسخهٔ یکسانِ یک کتاب را با دو قاعدهٔ متفاوت قفسهبندی کنی — یکی زیرِ «ج» و یکی زیرِ «د» — وقتی بروی دنبالِ کتاب زیرِ «د» بگردی، آنی که زیرِ «ج» گذاشتهای را هرگز پیدا نمیکنی. HashMap دقیقاً همینطور کار میکند و دقیقاً همینطور شیءهایت را گم میکند.
دقیقاً همان تصویرِ کتابخانه در جعبهٔ بالا. دو شیءی که تو «برابر» تعریفشان کردهای، میتوانند در دو bucket (قفسهٔ) متفاوت بیفتند، چون hashCodeِ پیشفرضشان (که وابسته به آدرسِ حافظه است) فرق میکند.
سازوکارِ دقیق: HashMap.get و HashSet.contains اول با hashCode قفسه را پیدا میکنند، بعد داخلِ آن قفسه با equals میگردند. پس اگر hashCode اشتباه باشد، اصلاً به قفسهٔ درست نمیروند و equalsِ درستت هیچوقت صدا زده نمیشود. باگِ کلاسیک: یک value object (شیءی که هویتش با مقدارش تعریف میشود، مثلِ یک نقطه یا یک پول) که بهعنوان کلیدِ map استفاده شده و ناگهان «ناپدید» میشود.
تصور کن یک شرکت برای صرفهجویی در کاغذ، هر عبارتِ پرتکرار را فقط یکبار روی یک تابلوی مرکزی مینویسد و همه به همان تابلو اشاره میکنند. اگر دو نفر بخواهند بنویسند «java»، هر دو به همان خانهٔ روی تابلو ارجاع داده میشوند. این «string pool» است: literalهای یکسانِ رشته فقط یک نسخه در حافظه دارند.
String a = "java";
String b = "java";
String c = new String("java");
System.out.println(a == b); // true – هر دو به literalِ داخل pool اشاره میکنند
System.out.println(a == c); // false – new String() یک شیء در heap میسازد
System.out.println(a == c.intern()); // true – intern() مرجعِ داخل pool را برمیگرداند
با تصویرِ تابلوی مرکزی: a و b هر دو به همان خانهٔ تابلو اشاره میکنند، پس a == b میشود true. اما new String("java") مثلِ این است که کسی اصرار کند «نه، من میخواهم یک تکه کاغذِ جدا برای خودم» — یک شیءِ تازه در heap میسازد که مقدارش برابر است اما آدرسش فرق دارد، پس a == c میشود false. متد intern() میگوید «این کلمه را ببر همان تابلوی مرکزی و آدرسِ تابلو را به من بده»، پس c.intern() دوباره همان مرجعِ pool را میدهد و a == c.intern() میشود true. جمعبندی: literalهای ثابتِ زمانِ کامپایل یکتاسازی میشوند، new String() همیشه تخصیصِ تازه است، و intern() به نسخهٔ کانونیِ pool نگاشت میکند.
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true – کششده (بازهٔ ۱۲۸- تا ۱۲۷)
System.out.println(c == d); // false – autoboxing شیء Integer جدید میسازد
اینجا «autoboxing» داریم: وقتی یک intِ خام را در یک Integer (نسخهٔ شیءایِ int) میریزی، جاوا پشتِ پرده Integer.valueOf(...) را صدا میزند. و این متد یک بهینهسازیِ کوچک دارد: بازهٔ پرکاربردِ -128..127 را از قبل ساخته و در یک کش نگه میدارد. پس 127 هر بار همان شیءِ کششده است ← a == b میشود true. اما 128 بیرونِ بازهٔ کش است، پس هر بار یک شیءِ تازه ساخته میشود و == که مرجعها را مقایسه میکند، false میدهد. کرانِ بالای این کش با فلگِ -XX:AutoBoxCacheMax قابل تنظیم است. درسِ عملی: برای مقایسهٔ مقادیرِ boxشده هرگز به == اعتماد نکن؛ همیشه .equals یا intValue() را به کار ببر.
تصور کن یک رستوران داری. «Overloading» یعنی یک نامِ غذا («ساندویچ») چند نسخه دارد که با مواد از هم جدا میشوند: ساندویچ با مرغ، ساندویچ با گوشت. آشپز از روی سفارشِ نوشتهشده (نوعِ ایستا) تصمیم میگیرد کدام را بپزد — این تصمیم موقعِ سفارش گرفته میشود، نه سرِ اجاق. «Overriding» یعنی همان دستورِ دقیقِ غذا را یک شعبهٔ دیگرِ رستوران با سبکِ خودش دوباره تعریف میکند؛ اینکه کدام سبک اجرا شود، به این بستگی دارد که واقعاً کدام شعبه غذا را میپزد (نوعِ پویا)، و این را فقط لحظهٔ پخت میفهمی.
Overloading = نامِ یکسان، لیستِ پارامترِ متفاوت. در زمانِ کامپایل و بر اساس نوعِ ایستا (نوعی که در کد اعلان کردهای) resolve میشود — یعنی کامپایلر همان موقع تصمیم میگیرد کدام نسخه صدا زده شود.
Overriding = امضای دقیقاً یکسان در زیرکلاس. در زمانِ اجرا و بر اساس نوعِ پویا (نوعِ واقعیِ شیء در آن لحظه) resolve میشود — به این «virtual dispatch» میگویند، یعنی «فراخوانیِ مجازی»: تصمیم به تعویق میافتد تا لحظهٔ اجرا. نکتهٔ طلایی برای مصاحبه: overloading بدلِ چندریختی (polymorphism) است — چون پویا نیست. چندریختیِ واقعی همان overriding است.
void f(Object o) {}
void f(String s) {}
f(null); // f(String) صدا زده میشود – خاصترین overloadِ قابلاعمال برنده است
null میتواند به هر دو نوع تبدیل شود، پس کدام برنده است؟ قاعدهٔ کامپایلر: خاصترین (most specific) نوعِ قابلِاعمال را انتخاب کن. چون String زیرمجموعهٔ Object است (خاصتر است)، f(String) برنده میشود. اما اگر یک overloadِ دیگرِ بههماناندازه خاص اضافه کنی، مثلِ f(Integer)، دیگر کامپایلر نمیتواند بینِ String و Integer یکی را خاصتر بداند ← خطای کامپایلِ ابهام. برای رفعِ ابهام، خودت با cast نوع را مشخص کن: f((Object) null) صریحاً نسخهٔ Object را صدا میزند.
از جاوا ۸ خطِ مرزشان محو شد، پس دقت کن. Interfaceها حالا میتوانند متدِ default (بدنهدارِ پیشفرض)، static و private داشته باشند — اما حالتِ نمونهای (instance state) ندارند؛ فقط میتوانند ثابتِ public static final داشته باشند. یک کلاس میتواند چند interface را implement کند اما فقط یک کلاسِ abstract را extend کند (جاوا وراثتِ چندگانهٔ کلاس ندارد).
قاعدهٔ عملیِ انتخاب: اگر میخواهی حالتِ مشترک و یک پیادهسازیِ پایهٔ ناقص بدهی، از abstract class استفاده کن. اگر میخواهی یک قابلیت یا قرارداد تعریف کنی («این چیز میتواند پرواز کند»)، از interface. و نکتهٔ diamond: interfaceها مشکلِ diamond را فقط وقتی حلشدنی میکنند که defaultِ متعارض را خودت override کنی.
interface A { default String hi() { return "A"; } }
interface B { default String hi() { return "B"; } }
class C implements A, B {
public String hi() { return A.super.hi(); } // باید override شود، وگرنه کامپایل نمیشود
}
«diamond» یعنی وقتی یک کلاس دو مسیرِ ارث را دارد که هر دو یک متدِ همنام با بدنهٔ پیشفرض میدهند — کامپایلر گیج میشود که کدام hi() را به ارث ببرد. جاوا این ابهام را به تو پاس میدهد: تا وقتی خودت hi() را override نکنی، کد کامپایل نمیشود. داخلِ override میتوانی با نحوِ خاصِ Interface.super.method() — اینجا A.super.hi() — صریحاً بگویی «نسخهٔ A را میخواهم». این تنها راهِ اشاره به یک default مشخص از میانِ چند interface است.
«immutable» یعنی بعد از ساختهشدن، هیچوقت محتوایش عوض نمیشود. جاوا این را با سه قفل تضمین میکند: آرایهٔ کاراکترهای پشتیبان private final است (بیرون از کلاس دیده نمیشود و دوباره مقداردهی نمیشود)، هرگز به بیرون داده نمیشود، و خودِ کلاسِ String هم final است تا هیچ زیرکلاسِ بدجنسی نتواند رفتارش را عوض کند.
چرا مهم است؟ چهار مزیت: (۱) کلیدِ امنِ HashMap است، چون hashCodeاش یکبار حساب و کش میشود و دیگر تغییر نمیکند. (۲) امن برای interning/pooling است (همان تابلوی مرکزی). (۳) ذاتاً thread-safe است — چیزی که تغییر نمیکند، رقابتِ همزمان هم ندارد. (۴) در بافتهای امنیتی امن است: اگر یک مسیرِ فایل را اعتبارسنجی کردی، کسی نمیتواند بعد از بررسی مخفیانه عوضش کند. هزینهاش؟ هر «تغییر» در واقع یک شیءِ کاملاً جدید تخصیص میدهد.
یک کلمه، چهار جای مختلف، چهار معنا:
- روی متغیر/فیلدِ محلی: فقط یکبار مقداردهی میشود (بعدش قفل است).
- روی متد: قابلِ override در زیرکلاس نیست.
- روی کلاس: قابلِ زیرکلاسسازی نیست (مثلِ خودِ
String).
و مهمترین تلهٔ مصاحبه: final به معنای immutable نیست. مثال:
final List<String> l = new ArrayList<>();
اینجا نمیتوانی l را به یک لیستِ دیگر دوباره مقداردهی کنی (مرجع قفل است)، اما کاملاً مجازی که l.add("x") بزنی — محتوای لیست هنوز تغییرپذیر است. final مرجع را قفل میکند، نه محتوای شیء را.
۲. جنریکها (Generics)
تصور کن جعبههایی داری که رویشان برچسبِ محتوا زدهای: «فقط سیب»، «فقط پرتقال». این برچسبها به تو کمک میکنند موقعِ بستهبندی اشتباه نکنی. اما فرض کن قانون این باشد که سرِ در انبار، همهٔ برچسبها کنده میشوند و همهٔ جعبهها به یک شکلِ خنثی «جعبه» تحویلِ انبار میشوند. داخلِ انبار، دیگر کسی نمیداند کدام جعبه سیب داشت و کدام پرتقال. این دقیقاً «type erasure» است: برچسبهای نوع فقط زمانِ کامپایل هستند و سرِ اجرا پاک میشوند.
جنریکها فقط ابزارِ زمانِ کامپایلاند. کامپایلر پارامترهای نوع را به کرانشان پاک میکند: T بیکران تبدیل به Object میشود، و <T extends Number> تبدیل به Number. سپس هرجا لازم باشد، castها را خودش تزریق میکند تا کد امن بماند. نتیجه: در زمانِ اجرا List<String> و List<Integer> یک کلاسِ یکسان هستند — هیچکدام «برچسبِ» نوعشان را با خود ندارند.
هزینهها (اینها را حفظ کن، چون مستقیم پرسیده میشوند): نمیتوانی new T[] بسازی، T.class نداری، instanceof List<String> غیرمجاز است، نمیتوانی روی امضاهایی که بعد از پاکشدن یکی میشوند overload کنی، و مدام هشدارِ «unchecked cast» میگیری. همهٔ این محدودیتها یک ریشه دارند: سرِ اجرا، اطلاعاتِ نوع دیگر آنجا نیست.
List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println(ls.getClass() == li.getClass()); // true – کلاسِ زمان اجرا یکسان
void m(List<String> a) {}
void m(List<Integer> b) {} // خطای کامپایل: هر دو به m(List) پاک میشوند
خطِ اول true چاپ میکند و همانجا کلِ داستان را لو میدهد: چون هر دو لیست سرِ اجرا فقط ArrayListاند، getClass() یکسان است. حالا خطِ دوم چرا خطا میدهد؟ چون بعد از erasure، هر دو متد به امضای یکسانِ m(List) پاک میشوند و کامپایلر دو متدِ با امضای یکسان را نمیپذیرد. overload کردن روی آرگومانهای نوعِ جنریک ممکن نیست، دقیقاً به همین دلیل.
یک نوارِ نقالهٔ کارخانه را دو حالت تصور کن. حالتِ اول: نقاله دارد جعبهها را به سمتِ تو تولید میکند؛ تو فقط برمیداری و نگاه میکنی (میخوانی). اینجا مهم نیست دقیقاً چه چیزی میآید، فقط باید بدانی «حداقل از این نوع یا زیرنوعش است» ← این ? extends T است، سمتِ تولیدکننده (Producer). حالتِ دوم: نقاله دارد چیزها را از تو میگیرد و مصرف میکند؛ تو داری روی نقاله جنس میگذاری (مینویسی) ← این ? super T است، سمتِ مصرفکننده (Consumer). از این تصویر، قاعدهٔ Producer-Extends, Consumer-Super بیرون میآید.
بله، و حالا با تصویرِ نقاله معنا پیدا میکند. وقتی از یک ساختار فقط T را میخوانی (ساختار برایت T تولید میکند)، از ? extends T استفاده کن. وقتی فقط T را داخلِ یک ساختار مینویسی (ساختار T را مصرف میکند)، از ? super T استفاده کن.
نمونهٔ کانونی که همه دوست دارند بپرسند:
Collections.copy(List<? super T> dest, List<? extends T> src)
مقصد (dest) مصرفکننده است (در آن مینویسیم) پس ? super T؛ مبدأ (src) تولیدکننده است (از آن میخوانیم) پس ? extends T. و دو محدودیتِ منطقی که از همین بیرون میآید: به یک لیستِ ? extends نمیتوانی چیزی جز null اضافه کنی (چون نمیدانی دقیقاً چه زیرنوعی است)، و از یک لیستِ ? super نمیتوانی نوعِ مشخصی را امن بخوانی (چون فقط میدانی «یک ابرنوعِ T» است).
هر سه شبیهاند اما رفتارشان زمین تا آسمان فرق دارد:
List<Object>: هر عنصری را میپذیرد، اما — این نکتهٔ کلیدی —List<String>یکList<Object>نیست. به این «invariance» یا ناوردایی میگویند: جنریکها زیرنوعیِ عناصرشان را به خودشان منتقل نمیکنند.List<?>: wildcardِ بیکران. عناصرش را فقط بهصورتِObjectمیخوانی و جزnullچیزی نمیتوانی اضافه کنی (همان محدودیتِ نقاله).Listخام (raw): کاملاً بررسیِ جنریک را خاموش میکند و ریسکِClassCastExceptionرا به کدت برمیگرداند. فقط برای سازگاری با کدِ قدیمیِ پیش از جنریکها وجود دارد؛ در کدِ نو ازش پرهیز کن.
اینجا دو فلسفهٔ متضاد به هم میخورند. آرایهها همردا (covariant) و reified هستند: «reified» یعنی نوعِ مؤلفهشان را سرِ اجرا هم به یاد دارند، و اگر بخواهی نوعِ اشتباه در آنها بریزی، سرِ اجرا ArrayStoreException میاندازند. اما جنریکها ناوردا و پاکشده (erased) هستند — سرِ اجرا هیچ اطلاعاتِ نوعی ندارند.
اگر new T[] مجاز بود، آرایه دیگر نمیدانست نوعِ واقعیاش چیست و آن نگهبانِ زمانِ اجرا (ArrayStoreException) از کار میافتاد. پس جاوا کلاً جلویش را میگیرد. دو راهِ دور زدن: یا (T[]) new Object[n] با یک هشدارِ سرکوبشده (@SuppressWarnings)، یا حرفهایترش، Array.newInstance(...) که یک توکنِ Class<T> میگیرد تا نوع را سرِ اجرا واقعاً بداند.
۳. Collections
تصور کن ایستگاهِ قطار یک ردیف کمدِ امانات دارد. وقتی چمدانی میدهی، متصدی یک فرمول روی نامت اجرا میکند تا شمارهٔ قفسهای بدهد («تابعِ hash»)، و چمدانت را آنجا میگذارد. موقعِ برگشتن، دوباره همان فرمول را روی نامت اجرا میکند و مستقیم میرود سرِ همان قفسه — بدونِ جستوجوی همهٔ کمدها. این دسترسیِ تقریباً آنی (O(1)) رازِ سرعتِ HashMap است. اما اگر دو نفر به یک قفسه برسند («collision»)، باید داخلِ آن قفسه یک لیستِ کوچک نگه دارد.
یک آرایه از bucketها (قفسهها) دارد. اندیسِ قفسه با (n-1) & hash حساب میشود که در آن n تعدادِ قفسههاست. اما یک ترفندِ ظریف: hash = h ^ (h >>> 16). این چه میکند؟ بیتهای بالای کدِ هش را با بیتهای پایین XOR میکند تا وقتی فقط بیتهای پایین برای اندیس استفاده میشوند، تنوعِ بیتهای بالا هم دخیل شود و برخوردها کمتر شود.
وقتی چند شیء به یک قفسه بیفتند، ابتدا یک linked list (زنجیرهٔ ساده) میسازند. اما اگر یک قفسه از ۸ عنصر عبور کند و همزمان اندازهٔ کلِ جدول ≥ ۶۴ باشد، آن زنجیره به یک درختِ قرمز-سیاه (red-black tree) تبدیل میشود (به این «treeify» میگویند) تا بدترین حالتِ جستوجو از O(n) به O(log n) بهبود یابد. ظرفیتِ پیشفرض ۱۶ است، load factor برابرِ ۰٫۷۵؛ همین که تعدادِ عناصر از ظرفیت × load factor عبور کند، یک resize رخ میدهد: جدول دو برابر میشود و همهچیز دوباره hash میشود (rehash).
دو شرط لازم است چون treeify فقط وقتی میارزد که برخورد از ازدحامِ واقعی باشد، نه از کوچک بودنِ جدول. اگر جدول کوچک است (< ۶۴)، جاوا اول resize را امتحان میکند که ارزانتر است؛ درختسازی فقط وقتی است که جدول بهاندازهٔ کافی بزرگ باشد و باز هم یک قفسه شلوغ بماند.
این یک مبادلهٔ (trade-off) فضا در برابر زمان است. اگر load factor را کم بگذاری (مثلاً ۰٫۵)، جدول همیشه خلوت است و برخورد کم — اما حافظهٔ زیادی هدر میرود و resizeها زودتر رخ میدهند. اگر زیاد بگذاری (مثلاً ۰٫۹)، جدول متراکم میشود، حافظه صرفهجویی میشود اما زنجیرهها بلند میشوند و جستوجو کند. عددِ ۰٫۷۵ نقطهٔ بهینهٔ تجربی است که بهطورِ میانگین جستوجوی خوبِ O(1) با مصرفِ حافظهٔ قابلقبول میدهد.
تصور کن داری فهرستِ مهمانهای یک مهمانی را بلند بلند از رویِ یک لیست میخوانی، و وسطِ کار یکی مخفیانه یک اسم را از لیست خط میزند. یک ناظرِ حساس همان لحظه داد میزند «صبر کن! لیست وسطِ خواندن عوض شد!» و کار را متوقف میکند. این «fail-fast» است: بهجای ادامه دادن با دادهٔ خراب، سریع و پرسروصدا شکست میخورد.
fail-fast (مثلِ ArrayList و HashMap): یک شمارندهٔ درونی به نامِ modCount نگه میدارند که با هر تغییرِ ساختاری زیاد میشود. اگر حینِ پیمایش این شمارنده عوض شود، iterator یک ConcurrentModificationException (بهاختصار CME) میاندازد. توجه: این «بهترینتلاش» است، نه تضمینِ قطعی — یعنی همیشه نمیگیردش، اما معمولاً میگیرد.
fail-safe (مثلِ CopyOnWriteArrayList و ConcurrentHashMap): یا روی یک snapshot (عکسِ لحظهای) پیمایش میکنند یا تغییرِ همزمان را تحمل میکنند؛ هرگز خطا نمیاندازند، اما ممکن است دادهٔ کمی کهنه نشانت دهند. نکتهٔ مهمی که خیلیها اشتباه میکنند: حتی در برنامهٔ تکنخی هم اگر داخلِ یک حلقهٔ for-each بزنی list.remove()، همان CME را میگیری. راهِ درست: Iterator.remove() یا removeIf.
for (String s : list) {
if (s.isBlank()) list.remove(s); // ConcurrentModificationException میاندازد
}
// اصلاح:
list.removeIf(String::isBlank); // یا Iterator.remove()
دلیل دقیقاً همان جعبهٔ قبلی است: for-each پشتِ پرده از یک iterator استفاده میکند، و list.remove(s) مستقیماً روی لیست، modCount را عوض میکند بدونِ اینکه iterator خبردار شود. سرِ چرخشِ بعدی، iterator ناسازگاری را میبیند و CME میاندازد. removeIf این کار را از داخلِ خودِ ساختار و امن انجام میدهد.
جوابِ صادقانه: تقریباً هرگز — و این خودش یک جوابِ خوبِ مصاحبه است. ArrayList یک آرایهٔ پیوسته در حافظه است: دسترسیِ تصادفیِ O(1) (مستقیم میروی خانهٔ i)، سازگار با cacheِ پردازنده (چون دادهها کنارِ هماند)، و append با هزینهٔ سرشکنِ (amortized) ارزان.
LinkedList درج/حذفِ O(1) دارد — اما فقط اگر گرهِ (node) موردنظر را از قبل در دست داشته باشی. در عمل get(i) و حذفِ بر اساسِ اندیس O(n) است، چون باید از سرِ زنجیره گرهبهگره جلو بروی. بدتر: هر گره یک شیءِ جدا با سربارِ حافظه است و پراکندگیاش cacheِ پردازنده را خراب میکند. تنها توجیهِ واقعیِ LinkedList نقشِ Deque (صفِ دوسر) است، و آنجا هم معمولاً ArrayDeque برنده است.
Hashtable: یک بازماندهٔ قدیمی. روی هر متد قفلِ synchronized دارد (یک قفلِ درشتِ سراسری که کلِ نقشه را میبندد)، وnullرا نه بهعنوان کلید و نه مقدار قبول نمیکند. کنارش بگذار.HashMap: غیرهمگام (بدونِ قفل)، سریع، و یک کلیدِnullرا مجاز میداند. برای استفادهٔ تکنخی عالی است.ConcurrentHashMap: امنیتِ نخ با همروندیِ بالا. جاوا ۸ روی binهای خالی از CAS (یک عملِ اتمیکِ «مقایسه و جایگزینی» بدونِ قفل) استفاده میکند و فقط گرهِ سرِ یک قفسه را synchronize میکند (قفلِ ریزدانه، نه سراسری).nullرا نه کلید و نه مقدار میپذیرد، و iteratorهایش ضعیفسازگار (weakly consistent) هستند: خطا نمیاندازند اما ممکن است تغییرات لحظهٔ آخر را نبینند.
بهخاطرِ یک ابهامِ کشنده در محیطِ همزمان. تصور کن map.get(k) مقدارِ null برگرداند. این یعنی چه؟ دو حالت ممکن است: «کلید اصلاً وجود ندارد» یا «کلید هست اما مقدارش عمداً null است». در یک نقشهٔ معمولی میتوانی با یک containsKey دوم تشخیص بدهی. اما در محیطِ همزمان، بینِ get و containsKeyِ تو، یک نخِ دیگر میتواند نقشه را عوض کند — پس آن بررسیِ دوم قابلاعتماد نیست (رقابتی/racy است). با ممنوع کردنِ null، این ابهام کلاً حذف میشود: بازگشتِ null قطعاً یعنی «غایب»، و خودِ get بهتنهایی معتبر است.
سه رفتارِ کاملاً متفاوت برای ترتیب:
HashMap: هیچ تضمینِ ترتیبی ندارد. ترتیبِ پیمایش میتواند هر بار فرق کند.LinkedHashMap: ترتیبِ درج را حفظ میکند. و یک ترفندِ زیبا: اگر باaccessOrder=trueبسازیاش، ترتیبش بر اساسِ آخرین دسترسی میشود؛ حالا اگرremoveEldestEntryرا هم override کنی، یک کشِ LRU (کماستفادهترین حذف میشود) آماده داری.TreeMap: عناصر را مرتب نگه میدارد، بر اساسِ ترتیبِ طبیعی یا یکComparator. عملیاتش O(log n) است (چون یک درختِ قرمز-سیاه است) و از پرسوجوهای بازهایِNavigableMapپشتیبانی میکند:floorKey(بزرگترین کلیدِ ≤ x)،ceilingEntry(کوچکترین ورودیِ ≥ x)،subMap(یک بازه) و امثالشان.
Map<List<Integer>, String> m = new HashMap<>();
List<Integer> key = new ArrayList<>(List.of(1, 2));
m.put(key, "x");
key.add(3); // بعد از درج، hashCode تغییر میکند
System.out.println(m.get(key)); // null – در bucket قدیمی است، حالا غیرقابلدسترس
یادت هست چرا String را immutable ساختند تا کلیدِ خوبی باشد؟ اینجا عکسش را میبینی. کلیدت یک List تغییرپذیر است. موقعِ put، جاوا با hashCodeِ آن لحظه (بر اساسِ [1, 2]) قفسهاش را انتخاب میکند. بعد key.add(3) میزنی و حالا hashCode بر اساسِ [1, 2, 3] عوض شده. موقعِ get، جاوا با hashCodeِ جدید دنبالِ قفسه میگردد و میرود سراغِ قفسهای که کلید اصلاً آنجا نیست — پس null. کلید هنوز در قفسهٔ قدیمی نشسته اما دیگر پیدا نمیشود. درس: هرگز کلید را بعد از درج تغییر نده؛ کلیدهای immutable را ترجیح بده.
Comparable = ترتیبِ طبیعیِ خودِ کلاس؛ متدِ compareTo را روی خودِ شیء پیاده میکنی («این چیز ذاتاً چطور مرتب میشود»). Comparator = یک ترتیبِ بیرونی و جایگزینپذیر که در یک شیءِ جدا میگذاری («یک بار بر اساسِ نام، یک بار بر اساسِ سن»).
و تلهٔ کلاسیک: (a, b) -> a - b. این برای intهای بزرگ سرریز (overflow) میکند — مثلاً اگر a برابرِ Integer.MIN_VALUE باشد، a - 1 دور میزند و علامتش برعکس میشود، پس comparator قراردادش را میشکند و مرتبسازی خراب میشود. راهِ درست: Integer.compare(a, b) که این تله را ندارد. نکتهٔ اضافه: یک comparator که با equals ناسازگار باشد، معنای TreeSet/TreeMap را هم خراب میکند (چون آنها برابری را با comparator میسنجند، نه با equals).
دو الگوریتمِ متفاوت، بسته به اینکه چه چیزی مرتب میکنی:
- اشیاء: از TimSort استفاده میشود؛ یک مرتبسازیِ ادغامیِ (merge sort) پایدار و تطبیقی با پیچیدگیِ O(n log n)، که روی دادهٔ تقریباً مرتب تا O(n) هم سریع میشود. «پایدار» یعنی ترتیبِ عناصرِ مساوی حفظ میشود.
- Primitiveها: از Quicksortِ دو-محوری (dual-pivot) استفاده میشود که ناپایدار است — اما روی primitive پایداری بیمعناست (دو عددِ ۵ از هم تشخیصناپذیرند)، پس اشکالی ندارد.
و یک هشدارِ کاربردی: اگر comparatorت نامتعدی (non-transitive) باشد (یعنی a<b و b<c باشد اما a<c نباشد)، TimSort ممکن است حینِ بررسیِ ناورداییهایش خطای معروفِ IllegalArgumentException: Comparison method violates its general contract را بیندازد.
۴. استثناها (Exceptions)
تصور کن دو نوع تابلوی خطر داری. تابلوی «checked» مثلِ تابلوی «جادهٔ لغزنده، زنجیرِ چرخ اجباری» است: قانون تو را مجبور میکند از قبل آماده باشی (catch یا declare کنی)، چون این وضعیت قابلِ پیشبینی و قابلِ مدیریت است. تابلوی «unchecked» مثلِ «رانندهٔ بیاحتیاط ممکن است تصادف کند» است: قانون نمیتواند تو را مجبور به آمادگی برای هر حماقتِ ممکن کند، چون اینها باگهای برنامهنویسیاند، نه وضعیتهای قابلِبازیابی.
checked (کلاسی که از Exception ارث میبرد اما نه از RuntimeException): کامپایلر مجبورت میکند یا catch کنی یا در امضای متد declare کنی. برای شرایطِ قابلِبازیابی طراحی شده — مثلِ «فایل پیدا نشد» که برنامه میتواند برایش کاری بکند.
unchecked (RuntimeException و Error): هیچ اجباری نیست. برای باگهای برنامهنویسی (مثلِ NullPointerException) و حالتهای غیرقابلبازیابی (مثلِ OutOfMemoryError) است.
و انتقادِ مدرن که خوب است در مصاحبه بگویی: checked exceptionها با lambda و stream ترکیب نمیشوند (نمیتوانی راحت داخلِ یک map(...) استثنای checked پرتاب کنی) و آدمها را تشویق به «بلعیدنِ» خطا (catch خالی) میکنند. برای همین بسیاری تیمها در مرزهای سیستم آنها را در uncheckedها میپیچند.
try-with-resources ساختاری است که منابع (مثلِ فایل یا کانکشن) را خودکار میبندد. دو نکتهٔ دقیق که مصاحبهگر عاشقشان است: منابع به ترتیبِ معکوسِ اعلانشان بسته میشوند (آخرین بازشده، اولین بستهشده — مثلِ درآوردنِ لباسها)، و این بستهشدن پیش از هر catch یا finally رخ میدهد.
حالا سناریوی ظریف: اگر هم بدنه خطا بیندازد و هم close() موقعِ بستهشدن خطا بیندازد، کدام منتشر میشود؟ خطای بدنه برنده است و منتشر میشود؛ خطای close suppress (سرکوب) شده و به خطای اصلی چسبانده میشود — میتوانی با getSuppressed() بازیابیاش کنی. این دقیقاً عکسِ یک finally { close(); }ِ دستساز است که در آن اگر close خطا بدهد، خطای اصلیِ بدنه گم میشود. برای همین همیشه try-with-resources بهتر است.
دو تلهٔ بعدی نشان میدهند که finally چقدر میتواند رفتارِ غیرمنتظره داشته باشد. قانونِ کلی که باید حفظ کنی: هرگز داخلِ finally یک return یا throw نگذار — چون همهچیز را بازنویسی میکند.
static int f() {
try { return 1; }
finally { return 2; } // returnِ finally هم returnِ try و هم هر استثنا را میبلعد
}
// f() مقدار 2 برمیگرداند. return/throw در finally همهچیز را بازنویسی میکند. هرگز این کار را نکن.
finally همیشه اجرا میشود، حتی بعد از یک returnِ آماده در try. وقتی خودِ finally یک return 2 دارد، آن مقدار جایگزینِ مقدارِ بافرشدهٔ try (که 1 بود) میشود. بدتر: اگر try یک استثنا هم پرتاب کرده بود، returnِ داخلِ finally آن استثنا را هم میبلعد و ناپدید میکند. برای همین یک باگِ خطرناک است.
static int g() {
int x = 1;
try { return x; } // مقدار بازگشتی (۱) اینجا ارزیابی و بافر میشود
finally { x = 99; } // محلی را تغییر میدهد، اما بافرِ return دستنخورده میماند
}
// g() مقدار 1 برمیگرداند، نه 99 — عبارتِ return از قبل محاسبه شده بود.
این ظرافت را خوب بفهم: وقتی به return x میرسی، جاوا همان لحظه مقدارِ x (یعنی ۱) را میخواند و کنار میگذارد (بافر میکند). سپس finally اجرا میشود و x را به ۹۹ عوض میکند — اما این دیگر روی مقدارِ بافرشده اثری ندارد، چون آن عدد قبلاً کپی شده. پس خروجی ۱ است. تفاوتش با س۳۰ این است: آنجا finally خودش return داشت (پس مقدارِ بازگشتی را عوض کرد)؛ اینجا finally فقط یک متغیرِ محلی را دستکاری میکند.
۵. JVM، بارگذاریِ کلاس و مدلِ حافظه
تصور کن یک شرکت داری. یک انبارِ مرکزیِ بزرگ هست که همهٔ کارمندان به آن دسترسی دارند و اجناسِ واقعی آنجاست (این «heap» است). یک بایگانیِ اسنادِ سازمانی هست که ساختارِ شرکت و نقشهها را نگه میدارد (این «Metaspace» است). و هر کارمند (هر نخ) یک میزِ کارِ شخصی دارد با یادداشتهای موقتِ خودش که کسی دیگر به آن دست نمیزند (این «پشته/stack» است). با این تصویر برویم سراغِ جزئیات.
دو دستهٔ کلی داریم:
مشترک بینِ کلِ JVM: (۱) heap — همهٔ اشیاء اینجا زندگی میکنند و GC مدیریتش میکند. (۲) Metaspace — متادیتای کلاسها (ساختارشان، متدها و...)؛ نکتهٔ نسخهای مهم: از جاوا ۸ این ناحیه در حافظهٔ native (خارج از heap) قرار گرفت و جایگزینِ ناحیهٔ قدیمیِ PermGen شد.
مخصوصِ هر نخ: (۳) پشتهٔ JVM — فریمها با متغیرهای محلی و عملوندها. (۴) رجیستر PC — میگوید نخ الان کدام دستور را اجرا میکند. (۵) پشتهٔ متدِ native.
دو نکتهٔ اضافه که خوب است بدانی: string pool در heap زندگی میکند (از جاوا ۷ از PermGen به heap منتقل شد). فلگِ -Xmx سقفِ heap را تعیین میکند، و -XX:MaxMetaspaceSize سقفِ Metaspace را.
یادت هست تصویرِ برگهٔ آدرس و خانه؟ اینجا کاربردش را میبینی. بدنهٔ شیء و همهٔ فیلدهای نمونهاش در heap زندگی میکنند (خانهٔ واقعی، در انبار). یک متغیرِ مرجعِ محلی در stack زندگی میکند و فقط به داخلِ heap اشاره میکند (برگهٔ آدرس، روی میزت). primitiveهایی که بهعنوانِ متغیرِ محلی اعلان شدهاند در stack زندگی میکنند (خودِ عدد، روی میزت)؛ اما primitiveهایی که فیلدِ یک شیءاند، داخلِ همان شیءِ heap هستند.
نکتهٔ ارشدپسند: escape analysis یک بهینهسازیِ JVM است که تشخیص میدهد یک شیء از محدودهٔ متد «فرار نمیکند» (جایی بیرون درز نمیکند)، و آنوقت ممکن است آن را روی stack تخصیص دهد یا حتی به فیلدهای ساده بشکند (scalar replacement). این یک بهینهسازیِ اختیاری است، نه تضمینِ زبان.
یک کلاس این مراحل را طی میکند: Loading → Linking (شاملِ Verify، Prepare، Resolve) → Initialization.
- Prepare: فیلدهای static را به مقادیرِ پیشفرض میگذارد (مثلاً
intرا ۰). - Initialization: initializerهای static و مقداردهیِ واقعیِ فیلدهای static را اجرا میکند — و این بهصورتِ تنبل (lazy) و در اولین استفادهٔ فعال از کلاس رخ میدهد.
parent-delegation (واگذاری به والد): هر classloader پیش از اینکه خودش کلاسی را بار کند، اول از والدش میپرسد. زنجیره: Bootstrap → Platform → Application. نتیجه: کلاسهای هستهٔ جاوا (مثلِ java.lang.String) همیشه از بالاترین سطح بار میشوند و کسی نمیتواند نسخهٔ جعلی جایشان بگذارد.
نکتهٔ طلایی: متدِ ویژهٔ <clinit> (که همان کدِ مقداردهیِ static است) یکبار و بهصورتِ thread-safe توسطِ خودِ JVM اجرا میشود. دقیقاً به همین دلیل، اصطلاحِ «initialization-on-demand holder» (نگهداشتنِ نمونه در یک کلاسِ درونیِ static) یک راهِ درستِ ساختِ singletonِ تنبل و امن است — بدونِ نیاز به قفلِ دستی.
class A {
static int x = 10;
static { x = 20; }
static int y = x + 5; // initializerها از بالا به پایین اجرا میشوند: اینجا x برابر ۲۰ است
}
// A.y == 25. بلوکهای static و مقداردهیِ فیلد به ترتیبِ متنی اجرا میشوند.
قانونِ ساده: بلوکهای static { ... } و مقداردهیِ فیلدهای static به ترتیبِ متنی، از بالا به پایین اجرا میشوند. پس گامبهگام: اول x برابرِ ۱۰ میشود، بعد بلوکِ static آن را به ۲۰ میرساند، و وقتی به y = x + 5 میرسیم، x همان ۲۰ است، پس y برابرِ ۲۵ میشود. اگر ترتیبِ خطوط عوض میشد، جواب هم عوض میشد.
تصور کن هر کارمند برای سرعت، یک کپیِ شخصی از یک عدد روی میزش نگه میدارد (کشِ محلیِ پردازنده). مشکل: اگر یکی عددش را عوض کند، بقیه از کپیِ کهنهٔ خودشان میخوانند. حالا اگر آن عدد را volatile اعلام کنی، مثلِ این است که بگویی «این عدد فقط روی تابلوی اعلاناتِ مرکزی نوشته میشود» — هرکس بنویسد فوراً روی تابلو میرود (flush)، و هرکس بخواند مستقیم از تابلو میخواند. این «مرئیت (visibility)» است.
دو تضمین میدهد: (۱) مرئیت (visibility) — همان تابلوی اعلانات: نوشتهها فوراً به حافظهٔ اصلی flush میشوند و خواندنها مستقیماً از آنجا میآیند، پس هیچ نخی کپیِ کهنه نمیبیند. (۲) ترتیب (ordering) — یک یالِ happens-before برقرار میکند و از بازچینشِ (reordering) دستورات به آنطرفِ دسترسیِ volatile جلوگیری میکند.
اما — و این مهمترین بخشِ جواب است — اتمیبودنِ عملیاتِ مرکب را نمیدهد. مثلاً count++ روی یک volatile int هنوز یک رقابتِ lost-update است، چون count++ در واقع سه عمل است (بخوان، جمع کن، بنویس) و دو نخ میتوانند وسطِ کارِ هم بپرند. برای عملیاتِ «بخوان-تغییرده-بنویس» باید از AtomicInteger یا قفل استفاده کنی.
مدلِ حافظهٔ جاوا (JMM) یک تضمینِ زیبا میدهد: اگر یک شیء بهدرستی ساخته شود — یعنی مرجعِ this از داخلِ constructor به بیرون درز نکند — آنوقت هر نخی که مرجعِ آن شیء را ببیند، فیلدهای finalاش را بهدرستی مقداردهیشده میبیند، بدونِ نیازی به هیچ همگامسازی. این تضمین همان چیزی است که اشیاء immutableِ امن و بهویژه امنیتِ خودِ String (که فیلدهایش final هستند) را زیر پا محکم میکند.
۶. جمعآوریِ زباله (Garbage Collection)
به میزِ کارت نگاه کن. اکثرِ کاغذهایی که در طولِ روز برمیداری — یک یادداشتِ سریع، یک برگهٔ حساب — چند دقیقه بعد دور میریزی؛ جوان میمیرند. اما چند سندِ مهم (شناسنامه، قرارداد) سالها میمانند. حالا اگر بخواهی میزت را مرتب کنی، عاقلانه است که سراغِ دستهٔ کاغذهای موقتِ تازه بروی (که اکثرش آشغال است) نه کلِ بایگانیِ قدیمی. GCِ نسلی دقیقاً همین کار را میکند.
مشاهدهٔ کلیدی — «فرضیهٔ نسلیِ ضعیف» — این است که اکثرِ اشیاء جوان میمیرند. پس heap را به دو ناحیه تقسیم میکنیم: Young (که خودش شاملِ Eden و دو فضای Survivor است) و Old.
سازوکار: اشیاء نو در Eden متولد میشوند. یک Minor GC بازماندگان را بینِ دو فضای Survivor کپی میکند و «سنشان» را یک واحد بالا میبرد. وقتی سنِ یک شیء از یک آستانه (tenuring threshold) عبور کرد، به Old ترفیع (promote) مییابد. Old دیرتر و با یک Major/Full GC جمع میشود. چرا جمعآوریِ Young ارزان است؟ چون از الگوریتمِ کپی استفاده میکند که فقط اشیاء زنده را لمس میکند — و طبقِ همان فرضیه، اشیاء زنده در Young بسیار کماند.
یک نقشهٔ کاملِ collectorها:
- G1 — از جاوا ۹ پیشفرض است. ناحیهمحور (heap را به regionهای کوچک میشکند)، یک هدفِ pauseِ نرم را دنبال میکند، و فشردهساز (compacting) است.
- Parallel — تمرکزش روی توان عملیاتی (throughput) است؛ کاملاً stop-the-world.
- Serial — تکنخی، برای heapهای کوچک و کانتینرها.
- ZGC و Shenandoah — collectorهای همزمانِ کمتأخیر با pauseهای زیرِ میلیثانیه.
- Generational ZGC (JEP 439، جاوا ۲۱) — نسلهای young/old را به ZGC افزود؛ مصرفِ حافظه را حدودِ ۷۵٪ کم و توان عملیاتی را حدودِ ۴ برابر نسبت به ZGCِ قدیمیِ غیرنسلی بهتر کرد.
قاعدهٔ انتخاب: بهطورِ پیشفرض G1 را بردار؛ برای heapهای بزرگِ حساس به تأخیر، ZGC یا Shenandoah.
تصور کن هر شیء یک بادبادک است و مرجعها نخهایی که بادبادکها را به هم و در نهایت به دستِ تو وصل میکنند. تا وقتی یک بادبادک از راهی — هرچقدر پیچدرپیچ — به دستِ تو (یک «root») بند باشد، زنده است. لحظهای که آخرین نخِ رابط با دستت قطع شود، بادبادک آزاد میشود و میرود؛ حتی اگر دو بادبادک هنوز به هم بند باشند، وقتی هیچکدام به دستِ تو وصل نیست، هر دو رها میشوند.
GC rootها همان «دستهای تو» در تصویرِ بادبادکاند: نقاطِ شروعی که collector قطعاً زنده میداندشان. شامل: مرجعها و متغیرهای محلیِ روی پشتهٔ نخهای فعال، فیلدهای static، مرجعهای JNI (از کدِ native)، و monitorهای در دست (قفلهای گرفتهشده).
قانونِ کلی: یک شیء زباله است اگر و فقط اگر از هیچ GC rootی قابلِدسترس (reachable) نباشد. و نکتهٔ ظریفی که خیلیها را میاندازد: دو شیءِ مرده که فقط به هم اشاره میکنند (مرجعِ حلقوی/cyclic) باز هم جمع میشوند — چون معیار دسترسپذیری از root است، نه شمارشِ مرجع (reference counting). اگر جاوا صرفاً مرجعها را میشمرد، این حلقهٔ مرده هرگز آزاد نمیشد.
چهار درجهٔ «چسبندگی» به حافظه، از محکم به سست:
- Strong: مرجعِ عادی و روزمره. تا وقتی قابلِدسترس باشد، هرگز جمع نمیشود.
- Soft: فقط زیرِ فشارِ حافظه جمع میشود. عالی برای کشهای حساس به حافظه — «تا وقتی جا هست نگهش دار، تنگ که شد رهایش کن».
- Weak: در همان GCِ بعدی جمع میشود، اگر فقط ضعیفدسترس باشد. پایهٔ
WeakHashMapو mapهای کانونیساز (canonicalizing) است. - Phantom: بعد از finalization برای پاکسازیِ قطعیِ پسازمرگ در یک صف قرار میگیرد؛ جایگزینِ مدرن و امنِ متدِ
finalize()است که خودش برای حذف deprecated شده.
خیلیها فکر میکنند «جاوا GC دارد، پس نشتیِ حافظه غیرممکن است». این اشتباهِ خطرناکی است. GC فقط اشیاء غیرقابلدسترس را آزاد میکند؛ اگر خودت ناخواسته یک مرجعِ زنده را نگه داری، GC هیچ کاری نمیتواند بکند.
بله، قطعاً. یک collectionِ static که مدام به آن add میکنی و هرگز چیزی حذف نمیکنی، مرجعهای strong را برای همیشه نگه میدارد — و چون static یک GC root است، هیچکدام از آن اشیاء هرگز آزاد نمیشوند. مقصرهای کلاسیک: کشهای بدونِ سیاستِ eviction (پاکسازی)، listenerهایی که ثبت میشوند اما هرگز unregister نمیشوند، و ThreadLocalهایی که روی نخهای poolشده پاک نمیشوند (چون آن نخ زنده میماند و ThreadLocalش را با خود حمل میکند). اینها «نشتیِ منطقی»اند: از نظرِ GC آن اشیاء کاملاً زنده و قابلِدسترساند، پس آزادشان نمیکند — مشکل از منطقِ توست، نه از GC.
نه. «stop-the-world» (بهاختصار STW) یعنی لحظهای که کلِ نخهای برنامه در یک safepoint (نقطهٔ امنی که وضعیت پایدار است) متوقف میشوند تا GC کارِ حساسش را بکند. حتی ZGC و Shenandoah — که «کمتأخیر» مینامندشان — هم pauseهای کوتاهِ STW دارند (برای پیمایشِ rootها و شروع/پایانِ mark)، اما آنها را زیرِ میلیثانیه نگه میدارند و بخشِ سنگین (mark و relocation) را همزمان با اجرای برنامه انجام میدهند. جملهٔ طلایی برای مصاحبه: «بدونِ pause» یک ادعای بازاریابی است؛ ادعای واقعی و درست این است: «pauseِ زیرِ میلیثانیه، مستقل از اندازهٔ heap».
۷. همروندی و JVMِ مدرن (ارشد)
یک رستورانِ شلوغ را تصور کن با فقط چند پیشخدمتِ واقعی (نخهای OS، گران و محدود). مدلِ قدیمی: هر پیشخدمت یک میز میگیرد و کنارِ میز میایستد تا مشتری غذایش را کامل بخورد — اگر مشتری کند باشد، پیشخدمت بیکار بلوکه میشود. مدلِ virtual thread: هر «سفارش» یک پیشخدمتِ مجازیِ سبک دارد، اما پیشخدمتهای واقعی فقط حاملِ (carrier) کارند؛ لحظهای که یک مشتری منتظرِ چیزی میشود (I/O)، پیشخدمتِ واقعی رهایش میکند (unmount) و میرود سراغِ سفارشِ دیگری. با همان چند پیشخدمتِ واقعی، میلیونها سفارش را میچرخانی.
Virtual threadها نخهای سبک هستند که خودِ JVM آنها را روی یک pool کوچک از نخهای حامل (carrier/platform) — همان پیشخدمتهای واقعیِ تصویر — زمانبندی میکند. جادویش اینجاست: I/Oِ بلاککننده بهجای اینکه یک نخِ OS را قفل کند، virtual thread را unmount میکند (از حامل جدا میکند). نتیجه: میتوانی میلیونها virtual thread را در سبکِ سادهٔ «یک نخ بهازای هر درخواست» اجرا کنی، بدونِ اینکه منابعِ OS تمام شود.
اما گیرِ اصلی: pinning (سنجاقشدن). پیش از جاوا ۲۴، اگر یک virtual thread داخلِ یک بلوکِ synchronized یا یک فراخوانِ native بلاک میشد، به حاملش pin میماند و نمیتوانست unmount شود — یعنی آن پیشخدمتِ واقعی گیر میکرد و مقیاسپذیری خفه میشد. راهِ حل: یا بهجای synchronized از ReentrantLock استفاده کن، یا ارتقا بده — JEP 491 (جاوا ۲۴) سنجاقشدنِ ناشی از synchronized را در سطحِ runtime حذف کرد.
چون کلِ فلسفهٔ virtual thread را نقض میکند. تو virtual threadها را pool نمیکنی — برای هر task یکی میسازی، با Executors.newVirtualThreadPerTaskExecutor(). یادت باشد اینها ارزاناند و قرار است کوتاهعمر باشند؛ pool کردنشان مثلِ این است که برای دستمالکاغذیهای یکبارمصرف یک سیستمِ شستوشو راه بیندازی — بیمعناست. pool برای نخهای حامل است که خودِ JVM مدیریتشان میکند و تو کاری با آن نداری. اگر میخواهی همزمانی را محدود کنی (مثلاً حداکثر ۱۰۰ تماسِ همزمان به یک سرویس)، از یک Semaphore استفاده کن، نه از یک thread poolِ کراندار.
HashMap اصلاً برای استفادهٔ همزمان ساخته نشده، و نتیجهاش صرفاً «دادهٔ اشتباه» نیست — میتواند فاجعهبار باشد. putهای همزمان درست وسطِ یک resize میتوانند جدولِ درونی را خراب کنند. در جاوا ۷، این میتوانست در زنجیرهٔ یک bucket یک حلقه بسازد، و آنوقت یک getِ ساده وارد یک حلقهٔ بینهایت میشد که پردازنده را ۱۰۰٪ میچسباند و کلِ سرویس را میخواباند. resizeِ جاوا ۸ کمتر فاجعهبار است (دیگر آن حلقهٔ بینهایت را نمیسازد) اما هنوز هم حالت را خراب و عناصر را گم میکند. هیچ «امنیتِ جزئی» وجود ندارد — برای هر mapِ اشتراکیِ تغییرپذیر، ConcurrentHashMap را به کار ببر.
۸. تلههای سریع — «خروجی این چیست؟»
اینها سؤالهای کوتاهِ «برقآسا»اند که برای سنجشِ دقتِ نظر پرسیده میشوند. هرکدام یک واقعیتِ عمیق را در یک خطِ کد پنهان کردهاند.
System.out.println(0.1 + 0.2 == 0.3); // false – ممیزِ شناورِ دودویی نمیتواند ۰٫۱ را دقیق نمایش دهد
System.out.println(0.1 + 0.2); // 0.30000000000000004
چرا؟ double اعداد را در مبنای دو (باینری) ذخیره میکند، و درست همانطور که 1/3 در مبنای ده میشود 0.3333...ِ بیپایان، عددِ 0.1 در مبنای دو یک کسرِ متناوبِ بیپایان است که باید بریده شود. این خطاهای بریدنِ کوچک جمع میشوند و 0.1 + 0.2 دقیقاً 0.3 درنمیآید، بلکه 0.30000000000000004 میشود. درسِ عملیِ حیاتی: برای پول هرگز از double استفاده نکن؛ از BigDecimal استفاده کن — و آن را از رشته بساز (new BigDecimal("0.1"))، نه از یک double، وگرنه همان خطا را از اول وارد کردهای.
Long l = 0L;
System.out.println(l.equals(0)); // false! صفر به Integer باکس میشود؛ Long.equals(Integer) نادرست است
اینجا 0 (بدونِ پسوندِ L) یک int است که به Integer باکس میشود، نه Long. و Long.equals(...) اولین کاری که میکند بررسیِ نوعِ زمانِ اجرا است: چون آرگومان یک Integer است نه Long، بیدرنگ false برمیگرداند — حتی اگر مقدارها برابر بهنظر برسند. equals هرگز بینِ نوعهای متفاوتِ عددی تبدیل نمیکند. راهِ درست: یا l.longValue() == 0 (مقایسهٔ مقداری) یا l.equals(0L) (با پسوندِ درست تا آرگومان Long باشد).
System.out.println(Integer.MAX_VALUE + 1); // -2147483648 – سرریزِ بیصدای int دور میزند
int فقط ۳۲ بیت دارد و بزرگترین مقدارش Integer.MAX_VALUE (برابرِ ۲۱۴۷۴۸۳۶۴۷) است. یکی که به آن اضافه کنی، مثلِ کیلومترشمارِ ماشین که از ۹۹۹۹۹۹ به ۰۰۰۰۰۰ میپرد، دور میزند و به منفیترین مقدار (-2147483648) میرسد — و هیچ خطایی هم نمیدهد، بیصدا. برای اینکه سرریز سریع و پرسروصدا شکست بخورد بهجای اینکه دادهٔ خراب تولید کند، از Math.addExact(...) استفاده کن (که روی سرریز استثنا میاندازد)، یا اصلاً از long استفاده کن.
String s = null;
switch (s) { ... } // NullPointerException – switch روی مرجعِ null پیش از تطبیق خطا میاندازد
switchِ کلاسیک روی یک String یا enum، پیش از اینکه به هیچ caseی برسد، اول باید مقدار را واکشی کند (مثلاً hashCodeاش را بگیرد)، و روی null همانجا NullPointerException میاندازد. راهِ حل: یا صریحاً null را قبلش محافظت کن (if (s == null) ...)، یا از switchِ pattern-matching جاوا ۲۱ استفاده کن که یک برچسبِ case null واقعی دارد و میتوانی null را مثلِ یک حالتِ عادی مدیریت کنی.
اینها را آنقدر مرور کن تا پاسخها بازتابی شوند. یادت باشد آن نکتهای که در ابتدای فصل گفتم: در مصاحبه، سؤالِ بعدی — «چرا؟» — همانجایی است که ارشدبودن دیده میشود. همیشه آماده باش سازوکار را توضیح دهی، نه فقط نتیجه را.
- برابری و هش:
==هویت است،equalsمعنا؛ و اگرequalsرا override کردی،hashCodeرا هم بکن وگرنه HashMap شیءهایت را گم میکند. - رشته و کش: literalها در string pool مشترکاند،
new Stringتخصیصِ تازه است؛ کشِIntegerفقط-128..127را میپوشاند — برای boxشدهها همیشه.equals. - جنریکها: همهچیز از type erasure میآید — بدونِ
new T[]، بدونِ overload روی نوعِ جنریک؛ و PECS را با تصویرِ نقاله به یاد بیاور. - Collections:
HashMap= قفسه + زنجیرهای که سرِ ۸ عنصر و جدولِ ۶۴ به درختِ قرمز-سیاه treeify میشود؛ load factor ۰٫۷۵؛ برای همزمانیConcurrentHashMap؛ کلید را immutable نگه دار. - استثناها: try-with-resources منابع را معکوس و امن میبندد و خطای close را suppress میکند؛ هرگز در
finallyمقدار برنگردان. - JVM و حافظه: اشیاء در heap، مرجعهای محلی در stack؛ کلاسها با parent-delegation و تنبل مقداردهی میشوند؛
volatileمرئیت و ترتیب میدهد اما نه اتمیبودن. - GC: نسلی است چون اشیاء جوان میمیرند؛ پیشفرض از جاوا ۹ G1، و Generational ZGC (JEP 439، جاوا ۲۱) برای تأخیرِ پایین؛ زباله یعنی از هیچ rootی reachable نیست؛ نشتی در زبانِ GCدار هم ممکن است.
- مدرن: virtual thread (JEP 444، جاوا ۲۱) میلیونها نخِ سبک میدهد؛ مراقبِ pinning باش (تا جاوا ۲۴ / JEP 491)؛ pool نکن،
Semaphoreبزن. - قانونِ نهایی: در مصاحبه، چرا را بلد باش، نه فقط چه. سازوکار، سازوکار، سازوکار.
منابع: JEP 444: Virtual Threads، JEP 439: Generational ZGC، تغییرات مهم در JDK 21، راهنمای تنظیمِ GCِ HotSpot: ZGC.
Let's be honest up front: this chapter is an "interview question bank," but it is not going to behave like the dry banks you've seen, the ones that dump an answer in your lap and walk away. I'm going to sit next to you and unpack every question so that when the interviewer asks "why?" — and they always ask why — you'll know not just the right answer but the mechanism underneath it. Because that is exactly where a senior engineer separates from the pack: they didn't memorize the result, they understand where the result comes from.
These fifty questions are the ones actually asked in mid-to-senior Java interviews. They're grouped by topic and escalate from warm-up to brutal. Where a fact is version-specific it is pinned to a concrete JDK — because in an interview, "I think from some version onward" loses points, while "since Java 9" earns them.
We'll travel through eight stations:
- Language & OOP — equality, hashing, strings,
final, overload/override. - Generics — type erasure and every limitation that flows from it.
- Collections — inside
HashMap, choosing a data structure, iterators. - Exceptions — checked/unchecked and the strange behavior of
finally. - JVM & memory model — stack/heap, class loading,
volatile. - Garbage collection — generational GC, collectors, reference types, leaks.
- Concurrency & modern JVM — virtual threads and the pinning trap.
- Rapid gotchas — "what does this print?"
Each question lives in a 🎯 interview box so you can review it like a flashcard — but inside each box, I've unpacked the mechanism for you.
Read the question first and answer it in your head, then open the box and read the explanation. Wherever the code asks "what does this print?", guess on paper before you look at the answer comment. Interviews are muscle memory; this is how you build the muscle.
Part 0 — a few words you must know first
Before we start, three words recur throughout the whole chapter. Let's ground them with analogies right now so I never drop them on you cold later.
Imagine you have a slip of paper with a house address written on it. The slip is a "reference"; the house is the "object." If two slips carry the same address, they are two different references pointing at one single object. Now if instead you write the bare number "7" on the slip, that's not an address anymore — it's the value itself. That's a "primitive": int, double, boolean and friends, where you hold the value directly, not an address to it.
Picture the heap as a giant warehouse where all the real goods (objects) are stored. The stack is the small personal notepad of each worker (each thread), where they jot only addresses and temporary notes. A worker doesn't put the goods on their notepad; they write down the shelf address in the warehouse. Hold onto this picture; we come back to it in the JVM section.
With those two pictures, the rest of the chapter gets much easier. Let's go.
1. Language & OOP fundamentals
Take two 10,000-toman banknotes. Are they "the same"? Depends what you're asking. If you ask "is this the exact same physical note?" the answer is no — two separate pieces of paper with different serial numbers. That's "identity." But if you ask "are they equal in worth?" the answer is yes. That's "semantic equality." In Java, == asks the first question and equals() asks the second.
With the banknotes picture: for objects, == compares references (identity) — "do these two slips carry the same address?". For primitives it compares the raw value (a primitive isn't an address, it's the number itself).
equals() is the semantic/value comparison that each class defines for itself. The crucial catch: the default Object.equals has no magic — it is literally just ==! Until you override it, "equal" means "the very same physical object."
Objects.equals(a, b) is the null-safe version. Its logic is simple: if both are null, true; if exactly one is null, false; otherwise it calls a.equals(b). Why is that good? Because if a is itself null and you write a.equals(b), you get a NullPointerException; Objects.equals defuses that trap for you.
This contract is the beating heart of how HashMap and HashSet work, so burn it in: if a.equals(b), then a.hashCode() == b.hashCode() — mandatory. It's one-directional and required.
The reverse is not required: two unequal objects may share a hashCode. That's called a "collision" and it's perfectly legal (later, in HashMap, you'll see why it's unavoidable).
Break this rule and hash-based collections silently lose your objects: you put, then get, and receive null — with no error at all. These bugs cost hours. On top of that, equals must have four properties: reflexive (a.equals(a) is always true), symmetric (if a.equals(b) then b.equals(a)), transitive (if a=b and b=c then a=c), and consistent (same answer until the object changes).
Imagine a library warehouse with a thousand shelves, and you shelve books by the first letter of the title (that's the "hashCode"). Now if you shelve two identical copies of a book under two different rules — one under "J" and one under "D" — then when you go looking under "D", you'll never find the one you filed under "J". HashMap works exactly this way, and loses your objects in exactly this way.
Exactly the library picture above. Two objects you've defined as "equal" can land in two different buckets (shelves), because their default hashCode (based on memory address) differs.
The precise mechanism: HashMap.get and HashSet.contains first locate the bucket by hashCode, then search inside that bucket with equals. So if the hashCode is wrong, they never even reach the right bucket and your correct equals never gets called. Classic bug: a value object (an object whose identity is defined by its value, like a point or a money amount) used as a map key that suddenly "disappears."
Imagine a company, to save paper, writes each frequently-used phrase once on a central board and everyone points to that same board. If two people want to write "java," both are pointed to the same slot on the board. That's the "string pool": identical string literals get only one copy in memory.
String a = "java";
String b = "java";
String c = new String("java");
System.out.println(a == b); // true – both point to the pooled literal
System.out.println(a == c); // false – new String() forces a heap object
System.out.println(a == c.intern()); // true – intern() returns the pooled ref
With the central-board picture: a and b both point to the same board slot, so a == b is true. But new String("java") is like someone insisting "no, I want my own separate sheet of paper" — it allocates a fresh heap object whose value is equal but whose address differs, so a == c is false. The intern() method says "take this word to the central board and give me the board's address," so c.intern() hands back the pooled reference and a == c.intern() is true. Summary: compile-time constant literals are deduplicated, new String() always allocates fresh, and intern() canonicalizes to the pool.
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true – cached (-128..127)
System.out.println(c == d); // false – autoboxing creates new Integer objects
Here we have "autoboxing": when you drop a raw int into an Integer (the object version of int), Java behind the scenes calls Integer.valueOf(...). And this method has a small optimization: it pre-builds the commonly-used range -128..127 and keeps it in a cache. So 127 is the same cached object every time ← a == b is true. But 128 is outside the cached range, so a fresh object is built each time, and == (which compares references) gives false. The upper bound of this cache is tunable via the -XX:AutoBoxCacheMax flag. Practical lesson: to compare boxed values, never trust ==; always use .equals or intValue().
Imagine you run a restaurant. "Overloading" means one dish name ("sandwich") has several versions distinguished by ingredients: chicken sandwich, beef sandwich. The cook decides which to make from the written order (the static type) — that decision happens at order time, not at the stove. "Overriding" means another branch of the restaurant redefines the exact same dish in its own style; which style runs depends on which branch actually cooks it (the dynamic type), and you only learn that at cooking time.
Overloading = same name, different parameter list. Resolved at compile time based on the static type (the type you declared in code) — the compiler decides right then which version gets called.
Overriding = the exact same signature in a subclass. Resolved at runtime based on the dynamic type (the object's actual type at that moment) — this is called "virtual dispatch": the decision is deferred to runtime. Golden interview line: overloading is polymorphism's impostor — because it isn't dynamic. Real polymorphism is overriding.
void f(Object o) {}
void f(String s) {}
f(null); // calls f(String) – most specific applicable overload wins
null can be converted to either type, so which wins? The compiler's rule: pick the most specific applicable type. Since String is a subtype of Object (more specific), f(String) wins. But if you add another equally-specific overload, like f(Integer), the compiler can no longer decide which of String or Integer is more specific ← ambiguity compile error. To disambiguate, spell out the type yourself with a cast: f((Object) null) explicitly calls the Object version.
Since Java 8 the line between them blurred, so be precise. Interfaces can now have default (bodied) methods, static, and private methods — but they have no instance state; they may only hold public static final constants. A class can implement many interfaces but extend only one abstract class (Java has no multiple class inheritance).
The practical rule for choosing: if you want to provide shared state and a partial base implementation, use an abstract class. If you want to define a capability or contract ("this thing can fly"), use an interface. And the diamond note: interfaces make the diamond problem solvable only when you override the conflicting default yourself.
interface A { default String hi() { return "A"; } }
interface B { default String hi() { return "B"; } }
class C implements A, B {
public String hi() { return A.super.hi(); } // MUST override, or won't compile
}
"Diamond" means a class inherits along two paths that both provide a same-named method with a default body — the compiler is stuck on which hi() to inherit. Java hands the ambiguity back to you: until you override hi() yourself, the code won't compile. Inside the override you can use the special syntax Interface.super.method() — here A.super.hi() — to explicitly say "I want A's version." That's the only way to point at a specific default among several interfaces.
"Immutable" means that after construction its contents never change. Java guarantees this with three locks: the backing character array is private final (not visible outside the class and never reassigned), it's never handed out, and the String class itself is final so no sneaky subclass can change its behavior.
Why does it matter? Four benefits: (1) It's a safe HashMap key, because its hashCode is computed once and cached and never shifts. (2) It's safe for interning/pooling (the central board). (3) It's inherently thread-safe — something that never changes has no concurrent race. (4) It's safe in security contexts: if you validated a file path, nobody can secretly change it after the check. The cost? Every "modification" actually allocates a brand-new object.
One word, four places, four meanings:
- On a local/field: assigned exactly once (then locked).
- On a method: cannot be overridden in a subclass.
- On a class: cannot be subclassed (like
Stringitself).
And the most important interview trap: final does not mean immutable. Example:
final List<String> l = new ArrayList<>();
Here you can't reassign l to a different list (the reference is locked), but you're perfectly allowed to call l.add("x") — the list's contents are still mutable. final locks the reference, not the object's contents.
2. Generics
Imagine you have boxes with content labels: "apples only," "oranges only." These labels help you not make mistakes while packing. But suppose the rule is that at the warehouse door, all labels are torn off and every box is handed over as a neutral generic "box." Inside the warehouse, nobody knows anymore which box held apples and which oranges. That's exactly "type erasure": type labels exist only at compile time and are stripped at runtime.
Generics are a compile-time-only tool. The compiler erases type parameters down to their bound: an unbounded T becomes Object, and <T extends Number> becomes Number. Then, wherever needed, it injects casts itself to keep the code safe. Result: at runtime List<String> and List<Integer> are the exact same class — neither carries its type "label."
The costs (memorize these; they're asked directly): you can't do new T[], there's no T.class, instanceof List<String> is illegal, you can't overload on signatures that collapse to the same thing after erasure, and you keep getting "unchecked cast" warnings. All these limitations have one root: at runtime, the type information is simply no longer there.
List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println(ls.getClass() == li.getClass()); // true – same runtime class
void m(List<String> a) {}
void m(List<Integer> b) {} // COMPILE ERROR: both erase to m(List)
The first line prints true and gives away the whole story: since both lists are just ArrayList at runtime, getClass() is identical. Now why does the second line fail? Because after erasure both methods collapse to the same signature m(List), and the compiler won't accept two methods with the same signature. Overloading on generic type arguments is impossible, for exactly this reason.
Picture a factory conveyor belt in two modes. Mode one: the belt is producing boxes toward you; you just take them and look (you read). Here it doesn't matter exactly what comes; you only need to know "it's at least this type or a subtype" ← that's ? extends T, the Producer side. Mode two: the belt is taking things from you and consuming them; you're placing goods onto the belt (you write) ← that's ? super T, the Consumer side. From this picture, the rule Producer-Extends, Consumer-Super falls out.
Yes, and now with the conveyor picture it makes sense. When you only read T out of a structure (the structure produces T for you), use ? extends T. When you only write T into a structure (the structure consumes T), use ? super T.
The canonical example everyone loves to ask:
Collections.copy(List<? super T> dest, List<? extends T> src)
The destination (dest) is a consumer (we write into it) so ? super T; the source (src) is a producer (we read from it) so ? extends T. And the two logical limitations that follow: you cannot add anything but null to a ? extends list (you don't know its exact subtype), and you cannot safely read a specific type from a ? super list (you only know it's "some supertype of T").
All three look similar but behave worlds apart:
List<Object>: accepts any element, but — key point — aList<String>is not aList<Object>. This is called "invariance": generics do not pass their elements' subtyping up to themselves.List<?>: the unbounded wildcard. You read its elements only asObjectand you can't add anything butnull(the conveyor limitation).- raw
List: disables generic checking entirely and reintroducesClassCastExceptionrisk into your code. It exists only for compatibility with pre-generics legacy code; avoid it in new code.
Here two opposing philosophies collide. Arrays are covariant and reified: "reified" means they remember their component type at runtime, and if you try to store the wrong type in them, they throw an ArrayStoreException at runtime. But generics are invariant and erased — at runtime they carry no type information at all.
If new T[] were allowed, the array wouldn't know its real type anymore and that runtime guard (ArrayStoreException) would break. So Java forbids it outright. Two workarounds: either (T[]) new Object[n] with a suppressed warning (@SuppressWarnings), or, more professionally, Array.newInstance(...), which takes a Class<T> token so it truly knows the type at runtime.
3. Collections
Imagine a train station with a row of storage lockers. When you hand over a suitcase, the attendant runs a formula on your name to produce a locker number (the "hash function") and puts your suitcase there. When you return, they run the same formula on your name again and go straight to that locker — no searching every locker. This near-instant (O(1)) access is the secret to HashMap's speed. But if two people land on the same locker (a "collision"), the locker has to hold a little list inside.
It has an array of buckets (lockers). The bucket index is computed by (n-1) & hash, where n is the number of buckets. But there's a subtle trick: hash = h ^ (h >>> 16). What does that do? It XORs the high bits of the hash code into the low bits, so that when only the low bits are used for the index, the variety of the high bits participates too and collisions drop.
When several objects land in one bucket, they first form a linked list (a simple chain). But if a single bucket exceeds 8 entries and simultaneously the whole table size is ≥ 64, that chain converts into a red-black tree (called "treeify") so the worst-case lookup improves from O(n) to O(log n). Default capacity is 16, load factor 0.75; as soon as the entry count crosses capacity × load factor, a resize happens: the table doubles and everything is re-hashed.
Two conditions are needed because treeifying only pays off when the collisions come from real crowding, not from a small table. If the table is small (< 64), Java first tries a resize, which is cheaper; it only builds a tree when the table is already big enough and a bucket still stays crowded.
It's a space-vs-time trade-off. If you set the load factor low (say 0.5), the table is always sparse and collisions are rare — but lots of memory is wasted and resizes happen sooner. If you set it high (say 0.9), the table gets dense, memory is saved, but chains get long and lookups slow down. 0.75 is the empirical sweet spot giving good average O(1) lookup at acceptable memory cost.
Imagine you're reading aloud a party's guest list off a sheet, and midway someone secretly crosses out a name. A sensitive watcher shouts right then: "Stop! The list changed mid-read!" and halts everything. That's "fail-fast": rather than continuing with corrupt data, it fails quickly and loudly.
fail-fast (like ArrayList and HashMap): they keep an internal counter called modCount that increments on every structural modification. If this counter changes during iteration, the iterator throws a ConcurrentModificationException (CME for short). Note: this is "best-effort," not a hard guarantee — it doesn't always catch it, but usually does.
fail-safe (like CopyOnWriteArrayList and ConcurrentHashMap): they either iterate over a snapshot or tolerate concurrent change; they never throw, but may show you slightly stale data. A point many get wrong: even in a single-threaded program, if you call list.remove() inside a for-each loop, you get that same CME. The right way: Iterator.remove() or removeIf.
for (String s : list) {
if (s.isBlank()) list.remove(s); // throws ConcurrentModificationException
}
// Fix:
list.removeIf(String::isBlank); // or Iterator.remove()
The reason is exactly the previous box: for-each uses an iterator behind the scenes, and list.remove(s) directly on the list changes modCount without the iterator knowing. On the next loop turn, the iterator sees the inconsistency and throws CME. removeIf does the job from inside the structure itself, safely.
The honest answer: almost never — and that's itself a good interview answer. ArrayList is a contiguous array in memory: O(1) random access (jump straight to slot i), CPU-cache-friendly (data sits together), and amortized cheap append.
LinkedList has O(1) insert/remove — but only if you already hold the target node. In practice get(i) and index-based removal are O(n), because you must walk node by node from the head. Worse: each node is a separate object with memory overhead, and its scattering trashes the CPU cache. The only real justification for LinkedList is as a Deque, and even there ArrayDeque usually wins.
Hashtable: an old relic. It has a synchronized lock on every method (a coarse global lock that locks the whole map), and it rejectsnullas both key and value. Set it aside.HashMap: unsynchronized (no lock), fast, and allows onenullkey. Great for single-threaded use.ConcurrentHashMap: thread-safety with high concurrency. Java 8 uses CAS (an atomic lock-free "compare-and-swap" operation) on empty bins and synchronizes only the head node of a bucket (fine-grained locking, not global). It rejectsnullas key or value, and its iterators are weakly consistent: they don't throw but may not see last-moment changes.
Because of a fatal ambiguity in a concurrent setting. Suppose map.get(k) returns null. What does that mean? Two cases are possible: "the key doesn't exist at all" or "the key exists but its value is deliberately null." In an ordinary map you could tell them apart with a second containsKey. But concurrently, between your get and your containsKey, another thread could change the map — so that second check isn't reliable (it's racy). By forbidding null, the ambiguity is eliminated entirely: a null return definitively means "absent," and get alone is authoritative.
Three completely different ordering behaviors:
HashMap: no ordering guarantee at all. Iteration order can differ each time.LinkedHashMap: preserves insertion order. And a lovely trick: if you build it withaccessOrder=true, its order becomes most-recently-accessed; now if you also overrideremoveEldestEntry, you have a ready-made LRU cache (least-recently-used gets evicted).TreeMap: keeps elements sorted, by natural order or aComparator. Its operations are O(log n) (it's a red-black tree) and it supportsNavigableMaprange queries:floorKey(largest key ≤ x),ceilingEntry(smallest entry ≥ x),subMap(a range), and so on.
Map<List<Integer>, String> m = new HashMap<>();
List<Integer> key = new ArrayList<>(List.of(1, 2));
m.put(key, "x");
key.add(3); // hashCode changes after insertion
System.out.println(m.get(key)); // null – it's in the old bucket, now unreachable
Remember why they made String immutable so it'd be a good key? Here you see the opposite. Your key is a mutable List. At put time, Java picks its bucket using the hashCode at that moment (based on [1, 2]). Then you call key.add(3) and now the hashCode, based on [1, 2, 3], has changed. At get time, Java searches with the new hashCode and goes to a bucket where the key simply isn't — so null. The key still sits in the old bucket but is no longer findable. Lesson: never mutate a key after insertion; prefer immutable keys.
Comparable = the class's own natural ordering; you implement compareTo on the object itself ("how does this thing inherently sort"). Comparator = an external, swappable ordering placed in a separate object ("sort by name once, by age another time").
And the classic trap: (a, b) -> a - b. This overflows for large ints — e.g. if a is Integer.MIN_VALUE, then a - 1 wraps around and flips sign, so the comparator breaks its contract and sorting corrupts. The right way: Integer.compare(a, b), which has no such trap. Bonus point: a comparator inconsistent with equals also corrupts TreeSet/TreeMap semantics (they judge equality by the comparator, not by equals).
Two different algorithms, depending on what you're sorting:
- Objects: TimSort is used; a stable, adaptive merge sort with O(n log n) complexity, that on nearly-sorted data speeds up to O(n). "Stable" means the order of equal elements is preserved.
- Primitives: a dual-pivot Quicksort is used, which is unstable — but stability is meaningless on primitives (two 5s are indistinguishable), so it doesn't matter.
And a practical warning: if your comparator is non-transitive (i.e. a<b and b<c but not a<c), TimSort may, during its invariant checks, throw the famous IllegalArgumentException: Comparison method violates its general contract.
4. Exceptions
Imagine two kinds of hazard sign. The "checked" sign is like "slippery road, snow chains required": the law forces you to prepare in advance (catch or declare), because this situation is foreseeable and manageable. The "unchecked" sign is like "reckless drivers may crash": the law can't force you to prepare for every possible stupidity, because these are programming bugs, not recoverable conditions.
checked (a class that extends Exception but not RuntimeException): the compiler forces you to either catch it or declare it in the method signature. Designed for recoverable conditions — like "file not found," where the program can do something about it.
unchecked (RuntimeException and Error): no obligation. For programming bugs (like NullPointerException) and unrecoverable states (like OutOfMemoryError).
And the modern criticism worth stating in an interview: checked exceptions don't compose with lambdas and streams (you can't easily throw a checked exception inside a map(...)) and they encourage "swallowing" errors (empty catch). That's why many teams wrap them in unchecked exceptions at system boundaries.
try-with-resources is a construct that automatically closes resources (like a file or connection). Two precise points interviewers love: resources close in reverse declaration order (last opened, first closed — like taking off clothes), and this closing happens before any catch or finally.
Now the subtle scenario: if the body throws AND close() also throws while closing, which one propagates? The body's exception wins and propagates; the close exception is suppressed and attached to the primary one — you can retrieve it via getSuppressed(). This is exactly the opposite of a hand-rolled finally { close(); }, in which if close throws, the original body exception is lost. That's why try-with-resources is always better.
The next two gotchas show how unexpectedly finally can behave. The general rule to memorize: never put a return or throw inside finally — it overwrites everything.
static int f() {
try { return 1; }
finally { return 2; } // finally's return SWALLOWS the try's return AND any exception
}
// f() returns 2. A return/throw in finally overrides everything. Never do this.
finally always runs, even after a return is queued in try. When finally itself has a return 2, that value replaces the buffered value from try (which was 1). Worse: if try had thrown an exception, the return inside finally swallows that exception too and makes it vanish. That's why it's a dangerous bug.
static int g() {
int x = 1;
try { return x; } // return value (1) is evaluated & buffered here
finally { x = 99; } // mutates local, but the buffered return is unaffected
}
// g() returns 1, not 99 — the return expression was already computed.
Grasp this subtlety well: when you reach return x, Java reads the value of x (i.e. 1) right then and sets it aside (buffers it). Then finally runs and changes x to 99 — but that no longer affects the buffered value, because that number was already copied. So the output is 1. The difference from Q30: there, finally itself had a return (so it replaced the return value); here, finally only tweaks a local variable.
5. JVM, class loading & memory model
Imagine you run a company. There's one big central warehouse that all employees can access and where the real goods live (that's the "heap"). There's an organizational archive holding the company structure and blueprints (that's "Metaspace"). And each employee (each thread) has a personal desk with their own temporary notes that nobody else touches (that's the "stack"). With this picture, let's get into the details.
Two broad categories:
Shared across the whole JVM: (1) heap — all objects live here and GC manages it. (2) Metaspace — class metadata (their structure, methods, etc.); an important version note: since Java 8 this area lives in native memory (outside the heap) and replaced the old PermGen area.
Per-thread: (3) JVM stack — frames with local variables and operands. (4) PC register — tells which instruction the thread is currently executing. (5) native method stack.
Two extra points worth knowing: the string pool lives in the heap (moved from PermGen to the heap in Java 7). The -Xmx flag sets the heap ceiling, and -XX:MaxMetaspaceSize bounds Metaspace.
Remember the address-slip-and-house picture? Here's where it pays off. The object body and all its instance fields live on the heap (the real house, in the warehouse). A local reference variable lives on the stack and merely points into the heap (the address slip, on your desk). Primitives declared as local variables live on the stack (the number itself, on your desk); but primitives that are fields of an object live inside that heap object.
The senior-flavored point: escape analysis is a JVM optimization that detects an object doesn't "escape" the method scope (never leaks out anywhere), and may then stack-allocate it or even break it into plain scalars (scalar replacement). This is an optional optimization, not a language guarantee.
A class goes through these phases: Loading → Linking (comprising Verify, Prepare, Resolve) → Initialization.
- Prepare: sets static fields to their default values (e.g. an
intto 0). - Initialization: runs static initializers and the actual assignment of static fields — and this happens lazily, on the first active use of the class.
parent-delegation: before a classloader loads a class itself, it first asks its parent. The chain: Bootstrap → Platform → Application. Result: Java's core classes (like java.lang.String) are always loaded from the highest level, and nobody can slip in a forged replacement.
The golden point: the special method <clinit> (which is the static initialization code) is run once and thread-safely by the JVM itself. This is precisely why the "initialization-on-demand holder" idiom (holding the instance in a static inner class) is a correct way to build a lazy, safe singleton — with no manual locking.
class A {
static int x = 10;
static { x = 20; }
static int y = x + 5; // initializers run top-to-bottom: x is 20 here
}
// A.y == 25. Static blocks and field initializers execute in textual order.
The simple rule: static { ... } blocks and static field initializers run in textual order, top to bottom. So step by step: first x becomes 10, then the static block bumps it to 20, and when we reach y = x + 5, x is 20, so y becomes 25. Reorder the lines and the answer changes.
Imagine each employee, for speed, keeps a personal copy of a number on their desk (the CPU's local cache). Problem: if one changes their number, the others read from their own stale copy. Now if you declare that number volatile, it's like saying "this number is only ever written on the central notice board" — whoever writes goes immediately to the board (flush), and whoever reads reads directly from the board. That's "visibility."
It gives two guarantees: (1) visibility — the notice board: writes are immediately flushed to main memory and reads come straight from there, so no thread sees a stale copy. (2) ordering — it establishes a happens-before edge and prevents reordering of instructions across the volatile access.
But — and this is the most important part of the answer — it does not give atomicity for compound actions. For instance, count++ on a volatile int is still a lost-update race, because count++ is really three operations (read, add, write) and two threads can interleave. For read-modify-write operations you must use AtomicInteger or a lock.
The Java Memory Model (JMM) gives a lovely guarantee: if an object is properly constructed — meaning the this reference doesn't leak out from inside the constructor — then any thread that sees the object's reference sees its final fields correctly initialized, without any synchronization. This guarantee is exactly what underpins safe immutable objects and, in particular, the safety of String itself (whose fields are final).
6. Garbage collection
Look at your desk. Most of the papers you pick up during the day — a quick note, a receipt — you throw out minutes later; they die young. But a few important documents (ID, a contract) stay for years. Now if you want to tidy your desk, it's smart to go after the fresh pile of temporary papers (mostly trash) rather than the entire old archive. Generational GC does exactly this.
The key observation — the "weak generational hypothesis" — is that most objects die young. So we split the heap into two areas: Young (which itself contains Eden and two Survivor spaces) and Old.
The mechanism: new objects are born in Eden. A Minor GC copies the survivors between the two Survivor spaces and bumps their "age" by one. When an object's age crosses a threshold (the tenuring threshold), it's promoted to Old. Old is collected less often, with a Major/Full GC. Why is collecting Young cheap? Because it uses a copying algorithm that only touches the live objects — and by that same hypothesis, live objects in Young are very few.
A full map of collectors:
- G1 — the default since Java 9. Region-based (it splits the heap into small regions), targets a soft pause goal, and is compacting.
- Parallel — focused on throughput; fully stop-the-world.
- Serial — single-threaded, for small heaps and containers.
- ZGC and Shenandoah — concurrent low-latency collectors with sub-millisecond pauses.
- Generational ZGC (JEP 439, Java 21) — added young/old generations to ZGC; it cut memory use by about 75% and boosted throughput about 4× over the old non-generational ZGC.
The choice rule: take G1 by default; for latency-critical large heaps, ZGC or Shenandoah.
Imagine each object is a kite and references are the strings connecting kites to each other and ultimately to your hand. As long as a kite is tied — however winding the path — to your hand (a "root"), it's alive. The moment the last connecting string to your hand is cut, the kite floats away; even if two kites are still tied to each other, when neither connects to your hand, both are released.
GC roots are the "your hands" of the kite picture: the starting points the collector treats as definitely alive. They include: references and local variables on the stacks of active threads, static fields, JNI references (from native code), and held monitors (acquired locks).
The general rule: an object is garbage if and only if it is unreachable from any GC root. And a subtlety that trips many up: two dead objects that only reference each other (a cyclic reference) are still collected — because the criterion is reachability from a root, not reference counting. If Java merely counted references, that dead cycle would never be freed.
Four degrees of "stickiness" to memory, from firm to loose:
- Strong: the ordinary, everyday reference. As long as it's reachable, it's never collected.
- Soft: collected only under memory pressure. Great for memory-sensitive caches — "keep it while there's room, drop it when things get tight."
- Weak: collected at the next GC, if only weakly reachable. It's the basis of
WeakHashMapand canonicalizing maps. - Phantom: enqueued after finalization for deterministic post-mortem cleanup; it's the modern, safe replacement for the
finalize()method, which itself is deprecated for removal.
Many think "Java has GC, so memory leaks are impossible." That's a dangerous mistake. GC only frees unreachable objects; if you unintentionally hold a live reference, GC can do nothing.
Yes, definitely. A static collection that you keep add-ing to and never remove from holds strong references forever — and since static is a GC root, none of those objects are ever freed. Classic culprits: caches without an eviction policy, listeners that get registered but never unregistered, and ThreadLocals not cleared on pooled threads (because that thread stays alive and carries its ThreadLocal with it). These are "logic leaks": from GC's viewpoint those objects are fully live and reachable, so it won't free them — the problem is in your logic, not in GC.
No. "stop-the-world" (STW for short) is the moment when all application threads pause at a safepoint (a safe point where state is stable) so GC can do its sensitive work. Even ZGC and Shenandoah — the ones called "low-latency" — have brief STW pauses (for root scanning and mark start/end), but they keep them sub-millisecond and do the heavy part (mark and relocation) concurrently with the running application. The golden interview line: "pauseless" is a marketing claim; the real, correct claim is "sub-millisecond pauses, decoupled from heap size."
7. Concurrency & modern JVM (senior)
Picture a busy restaurant with only a few real waiters (OS threads, expensive and limited). Old model: each waiter takes a table and stands there until the customer finishes eating — if the customer is slow, the waiter is idle, blocked. Virtual-thread model: each "order" has a lightweight virtual waiter, but the real waiters are only the carriers of work; the moment a customer waits for something (I/O), the real waiter releases it (unmount) and moves to another order. With the same few real waiters, you juggle millions of orders.
Virtual threads are lightweight threads that the JVM itself schedules onto a small pool of carrier (platform) threads — the picture's real waiters. Here's the magic: instead of locking an OS thread, blocking I/O unmounts the virtual thread (detaches it from the carrier). Result: you can run millions of virtual threads in the simple "one thread per request" style without exhausting OS resources.
But the main catch: pinning. Before Java 24, if a virtual thread blocked inside a synchronized block or a native call, it stayed pinned to its carrier and couldn't unmount — meaning that real waiter got stuck and scaling was throttled. The fix: either use ReentrantLock instead of synchronized, or upgrade — JEP 491 (Java 24) removed synchronized-induced pinning at the runtime level.
Because it violates the entire virtual-thread philosophy. You do not pool virtual threads — you create one per task, with Executors.newVirtualThreadPerTaskExecutor(). Remember these are cheap and meant to be short-lived; pooling them is like setting up a laundry system for disposable paper tissues — it's pointless. Pooling is for the carrier threads, which the JVM manages and you don't touch. If you want to limit concurrency (say, at most 100 simultaneous calls to a service), use a Semaphore, not a bounded thread pool.
HashMap was never built for concurrent use, and the result isn't merely "wrong data" — it can be catastrophic. Concurrent puts right in the middle of a resize can corrupt the internal table. In Java 7, this could form a cycle in a bucket chain, and then a simple get would spin into an infinite loop that pegged the CPU at 100% and took down the whole service. Java 8's resize is less catastrophic (it no longer forms that infinite loop) but it still corrupts state and loses entries. There's no "partial safety" — for any shared mutable map, use ConcurrentHashMap.
8. Rapid gotchas — "what does this print?"
These are short, "flash" questions asked to probe your attention to detail. Each hides a deep truth in a single line of code.
System.out.println(0.1 + 0.2 == 0.3); // false – binary floating-point can't represent 0.1 exactly
System.out.println(0.1 + 0.2); // 0.30000000000000004
Why? double stores numbers in base two (binary), and just as 1/3 in base ten becomes an endless 0.3333..., the number 0.1 in base two is an endless repeating fraction that must be truncated. These tiny truncation errors accumulate and 0.1 + 0.2 doesn't come out exactly 0.3 but 0.30000000000000004. The vital practical lesson: never use double for money; use BigDecimal — and construct it from a string (new BigDecimal("0.1")), not from a double, or you've imported the very same error from the start.
Long l = 0L;
System.out.println(l.equals(0)); // false! 0 autoboxes to Integer; Long.equals(Integer) is false
Here 0 (with no L suffix) is an int that boxes to Integer, not Long. And the first thing Long.equals(...) does is check the runtime type: since the argument is an Integer and not a Long, it immediately returns false — even though the values look equal. equals never converts between different numeric types. The right way: either l.longValue() == 0 (value comparison) or l.equals(0L) (with the right suffix so the argument is a Long).
System.out.println(Integer.MAX_VALUE + 1); // -2147483648 – silent int overflow wraps around
int has only 32 bits and its largest value is Integer.MAX_VALUE (which is 2147483647). Add one to it, and like a car's odometer flipping from 999999 to 000000, it wraps around to the most negative value (-2147483648) — and gives no error at all, silently. To make overflow fail fast and loudly instead of producing corrupt data, use Math.addExact(...) (which throws on overflow), or just use long.
String s = null;
switch (s) { ... } // NullPointerException – switch on a null reference throws before matching
A classic switch over a String or enum, before it reaches any case, must first extract the value (e.g. take its hashCode), and on null it throws a NullPointerException right there. The fix: either explicitly guard null beforehand (if (s == null) ...), or use the Java 21 pattern-matching switch, which has a real case null label so you can handle null like an ordinary case.
Review these until the answers become reflexes. Remember what I said at the start of the chapter: in an interview, the follow-up — "why?" — is where seniority shows. Always be ready to explain the mechanism, not just the result.
- Equality & hashing:
==is identity,equalsis meaning; and if you overrideequals, overridehashCodetoo or HashMap loses your objects. - Strings & caching: literals are shared in the string pool,
new Stringallocates fresh; theIntegercache only covers-128..127— for boxed values always.equals. - Generics: everything flows from type erasure — no
new T[], no overload on generic type; recall PECS with the conveyor-belt picture. - Collections:
HashMap= buckets + a chain that treeifies to a red-black tree at 8 entries and a 64-size table; load factor 0.75; for concurrencyConcurrentHashMap; keep keys immutable. - Exceptions: try-with-resources closes resources in reverse and safely, suppressing the close exception; never return from
finally. - JVM & memory: objects on the heap, local references on the stack; classes load via parent-delegation and initialize lazily;
volatilegives visibility and ordering but not atomicity. - GC: generational because objects die young; default since Java 9 is G1, and Generational ZGC (JEP 439, Java 21) for low latency; garbage means unreachable from any root; leaks are possible even in a GC'd language.
- Modern: virtual threads (JEP 444, Java 21) give millions of lightweight threads; watch out for pinning (until Java 24 / JEP 491); don't pool, use a
Semaphore. - Final rule: in an interview, know the why, not just the what. Mechanism, mechanism, mechanism.
Sources: JEP 444: Virtual Threads, JEP 439: Generational ZGC, Significant changes in JDK 21, HotSpot GC Tuning: ZGC.