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

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

Якщо в finally виникне виняток, він витіснить виняток з try. Оригінальна помилка буде втрачена.

Мовні версії: English Russian Ukrainian

Junior Level

Основний ефект

Якщо в finally виникне виняток, він витіснить виняток з try. Оригінальна помилка буде втрачена.

try {
    throw new RuntimeException("Original error");
} finally {
    throw new RuntimeException("Finally error");
}
// Буде видно тільки "Finally error"

Чому це небезпечно

try {
    // Критична помилка — БД недоступна
    connection.executeQuery("SELECT * FROM users");
} finally {
    // Помилка при закритті — NPE
    connection.close(); // connection == null
}
// У логах тільки NPE, інформація про БД втрачена

Ви будете чистити код від NPE, хоча реальна проблема — інфраструктура.

Як уникнути

Використовуйте try-with-resources — він автоматично обробляє suppressed винятки:

try (Connection conn = dataSource.getConnection()) {
    conn.executeQuery("SELECT * FROM users");
}
// Якщо і try, і close() впадуть — обидва винятки збережуться

Коли НЕ покладатися на finally для очищення

  1. Код у finally може викинути виняток — якщо close(), disconnect(), release() можуть впасти, огортайте кожен виклик в окремий try-catch всередині finally
  2. Кілька ресурсів без TWR — якщо закриваєте 3+ ресурси в одному finally, використовуйте окремі try-catch для кожного, інакше перший виняток перерве закриття решти
  3. Критичні транзакції — у finally не можна безпечно відкотити транзакцію, якщо основний виняток вже стався; використовуйте окремий catch блок для rollback
  4. Async-контекст — в CompletableFuture блок finally може виконатися в іншому потоці, і виняток там втратиться без UncaughtExceptionHandler
  5. Shutdown hooks — при System.exit() finally може не виконатися взагалі
  6. Native resource cleanup — для JNI/native пам’яті використовуйте Cleaner (Java 9+) замість finally, бо native-ресурси можуть не звільнятися коректно

Middle Level

Механізм придушення (Shadowing) — покроково

JVM несе у стеку тільки один об’єкт винятку як «активний» у кожен момент часу. Ось що відбувається покроково:

Крок 1: Код у try викидає виняток A (new RuntimeException("Original")).

Крок 2: JVM позначає поточний фрейм стека як «викидаючий виняток» і зберігає посилання на A у спеціальному слоті фрейма.

Крок 3: Перед виходом з методу JVM виконує блок finally (це гарантується специфікацією JLS 14.20.2).

Крок 4: Код у finally викидає виняток B (new RuntimeException("Finally")).

Крок 5: JVM замінює посилання у слоті винятків фрейма з A на B. Посилання на A втрачається назавжди — немає способу отримати його з catch або звідкись.

Крок 6: Виняток B propagates вгору по стеку. A стає недосяжним для GC тільки після того, як увесь стек розкрутиться.

Візуально:

Фрейм стека (до finally):  pendingException = A
Фрейм стека (після finally): pendingException = B  // A втрачений!

Конкретний приклад з кодом

public class FinallyShadowing {
    public static void main(String[] args) {
        try {
            System.out.println("Крок 1: викидаємо SQLException");
            throw new SQLException("Database connection lost");
        } finally {
            System.out.println("Крок 2: finally намагається закрити ресурс");
            // close() теж падає — і це затьмарює SQLException
            throw new NullPointerException("Connection was null in finally");
        }
    }
}

Вивід у стек-трейсі:

Exception in thread "main" java.lang.NullPointerException: Connection was null in finally
    at FinallyShadowing.main(FinallyShadowing.java:8)

Зверніть увагу: SQLException повністю відсутній у стек-трейсі. Ви будете налагоджувати NPE, хоча справжня проблема — втрата з’єднання з БД.

Як зберегти оригінальний виняток:

try {
    throw new SQLException("Database connection lost");
} catch (Exception e) {
    try {
        // finally-логіка окремо
        closeSafely();
    } catch (Exception closeEx) {
        e.addSuppressed(closeEx); // Зберігаємо обидва
    }
    throw e; // Оригінальний виняток propagates
}

