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

استثناها و مدیریت خطاExceptions & Error Handling

از صفر یاد می‌گیری استثنا واقعاً چیست، سلسله‌مراتب Throwable و بحث checked/unchecked را می‌فهمی، تله‌های finally و try-with-resources و هزینهٔ پنهان fillInStackTrace را می‌شکافی، و مدیریت خطا را تا مرزهای async و virtual thread دنبال می‌کنی.Learn from scratch what an exception really is, master the Throwable hierarchy and the checked/unchecked debate, defuse the finally and try-with-resources traps, understand the hidden cost of fillInStackTrace, and carry error handling all the way across async and virtual-thread boundaries.


خوش آمدی. این فصل یکی از آن موضوع‌هایی است که همه فکر می‌کنند بلدند تا وقتی که مصاحبه‌گر می‌پرسد «دقیقاً وقتی finally یک return دارد چه اتفاقی می‌افتد؟» یا «چرا NPE تولیدی من هیچ ردِ پشته‌ای ندارد؟». هدف ما این است که تو بعد از این فصل، نه‌تنها پاسخ‌ها را بدانی، بلکه بفهمی چرا آن‌طورند. قرار است هر واژهٔ فنی را از پایه بسازیم — هیچ اصطلاحی را بدون توضیح رها نمی‌کنیم.

نقشهٔ راه این فصل

اینجا هستند چیزهایی که قدم‌به‌قدم یاد می‌گیری:

  • استثنا واقعاً چیست — «انتقال کنترل غیرمحلی» و چرا این تعریف همه‌چیز را توضیح می‌دهد.
  • سلسله‌مراتب Throwable — نقشهٔ کامل Error در برابر Exception، و مرز checked/unchecked.
  • بحث بزرگ طراحی — چرا فریم‌ورک‌های مدرن به unchecked متمایل شدند.
  • try-with-resources، AutoCloseable و استثناهای سرکوب‌شده — بستن امن منابع.
  • تله‌های finally — سه گیراندازی که در مصاحبهٔ سنیور می‌آیند.
  • استثناهای سفارشی و ترجمهٔ استثنا — حفظ cause.
  • کاراییfillInStackTrace، بهینه‌سازی fast-throw، و استثناهای بدون پشته.
  • Optional/Result در برابر استثنا — چه زمانی اصلاً پرتاب نکنیم.
  • مرزهای async و virtual thread — دوباره لوله‌کشیِ خطا وقتی از نخ خارج می‌شود.
  • در پایان، ۱۵ سؤال مصاحبه با پاسخ کامل.

قسمت ۰ — کلماتی که باید بلد باشی

قبل از اینکه شروع کنیم، بیا چهار واژه را که در کل فصل بارها به کارشان می‌بریم، با تشبیه بسازیم. اگر این‌ها جا بیفتند، بقیهٔ فصل روان می‌شود.

پشتهٔ فراخوانی مثل دسته بشقاب کافه‌تریاست

تصور کن یک دسته بشقاب در کافه‌تریا داری. هر بار متدی متدِ دیگر را صدا می‌زند، انگار یک بشقاب تازه روی دسته می‌گذاری. متد main بشقاب زیرین است؛ متدی که همین الان در حال اجراست، بشقاب رویی. وقتی یک متد return می‌کند، بشقاب رویی برداشته می‌شود و به بشقاب زیرش برمی‌گردی. به این دسته بشقاب می‌گوییم پشتهٔ فراخوانی (call stack)، و به هر بشقاب یک فریم (stack frame) می‌گوییم. هر فریم متغیرهای محلیِ آن فراخوانی را در خود نگه می‌دارد.

  • Throwable: در جاوا هر چیزی که بتوان «پرتاب» کرد یک شیء از نوع Throwable است. این تنها نوعی است که کلمات کلیدی throw و catch با آن کار می‌کنند. آن را مثل یک پاکتِ گزارشِ خطا تصور کن که در دست بالا می‌رود.
  • throw: یعنی «کار خراب شد، این پاکت گزارش را بگیر و بالا بفرست».
  • catch: یعنی «من مسئولیت این پاکت را می‌پذیرم و رسیدگی می‌کنم».
  • باز کردن پشته (unwinding): وقتی پاکت پرتاب می‌شود و کسی سرِ همان فریم آن را نمی‌گیرد، جاوا شروع می‌کند به برداشتنِ بشقاب‌ها یکی‌یکی — هر فریم را حذف می‌کند و متغیرهای محلی‌اش را دور می‌ریزد — تا به فریمی برسد که یک catch مناسب دارد. به این «برداشتن مرحله‌به‌مرحلهٔ فریم‌ها» می‌گوییم unwinding.
مدل ذهنیِ کوتاه

return یعنی بشقاب رویی را آرام برداری و مقدار بدهی. throw یعنی زنگ خطر بزنی و بشقاب‌ها را پشت‌سرهم بریزی تا کسی جلویت را بگیرد. اولی ارزان و منظم است؛ دومی گران و پرسروصدا.

استثنا واقعاً چیست؟ مدل ذهنی

زنگ خطر آتش در یک ساختمان چندطبقه

در حالت عادی، وقتی کارَت در طبقهٔ پنجم تمام شد، با آسانسور پایین می‌آیی، طبقه‌به‌طبقه، منظم. این «مسیر بازگشت عادی» است. اما اگر آتش بگیرد، تو از مسیر عادی نمی‌روی — زنگ خطر را می‌زنی و همه بلافاصله از پله‌های اضطراری تخلیه می‌شوند، طبقه پشت طبقه، تا برسند به یک نقطهٔ امنِ از پیش‌تعیین‌شده در حیاط. استثنا دقیقاً همان زنگ خطر است.

حالا اسم فنی‌اش: استثنا یک انتقال کنترل غیرمحلی (non-local transfer of control) است. بگذار این عبارت را باز کنم: «کنترل» یعنی اینکه همین الان کدام خط کد در حال اجراست. «انتقال غیرمحلی» یعنی این کنترل نه به خط بعدی (که می‌شود «محلی»)، بلکه به جایی دور — چند فریم پایین‌تر در پشته — می‌پرد. وقتی یک متد نمی‌تواند قراردادش را کامل کند، مسیر بازگشت عادی را رها می‌کند و پشته را unwind می‌کند تا فریمی آن را catch کند. مقداری که در پشته بالا می‌رود، همان شیء Throwable است.

دو نتیجه از این تعریف بیرون می‌آید که تقریباً هر تصمیم طراحی در JVM را توضیح می‌دهد:

دو حقیقت بنیادی

۱. unwinding پرهزینه و ویرانگر است. هر فریمِ بین throw و catch حذف می‌شود و متغیرهای محلی‌اش دور ریخته می‌شوند. به همین دلیل نباید از استثنا برای جریان کنترل عادی استفاده کنی (مثلاً به‌جای یک حلقهٔ ساده). ۲. Throwable شواهد را حمل می‌کند. به‌طور پیش‌فرض هنگام ساخته‌شدن، ردِ پشته (stack trace) را ثبت می‌کند — با فراخوانی متدی به‌نام fillInStackTrace — تا بعداً بتوانی کجا و چرا را بازسازی کنی. و همین ثبتِ رد، گران‌ترین بخشِ کل ماجرای پرتاب است (بعداً عمیق موشکافی‌اش می‌کنیم).

یک قاعدهٔ طلایی برای طراحی: استثناهایت را طوری بساز که برای کسی که ساعت ۳ بامداد خواب‌آلود لاگ را می‌خواند، سه سؤال را جواب دهند: چه چیزی شکست خورد، چرا، و با چه زمینه‌ای (context). اگر لاگت این سه را نگوید، آن آدم (که ممکن است خودِ فردای تو باشد) ساعت‌ها هدر می‌دهد.

سلسله‌مراتب Throwable

حالا که می‌دانی هر خطا یک شیء Throwable است، بیا کل خانوادهٔ این اشیا را ببینیم. این نقشه را باید مثل کف دستت بشناسی:

Object
 └─ Throwable                (message، cause، stackTrace، suppressed[] دارد)
     ├─ Error                (جدی، معمولاً غیرقابل‌بازیابی — نگیرید)
     │   ├─ OutOfMemoryError
     │   ├─ StackOverflowError
     │   ├─ VirtualMachineError
     │   └─ AssertionError
     └─ Exception
         ├─ RuntimeException (UNCHECKED — بررسی‌نشده)
         │   ├─ NullPointerException
         │   ├─ IllegalArgumentException / IllegalStateException
         │   ├─ IndexOutOfBoundsException
         │   ├─ ClassCastException
         │   └─ ArithmeticException, ...
         └─ (بقیه)  CHECKED — بررسی‌شده
             ├─ IOException
             ├─ SQLException
             └─ InterruptedException, ...
دو نوع خرابی در یک کارخانه

تصور کن مدیر یک کارخانه‌ای. دو دستهٔ کاملاً متفاوت مشکل داری. دستهٔ اول: «ساختمان دارد فرو می‌ریزد» یا «برقِ کل شهر رفت» — این‌ها را تو نمی‌توانی سر خط تولید حل کنی؛ فقط باید امن تخلیه کنی. دستهٔ دوم: «این قطعه اندازه‌اش غلط است» یا «انبار خالی شد» — این‌ها را می‌توانی رسیدگی کنی و کار را ادامه دهی. در جاوا، دستهٔ اول Error است و دستهٔ دوم Exception.

قاعدهٔ کامپایلر برای مرزِ checked/unchecked کاملاً نحوی (syntactic) است — یعنی فقط به شکلِ درختِ ارث‌بری نگاه می‌کند، نه به معنا:

تعریف دقیق و بی‌ابهام

checked (بررسی‌شده) = زیرکلاس‌های Exception که زیرکلاس RuntimeException نیستند. همین. هر چیز دیگری — یعنی Error، RuntimeException و زیرکلاس‌هایشان — unchecked (بررسی‌نشده) است. فقط استثناهای checked را کامپایلر مجبورت می‌کند یا در بند throws اعلام کنی یا catch کنی.

Throwable تنها نوعی است که می‌توانی throw یا catch کنی. فیلدهای کلیدی‌اش را بشناس، چون بارها به آن‌ها برمی‌گردیم:

فیلد متد دسترسی معنا
message getMessage() رشتهٔ قابل‌فهمِ انسان
cause getCause() Throwable زیرین (زنجیرهٔ استثنا)
stackTrace getStackTrace() فریم‌های ثبت‌شده هنگام ساخت
suppressed getSuppressed() استثناهای کنارگذاشته‌شده هنگام مدیریت این یکی (try-with-resources)

Error در برابر Exception

بیا کارخانه را دقیق‌تر کنیم. Error شرایطی را نشان می‌دهد که برنامه معمولاً نمی‌تواند از آن بازیابی شود: یا خودِ JVM به‌خطر افتاده (OutOfMemoryError وقتی حافظه تمام شد، StackOverflowError وقتی بازگشتِ بی‌پایان پشته را پر کرد)، یا یک ناوردایی (invariant) که کامپایلر تضمینش کرده بود در زمان اجرا نقض شده (NoClassDefFoundError, AssertionError).

مکث کوتاه روی واژهٔ «ناوردایی»: invariant یعنی چیزی که همیشه باید درست بماند — مثل قانونِ «هر کلاسی که کامپایل شد، هنگام اجرا هم پیدا می‌شود». وقتی چنین قانونی می‌شکند، یعنی فرضِ بنیادین دنیا خراب شده و ادامه دادن بی‌معناست.

هرگز `Error` را برای «ادامه دادن» نگیر

گرفتنِ Error تا «برنامه سرِپا بماند» تقریباً همیشه غلط است — تو داری یک آتش‌سوزیِ واقعی را نادیده می‌گیری. تنها موردِ مشروع، یک ناظرِ سطح‌بالا (supervisor) است که فقط لاگ می‌کند و بعد تمیز خاموش می‌شود. و حواست باشد: catch (Throwable t) به‌طور تصادفی Error را هم جمع می‌کند. تقریباً همیشه منظورِ واقعی‌ات catch (Exception e) است.

checked در برابر unchecked — بحث بزرگ طراحی

این پرمناقشه‌ترین تصمیمِ API در تاریخ جاواست، و مصاحبه‌گر با این سؤال بررسی می‌کند که آیا تو یک موضعِ پشتیبانی‌شده با استدلال داری، یا فقط طوطی‌وار قاعده بلدی.

استدلال به‌نفعِ checked: آن‌ها بخشی از امضای متد هستند — یک قراردادِ زمانِ کامپایل که فراخواننده را مجبور می‌کند یک شکستِ قابل‌بازیابی را به رسمیت بشناسد. وقتی Files.newInputStream در امضایش می‌گوید می‌تواند IOException پرتاب کند، این مستندسازیِ واقعی است که کامپایلر اجرایش می‌کند؛ نمی‌توانی نادیده‌اش بگیری.

استدلال علیه: استثناهای checked بد مقیاس می‌گیرند (scale badly) — یعنی هرچه برنامه بزرگ‌تر و لایه‌دارتر شود، دردسرشان به‌طور نامتناسبی بیشتر می‌شود. سه دلیل:

  • lambda و stream را می‌شکنند، چون اینترفیس‌های تابعی مثل Function اصلاً بند throws ندارند.
  • جزئیات پیاده‌سازی را به لایه‌های انتزاعِ بالاتر نشت (leak) می‌دهند — لایهٔ بالا نباید بداند که پایین از SQL استفاده می‌کند، ولی SQLException مجبورش می‌کند بداند.
  • توسعه‌دهنده را به‌سمتِ دو بدترین ضدالگوی زبان هل می‌دهند:
// ضدالگوی ۱: قورت دادن. حالا شکست نامرئی است.
try { risky(); } catch (IOException e) { /* هیچ */ }

// ضدالگوی ۲: throws Exception همه‌جا، که هدف را نقض می‌کند.
void doWork() throws Exception { ... }
قورت دادن (swallow) بدترین کاری است که می‌توانی بکنی

