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

Які винятки потрібно завжди обробляти

Відповідь на це запитання залежить від того, хто визначає обов'язковість — компілятор Java (javac) чи архітектура надійності застосунку.


🟢 Junior Level

Відповідь на це запитання залежить від того, хто визначає обов’язковість — компілятор Java (javac) чи архітектура надійності застосунку.

1. З погляду компілятора Java:

Обов’язковими до обробки є виключно Checked Exceptions (перевірювані винятки):

  • Це всі класи, що успадковуються від java.lang.Exception, окрім гілки java.lang.RuntimeException.
  • Якщо метод викликає код, який викидає checked-виняток, розробник зобов’язаний обрати одну з двох дій:
    1. Обернути виклик у конструкцію try-catch.
    2. Оголосити перевірюваний виняток у сигнатурі методу через ключове слово throws.

Якщо жодної з дій не виконано, вихідний код не скомпілюється:

// ПОМИЛКА КОМПІЛЯЦІЇ: Unhandled exception: java.io.IOException
public void readFile() {
    Files.readAllBytes(Path.of("config.json")); 
}

2. Зведена таблиця обов’язковості обробки

Категорія винятків Приклади Обов’язково компілятором? Обов’язково в архітектурі?
Checked Exceptions IOException, SQLException, InterruptedException ✅ Так (суворо) ✅ Так (локально або трансляція)
Unchecked Exceptions NullPointerException, IllegalArgumentException ❌ Ні 🟡 Так (на межах шарів та потоків)
Errors OutOfMemoryError, StackOverflowError ❌ Ні 🛑 Заборонено перехоплювати

🟡 Middle Level

Обов’язкова обробка на межах системи (System Boundaries)

Хоча компілятор не змушує писати try-catch для RuntimeException, у реальному промисловому застосунку необроблений виняток:

  1. Завершить потік виконання (Thread death).
  2. Спричинить витік деталей реалізації клієнту (Internal Server Error 500 зі стек-трейсом).
  3. Призведе до зависання розподіленої транзакції або повідомлення в черзі брокера (Kafka / RabbitMQ).

Тому на периметрі системи (межі шарів) обробка unchecked-винятків є архітектурно обов’язковою:

  • У REST API: Верхньорівневий @RestControllerAdvice зобов’язаний перехоплювати всі непередбачені Exception і повертати клієнту знеособлений RFC 9457 ProblemDetail.
  • У Consumer Kafka / RabbitMQ: Обробник черги зобов’язаний перехоплювати помилки парсингу та бізнес-збої, спрямовуючи пошкоджені повідомлення в Dead Letter Queue (DLQ).

Чому перехоплення Error та Throwable категорично не рекомендується?

Розробники-початківці часто пишуть:

// ❌ НЕБЕЗПЕЧНИЙ АНТИПАТЕРН:
try {
    doSomeHeavyComputation();
} catch (Throwable t) { // Перехоплює Error, OutOfMemoryError, ThreadDeath!
    log.error("Error occurred", t);
}

Чим це загрожує:

  • java.lang.Error (зокрема OutOfMemoryError, InternalError, UnknownError) сигналізує про катастрофічну відмову середовища виконання JVM.
  • У разі виникнення OutOfMemoryError пам’ять купи (Heap) вичерпана, внутрішні структури JVM можуть бути пошкоджені, а монітори синхронізації розблоковані в некоректному стані.
  • Спроба «проковтнути» OutOfMemoryError і продовжити роботу програми переводить сервіс у стан «зомбі»: запити зависають, блокування не звільняються, інстанс не відповідає на Health Check. Єдина правильна дія при Error — дати процесу впасти та дозволити Kubernetes / Systemd перезапустити под.

🔴 Senior Level

Необроблені винятки на межі багатопоточності

Якщо всередині фонового потоку (Worker Thread) викидається unchecked-виняток, поведінка залежить від механізму запуску:

  1. new Thread(runnable).start(): Потік негайно завершує роботу. За замовчуванням стек-трейс друкується в System.err, а сам застосунок може навіть не зафіксувати збій у лог-файлі.
  2. ExecutorService.execute(Runnable): Потік завершується аварійно, пул потоків створює новий потік замість загиблого.
  3. ExecutorService.submit(Callable): Виняток перехоплюється реалізацією FutureTask і зберігається всередині. Якщо викликаючий код не викликав метод future.get(), виняток безслідно розчиняється, і ніхто не дізнається про аварію.

Production рішення: Встановлення UncaughtExceptionHandler

Для запобігання непомітній загибелі потоків на рівні ініціалізації застосунку реєструється обробник:

