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

Чому не варто ковтати винятки (порожній catch)

Це одна з найнебезпечніших помилок у програмуванні з таких причин:


🟢 Junior Level

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

«Ковтання» винятків (Exception Swallowing) — це антипатерн, за якого виняток перехоплюється в блоці catch, але жодним чином не обробляється, не логується і не прокидається далі (catch (Exception e) {}).

Це одна з найнебезпечніших помилок у програмуванні з таких причин:

  1. Програма переходить у неконсистентний стан: Метод аварійно перервався на середині виконання, критичні поля не проініціалізувалися, але викликаючий код переконаний, що операція завершилася успішно.
  2. Зникає першопричина збою: У логах немає жодного запису. Збій проявиться набагато пізніше в зовсім іншому модулі, й розслідувати його буде практично неможливо.
  3. Марна витрата ресурсів CPU: Віртуальна машина вже витратила ресурси на нативний обхід стека fillInStackTrace(), але результат обчислення був мовчки викинутий збирачу сміття (GC).

Механіка JVM: як губиться виняток

У разі виникнення помилки JVM призупиняє нормальний потік виконання і шукає відповідний обробник у таблиці винятків методу (Exception_table). Коли керування передається в блок catch, виняток вважається повністю обробленим. Якщо тіло блоку порожнє:

  1. JVM переходить до наступної інструкції байткоду за межами блоку try-catch.
  2. Об’єкт винятку втрачає посилання й утилізується збирачем сміття (GC).
  3. Усі вищі методи у стеку викликів залишаються в невіданні щодо аварії, яка щойно сталася.
// ❌ АНТИПАТЕРН: катастрофічне пошкодження даних
public void transferMoney(Account from, Account to, BigDecimal amount) {
    try {
        from.debit(amount);     // 1. Гроші списано з рахунку
        saveAccount(from);
        
        to.credit(amount);      // 2. Викидає виняток (наприклад, рахунок заблоковано)
        saveAccount(to);
    } catch (Exception e) {
        // ПОРОЖНЬО! Виняток проковтнуто.
    }
    // Метод завершився "успішно". Гроші у клієнта списалися, але одержувачу не дійшли!
}

🟡 Middle Level

Рідкісні винятки: коли порожній catch виправданий

Специфікація та статичні аналізатори допускають пригнічення винятку в суворо обмежених сценаріях:

1. Очікуване переривання потоку (InterruptedException)

Під час перехоплення InterruptedException категорично заборонено залишати блок порожнім без відновлення прапорця переривання, інакше зовнішні планувальники завдань (наприклад, ExecutorService) не зможуть коректно зупинити робочий потік:

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // ОБОВ'ЯЗКОВО відновлюємо статус переривання потоку!
    Thread.currentThread().interrupt();
    log.warn("Task was interrupted during sleep");
}

2. Угода про іменування ignored (Java Convention)

Якщо виняток дійсно очікуваний і його ігнорування передбачене дизайном алгоритму, змінну називають ignored або expected. Це сигналізує лінтерам (IntelliJ IDEA, SonarQube), що дія виконана усвідомлено:

// Спроба закрити застарілий сокет при аварійному завершенні з'єднання
try {
    socket.close();
} catch (IOException ignored) {
    // Нічого вдіяти не можна, з'єднання і так мертве
}

3. Необов’язкові побічні операції (Best-effort Actions)

Очищення тимчасових файлів під час зупинки JVM або оновлення локального кешу, відмова якого не ламає бізнес-логіку:

try {
    metricsClient.sendHit("user.login");
} catch (MetricsException e) {
    // Падіння сервісу метрик не повинно ламати логін користувача!
    // МІНІМУМ: логуємо на рівні DEBUG для розслідування
    log.debug("Failed to send metrics", e);
}

Катастрофа в Spring @Transactional та UnexpectedRollbackException

Якщо в методі, позначеному анотацією Spring @Transactional, виникає непролазний (unchecked) виняток, транзакційний аспект Spring перехоплює його і позначає поточну фізичну транзакцію БД як Rollback-Only (setRollbackOnly()).

Якщо розробник на рівні вище проковтне цей виняток:

@Service
public class OrderService {

    @Autowired
    private PaymentService paymentService;

    @Transactional
    public void placeOrder(Order order) {
        try {
            paymentService.charge(order); // Метод падає з RuntimeException -> транзакція позначена Rollback-Only!
        } catch (RuntimeException e) {
            // Виняток проковтнуто! Розробник вважає, що все під контролем.
        }
        // Метод placeOrder намагається закомітити транзакцію...
        // КРАХ: Spring викидає org.springframework.transaction.UnexpectedRollbackException!
    }
}