یک بلوکِ catch خالی، شکست را نامرئی می‌کند. برنامه ظاهراً کار می‌کند ولی داده‌ها بی‌سروصدا خراب می‌شوند و تو هیچ سرنخی در لاگ نداری. و throws Exception روی همه‌چیز، دقیقاً همان اطلاعاتی را که checked قرار بود بدهد نابود می‌کند — چون دیگر مشخص نیست کدام خطا ممکن است بیاید.

رویهٔ مدرن — و هر فریم‌ورک بزرگ از زمان Spring به بعد — به‌طور پیش‌فرض به unchecked متمایل است. مثلاً Spring دقیقاً به همین دلیل SQLException را می‌گیرد و به یک سلسله‌مراتبِ unchecked به‌نام DataAccessException ترجمه می‌کند، تا کدِ کسب‌وکارِ تو پر از catch (SQLException) نباشد.

قاعدهٔ عمل‌گرایانه برای انتخاب
  • از استثنای checked فقط وقتی استفاده کن که فراخواننده واقع‌بینانه می‌تواند بازیابی کند و می‌خواهی مجبورش کنی تصمیم بگیرد (در عمل نادر است).
  • برای خطاهای برنامه‌نویسی و شکست‌هایی که فراخواننده معمولاً نمی‌تواند محلی رفعشان کند، از unchecked استفاده کن (بیشترِ موارد).
  • هرگز throws Exception یا throws Throwable اعلام نکن؛ دقیق و مشخص باش.
سیگنالی از زبان‌های دیگر

Kotlin و Scala استثناهای checked را کاملاً حذف کردند — کامپایلرشان اصلاً مفهومش را ندارد. این یک سیگنالِ قوی است از اینکه بحث تاریخی به کجا رسید.

try-with-resources، AutoCloseable و استثناهای سرکوب‌شده

مستأجری که چراغ را خاموش می‌کند

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

پیش از جاوا ۷، پاک‌سازیِ درستِ منابع نیازمندِ آن رقصِ بدنامِ finallyِ تودرتو بود — با یک بررسیِ null و یک try دیگر داخلِ خودِ finally. زشت و پرخطا. جاوا ۷ آن را با try-with-resources جایگزین کرد. هر نوعی که اینترفیس AutoCloseable را پیاده کند، می‌تواند در «سرآیندِ منابع» (همان پرانتز بعد از try) اعلام شود.

نکتهٔ ریز: AutoCloseable یک زیرنوع به‌نام Closeable دارد که فقط امضای close() را محدودتر می‌کند تا IOException پرتاب کند (مناسبِ کلاس‌های I/O).

try (var in = Files.newInputStream(path);
     var out = Files.newOutputStream(dest)) {   // منابع اینجا اعلام می‌شوند
    in.transferTo(out);
}   // close() خودکار فراخوانی می‌شود، به ترتیب معکوس: اول out بعد in

دو تضمین هست که بیان‌شان ساده است ولی در مصاحبه راحت اشتباه می‌شوند:

دو تضمین طلاییِ try-with-resources

۱. منابع به ترتیبِ معکوسِ اعلام بسته می‌شوند. (آخرین چیزی که باز کردی، اولین چیزی است که می‌بندی — مثل درآوردنِ لباس: کُت را که آخر پوشیدی، اول درمی‌آوری.) ۲. استثنای بدنه برنده است؛ استثناهای close سرکوب (suppress) می‌شوند، نه اینکه گم شوند.

بیا تضمینِ دوم را باز کنم، چون همان‌جایی است که همه گیر می‌کنند. فرض کن بدنهٔ try یک استثنا پرتاب کند و هنگام بستنِ منبع، close() هم یک استثنای دیگر پرتاب کند. کدام به فراخواننده می‌رسد؟ استثنای بدنه منتشر می‌شود (چون خطای اصلی و مهم‌تر همان است)، و هر استثنای close از طریق متدِ Throwable.addSuppressed به آن پیوست می‌شود. بعداً می‌توانی آن‌ها را با getSuppressed() بازیابی کنی. این دقیقاً برعکسِ دنیای پیش از جاوا ۷ است، که در آن استثنای finally بی‌سروصدا استثنای واقعی را می‌کوبید و گم می‌کرد.

class Resource implements AutoCloseable {
    final String name;
    Resource(String name) { this.name = name; }
    void use() { throw new RuntimeException("انفجار در بدنه"); }
    public void close() { throw new IllegalStateException("انفجار هنگام بستن " + name); }
}

try (var r = new Resource("R1")) {
    r.use();
}
// منتشر می‌شود: RuntimeException "انفجار در بدنه"
// getSuppressed()[0] == IllegalStateException "انفجار هنگام بستن R1"
بهبود جاوا ۹

از جاوا ۹ به بعد، اگر متغیرِ منبع را از قبل ساخته‌ای و آن متغیر به‌طور مؤثر نهایی (effectively final) است — یعنی بعد از مقداردهیِ اول دیگر تغییرش نمی‌دهی — می‌توانی مستقیماً نامش را در سرآیند فهرست کنی، بدونِ اعلامِ مجدد:

Connection conn = dataSource.getConnection();   // effectively final
try (conn) {
    ...
}

finally: تله‌های جریان کنترل

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

تلهٔ ۱: return در finally همه‌چیز را قورت می‌دهد

static int f() {
    try {
        return 1;
    } finally {
        return 2;   // این return برنده است؛ return 1 دور ریخته می‌شود
    }
}
// f() == 2

چرا؟ چون finally آخرین حرف را می‌زند. وقتی به return 2 می‌رسد، عملاً می‌گوید «فراموش کن قبلاً چه گفتی، جوابِ نهایی ۲ است». بدتر از این، یک return (یا throw) در finally می‌تواند یک استثنای در حال عبور را هم بی‌سروصدا قورت دهد:

static int g() {
    try {
        throw new RuntimeException("خطای واقعی");
    } finally {
        return -1;   // استثنا محو می‌شود؛ فراخواننده -1 می‌بیند، نه پرتاب را
    }
}
قانونِ ساده و بی‌استثنا

هرگز return, break, continue یا throw را داخلِ finally نگذار. بسیاری از linterها (ابزارهای تحلیلِ ایستای کد) دقیقاً به همین دلیل این را به‌عنوان خطا علامت می‌زنند — چون یک استثنای واقعی را نامرئی می‌کند.

تلهٔ ۲: finally مقدارِ بازگشتیِ از قبل ارزیابی‌شده را تغییر نمی‌دهد

static int h() {
    int x = 1;
    try {
        return x;    // مقدار ۱ همینجا روی پشتهٔ عملوند ثبت می‌شود
    } finally {
        x = 2;       // تغییر متغیر محلی حالا اثری بر مقدار بازگشتی ندارد
    }
}
// h() == 1

بیا مکانیزم را دقیق ببینیم. وقتی جاوا به return x می‌رسد، اول عبارت x را کامل ارزیابی می‌کند و نتیجه‌اش — یعنی عددِ ۱ — را یک‌جا ذخیره می‌کند (روی «پشتهٔ عملوند»، یک ناحیهٔ کاریِ موقت). بعد finally اجرا می‌شود. حالا x = 2 فقط متغیرِ محلیِ x را عوض می‌کند، ولی آن عددِ ۱ که قبلاً ذخیره شده بود دست‌نخورده می‌ماند. پس خروجی ۱ است.

ظرافتِ شیءِ تغییرپذیر

اما دقت کن: اگر یک شیءِ تغییرپذیر (mutable) برگردانی و در finally خودِ آن شیء را تغییر دهی (نه اینکه متغیر را به شیءِ جدید تخصیص دهی)، آنگاه فراخواننده آن تغییر را می‌بیند! چرا؟ چون آنچه ذخیره شد یک ارجاع (reference) بود — یعنی یک آدرس به شیء — نه یک عکسِ فوری از محتوایش. آدرس ثابت ماند ولی محتوایِ پشتِ آدرس عوض شد. این تفاوتِ ظریفِ «مقدار در برابر ارجاع» جای فریبندهٔ سؤال است.

تلهٔ ۳: استثنا در try + استثنا در finally

try {
    throw new IOException("A");
} finally {
    throw new IllegalStateException("B");   // B منتشر می‌شود، A گم می‌شود (سرکوب نمی‌شود!)
}

اینجا نکتهٔ مهم این است: برخلافِ try-with-resources که دیدیم استثناهای اضافی را سرکوب (نه گم) می‌کند، finallyِ ساده هیچ سرکوبِ خودکاری انجام نمی‌دهد. استثنای B جایگزینِ A می‌شود و A کاملاً از بین می‌رود — بدونِ هیچ ردی. اگر مجبوری در پاک‌سازی کدی اجرا کنی که می‌تواند پرتاب کند، یا آن را در یک try/catch داخلی بپیچ، یا از try-with-resources استفاده کن تا استثنای اصلی زنده بماند.

استثناهای سفارشی و ترجمهٔ استثنا

به‌جای اینکه RuntimeException خام پرتاب کنی، یک سلسله‌مراتبِ کوچک و معنادار برای دامنهٔ خودت (کسب‌وکارت) بساز. و همیشه سازنده‌ای که cause می‌پذیرد را فراهم کن، تا زنجیره‌سازی کار کند:

public class OrderNotFoundException extends RuntimeException {
    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("سفارش یافت نشد: " + orderId);
        this.orderId = orderId;
    }
    public OrderNotFoundException(long orderId, Throwable cause) {
        super("سفارش یافت نشد: " + orderId, cause);
        this.orderId = orderId;
    }
    public long orderId() { return orderId; }
}
ترجمهٔ استثنا مثل سخنگوی شرکت است

تصور کن در انبار یک لوله ترکیده. کارگرِ انبار به مدیرِ فروش می‌گوید «شیرِ فشارِ خط ۳ نشتی دارد» (جزئیاتِ فنیِ سطح‌پایین). ولی مدیرِ فروش به مشتری نمی‌گوید «شیرِ فشارِ خط ۳...»؛ او ترجمه می‌کند: «سفارشِ شما با یک روز تأخیر می‌رسد». پیامِ سطح‌بالاتر برای مشتری معنادار است — ولی گزارشِ فنیِ اصلی هم گم نشده، در دفترِ داخلی ثبت شده تا فنی‌ها ببینند. این دقیقاً کاری است که ترجمهٔ استثنا می‌کند.

ترجمهٔ استثنا (exception translation) یعنی گرفتنِ یک استثنای سطح‌پایین در مرزِ یک انتزاع، و پرتابِ مجددِ یک استثنای سطح‌بالاتر — با حفظِ استثنای اصلی به‌عنوانِ cause:

public Order load(long id) {
    try {
        return repository.findById(id);
    } catch (SQLException e) {
        // جزئیات پایداری (persistence) را به شکستِ سطح دامنه ترجمه کن،
        // ولی SQLException را به‌عنوان cause برای لاگ نگه دار.
        throw new OrderLoadException("بارگذاری سفارش " + id + " شکست خورد", e);
    }
}

همان causeِ زنجیره‌شده است که باعث می‌شود ردِ پشته آن خطِ طلاییِ Caused by: java.sql.SQLException... را نشان دهد.

مخرب‌ترین اشتباهِ تولیدی

از دست دادنِ cause — یعنی نوشتنِ new OrderLoadException(msg) بدونِ پاس‌دادنِ e — یکی از خطرناک‌ترین اشتباهاتِ کدِ تولیدی است. تو دلیلِ واقعی را دور می‌ریزی و در لاگ هیچ Caused by:ای نمی‌بینی؛ یعنی می‌دانی «بارگذاری شکست خورد» ولی هرگز نمی‌فهمی چرا.

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

این‌ها را مثل چک‌لیست در ذهن نگه دار:

  • زودشکست (fail fast). آرگومان‌ها را در همان ورودیِ متد اعتبارسنجی کن و بلافاصله IllegalArgumentException / NullPointerException پرتاب کن — با Objects.requireNonNull(x, "x"). یک کرشِ نزدیک به علت، ارزشِ ده ساعت اشکال‌زداییِ حالتِ خراب‌شده در پایین‌دست را دارد.
  • هرگز قورت نده. بلوکِ catch خالی یک باگ است. حداقل در سطحِ ERROR با زمینه لاگ کن. اگر واقعاً قصدِ نادیده‌گرفتن داری، متغیر را ignored بنام و کامنتی بگذار که چرا.
  • با زمینه بپیچ، نه با نویز. چیزی را اضافه کن که سطحِ پایین نمی‌دانست: کدام order id، کدام فایل، کدام تلاشِ مجدد. ولی در هر لایه نپیچ-و-دوباره‌پرتاب نکن — این فقط پشته‌ای از پیام‌های تقریباً یکسان می‌سازد.
  • باریک بگیر، دیر بگیر. مشخص‌ترین نوعی را که می‌توانی واقعاً مدیریت کنی بگیر؛ بگذار بقیه منتشر شوند تا به یک مدیریت‌کنندهٔ سطح‌بالای واحد برسند (یک servlet filter، یک @ControllerAdvice، یا یک UncaughtExceptionHandler).
  • لاگ‌نکن-و-دوباره‌پرتاب‌نکن. یکی را انتخاب کن: یا مدیریتش کن (لاگ + بازیابی) یا منتشرش کن. انجامِ هر دو ردهای پشتهٔ تکراری در لاگ تولید می‌کند که گیج‌کننده است.
  • پرچمِ interrupt را حفظ کن. وقتی InterruptedException را می‌گیری و نمی‌توانی منتشرش کنی، پرچم را بازگردان: Thread.currentThread().interrupt();. قورت‌دادنش نخ‌ها را غیرقابل‌لغو می‌کند. (چند خط پایین‌تر بیشتر توضیح می‌دهم.)
  • از multi-catch استفاده کن برای مدیریتِ مشترک: catch (IOException | SQLException e). در این حالت متغیرِ e به‌طورِ ضمنی final است.

کارایی: هزینهٔ واقعی استثناها

اینجا جایی است که خیلی‌ها اشتباه می‌فهمند. سؤال: کدام بخشِ یک استثنا گران است؟

عکاسِ صحنهٔ جرم

