← назад к разделу

Любая программа рано или поздно сталкивается с тем, что что-то пошло не так: файл не найден, сеть отвалилась, в метод пришёл null. Исключение (exception) — это способ Java сказать «дальше выполнять этот код нельзя» и передать управление туда, где ошибку можно обработать. Разберёмся, как они устроены и как с ними работать без боли.

Зачем вообще исключения

Можно было бы возвращать из методов код ошибки — например -1 или null. Но тогда проверку «а не сломалось ли?» пришлось бы писать после каждого вызова, и легко её забыть. Исключения решают это иначе: проблемный код прерывается, а ошибка «всплывает» вверх по стеку вызовов, пока её кто-нибудь не поймает.

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

int parse(String s) {
    return Integer.parseInt(s); // бросит NumberFormatException, если s — не число
}

Если s равно "abc", метод не вернёт мусор — он прервётся и бросит исключение, которое обязан обработать вызывающий код.

Иерархия: Throwable, Error, Exception

В корне всего стоит класс Throwable — только его наследников можно бросать (throw) и ловить (catch). У него две главные ветки:

  • Error — серьёзные сбои самой JVM: OutOfMemoryError, StackOverflowError. Их не ловят и не обрабатывают — программа в таком состоянии чаще всего уже не жилец.
  • Exception — ошибки уровня приложения, с которыми можно и нужно работать.

Внутри Exception есть особая подветка — RuntimeException. Именно по ней проходит граница между checked и unchecked исключениями.

diagram

Checked и unchecked

Это деление — одна из самых обсуждаемых особенностей Java.

Checked-исключения — всё, что наследует Exception, но не RuntimeException (например IOException, SQLException). Компилятор заставляет их обработать: либо обернуть вызов в try/catch, либо объявить в сигнатуре метода через throws. Не сделаешь — код не скомпилируется.

// либо throws — пробрасываем ответственность выше
String read(Path path) throws IOException {
    return Files.readString(path);
}

// либо try/catch — обрабатываем здесь
String readSafe(Path path) {
    try {
        return Files.readString(path);
    } catch (IOException e) {
        return ""; // решили, что пустая строка — приемлемо
    }
}

Unchecked-исключения — наследники RuntimeException (NullPointerException, IllegalArgumentException, IllegalStateException, NumberFormatException). Компилятор их не контролирует: ловить можно, но не обязательно. Обычно это ошибки в коде — обращение к null, неверный аргумент, нарушение контракта метода.

Короткая формула: checked — «ожидаемая внешняя проблема, с которой вызывающий должен что-то сделать»; unchecked — «программист ошибся, чини код, а не лови исключение».

Споры вокруг checked

Идея checked-исключений — заставить разработчика не игнорировать ошибки. На практике у неё есть критики: в больших цепочках вызовов throws IOException тянется через десятки методов, а ленивые разработчики пишут пустой catch, лишь бы компилятор замолчал — и это хуже, чем вообще ничего. Многие современные библиотеки и фреймворки (включая значительную часть экосистемы Spring) тяготеют к unchecked-исключениям, оборачивая checked в runtime-обёртки. Готового «правильного» ответа нет — важно понимать обе стороны.

try / catch / finally

Базовая конструкция обработки:

try {
    process();                 // код, который может бросить исключение
} catch (IOException e) {
    log.error("ошибка ввода-вывода", e);
} catch (IllegalArgumentException e) {
    log.warn("неверный аргумент", e);
} finally {
    cleanup();                 // выполнится ВСЕГДА — и при ошибке, и без неё
}

Что важно знать:

  • Несколько catch проверяются сверху вниз — первый подходящий по типу срабатывает. Поэтому более конкретные типы пишут выше более общих.
  • Мульти-catch объединяет ветки с одинаковой обработкой: catch (IOException | SQLException e).
  • finally выполняется в любом случае — даже если внутри try был return. Сюда исторически клали освобождение ресурсов (закрытие файла, соединения).
  • Ловить «всё подряд» через catch (Exception e) стоит осторожно — так легко перехватить то, что обрабатывать не собирался.

