Любая программа рано или поздно сталкивается с тем, что что-то пошло не так: файл не найден, сеть отвалилась, в метод пришёл 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 исключениями.
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.