لحظه‌ای که استثنا ساخته می‌شود، انگار یک عکاسِ صحنهٔ جرم می‌آید و از کلِ پشتهٔ فراخوانی عکس می‌گیرد — از هر فریم یک قاب: کدام متد، کدام فایل، کدام شماره‌خط. اگر پشته کم‌عمق باشد (سه‌چهار فریم)، سریع تمام می‌شود. اگر پشته عمیق باشد (پنجاه فریم در یک برنامهٔ Spring)، این عکاسی زمان‌بر است. خودِ throw/catch — یعنی زنگ خطر و تخلیه — نسبتاً ارزان است؛ آن عکاسی گران است.

آن «عکاس» یک متد بومی (native) به‌نامِ fillInStackTrace() است که در سازندهٔ Throwable صدا زده می‌شود و پشتهٔ نخِ فعلی را پیمایش می‌کند و از هر فریم یک عکسِ فوری می‌گیرد. هزینه‌اش با عمقِ پشته مقیاس می‌گیرد. به همین دلیل پرتاب کردن مرتبه‌ها کندتر از یک return عادی است، و دقیقاً به همین دلیل «استثنا-به‌عنوانِ-جریانِ‌کنترل» یک ضدالگوست.

دو حقیقت که یک سنیور باید بداند:

حقیقت ۱ — بهینه‌سازی fast-throw در HotSpot

HotSpot (کامپایلرِ JIT در JVM استاندارد) یک بهینه‌سازی دارد: برای چند استثنای درون‌ساختِ خاص (مثل NullPointerException و ArithmeticException) که مکرراً در همان مکانِ داغِ بایت‌کد پرتاب می‌شوند، JIT تصمیم می‌گیرد از عکاسی صرف‌نظر کند و یک نمونهٔ از پیش تخصیص‌یافتهٔ بدونِ پشته (stackless) را دوباره پرتاب کند. نتیجه: ناگهان در لاگ فقط java.lang.NullPointerException را می‌بینی — بدونِ هیچ ردِ پشته و بدونِ هیچ پیامی. این با پرچمِ -XX:-OmitStackTraceInFastThrow کنترل می‌شود. اگر یک NPE تولیدی ردِ خالی نشان می‌دهد، دلیلش همین است؛ برای بازگرداندنِ رد هنگامِ تشخیص، این پرچم را خاموش کن.

حقیقت ۲ — می‌توانی عمداً استثنای بدونِ پشته بسازی

وقتی از استثنا برای یک سیگنالِ کنترلِ موردانتظار و پرتکرار استفاده می‌کنی (مثلاً «پایانِ ورودی» در یک parser)، می‌توانی عکاس را کلاً مرخص کنی — با فراخوانیِ سازندهٔ protected با دو پارامترِ false:

public final class NoStackException extends RuntimeException {
    public NoStackException(String msg) {
        // message، cause، enableSuppression=false، writableStackTrace=false
        super(msg, null, false, false);
    }
}
// بدون هزینهٔ fillInStackTrace. تقریباً به ارزانیِ یک return.

بازنویسیِ متدِ fillInStackTrace() طوری که فقط this را برگرداند هم همان اثر را دارد. ولی این را فقط برای مسیرهای واقعاً داغ نگه دار که ردِ پشته در آن‌ها هیچ ارزشِ تشخیصی ندارد — چون یک ردِ گمشده یعنی مالیاتی که هنگامِ اشکال‌زدایی می‌پردازی.

Optional / نوع‌های نتیجه در برابر استثناها

زنگِ آتش‌نشانی در برابر یک یادداشت روی در

اگر ساختمان آتش گرفت، زنگِ آتش‌نشانی می‌زنی — این یک رویدادِ استثنایی است. ولی اگر فقط یک نفر به مغازه آمد و جنسی که خواست موجود نبود، تو زنگِ آتش نمی‌زنی! فقط می‌گویی «متأسفم، نداریم». نبودنِ یک جنس یک نتیجهٔ عادی و موردانتظارِ کسب‌وکار است، نه یک فاجعه. استثنا برای آتش است؛ برای «نداریم» یک مقدارِ بازگشتیِ ساده کافی است.

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

  • Optional<T> برای «مقدار ممکن است به‌طورِ مشروع وجود نداشته باشد». مثلاً repository.findById(id) که Optional<Order> برمی‌گرداند، تمیزتر از پرتاب است، چون «یافت نشد» یک حالتِ عادی است.
  • یک نوعِ Result / Either — یک sealed interface که هر دو شاخهٔ موفقیت و شکست را در سیستمِ نوع صریح می‌کند و فراخواننده را مجبور می‌کند شکست را مدیریت کند. این همان مزیتِ checked است ولی بدونِ دردِ نحوی، و از طریقِ stream و map/flatMap قشنگ ترکیب می‌شود.
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String reason) implements Result<T> {}
قاعدهٔ سرانگشتیِ انتخاب

از استثناها برای ناوردایی‌های شکسته و شکست‌های I/O استفاده کن؛ از Optional/Result برای نتایجی که بخشی از دامنهٔ عادی‌اند. از متدی که «شکست»‌اش را فراخواننده در هر فراخوانیِ دوم انتظار دارد، پرتاب نکن — این فقط جریانِ کنترلِ کند است.

دام‌های Optional

هرگز از Optional برای فیلدها یا پارامترهای متد استفاده نکن (فقط برای مقدارِ بازگشتی طراحی شده). و هرگز Optional.get() را بدونِ بررسیِ حضور صدا نزن — این فقط NPE را از یک نقطه به نقطهٔ دیگر جابه‌جا می‌کند.

مدیریت خطا در مرزهای async

نامه‌ای که فرستادی و منتظرِ جوابی

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

لحظه‌ای که کار از نخِ فراخواننده خارج می‌شود، پیوندِ طبیعیِ throw/catch قطع می‌شود. باید آن را دوباره «لوله‌کشی» کنی. سه سناریو را ببینیم.

ExecutorService.submit در برابر execute

submit هر استثنای پرتاب‌شده را درونِ Futureای که برمی‌گرداند ثبت می‌کند؛ این استثنا فقط وقتی ظاهر می‌شود که تو future.get() را صدا بزنی — و آن‌وقت پیچیده در یک ExecutionException بیرون می‌آید. اگر هرگز get() را صدا نزنی، شکست بی‌صدا می‌ماند — یک شبحِ کلاسیکِ تولید که ساعت‌ها وقت می‌گیرد. در مقابل، execute هیچ Futureای ندارد، پس یک استثنای گرفته‌نشده مستقیم به UncaughtExceptionHandlerِ نخ می‌رود.

Future<?> f = executor.submit(task);
try {
    f.get();
} catch (ExecutionException e) {
    Throwable real = e.getCause();   // استثنای واقعی که task پرتاب کرد
    ...
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // بازگرداندن پرچم
}

CompletableFuture

اینجا استثناها ثبت و هنگامِ مصرف دوباره پرتاب می‌شوند، ولی پوششِ (wrapper) دورشان فرق می‌کند: get() یک ExecutionException پرتاب می‌کند، درحالی‌که join() یک CompletionExceptionِ بررسی‌نشده. شکست‌ها را بهتر است درونِ خط‌لوله مدیریت کنی:

CompletableFuture
    .supplyAsync(this::loadUser)
    .thenApply(this::enrich)                 // اگر loadUser شکست خورد رد می‌شود
    .exceptionally(ex -> fallbackUser())     // بازیابی، یک User برمی‌گرداند
    .handle((user, ex) -> ex == null         // هم نتیجه و هم خطا را ببین
        ? Response.ok(user)
        : Response.error(ex.getCause()));
دو تلهٔ CompletableFuture

۱. exceptionally/handle استثنا را احتمالاً پیچیده در CompletionException دریافت می‌کنند، پس با getCause() بازش کن تا به خطای اصلی برسی. ۲. یک شکست در یک مرحله، همهٔ مراحلِ پایین‌دستِ thenApply/thenAccept را رد می‌کند تا وقتی به یک مرحلهٔ مدیریت‌کننده (مثل exceptionally یا handle) برسد. یعنی enrich در مثالِ بالا اصلاً اجرا نمی‌شود اگر loadUser شکست بخورد.

نخ‌های مجازی (Virtual threads، جاوا ۲۱)

اینجا خبرِ خوش است. هر task روی نخِ مجازیِ خودش اجرا می‌شود، پس استثناها دوباره مثلِ کدِ مسدودکنندهٔ (blocking) عادی رفتار می‌کنند — یک try/catch دورِ فراخوانیِ مسدودکننده به‌طورِ عادی کار می‌کند، و یک استثنای گرفته‌نشده فقط همان یک نخِ مجازی را پایان می‌دهد.

همزمانیِ ساخت‌یافته (structured concurrency) — با کلاسِ StructuredTaskScope که در جاوا ۲۱ به‌صورتِ پیش‌نمایش (preview) آمد — این را دقیق می‌کند. با ShutdownOnFailure، اگر هر زیرکارِ فورک‌شده پرتاب کند، خواهر-برادرهایش لغو می‌شوند و استثنا در join()/throwIfFailed() ظاهر می‌شود — که همان رابطهٔ استثنای والد-فرزندِ گم‌شده در executorهای خام را بازمی‌گرداند:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user  = scope.fork(this::fetchUser);
    var order = scope.fork(this::fetchOrder);
    scope.join().throwIfFailed();            // اولین شکست را منتشر می‌کند
    return combine(user.get(), order.get());
}   // scope خودکار بسته می‌شود؛ همهٔ زیرکارها تضمیناً تمام یا لغو شده‌اند
قانونِ طلایی برای پس‌زمینه

همیشه یک UncaughtExceptionHandler برای استخرِ نخ تنظیم کن (یا از یک ThreadFactory استفاده کن که یکی تنظیم می‌کند)، تا شکست‌های پس‌زمینه هرگز بی‌سروصدا محو نشوند.

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

حالا وقتِ تمرین است. هر سؤال را اول خودت جواب بده، بعد پاسخ را ببین.

۱. دقیقاً checked را در برابر unchecked تعریف کن.

unchecked = Error، RuntimeException و زیرکلاس‌هایشان. checked = هر Throwableِ دیگر (یعنی زیرکلاس‌های Exception که RuntimeException نیستند). فقط checked باید اعلام یا گرفته شود. این تمایز، اعمال‌شده توسط کامپایلر و کاملاً نحوی (syntactic) است — JVM در زمانِ اجرا با هر دو دقیقاً یکسان رفتار می‌کند.

۲. این چه چاپ می‌کند؟ (گیرانداز)
static int f() {
    try { return 1; } finally { return 2; }
}

2. یک return در finally بازگشتِ try را نادیده می‌گیرد و حتی می‌تواند هر استثنای در حال عبور را هم قورت دهد. هرگز این کار را نکن.

۳. این چه برمی‌گرداند؟ (گیرانداز)
static int h() {
    int x = 1;
    try { return x; } finally { x = 2; }
}

1. مقدارِ بازگشتی پیش از اجرای finally ارزیابی و ثبت می‌شود؛ تخصیصِ مجددِ متغیرِ محلی بعد از آن، مقدارِ primitiveِ ثبت‌شده را تغییر نمی‌دهد. (اما اگر یک شیءِ تغییرپذیر برمی‌گرداندی و در finally خودِ آن شیء را تغییر می‌دادی، فراخواننده تغییر را می‌دید — چون ارجاع ثبت شده بود، نه یک عکسِ فوری.)

۴. بدنه پرتاب می‌کند و `close()` هم در try-with-resources پرتاب می‌کند — کدام به فراخواننده می‌رسد؟

استثنای بدنه. استثنای close از طریقِ addSuppressed به آن پیوست و با getSuppressed() بازیابی می‌شود. در try/finallyِ ساده هیچ سرکوبی نیست: یک پرتاب در finally جایگزینِ استثنای اصلی می‌شود و آن را گم می‌کند.

۵. چرا استثناها کندند، و دقیقاً کدام بخش؟

نه اعزامِ throw/catch — بلکه fillInStackTrace() در سازندهٔ Throwable، یک پیمایشِ بومیِ (native) پشته که هزینه‌اش با عمقِ پشته مقیاس می‌گیرد. از استثنا در مسیرهای داغ پرهیز کن؛ اگر ناگزیری، از استثنای بدونِ پشته استفاده کن — یا با super(msg, null, false, false) یا با بازنویسیِ fillInStackTrace.

۶. (سخت) یک NullPointerException تولیدی بدونِ ردِ پشته و بدونِ پیام لاگ می‌شود. چرا؟

بهینه‌سازیِ fast-throw در HotSpot: پس از اینکه همان استثنای درون‌ساخت مکرراً در یک مکانِ داغ پرتاب شد، JIT یک نمونهٔ از پیش تخصیص‌یافتهٔ بدونِ پشته را بازاستفاده می‌کند. با پرچمِ -XX:-OmitStackTraceInFastThrow خاموشش کن تا هنگامِ تشخیص، ردِ کامل را بازگردانی.

۷. تفاوتِ `ExecutorService.submit` و `execute` در موردِ استثناها؟

submit استثنا را درونِ Future قورت می‌دهد؛ فقط از طریقِ future.get() به‌صورتِ ExecutionException ظاهر می‌شود (با getCause() بازش کن). اگر هرگز get() را صدا نزنی، شکست بی‌صداست. execute یک استثنای گرفته‌نشده را به UncaughtExceptionHandlerِ نخ می‌فرستد.

۸. در CompletableFuture چرا join() نوعِ متفاوتی از get() پرتاب می‌کند؟

get() استثنای checkedِ ExecutionException را پرتاب می‌کند (از قراردادِ Future). join() یک متدِ راحتی است و CompletionExceptionِ بررسی‌نشده را پرتاب می‌کند. هر دو استثنای اصل را می‌پیچند — با getCause() بازش کن. همچنین exceptionally/handle ممکن است پوششِ CompletionException دریافت کنند.

۹. InterruptedException را می‌گیری و نمی‌توانی دوباره پرتابش کنی. چه باید بکنی؟