package com.example.init;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class GlobalExceptionHandlerInitializer implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandlerInitializer.class);

    @Override
    public void run(String... args) {
        // Перехоплює всі необроблені винятки в будь-яких потоках JVM
        Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
            if (throwable instanceof Error) {
                log.error("FATAL: JVM Error in thread [{}]. Initiating emergency shutdown.", 
                        thread.getName(), throwable);
                // Завершуємо процес при фатальних системних помилках
                System.exit(1);
            } else {
                log.error("CRITICAL: Uncaught exception in thread [{}]", 
                        thread.getName(), throwable);
            }
        });
    }
}

Особливий статус InterruptedException: контракт кооперативної багатопоточності

InterruptedException — це перевірюваний виняток, що вимагає обов’язкової архітектурної дисципліни. Потік у Java не можна безпечно «вбити» ззовні (метод Thread.stop() оголошено deprecated і видалено). Зупинка потоків є виключно кооперативною.

Коли потік спить у Thread.sleep(), чекає на моніторі wait() або блокується в черзі BlockingQueue.take(), виклик thread.interrupt() викидає InterruptedException і скидає прапорець переривання потоку в false.

Правило обробки:

public void runWorkerLoop() {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            Task task = taskQueue.take(); // Викидає InterruptedException
            task.execute();
        } catch (InterruptedException ex) {
            // ОБОВ'ЯЗКОВО: відновити перерваний статус потоку!
            Thread.currentThread().interrupt();
            log.info("Worker thread interrupted, exiting gracefully...");
            break; // Завершуємо робочий цикл
        }
    }
}

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

30-секундна відповідь

  • З погляду компілятора Java, обов’язковими до обробки є лише Checked Exceptions (IOException, SQLException, InterruptedException). Компілятор вимагає або обернути їх у try-catch, або задекларувати в секції throws.
  • З погляду архітектури, RuntimeException обов’язково перехоплюються на межах системи (у @RestControllerAdvice, обробниках черг Kafka, фільтрах безпеки) для запобігання падінню потоків і формування зрозумілих відповідей клієнту.
  • Системні помилки Error перехоплювати категорично заборонено: вони свідчать про фізичну непрацездатність JVM (OutOfMemoryError), і процес має бути перезапущений оркестратором.
  • На межах багатопоточності обов’язковим є встановлення Thread.setDefaultUncaughtExceptionHandler.

4 каверзних питання з відповідями

  1. Що станеться, якщо в завданні, переданому в executorService.submit(runnable), відбудеться ділення на нуль (ArithmeticException)? Відповідь: Пул потоків перехопить ArithmeticException і збереже його всередині повернутого об’єкта Future. Жодних логів у консоль виведено не буде. Якщо потік, що викликав завдання, не викличе future.get(), виняток буде безслідно проковтнутий. Якщо викликати future.get(), він буде викинутий загорнутим у ExecutionException.

  2. Чому перехоплення catch (Throwable t) вважається небезпечним антипатерном? Відповідь: Клас Throwable є спільним предком для Exception та Error. Перехоплюючи Throwable, блок catch заблокує природне падіння віртуальної машини при OutOfMemoryError або StackOverflowError. Застосунок залишиться в напівзруйнованому «зомбі»-стані з вичерпаною пам’яттю або пошкодженими блокуваннями.

  3. Що відбувається з прапорцем переривання потоку у разі виникнення InterruptedException і як правильно вчинити? Відповідь: Механізм JVM під час викидання InterruptedException автоматично скидає прапорець переривання в false. Якщо розробник просто залогує помилку і продовжить роботу, зовнішній сигнал про зупинку сервісу буде втрачено. Обробник зобов’язаний або прокинути виняток далі, або відновити статус викликом Thread.currentThread().interrupt().

  4. Як компілятор Java перевіряє обов’язковість обробки checked-винятків усередині багатомодульних проєктів? Відповідь: На основі атрибута байткоду Exceptions у .class файлах залежностей. Компілятор javac будує граф викликів і перевіряє, що кожен викликаний метод з оголошеним throws або укладений у блок try з відповідним catch, або поточний метод сам містить цей виняток (чи його предка) у своєму throws.

Червоні прапорці (чого категорично не можна говорити)

  • ❌ «Потрібно завжди загортати весь код методу в catch (Throwable t), щоб програма ніколи не падала» (груба помилка: це ламає моніторинг і призводить до зомбі-процесів при OutOfMemoryError).
  • ❌ «Компілятор змушує обробляти NullPointerException» (NPE — це unchecked виняток, компілятор його не контролює).
  • ❌ «Якщо потік перервали через InterruptedException, можна просто продовжити цикл» (порушує кооперативну багатопоточність і перешкоджає graceful shutdown).
  • ❌ «У мікросервісах checked-винятки зобов’язані доходити прямо до фронтенду» (checked-винятки інфраструктури зобов’язані транслюватися в доменні помилки на межі шарів).

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