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

В чём разница между checked и unchecked exceptions

В языке Java все исключительные ситуации являются объектами и происходят от базового класса java.lang.Throwable. Архитектурно они разделены на две принципиальные категории: Chec...


🟢 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 и сборщик мусора.

Оптимизация: Стек-безопасные исключения (Stackless Exceptions)

Если исключение используется для передачи бизнес-сигнала (например, валидация или flow control) и трассировка стека не нужна, переопределяют метод или вызывают специальный конструктор 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).

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