Вопрос 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
  • “Потерянное исключение не страшно” — вы будете отлаживать не ту проблему

Связанные темы:

  • [[23. Что такое suppressed exceptions]] — как TWR сохраняет оба исключения
  • [[9. Что такое try-with-resources]] — автоматическое управление ресурсами
  • [[8. Гарантируется ли выполнение блока finally]] — когда finally может не выполниться
  • [[19. Почему не стоит глотать исключения (catch empty)]] — потеря ошибки