پرچمِ interrupt را بازگردان: Thread.currentThread().interrupt();. گرفتنِ InterruptedException پرچم را پاک می‌کند؛ اگر بازش نگردانی، نخ به‌ظاهر بی‌وقفه نشان داده می‌شود و همکاریِ لغو (cancellation) می‌شکند — یعنی نخ دیگر قابل‌لغو نیست.

۱۰. چه زمانی به‌جای پرتاب از Optional/Result استفاده می‌کنی؟

وقتی نبودن یا شکست یک نتیجهٔ دامنه‌ایِ عادی و موردانتظار است که فراخواننده در فراخوانی‌های معمول مدیریتش می‌کند — مثلاً findById که Optional برمی‌گرداند. استثناها برای ناوردایی‌های شکسته، خطاهای I/O و شرایطِ پیش‌بینی‌ناپذیرند. پرتاب روی نتایجِ موردانتظار، جریانِ کنترلِ کند است و قرارداد را پنهان می‌کند.

۱۱. (سخت) باگ را پیدا کن.
try {
    process();
} catch (Exception e) {
    throw new ServiceException("پردازش شکست خورد");   // <-- باگ
}

eِ اصلی به‌عنوانِ cause پاس داده نشده، پس علتِ ریشه‌ای و ردِ پشته‌اش نابود می‌شود — هیچ Caused by:ای در لاگ نمی‌بینی. اصلاح: new ServiceException("پردازش شکست خورد", e).

۱۲. (سخت) `catch (Throwable t)` در کدِ برنامه چه اشکالی دارد؟

Error را هم می‌گیرد — OutOfMemoryError، StackOverflowError، AssertionError — که تقریباً همیشه یعنی JVM در حالتِ غیرقابل‌بازیابی است؛ قورت‌دادنشان شرایطِ کشنده را پنهان و می‌تواند داده را خراب کند. Exception را بگیر، مگر اینکه یک ناظرِ سطح‌بالا باشی که فقط لاگ می‌کند و خاموش می‌شود.

۱۳. آیا finally همیشه اجرا می‌شود؟

تقریباً همیشه. فقط وقتی رد می‌شود که: JVM حینِ try خارج شود (System.exit()، یا کرشِ سخت / Runtime.halt)، یا نخ در سطحِ سیستم‌عامل کشته شود، یا try تا ابد حلقه بزند. در غیرِ این‌صورت در تکمیلِ عادی، در استثنای گرفته‌شده، و در استثنای در حال عبور اجرا می‌شود.

۱۴. (سخت) چرا یک lambda که به Stream.map پاس داده می‌شود نمی‌تواند استثنای checked پرتاب کند، و چطور استثنای checked را درونِ stream مدیریت می‌کنی؟

Function.apply بندِ throws اعلام نمی‌کند، پس آن اینترفیسِ تابعی استثناهای checked را ممنوع می‌کند. گزینه‌ها: درونِ lambda بگیر و در یک استثنای unchecked بپیچ، یا یک Result/Optional برگردان و فیلتر کن، یا از یک کمک‌کنندهٔ «sneaky throws» استفاده کن (آخرین راه). این اصطکاک یک دلیلِ اصلی است که APIهای مدرن استثناهای unchecked را ترجیح می‌دهند.

۱۵. استثناهای سرکوب‌شده چیستند و چه کسی آن‌ها را می‌سازد؟

استثناهایی که هنگامِ مدیریتِ یک استثنای اصلی کنار گذاشته می‌شوند، از طریقِ addSuppressed پیوست و با getSuppressed() خوانده می‌شوند. تولیدکنندهٔ اصلی، try-with-resources است: وقتی close() پرتاب می‌کند درحالی‌که استثنایی از بدنه در حالِ انتشار است، استثنای close سرکوب می‌شود تا استثنای بدنه زنده بماند.

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

تا اینجا مکانیک استثنا را در سطح زبان فهمیدی. اما آنچه یک سنیور را از یک میدلِ خوب جدا می‌کند، جایی است که استثنا از مرزِ یک متد بیرون می‌زند و وارد یک سیستم واقعی می‌شود: تراکنش دیتابیس، لایهٔ HTTP، لاگ، مرزِ کلاس‌لودر، retry و مرزهای reactive. اینجا همان جاهایی است که در پروداکشن آتش می‌گیرد و در مصاحبهٔ سنیور رویش مانور می‌دهند.

نقشهٔ راهِ این بخش
  • تراکنش و استثنا: چرا @Transactional روی استثنای checked رول‌بک نمی‌کند.
  • لایهٔ HTTP: @ControllerAdvice، ProblemDetail (RFC 9457) و چرا نباید stack trace را به کلاینت نشت دهی.
  • Helpful NullPointerException (از جاوا ۱۴/۱۵) و اینکه چطور JVM اسم متغیرِ null را می‌داند.
  • تلهٔ کلاس‌لودر: ExceptionInInitializerError و کلاسِ «مسموم».
  • لاگ‌کردنِ درست استثنا (بزرگ‌ترین اشتباه روزمره).
  • precise rethrow، سریالایز شدن استثنا، و مرزِ reactive و retry.

استثنا در برابر تراکنش دیتابیس — تلهٔ شمارهٔ یک سنیور

قرارداد رول‌بک مثل بیمهٔ سفر

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

قانونِ پیش‌فرضِ Spring که خیلی‌ها نمی‌دانند و در پروداکشن دیتا خراب می‌کند:

`@Transactional` فقط روی unchecked رول‌بک می‌کند

به‌طور پیش‌فرض، Spring تراکنش را فقط وقتی رول‌بک می‌کند که یک RuntimeException یا Error از متد بیرون بزند. اگر یک استثنای checked (مثل IOException یا استثنای دامنه‌ای سفارشیِ خودت که از Exception ارث برده) پرتاب شود، Spring تراکنش را commit می‌کند! یعنی نیمی از کارت روی دیتابیس ماندگار می‌شود در حالی که فکر می‌کنی همه‌چیز برگشت خورد.

@Transactional
public void placeOrder(Order o) throws PaymentException {   // PaymentException extends Exception
    orderRepo.save(o);          // این INSERT ماندگار می‌شود...
    charge(o);                  // ...حتی اگر اینجا PaymentException پرتاب شود!
}

چرا؟ چون این پیش‌فرض از قرارداد EJB به ارث رسیده: در آن مدل، checked یعنی «شکستِ کسب‌وکارِ قابل‌بازیابی» و unchecked یعنی «خطای سیستمی». راه‌حل صریح است:

@Transactional(rollbackFor = PaymentException.class)   // حالا رول‌بک می‌شود
public void placeOrder(Order o) throws PaymentException { ... }
دو تلهٔ خواهرِ همین موضوع

۱. پرتاب و گرفتنِ استثنا داخلِ خودِ متدِ @Transactional: اگر استثنا را داخل متد catch کنی و رد نکنی، Spring هرگز خبردار نمی‌شود و commit می‌کند. رول‌بک با «بیرون‌زدنِ» استثنا فعال می‌شود، نه با وقوعش. ۲. فراخوانی متدِ @Transactional از داخلِ همان کلاس (self-invocation): پروکسیِ Spring دور زده می‌شود و تراکنش اصلاً باز نمی‌شود؛ آن‌وقت بحثِ رول‌بک بی‌معنی است. حتی اگر استثنا هم پرتاب شود، مرزِ تراکنشی وجود ندارد.

قضاوت سنیور: `unwrap` وقتی رول‌بک قطعی شد

وقتی داخل تراکنش استثنا خوردی و می‌خواهی رول‌بک قطعی باشد ولی خودت هم لاگ کنی، از TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() استفاده کن؛ این حتی اگر استثنای checked را هم خوردی، تراکنش را «آلوده» می‌کند تا حتماً برگردد. در معماری میکروسرویس این خطِ دفاعی جلوی «commit نیمه‌کاره» را می‌گیرد.

استثنا در مرزِ HTTP — از @ControllerAdvice تا ProblemDetail

استثنا وقتی به بالای stack رسید باید به یک پاسخِ HTTP ترجمه شود، نه اینکه به کاربر یک stack trace خام نشان بدهی. الگوی استانداردِ Spring یک هندلرِ متمرکز است:

نمودار جریانِ خطا از دل سرویس تا پاسخِ استانداردِ کلاینت — Error flow from service to client response:

flowchart LR
  Ctrl[Controller] --> Svc[Service]
  Svc --> Repo[(Repository)]
  Repo -- SQLException --> Svc
  Svc -- domain exception --> Advice[@ControllerAdvice]
  Advice --> PD[ProblemDetail RFC 9457]
  PD --> Client[HTTP 4xx/5xx JSON]
@RestControllerAdvice
class GlobalErrors extends ResponseEntityExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handleNotFound(OrderNotFoundException ex) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setType(URI.create("https://api.example.com/errors/order-not-found"));
        pd.setProperty("orderId", ex.orderId());   // فیلد غیراستاندارد
        return pd;   // خروجی با media type: application/problem+json
    }
}
ProblemDetail و RFC 9457 (Spring 6 / Boot 3)

از Spring Framework 6، کلاس ProblemDetail پیاده‌سازیِ استانداردِ RFC 9457 (که جایگزینِ RFC 7807 شد) است: یک بدنهٔ JSON یکنواخت با فیلدهای type، title، status، detail، instance. با spring.mvc.problemdetails.enabled=true تمام استثناهای داخلیِ Spring MVC هم به همین قالب درمی‌آیند. این یعنی همهٔ سرویس‌هایت یک شکلِ خطای واحد به کلاینت می‌دهند — چیزی که در معماریِ چند-سرویسه طلاست.

هرگز stack trace یا پیامِ خام استثنا را به کلاینت نده

دو دلیل: (۱) امنیت — پیامِ SQLException می‌تواند نامِ جدول، نسخهٔ دیتابیس و حتی بخشی از query را لو بدهد؛ این نقشهٔ راهِ حمله است. (۲) قرارداد — کلاینت نباید به جزئیاتِ داخلیِ تو وابسته شود. پیامِ داخلی را در لاگ نگه دار (با یک traceId)، و به کلاینت فقط یک پیامِ عمومی + همان traceId بده تا پشتیبانی بتواند ردش را بزند.

Helpful NullPointerException — چرا NPE امروز حرف می‌زند

از جاوا ۱۴ (JEP 358)، پیش‌فرض از جاوا ۱۵

قبلاً پیامِ NPE فقط شمارهٔ خط بود و اگر یک خط چند . داشت («a.b().c().d») نمی‌فهمیدی کدام‌شان null بود. حالا JVM پیامِ دقیق می‌سازد: Cannot invoke "Order.total()" because "order" is null.

چطور ممکن است؟ JVM با تحلیلِ bytecode در لحظهٔ پرتاب می‌فهمد کدام دستور (getfield, invokevirtual, aload) روی چه چیزی عمل می‌کرد و اسمِ متغیر را از جدولِ LocalVariableTable بازسازی می‌کند — که فقط وقتی کد با -g (اطلاعاتِ دیباگ) کامپایل شده باشد کاملاً دقیق است. این ساختِ پیام یک هزینهٔ کوچک دارد، برای همین فقط وقتی NPE واقعاً پرتاب شد اجرا می‌شود، نه در مسیرِ داغ.

تلهٔ fast-throw این ویژگی را می‌بلعد

اگر همان بهینه‌سازیِ fast-throw که در بخشِ قبلِ فصل دیدی فعال شود (NPE تکراری در یک نقطهٔ داغ)، JVM نمونهٔ stackless را دوباره استفاده می‌کند و آن‌وقت پیامِ Helpful NPE هم ناپدید می‌شود. اگر NPE با پیامِ خالی دیدی، با -XX:-OmitStackTraceInFastThrow هم trace و هم پیامِ کمکی برمی‌گردد.

تلهٔ کلاس‌لودر — کلاسِ «مسموم» و ExceptionInInitializerError

این یکی از موذی‌ترین باگ‌های پروداکشن است و کمتر کسی درست توضیحش می‌دهد.

یک بار خراب شوی، برای همیشه خرابی

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

اگر یک بلوکِ static { ... } یا مقداردهیِ یک فیلدِ static استثنا پرتاب کند، JVM آن را در ExceptionInInitializerError می‌پیچد و کلاس را به حالتِ «failed initialization» می‌برد:

class Config {
    static final String URL = load();   // اگر load() پرتاب کند...
    static String load() { throw new IllegalStateException("bad config"); }
}
پیامِ گمراه‌کننده در دومین دسترسی

اولین باری که به Config دست بزنی، ExceptionInInitializerError با علتِ واقعی (IllegalStateException: bad config) می‌بینی. اما دفعهٔ دوم به بعد، JVM دیگر تلاش نمی‌کند initializer را اجرا کند و فقط یک NoClassDefFoundError: Could not initialize class Config پرتاب می‌کند — بدون علتِ اصلی. تیم ساعت‌ها دنبالِ «چرا کلاس پیدا نمی‌شود» می‌گردد، در حالی که مشکل اصلاً پیدا نشدنِ کلاس نیست؛ مشکل این است که کلاس یک‌بار در راه‌اندازی مسموم شده. همیشه اولین خطا در لاگ را پیدا کن، نه NPE/NoClassDefFoundError‌های بعدی را.

تفاوتِ `ClassNotFoundException` و `NoClassDefFoundError`

این دو را در مصاحبه قاطی می‌کنند. ClassNotFoundException (checked) یعنی موقعِ بارگذاریِ صریح (Class.forName) کلاس در classpath نبود. NoClassDefFoundError (یک Error) یعنی کامپایلر کلاس را دیده بود ولی در runtime یا پیدا نشد یا — مثل بالا — initialize‌اش شکست خورد. اولی «هیچ‌وقت نبود»، دومی «بود ولی الان قابل استفاده نیست».

لاگ‌کردنِ درستِ استثنا — بزرگ‌ترین اشتباه روزمره

`logger.error("failed: " + e)` را با `logger.error("failed", e)` عوضی نگیر

