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

Що таке unchecked виняток (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).

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