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

В чому різниця між checked та unchecked винятками

У мові Java всі виняткові ситуації є об'єктами та походять від базового класу java.lang.Throwable. Архітектурно вони розділені на дві принципові категорії: Checked (перевірювані...


🟢 Junior Level

У мові Java всі виняткові ситуації є об’єктами та походять від базового класу java.lang.Throwable. Архітектурно вони розділені на дві принципові категорії: Checked (перевірювані) та Unchecked (неперевірювані).

                         java.lang.Throwable
                                  │
                 ┌────────────────┴────────────────┐
                 ▼                                 ▼
         java.lang.Error                 java.lang.Exception
          (Unchecked)                      (Checked за замовчуванням)
                                                   │
                                                   ▼
                                        java.lang.RuntimeException
                                               (Unchecked)

Головні відмінності

Критерій Checked Exceptions (Перевірювані) Unchecked Exceptions (Неперевірювані)
Базовий клас Нащадки Exception (за винятком RuntimeException). Нащадки RuntimeException та Error.
Контроль компілятора Обов’язковий. Компілятор javac не збере код без обробки. Не контролюється. Компілятор не вимагає явної обробки.
Спосіб обробки Зобов’язаний бути обгорнутий у try-catch або оголошений у сигнатурі: throws IOException. Обробка через try-catch є опціональною.
Семантичне призначення Зовнішні відновлювані збої інфраструктури, які програма може передбачити (мережа, диск). Помилки програміста (баги), порушення контракту методу або непередбачені збої.
Типові приклади IOException, SQLException, ClassNotFoundException, InterruptedException. NullPointerException, IllegalArgumentException, IndexOutOfBoundsException, IllegalStateException.

Приклад у коді

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ExceptionBasics {

    // Checked exception: метод ЗОБОВ'ЯЗАНИЙ оголосити throws або обробити всередині try-catch
    public void readConfigFile(Path path) throws IOException {
        byte[] bytes = Files.readAllBytes(path);
    }

    // Unchecked exception: компілятор не вимагає throws
    public int calculateRatio(int numerator, int denominator) {
        if (denominator == 0) {
            throw new IllegalArgumentException("Denominator cannot be zero"); // RuntimeException
        }
        return numerator / denominator;
    }
}

🟡 Middle Level

Розділення середовищ: Компілятор vs JVM Байткод

Один із ключових фактів платформи: JVM на рівні байткоду нічого не знає про checked винятки!

  • Розділення на checked та unchecked існує виключно на етапі компіляції в javac.
  • У специфікації віртуальної машини (JVMS) інструкція байткоду athrow приймає на вершині операндного стека будь-яке посилання на java.lang.Throwable і викидає його абсолютно однаково.
  • Таблиця винятків у байткоді (Exception_table) зіставляє діапазони байткоду зі зміщенням обробника catch без поділу типів на перевірювані та неперевірювані.
  • Сигнатура методу в байткоді містить атрибут Exceptions, проте він використовується завантажувачем класів лише для статичної валідації компілятора.

Чому сучасна індустрія відмовляється від Checked Exceptions?

Мови C#, Kotlin, Scala, Go, Rust та Python принципово відмовилися від механізму checked-винятків. Навіть в екосистемі Java 95% бібліотек та фреймворків (Spring, Hibernate, Jackson) мігрували на RuntimeException.

Причини деградації Checked Exceptions:

  1. Забруднення інтерфейсів та сигнатур (Signature Pollution): Якщо DAO-шар викидає SQLException, цей throws змушені дублювати сервісний шар, фасад та контролер. Заміна реляційної БД на NoSQL ламає сигнатури всіх методів угору по стеку викликів.
  2. Несумісність із Functional Programming та Stream API: Стандартні функціональні інтерфейси Java (Function<T, R>, Predicate<T>, Consumer<T>) не містять секції throws. Спроба викликати метод із checked-винятком усередині лямбди призводить до помилки компіляції:
    // ПОМИЛКА КОМПІЛЯЦІЇ: Unhandled exception: java.io.IOException
    List<String> lines = paths.stream()
            .map(path -> Files.readString(path))
            .toList();
    
  3. Марні порожні блоки catch (Catch and Swallow): Розробники, не знаючи, як відновити втрачене з’єднання, масово пишуть catch (Exception e) {}, приховуючи критичні помилки.

Патерн Exception Translation (Трансляція винятків)

У професійній архітектурі checked-винятки низького рівня перехоплюються на межі модулів і загортаються в доменні unchecked-винятки:

public class OrderRepositoryImpl implements OrderRepository {

    @Override
    public Order findById(Long id) {
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE id = ?")) {
            ps.setLong(1, id);
            ResultSet rs = ps.executeQuery();
            if (!rs.next()) {
                throw new OrderNotFoundException("Order not found with id: " + id);
            }
            return mapOrder(rs);
        } catch (SQLException ex) {
            // Загортаємо технічний checked SQLException у доменний unchecked
            throw new RepositoryAccessException("Database access error for order: " + id, ex);
        }
    }
}

У Spring Framework за це автоматично відповідає PersistenceExceptionTranslationPostProcessor, який перетворює всі vendor-specific винятки JPA/JDBC в єдину ієрархію org.springframework.dao.DataAccessException.


🔴 Senior Level

Sneaky Throws: обхід компілятора через Type Erasure