Shadowing через return

finally може «проковтнути» виняток навіть без викидання нового:

public int dangerousMethod() {
    try {
        throw new RuntimeException("Error");
    } finally {
        return 42; // Виняток проковтнуто! Метод поверне 42
    }
}

Інструкція return у байт-коді finally завершує фрейм стека раніше, ніж механізм обробки винятків встигне пробросити помилку. SonarQube маркує це як Blocker.

Сучасне рішення: Try-with-resources

Починаючи з Java 7:

  • Якщо виняток у try, а потім в автоматичному close() — виняток з try залишається основним
  • Виняток з close() додається як Suppressed
  • Повна картина: Error A (Suppressed: Error B)
try (Resource r = new Resource()) {
    r.doWork(); // IOException #1
} // close() кидає IOException #2

// #1 — основний, #2 — suppressed
catch (Exception e) {
    System.out.println(e.getMessage());         // #1
    System.out.println(e.getSuppressed()[0]);   // #2
}

Senior Level

Resource Leaks при винятку в finally

Якщо виняток у finally стався посеред очищення (закриття першого з п’яти сокетів), решта ресурсів не будуть закриті — виконання finally перерветься.

Senior Best Practice: огортайте кожен close() в окремий try-catch всередині finally:

finally {
    try { resource1.close(); } catch (Exception e) { log.warn("Failed to close 1", e); }
    try { resource2.close(); } catch (Exception e) { log.warn("Failed to close 2", e); }
}

Або використовуйте try-with-resources — він робить це автоматично.

Analyzing Logs

Якщо бачите у логах дивні винятки з блоків очищення (SocketClosedException), завжди перевіряйте, чи не приховують вони більш ранні помилки.

Debugger

При покроковому налагодженні стежте за переходом у finally. Якщо змінна винятку у кадрі стека раптово змінилася — ви зловили Shadowing.

Bytecode аналіз

javap -c MyClass

Покаже, як return у finally перезаписує значення повернення і як виняток затирає попередній.

Діагностика

  • getSuppressed() — перевіряйте масив suppressed винятків
  • Static Analysis — Sonar попереджає про risky finally блоки
  • Log Correlation — пов’язуйте винятки з try та finally за Trace ID

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

Обов’язково знати:

  • Виняток з finally витісняє виняток з try — оригінальна помилка втрачена назавжди
  • JVM зберігає тільки один pending exception у слоті фрейма стека — finally перезаписує його
  • return у finally теж «проковтує» виняток — метод поверне значення замість помилки
  • Try-with-resources автоматично зберігає обидва винятки: основний + suppressed з close()
  • Без TWR огортайте кожен close() в окремий try-catch всередині finally
  • При кількох ресурсах у finally перший виняток перерве закриття решти — resource leak
  • В async-контексті виняток у finally може втратитися без UncaughtExceptionHandler

Часті уточнюючі запитання:

  • Як побачити оригінальний виняток? — Без TWR ніяк — він втрачений. Використовуйте try-with-resources
  • Чому TWR краще за finally? — Зберігає обидва винятки через suppressed механізм
  • Що якщо finally робить return? — Виняток з try проковтнуто, метод поверне значення
  • Як правильно закривати ресурси без TWR? — Кожен close() в окремому try-catch всередині finally

Червоні прапорці (НЕ говорити):

  • “У finally можна безпечно кидати винятки” — вони затьмарять оригінальну помилку
  • “Використовую finally для закриття ресурсів” — застарілий підхід, використовуйте TWR
  • “Return у finally — нормальна практика” — SonarQube маркує як Blocker
  • “Втрачений виняток не страшний” — ви будете налагоджувати не ту проблему

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

  • [[24. Що таке suppressed винятки]] — як TWR зберігає обидва винятки
  • [[9. Що таке try-with-resources]] — автоматичне управління ресурсами
  • [[8. Чи гарантується виконання блоку finally]] — коли finally може не виконатися
  • [[20. Чому не варто ковтати винятки (порожній catch)]] — втрата помилки