try-with-resources и AutoCloseable

Ручное закрытие в finally — это шумно и легко ошибиться (а что если close() сам бросит исключение?). С Java 7 для этого есть try-with-resources: ресурсы объявляются в круглых скобках после try и закрываются автоматически, в обратном порядке, даже если возникнет исключение.

Работает это для любого класса, реализующего интерфейс AutoCloseable (у него один метод — close()). Большинство стандартных «закрываемых» типов (потоки, файлы, соединения) его реализуют.

// файл закроется сам — finally не нужен
String firstLine(Path path) throws IOException {
    try (var reader = Files.newBufferedReader(path)) {
        return reader.readLine();
    }
}

// несколько ресурсов — закроются в обратном порядке
void copy(Path from, Path to) throws IOException {
    try (var in = Files.newInputStream(from);
         var out = Files.newOutputStream(to)) {
        in.transferTo(out);
    }
}

Короткая формула: если у объекта есть что закрывать — открывай его в try-with-resources, а не закрывай руками.

Свои исключения

Когда стандартные типы не передают смысл ошибки в твоей предметной области, создают собственное исключение. Обычно наследуют от RuntimeException (если не хочешь навязывать обязательную обработку) или от Exception (если хочешь).

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(long orderId) {
        super("Заказ не найден: " + orderId); // понятное сообщение
    }
}

Order findOrder(long id) {
    return repository.find(id)
        .orElseThrow(() -> new OrderNotFoundException(id));
}

Полезные привычки:

  • Понятное сообщение — что произошло и с какими данными.
  • Сохраняй причину. Если оборачиваешь чужое исключение, передавай его вторым аргументом: throw new MyException("не удалось загрузить", e). Тогда в логе будет полная цепочка (Caused by: ...), а не оборванный след.

Антипаттерны

Самые частые способы навредить себе:

// 1. Пустой catch — исключение исчезает бесследно
try {
    risky();
} catch (Exception e) {
    // тишина — теперь никто не узнает, что сломалось
}

// 2. Проглатывание с потерей причины
try {
    risky();
} catch (IOException e) {
    throw new RuntimeException("ошибка"); // потеряли исходное e!
}

// 3. Управление потоком через исключения — медленно и нечитаемо
try {
    return list.get(index);
} catch (IndexOutOfBoundsException e) {
    return null; // лучше просто проверить index заранее
}

Правильно: никогда не оставляй catch пустым (минимум — залогируй), всегда сохраняй причину при оборачивании, и не используй исключения как обычный if — они для исключительных ситуаций, а не для штатной логики.

Коротко

  • Исключения отделяют обработку ошибок от основного кода: проблемный участок прерывается, ошибка всплывает вверх по стеку.
  • В корне — Throwable. Error не ловим (сбои JVM), Exception обрабатываем.
  • Checked (наследники Exception, кроме RuntimeException) компилятор заставляет обработать; unchecked (RuntimeException) — нет, это обычно ошибки в коде.
  • Споры вокруг checked реальны: они дисциплинируют, но порождают шум и пустые catch; многие фреймворки выбирают unchecked.
  • try/catch/finally: finally выполняется всегда; для ресурсов используй try-with-resources (AutoCloseable) вместо ручного закрытия.
  • Свои исключения дают осмысленные ошибки — пиши понятное сообщение и сохраняй причину.
  • Главные антипаттерны: пустой catch, потеря исходной причины, управление потоком через исключения.

Что почитать дальше

  • Синтаксис и типы данных — основы, на которых стоит всё остальное.
  • Инструменты разработчика — как собирать, запускать и отлаживать код.
  • Коллекции — где часто встречаются IndexOutOfBoundsException и NullPointerException.