⚡ Раздел 7 · Вопрос #3

Что такое unchecked exception (Runtime Exception)

В иерархии типов непроверяемыми являются класс java.lang.RuntimeException и все его прямые и косвенные потомки (а также ветка системных ошибок java.lang.Error).


🟢 Junior Level

Unchecked exception (непроверяемое исключение) — это тип исключительной ситуации в Java, который компилятор javac не требует принудительно оборачивать в try-catch или объявлять в сигнатуре метода через ключевое слово throws.

В иерархии типов непроверяемыми являются класс java.lang.RuntimeException и все его прямые и косвенные потомки (а также ветка системных ошибок java.lang.Error).

Причины возникновения unchecked-исключений

  1. Ошибки программирования (баги в коде):
    • NullPointerException (NPE): Попытка вызвать метод или обратиться к полю по ссылке, равной null.
    • IndexOutOfBoundsException: Обращение к несуществующему индексу массива или списка (list.get(10) при размере 5).
    • ClassCastException: Некорректное приведение типов в runtime.
    • ArithmeticException: Деление целого числа на ноль (10 / 0).
  2. Нарушение контракта вызова метода:
    • IllegalArgumentException: Передан некорректный аргумент (например, отрицательный возраст пользователя).
    • IllegalStateException: Метод вызван в неподходящий момент жизненного цикла объекта (например, попытка чтения из закрытого потока).
    • UnsupportedOperationException: Попытка модифицировать неизменяемую коллекцию (List.of().add(...)).

Базовый пример в коде

import java.util.Objects;

public class UncheckedBasics {

    // Метод НЕ обязан объявлять throws IllegalArgumentException
    public void setDiscountPercent(int percent) {
        if (percent < 0 || percent > 100) {
            throw new IllegalArgumentException("Discount must be between 0 and 100%");
        }
        // бизнес-логика...
    }

    public static void main(String[] args) {
        UncheckedBasics service = new UncheckedBasics();
        // Компилятор не заставляет писать try-catch
        service.setDiscountPercent(150); // Вызовет исключение в Runtime
    }
}

🟡 Middle Level

Доменные Unchecked Exceptions и принцип Fail-Fast

В современной разработке unchecked-исключения делятся на две категории:

  1. Технические дефекты кода (NullPointerException, IndexOutOfBoundsException) — их не нужно ловить через try-catch, их нужно исправлять в коде (багфиксы).
  2. Доменные бизнес-исключения (UserNotFoundException, OrderAlreadyPaidException) — они сознательно наследуются от RuntimeException для информирования о штатных отказах бизнес-логики.

Принцип Fail-Fast (Быстрый отказ)

Код должен проверять входные параметры на самой ранней стадии выполнения и немедленно выбрасывать unchecked-исключение, не допуская проникновения некорректного состояния вглубь системы:

public Order processOrder(Long userId, OrderRequest request) {
    Objects.requireNonNull(userId, "User ID must not be null");
    Objects.requireNonNull(request, "Order request must not be null");

    if (request.items().isEmpty()) {
        throw new IllegalArgumentException("Order must contain at least one item");
    }

    return orderRepository.save(new Order(userId, request.items()));
}

Архитектура Spring Boot: централизованная обработка исключений

Благодаря тому, что RuntimeException свободно поднимается вверх по стеку вызовов через все слои (Repository -> Service -> Controller), в Spring приложениях исчезает необходимость в громоздких блоках try-catch.

Все доменные ошибки перехватываются единым централизованным компонентом @RestControllerAdvice:

package com.example.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;
import java.time.Instant;

@RestControllerAdvice
public class GlobalErrorHandlingAdvice {

    // Обработка бизнес-ошибки: 404 Not Found
    @ExceptionHandler(EntityNotFoundException.class)
    public ProblemDetail handleNotFound(EntityNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Resource Not Found");
        problem.setType(URI.create("https://api.example.com/errors/not-found"));
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }

    // Обработка невалидных аргументов: 400 Bad Request
    @ExceptionHandler(IllegalArgumentException.class)
    public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
        problem.setTitle("Invalid Request Argument");
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

🔴 Senior Level

Антипаттерн “Exceptions as Control Flow” (Исключения для логики)

Использование исключений для управления потоком выполнения программы является грубейшим антипаттерном:

// ❌ АНТИПАТТЕРН: Исключение как ветвление логики (Control Flow)
public boolean userExists(Long userId) {
    try {
        userService.findById(userId); // Выбрасывает UserNotFoundException
        return true;
    } catch (UserNotFoundException ex) {
        return false; // Катастрофическое падение производительности!
    }
}

// ✅ ПРАВИЛЬНО: Использование Optional или boolean метода
public boolean userExists(Long userId) {
    return userRepository.existsById(userId);
}

Почему это критично в Highload:

  • Вызов метода existsById() — это быстрая проверка условия.
  • Выбрасывание UserNotFoundException вызывает нативный метод fillInStackTrace(), который производит дорогостоящую паузу потока и обход фреймов стека вызовов. Под нагрузкой в 50 000 RPS такой код приводит к 100% утилизации CPU и деградации приложения.

Опасность необработанных RuntimeException в пулах потоков

В многопоточном программировании unchecked-исключения таят скрытую опасность:

  1. Если поток в ExecutorService.submit(Callable) падает с RuntimeException, исключение перехватывается фреймворком и «засыпает» внутри объекта Future. Если разработчик не вызовет future.get(), ошибка будет проглочена бесследно, и задача молча пропадет.
  2. В ScheduledExecutorService необработанный RuntimeException внутри периодической задачи приводит к полной и безвозвратной остановке выполнения всех будущих итераций этой задачи без каких-либо логов!

Решение: Установка UncaughtExceptionHandler:

Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
    log.error("CRITICAL: Uncaught exception in thread: {}", thread.getName(), throwable);
});

Stackless Runtime Exceptions для критичных путей

Для высоконагруженных шлюзов и валидаторов, где исключение используется строго как аварийный сигнал выхода из транзакции, создают экземпляры без стек-трейса:

package com.example.exception;

public class FastValidationException extends RuntimeException {

    // Статический синглтон-экземпляр без оверхеда на память и процессор
    public static final FastValidationException TOKEN_EXPIRED =
            new FastValidationException("Security token expired");

    public FastValidationException(String message) {
        // writableStackTrace = false исключает обход стека JVM
        super(message, null, false, false);
    }
}

🎯 Шпаргалка для интервью

30-секундный ответ

Unchecked exception (непроверяемое исключение) — это исключение, наследующееся от java.lang.RuntimeException (или Error). Компилятор javac не принуждает разработчика оборачивать его в try-catch или объявлять в сигнатуре метода (throws).

Unchecked-исключения делятся на:

  1. Баги в коде (NullPointerException, IndexOutOfBoundsException), которые не надо ловить, а нужно исправлять.
  2. Доменные исключения (UserNotFoundException, ValidationException), которые сигнализируют об отказах бизнес-логики.

Вся современная разработка (Spring Boot, Hibernate, Reactive) построена на unchecked-исключениях, так как они не загрязняют сигнатуры методов, совместимы со Stream API/лямбдами и централизованно обрабатываются через @RestControllerAdvice.

4 каверзных вопроса с ответами

  1. Что произойдет, если в периодической задаче ScheduledExecutorService.scheduleAtFixedRate() выбросится unchecked-исключение RuntimeException? Ответ: Периодическое выполнение задачи навсегда прекратится. Планировщик подавляет исключение, помечает задачу как завершенную с ошибкой и больше никогда не запустит следующие итерации. Чтобы этого избежать, тело задачи обязательно оборачивают в верхнеуровневый try-catch (Throwable t) с логированием.

  2. Почему использование исключений для управления бизнес-логикой (Exceptions as Control Flow) является серьезным антипаттерном? Ответ: Во-первых, это делает код нечитаемым (нарушается предсказуемость потока управления). Во-вторых, это приводит к катастрофическому падению производительности: генерация стек-трейса в fillInStackTrace() при создании каждого Throwable обходится в сотни раз дороже, чем стандартная проверка через if или возврат Optional.

  3. Нужно ли перехватывать базовые исключения вроде NullPointerException в блоке try-catch? Ответ: Нет, перехватывать NullPointerException или IndexOutOfBoundsException в кодовой базе считается антипаттерном. Такие исключения свидетельствуют о наличии логической ошибки (бага) в коде, и их нужно устранять предварительной валидацией входных данных (Objects.requireNonNull, @NotNull, проверка длины) или использованием Optional.

  4. Чем конструктор Throwable(message, cause, enableSuppression, writableStackTrace) помогает в оптимизации Highload-систем? Ответ: Передача writableStackTrace = false полностью отключает вызов тяжелого нативного метода fillInStackTrace(). Исключение создается со скоростью обычного Java-объекта без обхода фреймов стека памяти, что исключает просадки CPU при высоких RPS.

Красные флаги (чего категорически нельзя говорить)

  • ❌ «Unchecked исключения никогда нельзя ловить через try-catch» (их можно и нужно перехватывать, например, централизованно в @RestControllerAdvice или на границах системных потоков).
  • ❌ «Если объект может быть null, нужно просто обернуть его вызов в try-catch (NullPointerException e)» (грубейший антипаттерн; проверять null нужно через Objects.requireNonNull или условия).
  • ❌ «RuntimeException и Error — это одно и то же» (Error сигнализирует о неустранимых сбоях JVM вроде OutOfMemoryError, а RuntimeException — об ошибках уровня приложения).
  • ❌ «Spring заставляет объявлять throws для всех сервисных методов» (наоборот, Spring полностью построен на иерархии RuntimeException).

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