اولی فقط e.toString() (یک خط) را می‌چسباند و stack trace را دور می‌ریزد — همان اطلاعاتی که برای دیباگ حیاتی است. دومی، که استثنا را به‌عنوان آخرین آرگومان می‌دهی، به فریم‌ورکِ لاگ (SLF4J/Logback/Log4j2) می‌گوید کل trace و زنجیرهٔ Caused by: را چاپ کن.

// غلط — trace نابود می‌شود
log.error("failed to load order " + id + ": " + e);
// درست — پارامتری + استثنا در آخر
log.error("failed to load order {}", id, e);
ضدالگوی «log-and-throw»

اگر هم لاگ کنی و هم دوباره پرتاب کنی، همان یک خطا در چند لایه چند بار لاگ می‌شود و در Kibana/Splunk سه‌تا stack trace تقریباً یکسان می‌بینی و فکر می‌کنی سه خطا بوده. قانون: یا رسیدگی کن (لاگ + بازیابی)، یا پرتاب کن و بگذار یک هندلرِ سطحِ‌بالا (همان @ControllerAdvice) یک‌بار لاگ کند. هیچ‌وقت هر دو.

زمینه را در MDC بگذار، نه در پیام

به‌جای اینکه orderId و userId را در متنِ هر پیام تکرار کنی، آن‌ها را در MDC (Mapped Diagnostic Context) بگذار تا خودکار به هر خطِ لاگِ آن request بچسبند. با یک traceId مشترک (از OpenTelemetry) می‌توانی یک خطا را در چند سرویس دنبال کنی — این همان چیزی است که دیباگِ توزیع‌شده را ممکن می‌کند.

چند نکتهٔ کوتاهِ سنیور که در مصاحبه برگ برنده‌اند

precise rethrow (از جاوا ۷)

از جاوا ۷ اگر یک استثنای پایه بگیری و دوباره پرتابش کنی، کامپایلر «هوشمندانه» می‌فهمد که واقعاً چه نوع‌هایی می‌توانستند پرتاب شوند:

void m() throws IOException, SQLException {
    try { risky(); }
    catch (Exception e) {   // e را دست‌کاری نمی‌کنیم → effectively final
        log.warn("retrying");
        throw e;   // کامپایلر می‌داند فقط IOException یا SQLException است، نه هر Exception
    }
}

در جاوا ۶ همین کد مجبورت می‌کرد throws Exception بنویسی. شرطش این است که e را دوباره مقداردهی نکنی.

استثنا Serializable است — و این تله دارد

هر Throwable سریالایزبل است (چون باید بین JVMها در RMI/کش رد شود). دو نکته: (۱) اگر فیلدِ سفارشی‌ای اضافه کردی که خودش سریالایزبل نیست، سریالایز کردنِ استثنا می‌شکند؛ آن را transient کن. (۲) یک serialVersionUID بگذار وگرنه با تغییرِ کلاس، deserialize بین نسخه‌ها می‌شکند. در کشِ توزیع‌شده که استثنا را ذخیره می‌کند این واقعاً اتفاق می‌افتد.

Throwable جلوی زنجیرهٔ حلقوی را می‌گیرد

اگر تصادفاً a.initCause(b) و b.initCause(a) بزنی، initCause استثنا پرتاب می‌کند (نمی‌گذارد یک استثنا علتِ خودش باشد یا حلقه بسازد). همچنین initCause را نمی‌توان دوبار صدا زد؛ اگر در constructor علت را داده‌ای، دوباره initCause خطا می‌دهد.

مرزِ reactive و منطقِ retry

اگر با Project Reactor (WebFlux) کار می‌کنی، try/catch بی‌فایده است چون خطا در زمانِ اجرای pipeline رخ می‌دهد نه در زمانِ ساختنش. خطا به‌صورتِ یک سیگنالِ onError در جریان حرکت می‌کند:

orderService.load(id)                       // Mono<Order>
    .onErrorResume(NotFoundException.class,  // مثل catch، اما در جریان
        e -> Mono.just(Order.empty()))
    .doOnError(e -> log.error("load failed", e))   // side-effect، جریان را عوض نمی‌کند
    .retryWhen(Retry.backoff(3, Duration.ofMillis(200)));
فقط خطاهای «گذرا» را retry کن، آن هم اگر عملیات idempotent باشد

retry کردنِ یک IllegalArgumentException (خطای دائمی) بی‌فایده است و فقط بار را زیاد می‌کند؛ فقط خطاهای گذرا (transient) مثل timeout یا 503 را retry کن. مهم‌تر: اگر عملیات idempotent نباشد (مثل «کسر پول»)، retry می‌تواند دوبار پول کم کند. یا عملیات را idempotent کن (با یک idempotency-key)، یا اصلاً retry نکن.

StructuredTaskScope در جاوا ۲۵ عوض شد

مثالِ new StructuredTaskScope.ShutdownOnFailure() که در بخشِ async فصل دیدی مربوط به preview جاوا ۲۱ بود. در جاوا ۲۵ (JEP 505) این API بازطراحی شد: سازنده‌ها با متدِ کارخانه‌ایِ StructuredTaskScope.open(...) جایگزین شدند و ShutdownOnFailure/ShutdownOnSuccess حذف و با یک اینترفیسِ انعطاف‌پذیرِ Joiner عوض شدند (مثلاً Joiner.allSuccessfulOrThrow()). ایده‌ها ثابت ماندند، فقط شکلِ فراخوانی مدرن‌تر شد. چون هنوز preview است، انتظارِ تغییرِ باز هم داشته باش.

۱. یک متدِ `@Transactional` استثنای checked پرتاب می‌کند ولی رول‌بک نمی‌شود. چرا و چطور درستش می‌کنی؟

چون پیش‌فرضِ Spring (به ارث از EJB) فقط روی RuntimeException و Error رول‌بک می‌کند؛ استثنای checked باعثِ commit می‌شود. سه راه: @Transactional(rollbackFor = MyChecked.class)؛ یا استثنا را unchecked کن؛ یا داخلِ متد TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() بزن. و یادت باشد اگر استثنا را داخلِ همان متد catch کنی و رد نکنی، اصلاً رول‌بکی رخ نمی‌دهد — رول‌بک با «بیرون‌زدنِ» استثنا فعال می‌شود.

۲. یک کلاس اولین بار `ExceptionInInitializerError` می‌دهد، ولی بعدش لاگ پر از `NoClassDefFoundError` است. چه خبر است؟

بلوکِ static یا مقداردهیِ فیلدِ static در آن کلاس استثنا پرتاب کرده و کلاس به حالتِ «failed initialization» رفته. static initializer فقط یک‌بار اجرا می‌شود، پس JVM دیگر تلاش نمی‌کند و از آن به بعد به‌جای علتِ اصلی فقط NoClassDefFoundError: Could not initialize class ... می‌دهد. برای دیباگ باید اولین خطای لاگ (همان ExceptionInInitializerError و Caused by: زیرش) را پیدا کنی، نه خطاهای بعدی را.

۳. `log.error("failed: " + e)` چه چیزی را از دست می‌دهد؟

با الحاقِ رشته‌ای فقط e.toString() (یک خط: نوع + پیام) چاپ می‌شود و کلِ stack trace و زنجیرهٔ Caused by: دور ریخته می‌شود. درستش این است که استثنا را به‌عنوان آخرین آرگومان بدهی: log.error("failed to load {}", id, e) — آن‌وقت فریم‌ورکِ لاگ trace کامل را چاپ می‌کند. این تفاوت در ۳ بامدادِ یک incident، فرقِ بینِ ۵ دقیقه و ۵ ساعت است.

۴. در جاوا ۱۵ به بعد NPE اسمِ متغیرِ null را می‌گوید. JVM از کجا می‌داند؟

با تحلیلِ bytecode در لحظهٔ پرتاب: JVM می‌فهمد کدام دستور (invokevirtual/getfield) روی reference‌ای عمل می‌کرد که null بود، و اسمِ متغیر یا فیلد را از LocalVariableTable/constant pool بازسازی می‌کند. برای دقتِ کامل باید کد با اطلاعاتِ دیباگ (-g) کامپایل شده باشد. این ویژگی (JEP 358) در جاوا ۱۴ اختیاری و از جاوا ۱۵ پیش‌فرض شد. اما اگر fast-throw فعال شود، هم پیام هم trace ناپدید می‌شوند.

۵. تفاوتِ `ClassNotFoundException` و `NoClassDefFoundError` چیست؟

ClassNotFoundException یک استثنای checked است که موقعِ بارگذاریِ صریح (Class.forName, loadClass) پرتاب می‌شود، یعنی کلاس اصلاً در classpath نبود. NoClassDefFoundError یک Error است: کلاس در زمانِ کامپایل موجود بود ولی در runtime یا نبود یا (رایج‌تر) static initializerش شکست خورد و کلاس مسموم شد. خلاصه: اولی «هیچ‌وقت پیدا نشد»، دومی «بود ولی الان قابلِ استفاده نیست».

۶. چرا در WebFlux/Reactor نمی‌توانی با `try/catch` خطا بگیری، و به‌جایش چه می‌کنی؟

چون کدِ reactive فقط pipeline را می‌سازد؛ اجرا بعداً و روی نخِ دیگری (هنگامِ subscribe) رخ می‌دهد، پس try/catch دورِ ساختِ pipeline هیچ خطایی نمی‌بیند. خطا به‌صورتِ سیگنالِ onError در جریان حرکت می‌کند و با operatorها می‌گیری‌اش: onErrorResume/onErrorReturn (مثل catch + بازیابی)، doOnError (فقط side-effect مثل لاگ)، و retryWhen برای تلاشِ مجدد. همین منطق برای CompletableFuture هم هست (exceptionally/handle).

۷. کِی یک عملیاتِ شکست‌خورده را retry می‌کنی و چه خطری دارد؟

فقط خطاهای گذرا (transient) — timeout، 503، قطعیِ لحظه‌ایِ شبکه — را retry کن؛ خطای دائمی مثل 400/IllegalArgumentException را هرگز، چون فقط بار اضافه می‌کنی. خطرِ اصلی: اگر عملیات idempotent نباشد (کسرِ پول، ارسالِ ایمیل) retry می‌تواند اثر را دوبار اعمال کند. راه‌حل: idempotency-key سمتِ سرور، backoff نمایی + jitter برای جلوگیری از thundering herd، و circuit breaker (مثل Resilience4j) تا وقتی سرویسِ مقصد افتاده اصلاً تلاش نکنی.

۸. چرا الگوی «log-and-throw» بد است؟

چون همان یک خطا در هر لایه یک‌بار لاگ می‌شود و در سیستمِ لاگِ متمرکز به‌شکلِ چند stack traceِ تقریباً یکسان ظاهر می‌شود؛ آدم فکر می‌کند چند incident جدا رخ داده و وقت هدر می‌رود. قانون: هر خطا را یک‌بار لاگ کن، آن هم در نقطه‌ای که واقعاً تصمیمی درباره‌اش می‌گیری — معمولاً یک هندلرِ سطحِ‌بالا مثل @ControllerAdvice یا UncaughtExceptionHandler. یا رسیدگی کن یا پرتاب کن، نه هر دو.

۹. یک استثنای سفارشی نوشتی که یک فیلدِ `Connection` نگه می‌دارد و در کش سریالایز می‌شود؛ چه اتفاقی می‌افتد؟

Throwable خودش Serializable است، پس JVM می‌خواهد کلِ آبجکت را سریالایز کند — ولی Connection سریالایزبل نیست و یک NotSerializableException می‌گیری. راه‌حل: فیلدهای غیرسریالایزبل را transient کن (و بپذیر که پس از deserialize null‌اند). ضمناً یک serialVersionUID صریح بگذار تا با تغییرِ کلاس، نمونه‌های قدیمیِ کش هنگامِ deserialize نشکنند. قاعدهٔ سنیور: در استثنا فقط دادهٔ سبک و سریالایزبل (id، پیام) نگه دار، نه منابعِ زنده.

جمع‌بندیِ نکاتِ سنیور
  • @Transactional به‌طور پیش‌فرض فقط روی unchecked رول‌بک می‌کند؛ برای checked از rollbackFor یا setRollbackOnly() استفاده کن، و مراقبِ catch‌کردن و self-invocation باش.
  • در مرزِ HTTP خطا را با @ControllerAdvice به ProblemDetail (RFC 9457) ترجمه کن؛ هرگز stack trace را به کلاینت نده — فقط traceId.
  • Helpful NPE (جاوا ۱۵+) اسمِ متغیر را از bytecode می‌سازد، ولی fast-throw می‌بلعدش.
  • کلاسِ مسموم: ExceptionInInitializerError یک‌بار، سپس NoClassDefFoundError برای همیشه — همیشه اولین خطا را بخوان.
  • لاگ: استثنا را آخرین آرگومان بده، «log-and-throw» نکن، زمینه را در MDC بگذار.
  • در reactive با onErrorResume/retryWhen کار کن؛ فقط خطای گذرا و عملیاتِ idempotent را retry کن.
جمع‌بندی
  • استثنا یک انتقالِ کنترلِ غیرمحلی است: پشته را unwind می‌کند تا کسی catch‌اش کند. هزینه‌اش عمدتاً از fillInStackTrace است، نه از خودِ throw/catch.
  • سلسله‌مراتب: Throwable → {Error جدی و نگیرش، Exception}. checked = زیرکلاس‌های Exception که RuntimeException نیستند؛ بقیه unchecked.
  • بحثِ طراحی: checked بد مقیاس می‌گیرد و lambda/stream را می‌شکند؛ رویهٔ مدرن به unchecked متمایل است (Spring، Kotlin، Scala).
  • try-with-resources: منابعِ AutoCloseable را به ترتیبِ معکوس می‌بندد؛ استثنای بدنه برنده و استثناهای close سرکوب می‌شوند (getSuppressed()).
  • تله‌های finally: return/throw در finally همه‌چیز را قورت می‌دهد؛ مقدارِ بازگشتیِ ثبت‌شده تغییر نمی‌کند؛ finallyِ ساده سرکوب نمی‌کند.
  • همیشه cause را حفظ کن (ترجمهٔ استثنا)، زودشکست باش، هرگز قورت نده، و پرچمِ interrupt را بازگردان.
  • برای نتایجِ عادی از Optional/Result استفاده کن، نه استثنا.
  • در مرزهای async خطا را دوباره لوله‌کشی کن: Future.getExecutionException؛ CompletableFuture.joinCompletionException؛ virtual thread + structured concurrency رابطهٔ والد-فرزند را بازمی‌گرداند.

