Любая программа рано или поздно сталкивается с тем, что что-то пошло не так: файл не найден, сеть отвалилась, в метод пришёл null. Исключение (exception) — это способ Java сказать «дальше выполнять этот код нельзя» и передать управление туда, где ошибку можно обработать.
Зачем вообще исключения
Можно было бы возвращать из методов код ошибки — например -1 или null. Но тогда проверку «а не сломалось ли?» пришлось бы писать после каждого вызова, и легко её забыть. Исключения решают это иначе: проблемный код прерывается, а ошибка «всплывает» вверх по стеку вызовов, пока её кто-нибудь не поймает.
Короткая формула: исключение разделяет нормальный путь выполнения и обработку ошибок — они не перемешаны в одном потоке кода.
Исключение из parseInt поднимается по кадрам стека. Кадр, в котором нет подходящего catch, снимается вместе со своими переменными; выполнение продолжается в первом кадре, где тип совпал.
живой пример
public class ParseDemo {
static int parse(String s) {
return Integer.parseInt(s);
}
public static void main(String[] args) {
System.out.println("число: " + parse("42"));
try {
parse("abc");
} catch (NumberFormatException e) {
System.out.println("не число: " + e.getMessage());
}
System.out.println("программа продолжает работать");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
На строке "abc" метод не возвращает мусор: он прерывается, а решение принимает вызывающий код.
Иерархия: Throwable, Error, Exception
В корне всего стоит класс Throwable — только его наследников можно бросать (throw) и ловить (catch). У него две главные ветки:
Error— отказы, после которых продолжать бессмысленно:OutOfMemoryError,StackOverflowError. Слово «Error» обманывает: это почти никогда не поломка машины, а следствие вашего же кода — бесконечная рекурсия, утечка, коллекция, в которую сложили больше, чем помещается в память. Чинить надо код, а не JVM. Но не ловят их по месту: чинить такое вcatchпосреди бизнес-логики бессмысленно, программа уже в плохом состоянии. Единственное разумное место — самый верх: там ловятThrowable, чтобы записать причину в лог и остановиться по-человечески, а не умереть молча. Нехватку памяти чаще закрывают вообще без кода — флагом-XX:+ExitOnOutOfMemoryError: процесс завершается, и его перезапускают.Exception— ошибки уровня приложения, с которыми можно и нужно работать.
Внутри Exception есть особая подветка — RuntimeException. Именно по ней проходит граница между checked и unchecked исключениями.
Checked и unchecked
Это деление — одна из самых обсуждаемых особенностей Java.
Checked-исключения — всё, что наследует Exception, но не RuntimeException (например IOException, SQLException). Компилятор заставляет их обработать: либо обернуть вызов в try/catch, либо объявить в сигнатуре метода через throws. Не сделаешь — код не скомпилируется. Оба выхода видно в одной программе:
живой пример
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class ConfigDemo {
static String read(Path path) throws IOException {
return Files.readString(path);
}
static String readOrDefault(Path path) {
try {
return read(path);
} catch (IOException e) {
return "порт=8080";
}
}
public static void main(String[] args) {
System.out.println(readOrDefault(Path.of("config.properties")));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Файла нет, Files.readString бросает IOException — но программа не падает: readOrDefault подставляет значение по умолчанию, а read передаёт ответственность выше через throws.
Unchecked-исключения — наследники RuntimeException (NullPointerException, IllegalArgumentException, IllegalStateException, NumberFormatException). Компилятор их не контролирует: ловить можно, но не обязательно. Обычно это ошибки в коде — обращение к null, неверный аргумент, нарушение контракта метода.
Короткая формула: checked — «ожидаемая внешняя проблема, с которой вызывающий должен что-то сделать»; unchecked — «программист ошибся, чини код, а не лови исключение».
Есть правило, в которое упираются при первой же реализации интерфейса. Метод, который переопределяет родительский, не может объявить checked-исключений больше, чем родитель: убрать throws или сузить его можно, расширить нельзя. Причина в полиморфизме: код, который работает с типом родителя, обработал ровно те исключения, что объявлены у родителя, и не готов к новым. Поэтому Runnable.run() без throws нельзя реализовать методом с throws IOException, и по той же причине в лямбде, о которой статья про лямбды, нельзя вызвать метод с checked-исключением: у Function.apply в сигнатуре его нет. Выход один и тот же: поймать и обернуть в unchecked.
Споры вокруг checked
Идея checked-исключений — заставить разработчика не игнорировать ошибки. Критика тоже по делу: в длинных цепочках throws IOException тянется через десятки методов, а пустой catch, написанный лишь бы компилятор замолчал, хуже, чем ничего. Многие библиотеки и фреймворки (включая значительную часть экосистемы Spring) заворачивают checked в runtime-обёртки. Готового ответа для всей индустрии нет, а для своего кода рабочее правило есть: в прикладном коде свои исключения делают unchecked, наследуя RuntimeException, потому что почти всегда вызывающий ничего осмысленного сделать не может и ошибка должна долететь до общего обработчика (у сервиса это ответ с кодом ошибки и запись в лог). Checked оставляют для тех редких случаев, где у вызывающего есть реальный второй путь: файла нет — взять значение по умолчанию, соединение занято — повторить. Чужие checked-исключения на границе с библиотекой ловят и оборачивают в своё unchecked с сохранением причины, чтобы throws IOException не расползался по сигнатурам.
try / catch / finally
Рискованный код идёт в try, разбор ошибки — в catch, обязательная уборка — в finally. Три входных значения в примере проходят по трём разным путям.
живой пример
public class CatchOrderDemo {
static void divide(String value) {
try {
System.out.println("частное: " + 100 / Integer.parseInt(value));
} catch (ArithmeticException e) {
System.out.println("на ноль делить нельзя");
} catch (RuntimeException e) {
System.out.println("другая беда: " + e.getClass().getSimpleName());
} finally {
System.out.println("finally — в любом случае");
}
}
public static void main(String[] args) {
divide("4");
divide("0");
divide("не число");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Что здесь важно:
- Несколько
catchпроверяются сверху вниз — срабатывает первый подходящий по типу. Поэтому конкретныйArithmeticExceptionстоит выше общегоRuntimeException: поменяешь их местами — код не скомпилируется, вторая ветка окажется недостижимой. - Мульти-catch объединяет ветки с одинаковой обработкой:
catch (IOException | SQLException e). finallyвыполняется в любом случае — даже если внутриtryбылreturn. Сюда исторически клали освобождение ресурсов (закрытие файла, соединения).- А вот
returnилиthrowв самомfinallyписать нельзя: они затирают и результат, и исключение изtry. Ошибка при этом не всплывёт и не попадёт в лог — она просто исчезнет, а метод вернёт значение изfinally, как будто ничего не было. Это самый тихий способ потерять ошибку в Java. - Ловить «всё подряд» через
catch (Exception e)стоит осторожно — так легко перехватить то, что обрабатывать не собирался.
try-with-resources и AutoCloseable
Ручное закрытие в finally — это шумно и легко ошибиться (а что если close() сам бросит исключение?). С Java 7 для этого есть try-with-resources: ресурсы объявляются в круглых скобках после try и закрываются автоматически, в обратном порядке, даже если возникнет исключение.
Работает это для любого класса, реализующего интерфейс AutoCloseable (у него один метод — close()). Большинство стандартных «закрываемых» типов (потоки, файлы, соединения) его реализуют.
живой пример
public class ResourceDemo {
record Connection(String name, boolean brokenOnClose) implements AutoCloseable {
@Override public void close() {
System.out.println("закрыли " + name);
if (brokenOnClose) {
throw new IllegalStateException("не смог закрыть " + name);
}
}
}
public static void main(String[] args) {
try (var file = new Connection("файл", false);
var socket = new Connection("сокет", true)) {
System.out.println("работаем: " + file.name() + ", " + socket.name());
throw new IllegalStateException("сеть отвалилась");
} catch (IllegalStateException e) {
System.out.println("поймали: " + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("а ещё подавленное: " + suppressed.getMessage());
}
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В выводе видно порядок: сокет закрылся раньше файла, и оба закрылись до того, как сработал catch. И видно, что стало со второй ошибкой. Закрытие сокета тоже упало, но исходное «сеть отвалилась» оно не затёрло: приехало рядом как подавленное и достаётся через getSuppressed(). В ручном finally эта вторая ошибка либо потерялась бы, либо вытеснила бы настоящую причину.
Шаги примера по порядку: ресурсы закрываются в обратном порядке объявления, и оба успевают закрыться до входа в catch.
Свои исключения
Когда стандартные типы не передают смысл ошибки в вашей предметной области, создают собственное исключение. Обычно наследуют от RuntimeException (если не хотите навязывать обязательную обработку) или от Exception (если хотите).
живой пример
public class OrderDemo {
public static void main(String[] args) {
try {
loadOrder(42);
} catch (OrderLoadException e) {
System.out.println(e.getMessage());
System.out.println("причина: " + e.getCause());
}
}
static void loadOrder(long id) {
try {
long total = Long.parseLong("сломанные данные");
System.out.println("сумма заказа: " + total);
} catch (NumberFormatException e) {
throw new OrderLoadException(id, e);
}
}
}
class OrderLoadException extends RuntimeException {
OrderLoadException(long orderId, Throwable cause) {
super("Не удалось загрузить заказ " + orderId, cause);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Полезные привычки:
- Понятное сообщение — что произошло и с какими данными: в выводе видно номер заказа, а не безличное «ошибка».
- Сохраняй причину. Исходное исключение передаётся вторым аргументом конструктора — тогда в логе будет полная цепочка (
Caused by: ...), а не оборванный след.
Антипаттерны
Три способа навредить себе, по убыванию ущерба.
Потеря причины. Исключение обернули в своё, а исходное не передали:
try {
risky();
} catch (IOException e) {
throw new RuntimeException("ошибка");
}
Наверх уходит RuntimeException("ошибка") без e: в логе не будет ни имени файла, ни стека того места, где всё сломалось, и искать придётся по времени в журнале. Оборачивая, передавайте причину вторым аргументом: new RuntimeException("ошибка", e).
Пустой catch.
try {
risky();
} catch (Exception e) {
}
Исключение исчезло, программа пошла дальше с неполными данными, и проблему заметят через неделю по отчёту, а не в момент сбоя. Минимум — залогировать.
Управление потоком через исключения.
try {
return list.get(index);
} catch (IndexOutOfBoundsException e) {
return null;
}
Дорого здесь не throw и не catch, а само создание объекта: конструктор Throwable вызывает fillInStackTrace, который проходит по всем кадрам стека и записывает их, и на глубоком стеке сервиса это микросекунды против наносекунд на проверку index < list.size(), то есть разница в тысячи раз. Читатель кода при этом не понимает, штатный это случай или авария. Исключения для исключительных ситуаций, не для обычного if. Две детали для тех, кто всё же гонит через них поток: у Throwable есть конструктор с флагом writableStackTrace = false, который отключает сбор стека и делает исключение дешёвым; а JIT в HotSpot сам перестаёт собирать стек у часто бросаемых встроенных исключений вроде NullPointerException в горячем коде, и в логе появляется исключение без единой строки at. Второе выглядит как загадка, пока не знаешь, что это оптимизация (-XX:-OmitStackTraceInFastThrow её выключает).
Глубже: как читать стек-трейс и как отлаживатьсярасширенное
Фаза учит писать код, который падает, и ни разу не показывает, как выглядит падение целиком. Стек-трейс это первое, что вы увидите каждый день, и читать его нужно уметь до того, как писать catch.
Exception in thread "main" java.lang.IllegalStateException: заказ 42 уже оплачен
at shop.Order.pay(Order.java:58)
at shop.PaymentService.charge(PaymentService.java:31)
at shop.Main.main(Main.java:12)
Caused by: java.lang.NumberFormatException: For input string: "abc"
at java.base/java.lang.Integer.parseInt(Integer.java:652)
at shop.Order.pay(Order.java:55)
... 2 more
Читают сверху вниз и снизу вверх одновременно. Первая строка это тип исключения и сообщение: что случилось. Строки at это стек вызовов на момент броска, сверху то место, где исключение возникло, ниже кто его вызвал, и так до main; первая строка с вашим пакетом (shop.Order.pay, файл и строка) это куда смотреть в коде, строки из java.base и фреймворков обычно пропускают. Блок Caused by это исходная причина, обёрнутая в другое исключение: настоящий виновник внизу цепочки, и ... 2 more означает, что хвост стека совпадает с уже напечатанным. Правило: смотреть на самый нижний Caused by, потом на первую свою строку в нём.
Три ловушки чтения. Стек показывает, где бросили, а не где ошиблись: NullPointerException в order.getCustomer().getEmail() возник здесь, а null пришёл откуда-то раньше, и с Java 14 сообщение подсказывает, что именно было null. Стек в логе сервера бывает обрезан или перепутан, если исключение перебросили без cause, о чём раздел про антипаттерны. И асинхронный код (пулы потоков, стримы) даёт стек, в котором нет вашего вызывающего кода, только внутренности исполнителя, потому что вызов был в другом потоке.
Отладка это второй инструмент, когда трейса мало. В IDE ставят точку останова на строке (клик слева от номера), запускают в режиме отладки, и программа замирает перед выполнением этой строки: справа видны значения всех переменных кадра, снизу стек вызовов, по которому можно перейти в любой кадр и посмотреть его переменные. Три кнопки: шаг через строку (выполнить и остановиться на следующей), шаг внутрь (зайти в вызываемый метод), продолжить до следующей точки. Условная точка останова (остановиться, только когда orderId == 42) экономит часы в циклах; точка останова на исключении останавливает в момент броска любого NullPointerException, до того как его кто-то поймал. И «вычислить выражение» позволяет вызвать любой метод на живых объектах. Отладчик для тестов работает так же, и это обычный способ разобраться в чужом коде: поставить точку в начале сценария и пройти его по шагам.
Глубже: файлы и ввод-вывод: java.nio.file, кодировки и потокирасширенное
Files.readString используется в этой статье и в статье про лямбды как данность. Работа с файлами в Java стоит на нескольких понятиях, и без них первый же файл с кириллицей читается кракозябрами.
Путь и файл. Path из java.nio.file описывает путь (Path.of("data", "orders.csv"), разделитель подставится под систему), Files делает с ним всё: существует ли, создать каталоги, прочитать, записать, удалить, перечислить. Старый java.io.File встречается в библиотеках, и у него есть toPath().
Маленькие файлы целиком. Files.readString(path) и Files.readAllLines(path) читают всё в память, Files.writeString(path, text) пишет. Годится для конфигураций и небольших данных; файл на гигабайт так читать нельзя, потому что он целиком окажется в куче.
Большие файлы потоком. Files.lines(path) отдаёт Stream<String> строк, читаемых по мере обработки, и его закрывают через try-with-resources, как любой ресурс с дескриптором; Files.newBufferedReader и newBufferedWriter для построчной работы; InputStream и OutputStream для байтов (картинки, архивы), всегда буферизованные и всегда закрытые.
Кодировка. Файл это байты, а строка это символы, и между ними кодировка. С Java 18 умолчание везде UTF-8, до этого оно зависело от системы, и файл, записанный на Windows в cp1251, читался на сервере мусором. Кодировку указывают явно там, где файл пришёл извне: Files.readString(path, Charset.forName("windows-1251")); свои файлы пишут в UTF-8 и говорят об этом в контракте.
живой пример
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.stream.Stream;
public class FilesDemo {
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("demo");
Path file = dir.resolve("orders.csv");
Files.writeString(file, "id;total\n1;120\n2;350\n", StandardCharsets.UTF_8);
System.out.println(Files.exists(file) + " " + Files.size(file));
try (Stream<String> lines = Files.lines(file)) {
System.out.println(lines.skip(1).mapToInt(l -> Integer.parseInt(l.split(";")[1])).sum());
}
Files.delete(file);
Files.delete(dir);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Три привычки. Пути не склеивают строками с /, а собирают через Path.of и resolve, и путь от пользователя проверяют на выход за пределы каталога (normalize().startsWith(base)), о чём статья про безопасность. Ресурсы из classpath (файлы внутри jar) читают через getResourceAsStream, а не через Path: внутри архива путей файловой системы нет. И временные файлы создают через Files.createTempFile, а не руками в /tmp, чтобы не спорить с соседями за имя.
Коротко
- Исключения отделяют обработку ошибок от основного кода: проблемный участок прерывается, ошибка всплывает вверх по стеку до первого подходящего
catch. - В корне —
Throwable.Errorне ловим по месту (сбои JVM),Exceptionобрабатываем. - Checked (наследники
Exception, кромеRuntimeException) компилятор заставляет обработать; unchecked (RuntimeException) — нет, это обычно ошибки в коде. catchпроверяются сверху вниз, от конкретного типа к общему;finallyвыполняется всегда — даже приreturnвнутриtry. Ноreturnилиthrowвнутри самогоfinallyмолча съедают исключение изtry.- Для ресурсов — try-with-resources (
AutoCloseable): закрывает сам, в обратном порядке, до входа вcatch. - Свои исключения дают осмысленные ошибки; главные антипаттерны — пустой
catch, потеря исходной причины и управление потоком через исключения. - Стек-трейс читают по первой своей строке
atи по самому нижнемуCaused by; стек показывает, где бросили, а не где ошиблись; отладчик даёт точку останова, шаги, условия и остановку на исключении. - Файлы:
PathиFiles, маленькие целиком черезreadString, большие потоком черезlinesвtry-with-resources, кодировка явно для чужих файлов и UTF-8 для своих, пути черезresolve, ресурсы из jar черезgetResourceAsStream. - Переопределяя метод, нельзя расширить его список checked-исключений (отсюда обёртки в лямбдах); рабочее правило: свои исключения unchecked, checked только там, где у вызывающего есть реальный второй путь.
- Дорого создание исключения (сбор стека в
fillInStackTrace, микросекунды против наносекунд), а неthrow; JIT в горячем коде может отдатьNullPointerExceptionбез строкat.
Что почитать дальше
- Синтаксис и типы данных — основы, на которых стоит всё остальное.
- Коллекции — где часто встречаются
IndexOutOfBoundsExceptionиNullPointerException. - Records, Optional и современный Java — как
Optionalубирает часть поводов дляNullPointerException. - Иерархия исключений в Spring Boot — как эти же правила выглядят в рабочем сервисе.