Оскільки JVM у runtime не перевіряє типи винятків, механізм стирання типів (Type Erasure) дозволяє викинути будь-який перевірюваний виняток без оголошення throws:

public class SneakyThrowUtil {

    // Дженерик E стирається в байткоді до Throwable
    @SuppressWarnings("unchecked")
    private static <E extends Throwable> void sneakyThrowInternal(Throwable ex) throws E {
        throw (E) ex;
    }

    public static void sneakyThrow(Throwable ex) {
        SneakyThrowUtil.<RuntimeException>sneakyThrowInternal(ex);
    }
}

Використання у Stream API:

List<String> list = paths.stream()
        .map(path -> {
            try {
                return Files.readString(path);
            } catch (IOException e) {
                SneakyThrowUtil.sneakyThrow(e); // Викидає IOException без throws!
                return null;
            }
        })
        .toList();

Анотація @SneakyThrows бібліотеки Project Lombok генерує в байткоді аналогічну конструкцію.

Продуктивність: Вартість fillInStackTrace() у Highload

У Java створення об’єкта винятку (new MyException()) коштує в сотні разів дорожче, ніж створення звичайного об’єкта.

  • Основні накладні витрати припадають на виклик нативного методу Throwable.fillInStackTrace(): JVM призупиняє потік, проходить по всіх фреймах стека потоку, витягує імена класів, методів та номери рядків із метаданих JVM.
  • При навантаженні в десятки тисяч RPS часте викидання винятків паралізує CPU та збирач сміття (Garbage Collector).

Оптимізація: Стек-безпечні винятки (Stackless Exceptions)

Якщо виняток використовується для передачі бізнес-сигналу (наприклад, валідація або керування потоком виконання) і трасування стека не потрібне, перевизначають метод або викликають спеціальний конструктор Java 7+:

package com.example.exception;

public class BusinessValidationException extends RuntimeException {

    public BusinessValidationException(String message) {
        // message, cause, enableSuppression = false, writableStackTrace = false
        super(message, null, false, false);
    }

    // Або альтернативне перевизначення для старих версій:
    @Override
    public synchronized Throwable fillInStackTrace() {
        return this; // Вимикаємо генерацію стека! Прискорення в 30-50 разів
    }
}

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

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

  • Checked (перевірювані) винятки успадковуються від Exception (окрім RuntimeException). Компілятор javac примусово зобов’язує розробника обробити їх (try-catch) або оголосити в сигнатурі методу (throws). Призначені для зовнішніх відновлюваних збоїв (IOException, SQLException).
  • Unchecked (неперевірювані) винятки успадковуються від RuntimeException (або Error). Компілятор не вимагає їхньої обов’язкової обробки. Вони символізують помилки програмування (NullPointerException), порушення контракту (IllegalArgumentException) або доменні збої.
  • JVM у байткоді не робить жодних відмінностей: інструкція athrow виконує всі Throwable однаково.
  • У сучасному enterprise-коді checked-винятки транслюють у доменні unchecked, оскільки checked-винятки забруднюють сигнатури та несумісні з лямбдами й Stream API.

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

  1. Чи знає JVM у процесі виконання (runtime) про те, чи є викинутий виняток checked чи unchecked? Відповідь: Ні, у байткоді та в runtime віртуальна машина JVM не розрізняє checked та unchecked винятки. Поділ існує виключно на рівні перевірок компілятора javac. Інструкція athrow однаково обробляє будь-який об’єкт класу Throwable.

  2. Як метод @SneakyThrows у Lombok або generic-хак дозволяє викинути checked-виняток без оголошення throws? Відповідь: Завдяки механізму стирання типів (Type Erasure). Метод кастує Throwable до дженерика <E extends Throwable>, де компілятор виводить E як RuntimeException, але в рантаймі інформація про тип стирається до Throwable, і JVM без перешкод викидає оригінальний checked-виняток.

  3. Чому створення new Exception() працює в сотні разів повільніше за створення new Object() і як це оптимізувати? Відповідь: Конструктор Throwable за замовчуванням викликає нативний метод fillInStackTrace(), який виконує обхід фреймів стека викликів поточного потоку в пам’яті JVM. Для оптимізації в Highload створюють стек-безпечні винятки, викликаючи super(message, null, false, false) з прапорцем writableStackTrace = false.

  4. Що відбувається під час виникнення checked-винятку всередині лямбда-виразу Stream API і як це вирішити? Відповідь: Код не компілюється, оскільки методи функціональних інтерфейсів (Function.apply, Consumer.accept) не оголошують throws Exception. Рішення: обгорнути виклик у try-catch всередині лямбди, транслювати в RuntimeException, використовувати бібліотеку Vavr (Try.of(...)) або SneakyThrows.

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

  • ❌ «Unchecked винятки — це помилки JVM, які не можна перехоплювати» (перехоплювати не можна Error, а unchecked RuntimeException перехоплювати можна й потрібно).
  • ❌ «JVM перевіряє checked-винятки під час запуску програми» (перевірку виконує компілятор javac, у байткоді JVM усі винятки рівні).
  • ❌ «У сучасній розробці всі бізнес-помилки слід робити checked, щоб змусити програміста обробити їх» (антипатерн, що призводить до забруднення сигнатур та порожніх блоків catch).
  • ❌ «Throwable та Exception — це синоніми» (Throwable — предок, від якого також успадковується Error).

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