⚡ Розділ 7 · Питання #23

Що станеться, якщо виняток виникне ще й в блоці finally

Якщо в блоці finally виникне виняток, відбудеться ефект маскування винятку (Exception Masking / Shadowing):


🟢 Junior Level

Коротка відповідь для співбесіди (30 секунд)

Якщо в блоці finally виникне виняток, відбудеться ефект маскування винятку (Exception Masking / Shadowing):

  1. Виняток із блоку finally повністю витіснить первинний виняток, що виник у блоці try або catch.
  2. Первинний виняток буде безповоротно втрачено: він не потрапить у лог, не буде переданий викликаючому коду і буде видалений збирачем сміття (GC).
  3. Виконання блоку finally негайно перерветься в точці викидання помилки. Якщо після цього мало відбутися закриття інших ресурсів, вони не будуть закриті, що призведе до витоку системних дескрипторів (Resource Leak).

Для запобігання втраті помилок у сучасному коді замість блоків finally завжди використовують try-with-resources, який зберігає первинну помилку, а вторинні додає як пригнічені (Suppressed Exceptions).


Наочний приклад витіснення помилки

public class FinallyMaskingDemo {
    public static void main(String[] args) {
        try {
            processData();
        } catch (Exception e) {
            // У лог потрапить ТІЛЬКИ NullPointerException з блоку finally!
            // Первинний критичний збій DatabaseTimeoutException повністю стерто!
            System.err.println("Caught exception: " + e.getClass().getSimpleName() + " -> " + e.getMessage());
        }
    }

    public static void processData() {
        Connection conn = null;
        try {
            // 1. Виникає критична помилка бізнес-шару
            throw new IllegalStateException("Database query timeout (Primary error)");
        } finally {
            // 2. Помилка під час закриття недоініціалізованого ресурсу
            conn.close(); // conn == null -> викидає NullPointerException!
        }
    }

    interface Connection { void close(); }
}

Вивід програми:

Caught exception: NullPointerException -> Cannot invoke "FinallyMaskingDemo$Connection.close()" because "conn" is null

Інженер розслідуватиме NullPointerException, навіть не підозрюючи, що справжня проблема полягала в таймауті бази даних.


🟡 Middle Level

Покроковий механізм на рівні JVM

Специфікація віртуальної машини Java (JVM Specification, §3.13) визначає суворий порядок обробки аварійних виходів:

  1. Фаза Try: Інструкція athrow ініціює розгортання стека. JVM зберігає посилання на первинний виняток E1 у локальному слоті стекового фрейму (Pending Exception Slot).
  2. Перехід у Finally: Перед тим як покинути метод, керування примусово передається на стартову адресу блоку інструкцій finally.
  3. Фаза Finally: Усередині блоку finally стається збій і виконується нова інструкція athrow з об’єктом E2.
  4. Перезапис вказівника: Віртуальна машина перезаписує активний виняток у фреймі: вказівник на E1 замінюється на E2. Посилання на об’єкт E1 втрачається назавжди.
  5. Розгортання стека: Керування передається вгору по стеку викликів уже з винятком E2.

Антипатерн “Return in Finally” (Пригнічення через повернення)

Ще підступнішим проявом ефекту витіснення є оператор return усередині finally:

public class ReturnInFinallyTrap {
    public static int calculate() {
        try {
            throw new RuntimeException("Fatal business error!");
        } finally {
            return 42; // ❌ КАТАСТРОФА: виняток тихо ковтається!
        }
    }

    public static void main(String[] args) {
        int result = calculate();
        System.out.println("Result: " + result); // Виведе 42, помилки немає!
    }
}

Інструкція байткоду ireturn у блоці finally завершує фрейм методу в штатному режимі. Будь-який активний виняток із блоку try мовчки знищується. З цієї причини правило SonarQube java:S1143 класифікує return у блоці finally як критичний баг (Blocker).


Каскадний витік ресурсів (Cascading Resource Leak)

Класичний код закриття декількох ресурсів в одному блоці finally без try-with-resources:

// ❌ АНТИПАТЕРН ДО JAVA 7:
finally {
    in.close();  // Якщо in.close() викине IOException...
    out.close(); // ...цей рядок НІКОЛИ НЕ ВИКОНАЄТЬСЯ! Ресурс out витече!
}

// Ручне виправлення до Java 7 (громіздкий Boilerplate):
finally {
    try {
        if (in != null) in.close();
    } catch (IOException e) {
        log.warn("Failed to close in", e);
    }
    try {
        if (out != null) out.close();
    } catch (IOException e) {
        log.warn("Failed to close out", e);
    }
}

🔴 Senior Level

Як javac компілює try-finally у сучасному байткоді

До Java 6 компілятор генерував інструкції підпрограм jsr (jump subroutine) та ret. Починаючи з Java 6 (введення StackMapTable для швидкої однопрохідної верифікації типів), інструкції jsr/ret були заборонені.

У сучасному байткоді компілятор дублює байткод блоку finally для всіх можливих шляхів виходу з методу:

  1. Копія finally для нормального завершення блоку try.
  2. Копія finally для кожної секції catch.
  3. Копія finally всередині спеціального службового обробника будь-якого винятку (any), що генерується в Exception_table.
Exception table:
   from    to  target type
       0     5    10   Class java/lang/Exception
       0     5    20   any
      10    15    20   any

Якщо секція any під час виконання свого тіла finally сама викидає виняток, виконання методу аварійно переривається інструкцією athrow, не доходячи до відновлення первинного винятку.


Архітектурне рішення: Suppressed Exceptions у Try-With-Resources

Для усунення маскування в Java 7 було запроваджено механізм пригнічених винятків (Suppressed Exceptions). У конструкції try-with-resources:

  • Виняток із тіла try завжди визнається головним (Primary).
  • Якщо під час виклику close() також виникає виняток, компілятор перехоплює його та прикріплює до головного через виклик методу primary.addSuppressed(closeException).
try (var r = new FaultyResource()) {
    r.doWork(); // Викидає WorkException (Primary)
} // close() викидає CloseException

// У результаті стек-трейс зберігає ОБИДВА винятки:
// WorkException: Work failed
//     at ...
//     Suppressed: CloseException: Close failed
//         at ...

🎯 Шпаргалка для інтерв’ю

Питання для швидкої перевірки

  1. Що станеться, якщо виняток виникне одночасно і в try, і в finally? Відповідь: Виняток із блоку finally повністю витіснить виняток із блоку try. Первинний виняток буде втрачено безповоротно, а викликаючий код отримає виняток, що вилетів із finally.
  2. Що станеться, якщо в блоці finally написати return 10;, коли в блоці try викинуто виняток? Відповідь: Виняток буде повністю пригнічено. Метод завершиться штатно і поверне число 10. Така конструкція вважається грубим антипатерном.
  3. Чому під час закриття кількох ресурсів у звичайному блоці finally виникає витік ресурсів? Відповідь: Якщо закриття першого ресурсу завершиться винятком, керування негайно покине блок finally. Інструкції закриття наступних ресурсів не виконаються, спричинивши витік нативних дескрипторів (файлів, сокетів).
  4. Як try-with-resources вирішує проблему витіснення винятків блоку finally? Відповідь: Механізм TWR зберігає помилку з блоку try як основну (primary), а всі помилки, що виникли під час закриття ресурсів у методах close(), зберігає всередині основного винятку за допомогою методу addSuppressed(Throwable).

Типові помилки та Red Flags

  • ❌ Твердження: “JVM об’єднає обидва винятки в один”: Без механізму try-with-resources звичайний finally викидає лише останній виняток, повністю затираючи перший.
  • ❌ Використання операторів return, break або continue всередині finally: Веде до немотивованого пригнічення активних помилок.
  • ❌ Спроба закриття кількох ресурсів в одному блоці finally одним рядком за іншим без індивідуальних try-catch: Гарантований витік ресурсів при першому ж збої.

Пов’язані теми