Welcome. This is one of those topics everyone thinks they've mastered — right up until an interviewer asks "exactly what happens when a finally block has a return?" or "why does my production NPE have no stack trace?". Our goal is that by the end of this chapter you don't just know the answers, you understand why they're true. We'll build every technical word from the ground up — no jargon dropped without unpacking it.

Roadmap for this chapter

Here's what you'll learn, step by step:

  • What an exception really is — "non-local transfer of control" and why that one definition explains everything.
  • The Throwable hierarchy — the full map of Error vs Exception, and the checked/unchecked line.
  • The great design debate — why modern frameworks lean unchecked.
  • try-with-resources, AutoCloseable and suppressed exceptions — closing resources safely.
  • The finally traps — three gotchas that show up in senior interviews.
  • Custom exceptions and exception translation — preserving the cause.
  • PerformancefillInStackTrace, the fast-throw optimization, and stackless exceptions.
  • Optional/Result vs exceptions — when not to throw at all.
  • Async and virtual-thread boundaries — re-plumbing errors once they leave the thread.
  • And at the end, 15 interview questions with full answers.

Part 0 — words you must know

Before we start, let's build the four words we'll lean on all chapter, each with an analogy. Nail these and the rest flows.

The call stack is like a stack of cafeteria plates

Picture a spring-loaded stack of plates in a cafeteria. Every time one method calls another, it's like placing a fresh plate on top of the stack. main is the bottom plate; the method executing right now is the top plate. When a method returns, its plate is lifted off and you drop back to the plate beneath. That plate stack is the call stack, and each plate is a stack frame. Each frame holds the local variables of that one call.

  • Throwable: in Java, anything you can "throw" is an object of type Throwable. It is the only type the throw and catch keywords work with. Picture it as an error-report envelope traveling up your hand.
  • throw: means "something broke — take this report envelope and pass it up."
  • catch: means "I accept responsibility for this envelope and will deal with it."
  • Unwinding: when the envelope is thrown and nobody catches it at the current frame, Java starts lifting plates off one at a time — deleting each frame and discarding its locals — until it reaches a frame with a matching catch. That step-by-step frame removal is called unwinding.
A one-line mental model

return means gently lift the top plate and hand back a value. throw means pull the fire alarm and let plates crash off one after another until someone stops you. The first is cheap and orderly; the second is expensive and loud.

What an exception really is — the mental model

A fire alarm in a multi-story building

Normally, when your work on the fifth floor is done, you take the elevator down, floor by floor, in an orderly way. That's the "normal return path." But if there's a fire, you don't take the normal route — you pull the alarm and everyone immediately evacuates down the fire stairs, floor after floor, to a predetermined safe spot in the yard. An exception is exactly that fire alarm.

Now the technical name: an exception is a non-local transfer of control. Let me unpack that phrase. "Control" is which line of code is running right now. "Non-local transfer" means that control jumps not to the next line (which would be "local"), but somewhere far away — several frames down the stack. When a method cannot fulfill its contract, it abandons the normal return path and unwinds the stack until some frame catches it. The value that travels up the stack is that Throwable object.

Two consequences fall out of this definition, and they explain almost every design decision in the JVM:

Two foundational facts
  1. Unwinding is expensive and destructive. Every frame between the throw and the catch is deleted, its locals discarded. This is exactly why you must not use exceptions for ordinary control flow (e.g. instead of a simple loop).
  2. Throwable carries evidence. By default, when it's constructed it captures a stack trace — by calling a method named fillInStackTrace — so you can later reconstruct where and why. And that trace capture is the single most expensive part of the whole throw (we'll dissect it later).

A golden design rule: build your exceptions so that for someone reading the log bleary-eyed at 3 a.m., they answer three questions: what failed, why, and with what context. If your log doesn't say all three, that person (who may be future-you) burns hours.

The Throwable hierarchy

Now that you know every error is a Throwable object, let's see the whole family. Know this map like the back of your hand:

Object
 └─ Throwable                (has message, cause, stackTrace, suppressed[])
     ├─ Error                (serious, usually unrecoverable — don't catch)
     │   ├─ OutOfMemoryError
     │   ├─ StackOverflowError
     │   ├─ VirtualMachineError
     │   └─ AssertionError
     └─ Exception
         ├─ RuntimeException (UNCHECKED)
         │   ├─ NullPointerException
         │   ├─ IllegalArgumentException / IllegalStateException
         │   ├─ IndexOutOfBoundsException
         │   ├─ ClassCastException
         │   └─ ArithmeticException, ...
         └─ (everything else)  CHECKED
             ├─ IOException
             ├─ SQLException
             └─ InterruptedException, ...
Two kinds of breakdown in a factory

Imagine you run a factory. You face two utterly different classes of problem. Class one: "the building is collapsing" or "the whole city lost power" — you cannot fix these on the production line; you can only evacuate safely. Class two: "this part is the wrong size" or "we ran out of stock" — you can handle these and keep going. In Java, class one is Error and class two is Exception.

The compiler's rule for the checked/unchecked line is purely syntactic — it looks only at the inheritance tree, not at meaning:

The precise, unambiguous definition

checked = subclasses of Exception that are NOT subclasses of RuntimeException. That's it. Everything else — Error, RuntimeException, and their subclasses — is unchecked. Only checked exceptions does the compiler force you to either declare in a throws clause or catch.

Throwable is the only type you can throw or catch. Learn its key fields, because we keep coming back to them:

Field Accessor Meaning
message getMessage() human string
cause getCause() the underlying Throwable (exception chaining)
stackTrace getStackTrace() frames captured at construction
suppressed getSuppressed() exceptions dropped while handling this one (try-with-resources)

Error vs Exception

Let's sharpen the factory picture. Error signals conditions the application normally cannot recover from: either the JVM itself is compromised (OutOfMemoryError when memory runs out, StackOverflowError when infinite recursion fills the stack), or an invariant the compiler had guaranteed was violated at runtime (NoClassDefFoundError, AssertionError).

A quick pause on "invariant": an invariant is something that must always stay true — like the rule "any class that compiled will be found at runtime." When such a rule breaks, a fundamental assumption of your world is broken, and continuing is meaningless.

Never catch `Error` just to "keep going"

Catching Error to keep the app alive is almost always wrong — you're ignoring a real fire. The one legitimate case is a top-level supervisor that merely logs and then shuts down cleanly. And beware: catch (Throwable t) accidentally scoops up Error too. Almost always what you really mean is catch (Exception e).

Checked vs unchecked — the great design debate

This is the most contested API decision in Java's history, and interviewers use it to check whether you hold a reasoned position or just parrot the rule.

The argument for checked: they are part of the method signature — a compile-time contract that forces the caller to acknowledge a recoverable failure. When Files.newInputStream declares it can throw IOException, that is genuine documentation the compiler enforces; you can't ignore it.

The argument against: checked exceptions scale badly — meaning the bigger and more layered your program, the disproportionately worse the pain. Three reasons:

  • They break lambdas and streams, because functional interfaces like Function don't declare throws at all.
  • They leak implementation details up through abstraction layers — the upper layer shouldn't know the bottom uses SQL, but SQLException forces it to.
  • They push developers toward the two worst anti-patterns in the language:
// Anti-pattern 1: swallow. Now the failure is invisible.
try { risky(); } catch (IOException e) { /* nothing */ }

// Anti-pattern 2: throws Exception everywhere, defeating the purpose.
void doWork() throws Exception { ... }
Swallowing is the worst thing you can do

An empty catch block makes the failure invisible. The app appears to work while data silently corrupts, and you have no clue in the log. And throws Exception on everything destroys the very information checked was supposed to give — because now it's no longer clear which failure can occur.

Modern practice — and every major framework since Spring — leans unchecked by default. Spring, for example, precisely catches SQLException and translates it into an unchecked hierarchy called DataAccessException, so your business code isn't littered with catch (SQLException).

The pragmatic rule for choosing
  • Use a checked exception only when the caller can realistically recover and you want to force them to decide (rare in practice).
  • Use unchecked for programming errors and failures the caller usually can't fix locally (most cases).
  • Never declare throws Exception or throws Throwable; be specific.
A signal from other languages

Kotlin and Scala dropped checked exceptions entirely — their compilers don't even have the concept. That's a strong signal about where the historical debate landed.

try-with-resources, AutoCloseable, and suppressed exceptions

The tenant who turns off the lights

Imagine you rent an apartment. The rule is: whenever you leave, you must turn off the lights and lock the door — whether your work finished normally or you fled mid-task because of a fire. A resource, like an open file or a database connection, is that apartment: it must be closed or you spring a leak. Before Java 7, this "turning off the lights" had to be done manually, with an intricate dance.

Before Java 7, correct resource cleanup required that notorious nested-finally dance — with a null check and a second try inside the finally. Ugly and error-prone. Java 7 replaced it with try-with-resources. Any type that implements the AutoCloseable interface can be declared in the "resource header" (the parentheses after try).

A fine point: AutoCloseable has a subtype called Closeable that just narrows the close() signature to throw IOException (suited to I/O classes).

try (var in = Files.newInputStream(path);
     var out = Files.newOutputStream(dest)) {   // resources declared here
    in.transferTo(out);
}   // close() called automatically, in REVERSE order: out first, then in

Two guarantees are easy to state but easy to get wrong in interviews:

The two golden guarantees of try-with-resources
  1. Resources are closed in the reverse order of declaration. (The last thing you opened is the first thing you close — like undressing: the coat you put on last comes off first.)
  2. The body exception wins; close exceptions are suppressed, not lost.

Let me unpack guarantee two, because that's where everyone stumbles. Suppose the try body throws an exception and, while closing, close() throws another. Which reaches the caller? The body's exception propagates (it's the original, more important error), and each close exception is attached to it via the method Throwable.addSuppressed. You can later retrieve them with getSuppressed(). This is exactly the reverse of the pre-Java-7 world, where the finally's exception silently clobbered and lost the real one.

class Resource implements AutoCloseable {
    final String name;
    Resource(String name) { this.name = name; }
    void use() { throw new RuntimeException("boom in body"); }
    public void close() { throw new IllegalStateException("boom closing " + name); }
}

try (var r = new Resource("R1")) {
    r.use();
}
// Propagates: RuntimeException "boom in body"
// getSuppressed()[0] == IllegalStateException "boom closing R1"
Java 9 improvement

Since Java 9, if you already built the resource variable and that variable is effectively final — meaning you never reassign it after the first assignment — you can list its name directly in the header without re-declaring:

Connection conn = dataSource.getConnection();   // effectively final
try (conn) {
    ...
}

finally: control-flow gotchas

finally is powerful: it runs no matter what — whether the body completes normally, an exception is caught, or an exception is in transit through it. But that very power hides several traps that show up constantly in senior interviews. Let's dissect all three.

Trap 1: return in finally swallows everything

static int f() {
    try {
        return 1;
    } finally {
        return 2;   // this return WINS; the try's return 1 is discarded
    }
}
// f() == 2

Why? Because finally gets the last word. When it reaches return 2, it effectively says "forget what you said before, the final answer is 2." Worse, a return (or throw) in finally can also silently swallow an in-flight exception:

static int g() {
    try {
        throw new RuntimeException("real error");
    } finally {
        return -1;   // exception vanishes; caller sees -1, never the throw
    }
}
A simple, exception-free rule

Never put return, break, continue, or throw inside finally. Many linters (static-analysis tools) flag exactly this as an error — because it makes a real exception invisible.

Trap 2: finally does NOT change an already-evaluated return value

static int h() {
    int x = 1;
    try {
        return x;    // the VALUE 1 is captured onto the operand stack HERE
    } finally {
        x = 2;       // mutating the local now has no effect on the return
    }
}
// h() == 1

Let's watch the mechanism precisely. When Java reaches return x, it first fully evaluates the expression x and stashes the result — the number 1 — somewhere (on the "operand stack," a temporary work area). Then finally runs. Now x = 2 only changes the local variable x, but that stashed number 1 stays untouched. So the output is 1.

The mutable-object subtlety

But note: if you return a mutable object and, in finally, mutate that object (rather than reassign the variable to a new object), then the caller does see the change! Why? Because what got stashed was a reference — an address to the object — not a snapshot of its contents. The address stayed the same but the contents behind it changed. This "value vs reference" subtlety is a favorite trick question.

Trap 3: exception in try + exception in finally

try {
    throw new IOException("A");
} finally {
    throw new IllegalStateException("B");   // B propagates, A is LOST (not suppressed!)
}

The key point here: unlike try-with-resources, which we saw suppresses (not loses) extra exceptions, a plain finally does no auto-suppression. Exception B replaces A, and A vanishes completely — with no trace. If you must run cleanup that can throw, either wrap it in an inner try/catch or use try-with-resources so the primary exception survives.

Custom exceptions and exception translation

Instead of throwing a raw RuntimeException, define a small, meaningful hierarchy for your own domain (your business). And always provide a constructor that accepts a cause, so chaining works:

public class OrderNotFoundException extends RuntimeException {
    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("Order not found: " + orderId);
        this.orderId = orderId;
    }
    public OrderNotFoundException(long orderId, Throwable cause) {
        super("Order not found: " + orderId, cause);
        this.orderId = orderId;
    }
    public long orderId() { return orderId; }
}
Exception translation is like a company spokesperson

Picture a pipe bursting in the warehouse. The warehouse worker tells the sales manager "the pressure valve on line 3 is leaking" (low-level technical detail). But the sales manager doesn't tell the customer "the pressure valve on line 3..."; they translate: "your order will arrive a day late." The higher-level message is meaningful to the customer — yet the original technical report is not lost, it's recorded in the internal log for the engineers to see. That's exactly what exception translation does.

Exception translation means catching a low-level exception at an abstraction boundary and rethrowing a higher-level one — while preserving the original exception as the cause:

public Order load(long id) {
    try {
        return repository.findById(id);
    } catch (SQLException e) {
        // Translate persistence detail into a domain-level failure,
        // but KEEP the SQLException as the cause for the log.
        throw new OrderLoadException("Failed to load order " + id, e);
    }
}

It's that chained cause that makes the stack trace show the golden Caused by: java.sql.SQLException... line.

The most damaging production mistake

Losing the cause — writing new OrderLoadException(msg) without passing e — is one of the most dangerous mistakes in production code. You throw away the actual reason and see no Caused by: in the log; you know "load failed" but never learn why.

Best practices

Keep these as a mental checklist:

  • Fail fast. Validate arguments right at method entry and throw IllegalArgumentException / NullPointerException immediately — with Objects.requireNonNull(x, "x"). A crash near the cause is worth ten hours of debugging corrupted state far downstream.
  • Never swallow. An empty catch block is a bug. At minimum, log at ERROR with context. If you truly intend to ignore it, name the variable ignored and add a comment stating why.
  • Wrap with context, not noise. Add what the low level didn't know: which order id, which file, which retry attempt. But don't wrap-and-rethrow at every layer — that just builds a stack of near-identical messages.
  • Catch narrow, catch late. Catch the most specific type you can actually handle; let everything else propagate to a single top-level handler (a servlet filter, a @ControllerAdvice, or an UncaughtExceptionHandler).
  • Don't log and rethrow. Pick one: either handle it (log + recover) or propagate it. Doing both produces duplicate stack traces in the log, which is confusing.
  • Preserve the interrupt. When you catch InterruptedException and can't propagate it, restore the flag: Thread.currentThread().interrupt();. Swallowing it makes threads un-cancellable. (More on this a few lines down.)
  • Use multi-catch for shared handling: catch (IOException | SQLException e). Here the variable e is implicitly final.

Performance: the real cost of exceptions

Here's where many people misunderstand. Question: which part of an exception is expensive?

The crime-scene photographer

The moment an exception is constructed, it's as if a crime-scene photographer arrives and photographs the entire call stack — one frame at a time: which method, which file, which line number. If the stack is shallow (three or four frames), it's quick. If the stack is deep (fifty frames in a Spring app), that photography is time-consuming. The throw/catch itself — the alarm and evacuation — is relatively cheap; the photography is what's expensive.

That "photographer" is a native method called fillInStackTrace(), invoked in the Throwable constructor, which walks the current thread's stack and snapshots every frame. Its cost scales with the depth of the stack. This is why throwing is orders of magnitude slower than a normal return, and precisely why "exceptions-as-control-flow" is an anti-pattern.

Two facts a senior must know:

Fact 1 — HotSpot's fast-throw optimization

HotSpot (the JIT compiler in the standard JVM) has an optimization: for a handful of specific built-in exceptions (like NullPointerException and ArithmeticException) thrown repeatedly at the same hot bytecode location, the JIT decides to skip the photography and rethrow a pre-allocated, stackless instance. The result: suddenly your log shows just java.lang.NullPointerExceptionwith no stack trace and no message. This is controlled by the flag -XX:-OmitStackTraceInFastThrow. If a production NPE shows an empty trace, this is why; disable the flag to recover the trace while you diagnose.

Fact 2 — you can build stackless exceptions on purpose

When you use an exception for an expected, high-frequency control signal (like "end of input" in a parser), you can dismiss the photographer entirely — by calling the protected constructor with two false parameters:

public final class NoStackException extends RuntimeException {
    public NoStackException(String msg) {
        // message, cause, enableSuppression=false, writableStackTrace=false
        super(msg, null, false, false);
    }
}
// No fillInStackTrace cost. Nearly as cheap as a return.

Overriding the fillInStackTrace() method to just return this achieves the same. But reserve this only for genuinely hot paths where the stack trace has no diagnostic value — because a missing trace is a tax you pay while debugging.

Optional / result types vs exceptions

The fire alarm vs a note on the door

If the building is on fire, you pull the fire alarm — that's an exceptional event. But if a customer walks in and the item they wanted is out of stock, you don't pull the fire alarm! You just say "sorry, we're out." An item being absent is a normal, expected business outcome, not a catastrophe. Exceptions are for fire; for "we're out," a simple return value is enough.

Exceptions are for the exceptional — conditions the immediate caller usually can't foresee at that call site. For expected absence or predictable domain failure, prefer a return-based signal:

  • Optional<T> for "the value might legitimately not exist." For example, repository.findById(id) returning Optional<Order> is cleaner than throwing, since "not found" is a normal state.
  • A Result / Either type — a sealed interface that makes both the success and failure branches explicit in the type system and forces the caller to handle failure. This is the same benefit as checked exceptions but without the syntactic pain, and it composes nicely through streams and map/flatMap.
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String reason) implements Result<T> {}
The rule of thumb for choosing

