⚡ Раздел 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-исключения инфраструктуры обязаны транслироваться в доменные ошибки на границе слоев).

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