Результат: Коміт завершиться фатальним UnexpectedRollbackException. Спроба приховати помилку через catch лише поглибила проблему й призвела до аварії на етапі завершення транзакції.


Проковтування помилок у Stream API та реактивних ланцюжках

Проковтування винятків усередині функціональних потоків руйнує цілісність даних:

// ❌ АНТИПАТЕРН: повертаємо null замість обробки
List<UserDto> users = userIds.stream()
    .map(id -> {
        try {
            return client.fetchUser(id);
        } catch (Exception e) {
            return null; // У колекцію проникають приховані null-елементи!
        }
    })
    .toList(); // Підсумковий список містить [User, null, User, null] -> NullPointerException далі за кодом

🔴 Senior Level

“Zombie Architecture” та приховані збої (Silent Corruption)

Проковтування винятків породжує так звані системи-зомбі:

  • Процес Java запущений, порт слухається, метрики /actuator/health віддають 200 OK.
  • Проте внутрішній кеш розсинхронізований із базою, події з Kafka перестали вичитуватися, а фонові планувальники зависли.
  • Знадобляться тижні, щоб виявити фінансові розбіжності, викликані одним проковтнутим рядком помилки.

Вартість прихованих винятків під високим навантаженням (Highload Penalty)

Деякі розробники вважають: «Якщо я ловлю виняток порожнім catch, він працює швидко, немов порожній if». Це фундаментальна помилка.

Виклик методу
    │
    ▼
new CustomException() ──> JVM HotSpot переходить у нативний C++ код
                            │
                            ▼
                    fillInStackTrace() (обхід стека фреймів потоку ядра ОС)
                            │
                            ▼
                    Алокація масиву StackTraceElement[] в Heap
                            │
                            ▼
                    catch (Exception ignored) { } // Об'єкт миттєво відкинуто до GC!

Якщо в циклі обробки 10 000 повідомлень на секунду виняток регулярно викидається і ковтається:

  • Навантаження на процесор зростає до 100% виключно через розгортання стеків викликів.
  • Створюється колосальний тиск на Garbage Collector (GC Pressure) через мільярди короткоживучих об’єктів винятків.
  • У логах при цьому панує абсолютна тиша, що робить профілювання надзвичайно складним без async-profiler.

Контроль якості коду в CI/CD: Quality Gates

У корпоративному пайплайні кодова база захищається статичними аналізаторами:

  1. SonarQube Rule java:S1166: “Exception handlers should preserve the original exceptions”. Забороняє перехоплення без логування або повторного викидання.
  2. SonarQube Rule java:S108: “Nested blocks of code should not be empty”.
  3. Google Error-Prone: перевірка EmptyCatch.

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

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

  1. Що станеться у Spring @Transactional, якщо проковтнути RuntimeException у блоці catch викликаючого методу? Відповідь: Транзакція все одно не зафіксується (не закомітиться). Внутрішній механізм Spring позначить транзакцію міткою rollback-only. Під час виходу з зовнішнього транзакційного методу Spring спробує зафіксувати транзакцію, виявить мітку відкату й викине UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only.
  2. Чому під час перехоплення InterruptedException не можна залишати блок catch порожнім? Відповідь: Під час викидання InterruptedException JVM автоматично скидає статус переривання поточного потоку (isInterrupted() == false). Якщо не відновити його викликом Thread.currentThread().interrupt(), зовнішній пул потоків або координатор завдань не дізнається про сигнал завершення, і потік продовжить нескінченно працювати.
  3. Чому ковтання винятку в циклі високонавантаженого сервісу призводить до деградації продуктивності? Відповідь: Створення об’єкта винятку супроводжується нативним викликом fillInStackTrace(), що вимагає зупинки виконання й лінійного обходу стека викликів. Навіть якщо виняток одразу проковтнуто, ресурси CPU та пам’яті на збирання стека вже витрачено.
  4. Яка угода про іменування використовується в Java для змінних винятків, які свідомо ігноруються? Відповідь: Змінну називають словом ignored (наприклад, catch (IOException ignored) {}). Це стандартизований маркер для статичних аналізаторів (Sonar, IntelliJ IDEA), що підтверджує навмисне ігнорування помилки.

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

  • ❌ Твердження: “Якщо помилка не важлива, простіше поставити порожній catch”: Призводить до прихованих багів та систем-зомбі.
  • ❌ Порожній catch (InterruptedException e) {}: Втрата сигналу завершення потоку та зависання застосунків під час зупинки контейнера.
  • ❌ Проковтування помилок валідації в Stream API з поверненням null: Породжує відкладені NullPointerException в інших модулях.
  • ❌ Відсутність навіть log.debug() за усвідомленого ігнорування збою: Робить неможливою діагностику в production.

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