Use exceptions for broken invariants and I/O failures; use Optional/Result for outcomes that are part of the normal domain. Don't throw from a method whose "failure" the caller expects on every other call — that's just slow control flow.

Optional pitfalls

Never use Optional for fields or method parameters (it was designed only for return values). And never call Optional.get() without checking presence — that just moves the NPE from one spot to another.

Error handling across async boundaries

A letter you mailed while waiting for a reply

As long as you do the work yourself, if something goes wrong you see it immediately. But the moment you hand the work to another thread, it's like you mailed a letter and walked away: if something goes wrong, you don't find out right there. The error news has to come back in a separate envelope, and you must deliberately open that envelope. If you don't, the error silently vanishes.

The moment work leaves the calling thread, the natural throw/catch linkage is severed. You must re-plumb it. Let's see three scenarios.

ExecutorService.submit vs execute

submit captures any thrown exception inside the Future it returns; that exception only surfaces when you call future.get() — and then it comes out wrapped in an ExecutionException. If you never call get(), the failure stays silent — a classic production ghost that costs hours. By contrast, execute has no Future, so an uncaught exception goes straight to the thread's UncaughtExceptionHandler.

Future<?> f = executor.submit(task);
try {
    f.get();
} catch (ExecutionException e) {
    Throwable real = e.getCause();   // the actual exception the task threw
    ...
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // restore the flag
}

CompletableFuture

Here exceptions are captured and re-thrown at consumption, but the wrapper around them differs: get() throws an ExecutionException, while join() throws an unchecked CompletionException. It's best to handle failures inside the pipeline:

CompletableFuture
    .supplyAsync(this::loadUser)
    .thenApply(this::enrich)                 // skipped if loadUser failed
    .exceptionally(ex -> fallbackUser())     // recover, returns a User
    .handle((user, ex) -> ex == null         // see BOTH result and error
        ? Response.ok(user)
        : Response.error(ex.getCause()));
Two CompletableFuture traps
  1. exceptionally/handle may receive the exception wrapped in a CompletionException, so unwrap with getCause() to reach the real error.
  2. A failure in one stage skips all downstream thenApply/thenAccept stages until it reaches a handler stage (like exceptionally or handle). That means enrich in the example above won't run at all if loadUser fails.

Virtual threads (Java 21)

Here's the good news. Each task runs on its own virtual thread, so exceptions behave like ordinary blocking code again — a try/catch around the blocking call works normally, and an uncaught exception ends only that one virtual thread.

Structured concurrency — via the StructuredTaskScope class, which arrived as a preview in Java 21 — makes this rigorous. With ShutdownOnFailure, if any forked subtask throws, its siblings are cancelled and the exception surfaces at join()/throwIfFailed() — restoring the parent-child exception relationship that raw executors had lost:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user  = scope.fork(this::fetchUser);
    var order = scope.fork(this::fetchOrder);
    scope.join().throwIfFailed();            // propagates the first failure
    return combine(user.get(), order.get());
}   // scope auto-closed; all subtasks guaranteed done or cancelled
The golden rule for background work

Always set an UncaughtExceptionHandler on your thread pool (or use a ThreadFactory that sets one), so background failures never silently vanish.

Interview Questions

Now it's practice time. Answer each yourself first, then read the answer.

1. Precisely define checked vs unchecked.

Unchecked = Error, RuntimeException, and their subclasses. Checked = every other Throwable (subclasses of Exception that aren't RuntimeException). Only checked must be declared or caught. The distinction is compiler-enforced and purely syntactic — the JVM treats both identically at runtime.

2. What does this print? (gotcha)
static int f() {
    try { return 1; } finally { return 2; }
}

2. A return in finally overrides the try's return and would also swallow any in-flight exception. Never do it.

3. What does this return? (gotcha)
static int h() {
    int x = 1;
    try { return x; } finally { x = 2; }
}

1. The return value is evaluated and captured before finally runs; reassigning the local afterward doesn't change the captured primitive. (But if it returned a mutable object and you mutated that object in finally, the caller would see the mutation — because you captured the reference, not a snapshot.)

4. Body throws and `close()` throws in try-with-resources — which reaches the caller?

The body's exception. The close exception is attached to it via addSuppressed and retrieved with getSuppressed(). In plain try/finally there's no suppression: a throw in finally replaces and loses the original.

5. Why are exceptions slow, and what part exactly?

Not the throw/catch dispatch — it's fillInStackTrace() in the Throwable constructor, a native stack walk whose cost scales with stack depth. Avoid exceptions on hot paths; if unavoidable, use a stackless exception (super(msg, null, false, false) or override fillInStackTrace).

6. (Hard) A production NullPointerException logs with no stack trace and no message. Why?

HotSpot's fast-throw optimization: after the same built-in exception is thrown repeatedly at one hot location, the JIT reuses a pre-allocated stackless instance. Disable with -XX:-OmitStackTraceInFastThrow to get the full trace back while diagnosing.

7. Difference between `ExecutorService.submit` and `execute` regarding exceptions?

submit swallows the exception into the Future; it surfaces only via future.get() as an ExecutionException (unwrap with getCause()). If you never call get(), the failure is silent. execute sends an uncaught exception to the thread's UncaughtExceptionHandler.

8. In CompletableFuture, why does join() throw a different type than get()?

get() throws the checked ExecutionException (from the Future contract). join() is a convenience method and throws the unchecked CompletionException. Both wrap the original — unwrap with getCause(). Also, exceptionally/handle may receive a CompletionException wrapper.

9. You catch InterruptedException and can't rethrow it. What must you do?

Restore the interrupt: Thread.currentThread().interrupt();. Catching InterruptedException clears the flag; if you don't restore it, the thread appears un-interrupted and cancellation cooperation breaks — the thread is no longer cancellable.

10. When would you use Optional/Result instead of throwing?

When absence or failure is a normal, expected domain outcome the caller handles on ordinary calls — e.g. findById returning Optional. Exceptions are for broken invariants, I/O errors, and unforeseeable conditions. Throwing on expected outcomes is slow control flow and hides the contract.

11. (Hard) Find the bug.
try {
    process();
} catch (Exception e) {
    throw new ServiceException("processing failed");   // <-- bug
}

The original e isn't passed as the cause, so the root cause and its stack trace are destroyed — no Caused by: in the log. Fix: new ServiceException("processing failed", e).

12. (Hard) What's wrong with `catch (Throwable t)` in application code?

It catches Error too — OutOfMemoryError, StackOverflowError, AssertionError — which almost always means the JVM is in an unrecoverable state; swallowing them hides fatal conditions and can corrupt data. Catch Exception unless you're a top-level supervisor that logs and shuts down.

13. Does finally always run?

Almost always. It's skipped only if: the JVM exits during the try (System.exit(), or a hard crash / Runtime.halt), the thread is killed at the OS level, or the try loops forever. Otherwise it runs on normal completion, on caught exception, and on exception-in-transit.

14. (Hard) Why can't a lambda passed to Stream.map throw a checked exception, and how do you handle a checked exception inside a stream?

Function.apply doesn't declare throws, so that functional interface forbids checked exceptions. Options: catch inside the lambda and wrap in an unchecked exception, return a Result/Optional and filter, or use a "sneaky throws" helper (last resort). This friction is a core reason modern APIs favor unchecked exceptions.

15. What are suppressed exceptions and who creates them?

Exceptions dropped while handling a primary exception, attached via addSuppressed and read via getSuppressed(). The main producer is try-with-resources: when close() throws while an exception is already propagating from the body, the close exception is suppressed so the body's exception survives.

Senior notes & advanced edge cases

By now you understand the language-level mechanics of exceptions. What separates a senior from a strong mid-level engineer is what happens once an exception escapes a single method and enters a real system: a database transaction, the HTTP boundary, your logs, a class-loader boundary, retries, and reactive pipelines. That is exactly where production catches fire — and where senior interviews probe.

Roadmap for this section
  • Transactions vs exceptions: why @Transactional does not roll back on a checked exception.
  • The HTTP boundary: @ControllerAdvice, ProblemDetail (RFC 9457), and why you must never leak a stack trace to clients.
  • Helpful NullPointerExceptions (Java 14/15) and how the JVM knows which variable was null.
  • The class-loader trap: ExceptionInInitializerError and the "poisoned" class.
  • Logging exceptions correctly (the most common everyday mistake).
  • Precise rethrow, exception serialization, reactive boundaries, and retry logic.

Exceptions vs the database transaction — the senior trap #1

The rollback contract is like travel insurance

Imagine you bought travel insurance that only covers a "sudden accident," not a "planned change of heart." If your car crashes (an unforeseen event), you get your money back; but if you decide to cancel the trip (an expected outcome), the insurer pays nothing. Spring thinks exactly this way: it only rolls back for the "sudden accident."

Here is Spring's default that surprises even experienced developers and silently corrupts data:

`@Transactional` only rolls back on unchecked exceptions

By default, Spring rolls back a transaction only when a RuntimeException or Error escapes the method. If a checked exception (like IOException, or your own custom domain exception that extends Exception) is thrown, Spring commits the transaction! Half of your work is now persisted while you believe everything was rolled back.

@Transactional
public void placeOrder(Order o) throws PaymentException {   // PaymentException extends Exception
    orderRepo.save(o);          // this INSERT stays committed...
    charge(o);                  // ...even if PaymentException is thrown here!
}

Why? This default is inherited from the EJB contract, where checked meant "recoverable business failure" and unchecked meant "system error." The explicit fix:

@Transactional(rollbackFor = PaymentException.class)   // now it rolls back
public void placeOrder(Order o) throws PaymentException { ... }
Two sibling traps of the same theme
  1. Catching the exception inside the @Transactional method: if you catch it and don't rethrow, Spring never sees it and commits. Rollback is triggered by the exception escaping, not by it occurring.
  2. Self-invocation — calling a @Transactional method from within the same class: Spring's proxy is bypassed, so the transaction never even opens. Rollback semantics are moot because there is no transactional boundary at all.
Senior judgment: force the rollback when it's certain

When you catch an exception inside the transaction but still want a guaranteed rollback (say, to also log it), call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(). This "poisons" the transaction so it rolls back even for a checked exception. In a microservice architecture this defensive line prevents the "half-committed" state.

Exceptions at the HTTP boundary — from @ControllerAdvice to ProblemDetail

Once an exception reaches the top of the stack, it must be translated into an HTTP response — not dumped on the user as a raw stack trace. Spring's standard pattern is a single centralized handler:

Error flow from deep inside the service up to the client's standardized response:

flowchart LR
  Ctrl[Controller] --> Svc[Service]
  Svc --> Repo[(Repository)]
  Repo -- SQLException --> Svc
  Svc -- domain exception --> Advice[@ControllerAdvice]
  Advice --> PD[ProblemDetail RFC 9457]
  PD --> Client[HTTP 4xx/5xx JSON]
@RestControllerAdvice
class GlobalErrors extends ResponseEntityExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handleNotFound(OrderNotFoundException ex) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setType(URI.create("https://api.example.com/errors/order-not-found"));
        pd.setProperty("orderId", ex.orderId());   // non-standard extension field
        return pd;   // served with media type application/problem+json
    }
}
ProblemDetail and RFC 9457 (Spring 6 / Boot 3)

Since Spring Framework 6, the ProblemDetail class implements the RFC 9457 standard (which superseded RFC 7807): a uniform JSON body with type, title, status, detail, and instance fields. Set spring.mvc.problemdetails.enabled=true and even Spring MVC's built-in exceptions render in this shape. That means every one of your services returns a single, uniform error format to clients — pure gold in a multi-service architecture.

Never send a stack trace or raw exception message to the client

Two reasons. (1) Security — a SQLException message can leak a table name, the database version, even part of the query; that's a roadmap for an attacker. (2) Contract — the client must not couple to your internal details. Keep the internal message in the log (tied to a traceId), and return only a generic message plus that traceId so support can trace it.

Helpful NullPointerException — why an NPE talks today

Java 14 (JEP 358), on by default since Java 15

An NPE message used to be just a line number, and if a line had multiple dereferences (a.b().c().d) you couldn't tell which one was null. Now the JVM builds a precise message: Cannot invoke "Order.total()" because "order" is null.

How is that possible? At the moment of the throw the JVM analyzes the bytecode to determine which instruction (getfield, invokevirtual, aload) operated on what, and reconstructs the variable name from the LocalVariableTable — which is fully accurate only when the code was compiled with debug info (-g). Building this message has a small cost, so it runs only when an NPE actually fires, never on the hot path.

The fast-throw optimization swallows this feature

If the fast-throw optimization from earlier in the chapter kicks in (the same NPE repeatedly at a hot site), the JVM reuses a stackless instance — and then the Helpful NPE message disappears too. If you see an NPE with an empty message, -XX:-OmitStackTraceInFastThrow restores both the trace and the helpful message.

The class-loader trap — the "poisoned" class and ExceptionInInitializerError

This is one of the most insidious production bugs, and few people explain it correctly.

Break once, stay broken forever

Picture an elevator whose wiring burns out during its once-a-day morning startup. From that moment on, everyone who presses the button sees only a "broken" light — never the original smoke. A Java class is the same: its static initializer runs exactly once.

If a static { ... } block or a static field initializer throws, the JVM wraps it in ExceptionInInitializerError and moves the class into a "failed initialization" state:

class Config {
    static final String URL = load();   // if load() throws...
    static String load() { throw new IllegalStateException("bad config"); }
}
The misleading message on the second access

The first time you touch Config, you get ExceptionInInitializerError with the real cause (IllegalStateException: bad config). But from the second access on, the JVM won't retry the initializer and instead throws NoClassDefFoundError: Could not initialize class Configwithout the original cause. The team burns hours hunting "why can't the class be found," when the class was found just fine; it was poisoned once during startup. Always find the first error in the log, not the later NPE/NoClassDefFoundErrors.

`ClassNotFoundException` vs `NoClassDefFoundError`

Interviewers love conflating these. ClassNotFoundException (checked) means that during explicit loading (Class.forName, loadClass) the class wasn't on the classpath. NoClassDefFoundError (an Error) means the compiler had seen the class but at runtime it was either absent or — as above — its initialization failed. The first is "never existed," the second is "existed but is unusable now."

Logging exceptions correctly — the most common everyday mistake

Don't confuse `logger.error("failed: " + e)` with `logger.error("failed", e)`

The first concatenates only e.toString() (a single line) and throws the stack trace away — the very information you need to debug. The second, passing the exception as the last argument, tells the logging framework (SLF4J/Logback/Log4j2) to print the whole trace and the Caused by: chain.

// wrong — trace destroyed
log.error("failed to load order " + id + ": " + e);
// right — parameterized, exception last
log.error("failed to load order {}", id, e);
The "log-and-throw" anti-pattern

If you both log and rethrow, the same error is logged multiple times across layers, and in Kibana/Splunk you see three nearly identical stack traces and think there were three failures. The rule: either handle it (log + recover) or throw it and let one top-level handler (that @ControllerAdvice) log it once. Never both.

Put context in the MDC, not the message

Instead of repeating orderId and userId inside every message string, put them in the MDC (Mapped Diagnostic Context) so they attach automatically to every log line of that request. With a shared traceId (from OpenTelemetry) you can follow one error across several services — which is what makes distributed debugging possible at all.

Short senior facts that win interviews

Precise rethrow (Java 7+)

Since Java 7, if you catch a broad exception and rethrow it, the compiler "intelligently" narrows to the types that could actually be thrown:

void m() throws IOException, SQLException {
    try { risky(); }
    catch (Exception e) {   // e is never reassigned → effectively final
        log.warn("retrying");
        throw e;   // compiler knows it's only IOException or SQLException, not any Exception
    }
}

In Java 6 this same code forced you to declare throws Exception. The condition is that you never reassign e.

Exceptions are Serializable — and that has a trap

Every Throwable is serializable (it must travel between JVMs over RMI/caches). Two notes: (1) if you add a custom field that isn't itself serializable, serializing the exception breaks — mark it transient. (2) Declare a serialVersionUID, or deserialization breaks across versions when the class changes. In a distributed cache that stores exceptions, this really does happen.

Throwable blocks a circular cause chain

If you accidentally do a.initCause(b) and b.initCause(a), initCause throws (it forbids a throwable being its own cause or forming a loop). Also, initCause can't be called twice; if you already passed a cause in the constructor, a later initCause fails.

Reactive boundaries and retry logic

If you work with Project Reactor (WebFlux), a try/catch is useless because the error happens when the pipeline executes, not when it is built. The error travels as an onError signal through the stream:

orderService.load(id)                       // Mono<Order>
    .onErrorResume(NotFoundException.class,  // like catch, but in-stream
        e -> Mono.just(Order.empty()))
    .doOnError(e -> log.error("load failed", e))   // side-effect, doesn't change the stream
    .retryWhen(Retry.backoff(3, Duration.ofMillis(200)));
Retry only "transient" errors, and only if the operation is idempotent

Retrying an IllegalArgumentException (a permanent error) is pointless and just adds load; retry only transient errors like a timeout or a 503. More importantly: if the operation is not idempotent (like "charge the card"), a retry can charge twice. Either make the operation idempotent (with an idempotency key) or don't retry at all.

StructuredTaskScope changed in Java 25

The new StructuredTaskScope.ShutdownOnFailure() example you saw in the async section was the Java 21 preview API. In Java 25 (JEP 505) the API was redesigned: constructors were replaced by the factory method StructuredTaskScope.open(...), and ShutdownOnFailure/ShutdownOnSuccess were removed in favor of a flexible Joiner interface (e.g. Joiner.allSuccessfulOrThrow()). The concepts stayed identical; only the call shape modernized. Since it's still preview, expect further change.

1. A `@Transactional` method throws a checked exception but doesn't roll back. Why, and how do you fix it?

Because Spring's default (inherited from EJB) rolls back only on RuntimeException and Error; a checked exception causes a commit. Three fixes: @Transactional(rollbackFor = MyChecked.class); make the exception unchecked; or call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() inside the method. And remember: if you catch the exception inside the method and don't rethrow, no rollback happens at all — rollback is triggered by the exception escaping.

2. A class first throws `ExceptionInInitializerError`, then the log fills with `NoClassDefFoundError`. What's going on?

A static block or static-field initializer in that class threw, and the class went into "failed initialization." A static initializer runs only once, so the JVM won't retry it and from then on throws NoClassDefFoundError: Could not initialize class ... instead of the real cause. To debug, find the first error in the log (the ExceptionInInitializerError and its Caused by:), not the later ones.

3. What does `log.error("failed: " + e)` lose?

String concatenation prints only e.toString() (one line: type + message) and discards the entire stack trace and Caused by: chain. The correct form passes the exception as the last argument: log.error("failed to load {}", id, e) — then the logging framework prints the full trace. At 3 a.m. during an incident, this difference is 5 minutes vs 5 hours.

4. Since Java 15 an NPE names the null variable. How does the JVM know?

By bytecode analysis at throw time: the JVM determines which instruction (invokevirtual/getfield) operated on the reference that was null and reconstructs the variable or field name from the LocalVariableTable/constant pool. For full accuracy the code must be compiled with debug info (-g). This feature (JEP 358) was opt-in in Java 14 and on by default from Java 15. But if fast-throw kicks in, both the message and trace disappear.

5. Difference between `ClassNotFoundException` and `NoClassDefFoundError`?

ClassNotFoundException is a checked exception thrown during explicit loading (Class.forName, loadClass) — the class simply wasn't on the classpath. NoClassDefFoundError is an Error: the class was present at compile time but at runtime was either absent or (more commonly) its static initializer failed and poisoned the class. In short: the first "never found," the second "was there but is unusable now."

6. Why can't you catch errors with `try/catch` in WebFlux/Reactor, and what do you use instead?

Because reactive code only builds the pipeline; execution happens later, on another thread (at subscribe time), so a try/catch around pipeline construction sees no error. The error flows as an onError signal and you handle it with operators: onErrorResume/onErrorReturn (catch + recover), doOnError (side-effect only, like logging), and retryWhen for retries. The same logic applies to CompletableFuture (exceptionally/handle).

7. When do you retry a failed operation, and what's the danger?

Retry only transient errors — a timeout, a 503, a momentary network blip; never a permanent error like a 400/IllegalArgumentException, which just adds load. The main danger: if the operation is not idempotent (charging a card, sending an email), a retry can apply the effect twice. The fixes: a server-side idempotency key, exponential backoff + jitter to avoid a thundering herd, and a circuit breaker (like Resilience4j) so you stop hammering a downstream service that's already down.

8. Why is the "log-and-throw" pattern bad?

Because the same error gets logged once per layer and appears in your centralized log system as several near-identical stack traces; someone assumes several separate incidents and wastes time. The rule: log each error once, at the point where you actually decide something about it — usually a top-level handler like @ControllerAdvice or an UncaughtExceptionHandler. Either handle it or throw it, not both.

9. You wrote a custom exception holding a `Connection` field, and it gets serialized into a cache. What happens?

Throwable is Serializable, so the JVM tries to serialize the whole object — but Connection isn't serializable and you get a NotSerializableException. The fix: mark non-serializable fields transient (and accept they're null after deserialization). Also declare an explicit serialVersionUID so old cached instances don't break on deserialization when the class changes. The senior rule: keep only lightweight, serializable data (ids, messages) in an exception — never live resources.

Senior notes recap
  • @Transactional rolls back only on unchecked by default; for checked use rollbackFor or setRollbackOnly(), and watch out for catching and self-invocation.
  • At the HTTP boundary, translate errors to ProblemDetail (RFC 9457) via @ControllerAdvice; never hand a stack trace to the client — only a traceId.
  • Helpful NPE (Java 15+) reconstructs the variable name from bytecode, but fast-throw swallows it.
  • The poisoned class: ExceptionInInitializerError once, then NoClassDefFoundError forever — always read the first error.
  • Logging: pass the exception as the last argument, don't "log-and-throw," put context in the MDC.
  • In reactive code use onErrorResume/retryWhen; retry only transient errors and idempotent operations.
In a nutshell
  • An exception is a non-local transfer of control: it unwinds the stack until someone catches it. Its cost is dominated by fillInStackTrace, not the throw/catch itself.
  • Hierarchy: Throwable → {Error — serious, don't catch; Exception}. checked = subclasses of Exception that aren't RuntimeException; everything else is unchecked.
  • Design debate: checked scales badly and breaks lambdas/streams; modern practice leans unchecked (Spring, Kotlin, Scala).
  • try-with-resources: closes AutoCloseable resources in reverse order; the body exception wins and close exceptions are suppressed (getSuppressed()).
  • finally traps: return/throw in finally swallows everything; a captured return value isn't changed; plain finally doesn't suppress.
  • Always preserve the cause (exception translation), fail fast, never swallow, and restore the interrupt flag.
  • For normal outcomes use Optional/Result, not exceptions.
  • Across async boundaries, re-plumb the error: Future.getExecutionException; CompletableFuture.joinCompletionException; virtual threads + structured concurrency restore the parent-child relationship.