Обработка исключений в Java: лучшие практики и типичные ошибки
Блок catch (Exception e) { } с комментарием // TODO компилируется без предупреждений и легко проходит код-ревью. Программа ниже честно сообщает, что ошибок нет, хотя один заказ из трёх так и не сохранился — и ни в консоли, ни в логе об этом нет ни строки.
import java.util.List;
public class SwallowedDemo {
static int saved = 0;
static void save(String order) {
if (order.isBlank()) {
throw new IllegalArgumentException("Пустой заказ");
}
saved++;
}
public static void main(String[] args) {
List<String> orders = List.of("A-1", " ", "A-3");
for (String order : orders) {
try {
save(order);
} catch (Exception e) {
// TODO: обработать потом
}
}
System.out.println("Обработка завершена, ошибок нет");
System.out.println("Сохранено: " + saved + " из " + orders.size());
}
} Обработка завершена, ошибок нет
Сохранено: 2 из 3 Лучшие практики обработки исключений в Java — это набор правил, при которых ошибка не теряется и не дублируется: исключение перехватывают только там, где на него можно осмысленно отреагировать, ловят конкретный тип, логируют один раз вместе со стеком вызовов, при смене типа сохраняют исходную причину, а ресурсы закрывают через try-with-resources. Нарушения этих правил — одна из самых частых тем вопросов на собеседованиях по Java. В уроке каждая практика разобрана по схеме: плохой код, что именно в нём ломается, правильный код.
Не «глотать» исключения
«Проглотить» исключение (exception swallowing) — значит перехватить его и никому о нём не сообщить: оставить пустой catch, ограничиться вызовом printStackTrace() или вернуть вместо ошибки null или -1. Программа продолжает работать с неверным состоянием, а настоящая причина сбоя всплывает позже и совсем в другом месте.
Пустой catch
В примере из начала урока пустой catch превратил ошибку в тишину: счётчик показывает 2 из 3, но ни один вызывающий метод не знает, какой заказ и почему не сохранился. Если ошибку одного элемента действительно нужно пережить и продолжить цикл, её надо хотя бы зафиксировать — собрать в список, записать в лог или вернуть в отчёте:
import java.util.ArrayList;
import java.util.List;
public class ReportedDemo {
static void save(String order) {
if (order.isBlank()) {
throw new IllegalArgumentException("Пустой заказ");
}
}
public static void main(String[] args) {
List<String> orders = List.of("A-1", " ", "A-3");
List<String> failed = new ArrayList<>();
for (int i = 0; i < orders.size(); i++) {
try {
save(orders.get(i));
} catch (IllegalArgumentException e) {
failed.add("#" + (i + 1) + ": " + e.getMessage());
}
}
System.out.println("Сохранено: " + (orders.size() - failed.size()) + " из " + orders.size());
System.out.println("Ошибки: " + failed);
}
} Сохранено: 2 из 3
Ошибки: [#2: Пустой заказ] Заодно catch (Exception e) заменён на конкретный IllegalArgumentException — почему это важно, разобрано в следующем разделе.
catch только с printStackTrace()
try {
saveOrder(order);
} catch (SQLException e) {
e.printStackTrace(); // выполнение идёт дальше, как будто заказ сохранён
}
sendConfirmationEmail(order); Этот вариант выглядит «обработанным», но ломает две вещи. Во-первых, метод продолжает работу: клиент получит письмо о заказе, которого нет в базе. Во-вторых, printStackTrace() пишет в System.err в обход системы логирования — без времени, уровня, идентификатора запроса, без ротации файлов, а в контейнере или на сервере приложений такой вывод часто просто теряется. Правильно — не перехватывать исключение там, где с ним нечего делать, или перевести его в исключение своего слоя (см. обёртывание).
return null или -1 вместо ошибки
// Плохо: сбой диска выглядит как «пользователя нет»
static User findUser(String id) {
try {
return storage.load(id);
} catch (IOException e) {
return null;
}
} Здесь две разные ситуации — «пользователя с таким id нет» и «хранилище недоступно» — слились в одно значение null. Вызывающий код либо забудет проверку и получит NullPointerException через десяток строк, либо покажет пользователю «аккаунт не найден» во время аварии сервера. С -1 хуже: это бывает допустимым значением (температура, смещение, баланс). Отсутствие результата выражайте через Optional, а сбой — исключением:
// Хорошо: «нет пользователя» - Optional.empty(), сбой хранилища - исключение
static Optional<User> findUser(String id) throws IOException {
return storage.find(id);
} Если игнорировать действительно нужно
Бывают исключения, которые осознанно игнорируют: например, ошибку при закрытии уже ненужного ресурса. Тогда сделайте намерение явным — назовите параметр ignored и напишите в комментарии, почему это безопасно. В Java 22+ можно использовать безымянную переменную: catch (NumberFormatException _) (JEP 456). Пустой catch без объяснения читатель кода справедливо примет за ошибку.
Не ловить Exception, Throwable и Error без нужды
Блок catch (Exception e) перехватывает не только ту ошибку, которую вы ожидали, но и все RuntimeException — то есть баги вроде NullPointerException и ClassCastException, а также проверяемое InterruptedException. catch (Throwable t) добавляет к этому OutOfMemoryError и StackOverflowError. Ловите самый конкретный тип, который умеете обработать.
public class BroadCatchDemo {
static int parseOrDefault(String s, int def) {
try {
return Integer.parseInt(s.trim());
} catch (Exception e) {
return def;
}
}
public static void main(String[] args) {
System.out.println(parseOrDefault("42", 0));
System.out.println(parseOrDefault("abc", 0));
System.out.println(parseOrDefault(null, 0));
}
} 42
0
0 Третий вызов — ошибка программиста: в метод передали null, и s.trim() выбросил NullPointerException. Но catch (Exception e) принял его за «строку не того формата» и вернул значение по умолчанию. Баг замаскирован и всплывёт там, где ноль приведёт к неверному расчёту. Исправление — ловить ровно то, что ожидается:
} catch (NumberFormatException e) {
return def;
} Теперь parseOrDefault(null, 0) сразу падает с NullPointerException и стеком, который указывает на вызывающий код. Если блок try может выбросить несколько ожидаемых исключений, перечислите их через multi-catch: catch (IOException | ParseException e).
Что ещё перехватывают широкие блоки catch:
| Блок | Что перехватывается заодно | Что ломается |
|---|---|---|
catch (RuntimeException e) | NullPointerException, ClassCastException, IndexOutOfBoundsException, ConcurrentModificationException | Ошибки в коде маскируются под ожидаемую ситуацию, программа работает дальше с неверными данными |
catch (Exception e) | Всё перечисленное выше + проверяемое InterruptedException | Флаг прерывания потока теряется, поток невозможно корректно остановить (см. раздел про InterruptedException) |
catch (Error e) | OutOfMemoryError, StackOverflowError, NoClassDefFoundError, AssertionError | Скрываются сбои JVM и окружения, а также проваленные assert в тестах |
catch (Throwable t) | Всё сразу: и Exception, и Error | После OutOfMemoryError состояние программы не гарантировано: продолжать работу как ни в чём не бывало опасно |
Почему catch (Exception e) не ловит StackOverflowError, показано на примере в разделе catch (Exception e) и StackOverflowError.
Где catch (Exception e) уместен: граница приложения
Широкий catch оправдан на границе приложения — в самом верхнем месте, где одна сбойная операция не должна останавливать всё остальное: в методе main, в обработчике HTTP-запроса, в цикле потока-воркера, который обрабатывает очередь задач. Там исключение записывают в лог и переходят к следующей задаче или возвращают клиенту ошибку 500.
import java.util.List;
public class WorkerLoop {
static void process(String task) {
if (task.equals("bad")) {
throw new IllegalStateException("Не удалось обработать " + task);
}
System.out.println("Готово: " + task);
}
public static void main(String[] args) {
for (String task : List.of("t1", "bad", "t3")) {
try {
process(task);
} catch (Exception e) { // граница: одна задача не должна останавливать остальные
System.out.println("Задача пропущена: " + e);
}
}
}
} Готово: t1
Задача пропущена: java.lang.IllegalStateException: Не удалось обработать bad
Готово: t3 В рабочем коде вместо System.out.println здесь будет вызов логгера с передачей e — как именно, разобрано в следующем разделе. В Spring такую границу обычно оформляют одним обработчиком @ControllerAdvice вместо try-catch в каждом контроллере.
Логирование исключений: один раз и со стеком
Исключение нужно либо записать в лог, либо пробросить дальше — но не делать и то и другое: каждый уровень, который логирует и пробрасывает, добавляет в лог ещё одну копию той же ошибки. Логируют один раз, там, где исключение окончательно обрабатывается, передавая в логгер сам объект исключения и контекст операции.
Anti-pattern log-and-rethrow: дубли в логе
Репозиторий, сервис и контроллер по очереди «на всякий случай» пишут ошибку в лог и пробрасывают её выше. В примере используется стандартный java.util.logging.Logger; обработчик в блоке static выводит записи в консоль в одну строку только для того, чтобы вывод был стабильным при каждом запуске (по умолчанию java.util.logging пишет в System.err с датой и временем).
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
public class LogAndRethrowDemo {
private static final Logger log = Logger.getLogger("demo");
static {
log.setUseParentHandlers(false);
log.addHandler(new Handler() {
@Override
public void publish(LogRecord record) {
System.out.println(record.getLevel() + ": " + record.getMessage()
+ " | " + record.getThrown());
}
@Override
public void flush() {
}
@Override
public void close() {
}
});
}
static void loadFromDb() {
try {
throw new IllegalStateException("Соединение с БД закрыто");
} catch (IllegalStateException e) {
log.log(Level.SEVERE, "Ошибка в репозитории", e);
throw e;
}
}
static void loadOrder() {
try {
loadFromDb();
} catch (IllegalStateException e) {
log.log(Level.SEVERE, "Ошибка в сервисе", e);
throw e;
}
}
public static void main(String[] args) {
try {
loadOrder();
} catch (IllegalStateException e) {
log.log(Level.SEVERE, "Не удалось загрузить заказ", e);
}
}
} SEVERE: Ошибка в репозитории | java.lang.IllegalStateException: Соединение с БД закрыто
SEVERE: Ошибка в сервисе | java.lang.IllegalStateException: Соединение с БД закрыто
SEVERE: Не удалось загрузить заказ | java.lang.IllegalStateException: Соединение с БД закрыто Одна ошибка — три записи уровня SEVERE, а в реальном логе ещё и три полных стека вызовов. Система мониторинга насчитает три инцидента, дежурный будет искать три причины, а при поиске по логу трудно понять, где ошибка возникла впервые. Правильно: промежуточные уровни либо ничего не делают с исключением, либо оборачивают его, добавляя контекст, а логирует только верхний уровень — граница приложения из предыдущего раздела.
Правило одного лога
Кто обрабатывает исключение — тот и логирует. Если метод пишет throw в блоке catch, значит, он не обрабатывает ошибку, а передаёт её дальше, и строка log.error(...) перед ним почти всегда лишняя. Исключение из правила — граница системы, за которую стек не уйдёт: например, ответ внешнему клиенту, которому вы отдаёте только код ошибки.
Передавайте в логгер исключение, а не e.getMessage()
Сообщение исключения — лишь малая часть информации о нём: в нём нет стека вызовов и цепочки Caused by, а иногда нет и самого текста.
import java.io.IOException;
public class MessageOnlyDemo {
public static void main(String[] args) {
Exception[] errors = {
new IllegalStateException(),
new RuntimeException("Сбой записи", new IOException("Диск переполнен"))
};
for (Exception e : errors) {
System.out.println("Ошибка: " + e.getMessage());
}
}
} Ошибка: null
Ошибка: Сбой записи Первая строка лога бесполезна, а во второй пропала настоящая причина — «Диск переполнен». Передайте исключение последним аргументом, и логгер сам выведет класс, сообщение, стек и все причины:
// Плохо
log.severe("Ошибка: " + e.getMessage()); // стек и причина потеряны
System.out.println("Ошибка: " + e); // не попадёт в лог-файл
e.printStackTrace(); // System.err, без времени и уровня
// Хорошо: java.util.logging (входит в JDK)
log.log(Level.SEVERE, "Не удалось сохранить заказ id=" + orderId, e);
// Хорошо: SLF4J (внешняя библиотека org.slf4j, работает поверх Logback или Log4j 2)
log.error("Не удалось сохранить заказ id={}", orderId, e); В SLF4J исключение, переданное последним аргументом после всех значений для {}, распознаётся как Throwable, и стек выводится полностью.
Контекст в сообщении
Текст записи в лог должен отвечать на вопрос «что пытались сделать и с какими данными»: "Не удалось сохранить заказ id=42 клиента 7" вместо "Ошибка" или "Exception in save". Тип исключения и стек логгер добавит сам. Пароли, номера карт и токены в сообщение не включайте: логи читает больше людей, чем базу данных.
Checked или unchecked для своего API
Проверяемое (checked) исключение выбирают, когда ситуация ожидаема и вызывающий код может на неё осмысленно отреагировать: повторить запрос, выбрать другой путь, показать пользователю понятное сообщение. Непроверяемое (unchecked) — когда это ошибка программиста или нарушение контракта метода, которую нужно не обрабатывать, а исправлять в коде.
- Checked: причина внешняя и от вызывающего кода не зависит — файл не найден, платёж отклонён банком, сервис не ответил за отведённое время. Компилятор заставит каждого вызывающего принять решение.
- Unchecked: передан
nullили недопустимый аргумент, метод вызван не вовремя, нарушен инвариант объекта. Такую ошибку исправляют правкой кода, а не блокомcatch. - Проверка на практике: если почти каждый вызывающий метод пишет
catch (XException e) { throw new RuntimeException(e); }, исключение должно было быть непроверяемым.
Какой класс наследовать в каждом случае и сводная таблица вариантов — в разделе extends Exception или extends RuntimeException; какие исключения JDK относятся к проверяемым — в разделе Проверяемые и непроверяемые исключения.
Почему Spring и другие фреймворки выбирают unchecked
Проверяемые исключения хорошо работают на одном уровне, но плохо масштабируются по слоям. SQLException из репозитория требует throws SQLException в сервисе, затем в контроллере — и детали JDBC протекают через всё приложение. Каждый уровень вынужден либо объявлять чужое исключение, либо писать catch, который часто превращается в «проглатывание». Поэтому Spring переводит SQLException в непроверяемую иерархию DataAccessException, а Hibernate и JPA бросают PersistenceException — тоже unchecked.
Ещё одна практическая деталь для собеседования: аннотация Spring @Transactional по умолчанию откатывает транзакцию только при RuntimeException и Error. Если сервис бросает проверяемое исключение, данные будут закоммичены, пока вы явно не укажете @Transactional(rollbackFor = Exception.class). В Kotlin, Scala и C# проверяемых исключений нет вовсе.
Checked-исключения в лямбдах и Stream API
Функциональные интерфейсы Function, Consumer, ToIntFunction не объявляют throws, поэтому проверяемое исключение не может выйти из лямбды: skus.stream().mapToInt(sku -> price(sku)) не скомпилируется с ошибкой unreported exception PriceException; must be caught or declared to be thrown. Приходится перехватывать его внутри лямбды и оборачивать в непроверяемое:
import java.util.List;
public class CheckedInLambdaDemo {
static class PriceException extends Exception {
PriceException(String message) {
super(message);
}
}
static int price(String sku) throws PriceException {
if (sku.startsWith("X")) {
throw new PriceException("Нет цены для " + sku);
}
return sku.length() * 100;
}
public static void main(String[] args) {
List<String> skus = List.of("AB", "ABC", "X1");
try {
int total = skus.stream()
.mapToInt(sku -> {
try {
return price(sku);
} catch (PriceException e) {
throw new IllegalStateException(e);
}
})
.sum();
System.out.println("Итого: " + total);
} catch (IllegalStateException e) {
System.out.println("Ошибка: " + e.getCause().getMessage());
}
}
} Ошибка: Нет цены для X1 Пять строк обвязки ради одного вызова — это и есть главная претензия к проверяемым исключениям в современном коде. Для IOException в JDK есть готовая обёртка UncheckedIOException (Java 8+). Если ваш метод в основном вызывают из лямбд, это весомый аргумент сделать его исключение непроверяемым.
Обёртывание исключения с сохранением причины
Когда исключение переходит границу слоя (репозиторий, сервис, API), его переводят в исключение этого слоя — exception translation — и обязательно передают исходное исключение вторым аргументом конструктора. Тогда вызывающий код видит понятный ему тип, а в логе остаётся полная цепочка Caused by до первопричины.
// Плохо: причина потеряна, в логе не будет ни SQLException, ни его стека
} catch (SQLException e) {
throw new OrderStorageException("Ошибка БД");
}
// Плохо: тип и стек исходного исключения потеряны, остался только текст
} catch (SQLException e) {
throw new RuntimeException(e.getMessage());
}
// Хорошо: свой тип, контекст и причина
} catch (SQLException e) {
throw new OrderStorageException("Не удалось сохранить заказ id=" + order.getId(), e);
} Не оборачивайте без необходимости: если исключение уже имеет подходящий тип, обёртка только удлиняет стек. Повторный выброс того же объекта через throw e разобран в разделе Повторный выброс исключения, а конструкторы с параметром cause, без которых обёртывание невозможно, — в разделе Конструкторы исключения: message и cause.
Исключения не для управления потоком выполнения
Исключение сообщает о ситуации, которую метод не может обработать, а не заменяет обычное ветвление. Если ситуацию можно проверить заранее простым условием — длиной массива, наличием ключа в карте, hasNextInt() у Scanner, — проверяйте условием: такой код быстрее, короче и не маскирует настоящие ошибки.
// Плохо: выход из цикла через исключение
int i = 0;
int sum = 0;
try {
while (true) {
sum += data[i++];
}
} catch (ArrayIndexOutOfBoundsException e) {
// конец массива
}
// Хорошо
int sum = 0;
for (int value : data) {
sum += value;
} В первом варианте catch поймает и настоящий выход за границы где-нибудь глубже в вызываемом коде, приняв его за «конец массива». То же касается try { map.get(key).process(); } catch (NullPointerException e) вместо containsKey() или getOrDefault().
Стоимость исключения. При создании объекта исключения конструктор Throwable вызывает fillInStackTrace() и обходит весь текущий стек вызовов, поэтому исключение стоит намного дороже проверки if — тем дороже, чем глубже стек, а в приложениях на фреймворках он часто длиной в сотню кадров. В горячем цикле это заметно. Отключить заполнение стека можно специальным конструктором (см. таблицу конструкторов), но это оправдано лишь в редких случаях.
Граница здесь разумная: если проверить заранее нельзя или это сложнее самой операции, исключение уместно. Например, в JDK нет метода «можно ли распарсить строку как число», поэтому catch (NumberFormatException e) на границе пользовательского ввода — нормальная практика (пример — в разделе NumberFormatException). Так же с файлами: проверка Files.exists() перед чтением не спасает от IOException, ведь файл могут удалить между проверкой и открытием.
Ресурсы и finally: try-with-resources
Файлы, соединения и потоки закрывайте через try-with-resources, а не вручную в finally. Если close() в finally выбросит исключение, оно заменит исходное исключение из блока try, и настоящая причина сбоя пропадёт. Try-with-resources сохраняет исходное исключение и прикрепляет ошибку закрытия к нему как подавленную (suppressed).
public class ResourceErrorsDemo {
static class Connection implements AutoCloseable {
@Override
public void close() {
throw new IllegalStateException("Ошибка при закрытии");
}
}
static void withFinally() {
Connection c = new Connection();
try {
throw new IllegalArgumentException("Ошибка обработки");
} finally {
c.close();
}
}
static void withResources() {
try (Connection c = new Connection()) {
throw new IllegalArgumentException("Ошибка обработки");
}
}
public static void main(String[] args) {
try {
withFinally();
} catch (RuntimeException e) {
System.out.println("finally: " + e.getMessage()
+ ", подавленных: " + e.getSuppressed().length);
}
try {
withResources();
} catch (RuntimeException e) {
System.out.println("try-with-resources: " + e.getMessage()
+ ", подавленное: " + e.getSuppressed()[0].getMessage());
}
}
} finally: Ошибка при закрытии, подавленных: 0
try-with-resources: Ошибка обработки, подавленное: Ошибка при закрытии С ручным finally вызывающий код узнал только о второстепенной ошибке закрытия, а «Ошибка обработки» исчезла бесследно. По той же причине не пишите в finally ни throw, ни return: return в finally отменяет исключение целиком, и метод завершается как будто успешно — пример в разделе return в finally. Синтаксис, несколько ресурсов и порядок их закрытия разобраны в уроке try-with-resources.
InterruptedException: не терять флаг прерывания
InterruptedException означает, что другой поток попросил текущий остановиться. Выбрасывая его, методы вроде Thread.sleep() и wait() сбрасывают флаг прерывания, поэтому пустой catch стирает эту просьбу без следа. Перехватив исключение, либо пробросьте его через throws, либо восстановите флаг вызовом Thread.currentThread().interrupt().
public class InterruptDemo {
static void pause() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
// плохо: флаг прерывания сброшен и потерян
}
}
static void pauseCorrect() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // восстановить флаг
}
}
public static void main(String[] args) {
Thread.currentThread().interrupt();
pause();
System.out.println("После pause(): " + Thread.currentThread().isInterrupted());
Thread.currentThread().interrupt();
pauseCorrect();
System.out.println("После pauseCorrect(): " + Thread.currentThread().isInterrupted());
}
} После pause(): false
После pauseCorrect(): true Если флаг установлен до вызова, Thread.sleep() сразу выбрасывает InterruptedException и сбрасывает флаг. После pause() код выше по стеку уже не узнает, что поток просили остановиться: цикл while (!Thread.currentThread().isInterrupted()) в воркере продолжит крутиться, а ExecutorService.shutdownNow(), который останавливает задачи именно через interrupt(), не сможет завершить пул. Тот же эффект даёт catch (Exception e) вокруг кода с sleep() или BlockingQueue.take() — поэтому такой блок нужно дополнить отдельным catch (InterruptedException e) перед ним.
Fail fast: проверка аргументов в начале метода
Принцип fail fast: проверяйте аргументы в первых строках метода или конструктора и сразу выбрасывайте исключение с понятным сообщением. Тогда ошибка проявится там, где появились некорректные данные, а не через десять вызовов в виде загадочного NullPointerException или объекта в испорченном состоянии.
public Order(String customer, int quantity) {
this.customer = Objects.requireNonNull(customer, "customer");
if (quantity <= 0) {
throw new IllegalArgumentException("quantity должно быть больше 0: " + quantity);
}
this.quantity = quantity;
} Objects.requireNonNull() выбрасывает NullPointerException с указанным текстом, а IllegalArgumentException — стандартный сигнал о недопустимом значении. Какое стандартное исключение выбрать для каждой ситуации, показано в разделе Оператор throw и throw new, а как такие проверки предотвращают частые исключения — в разделах IllegalArgumentException и Как не допускать частых исключений.
Итоговый чек-лист: плохо и хорошо
Чек-лист собирает все практики урока в одну таблицу: в первой колонке — код, который стоит искать на код-ревью, во второй — чем его заменить, в третьей — что именно ломается, если оставить как есть. Подробный разбор с примерами — в разделе по ссылке.
| Плохо | Хорошо | Почему |
|---|---|---|
Пустой catch (Exception e) { } | Не перехватывать или обработать конкретный тип: разбор | Ошибка исчезает, программа работает с неверным состоянием |
catch только с e.printStackTrace() | Пробросить или залогировать через логгер с e | Вывод в System.err теряется, а выполнение идёт дальше |
return null или -1 при сбое | Optional для отсутствия значения, исключение для сбоя | «Нет данных» и «авария» неразличимы, NPE позже |
catch (Exception e) вокруг бизнес-логики | Конкретный тип или multi-catch; Exception только на границе приложения | Баги (NPE, ClassCast) маскируются под ожидаемую ошибку |
catch (Throwable t) / catch (Error e) | Не перехватывать Error в прикладном коде | После OutOfMemoryError состояние JVM не гарантировано |
log.error(...) и затем throw e | Либо лог, либо проброс: разбор | Одна ошибка — несколько записей и стеков в логе |
log.error(e.getMessage()) | log.log(Level.SEVERE, "контекст", e) | Теряются стек и цепочка Caused by, сообщение может быть null |
throw new XException("...") в catch без e | throw new XException("...", e) | Обрывается цепочка причин |
| Checked-исключение для ошибки программиста | Unchecked для нарушения контракта: разбор | Вызывающие пишут бесполезные catch, лямбды обрастают обвязкой |
catch (ArrayIndexOutOfBoundsException e) для выхода из цикла | Проверка условием, for-each | Дорого и прячет настоящие выходы за границы |
close() вручную в finally | try-with-resources | Исключение закрытия заменяет исходное |
return или throw в finally | Только освобождение ресурсов в finally | Исходное исключение теряется |
Пустой catch (InterruptedException e) | Thread.currentThread().interrupt() или throws | Поток невозможно корректно остановить |
| Аргументы не проверяются, NPE в глубине вызовов | Objects.requireNonNull(), IllegalArgumentException в начале метода | Ошибка видна там, где появились плохие данные |
Часто задаваемые вопросы
Можно ли ловить Throwable?
Синтаксически можно, но в прикладном коде почти никогда не нужно: вместе с исключениями перехватываются OutOfMemoryError и StackOverflowError, после которых продолжать работу небезопасно. Оправданный случай — самый верхний уровень потока или фреймворка, где ошибку нужно записать в лог перед завершением; после этого Error следует пробросить дальше, а не продолжать как обычно. Что именно перехватывает каждый широкий catch — в таблице раздела Не ловить Exception, Throwable и Error.
Что такое exception swallowing и как найти его в чужом коде?
Exception swallowing («проглатывание» исключения) — перехват без какой-либо реакции: пустой catch, catch только с printStackTrace() или подмена ошибки на null. Искать удобно инструментами: IntelliJ IDEA подсвечивает пустой блок catch инспекцией, SonarQube и другие статические анализаторы помечают пустые и слишком широкие catch. Вручную ищите catch, после которого нет ни throw, ни вызова логгера с e.
Что делать на промежуточном слое: залогировать исключение и пробросить?
Нет, это anti-pattern log-and-rethrow: каждый слой добавит в лог свою копию ошибки. Если промежуточному слою есть что добавить, он оборачивает исключение в своё, кладёт контекст (id заказа, имя файла) в сообщение и передаёт исходное исключение как причину — throw new OrderStorageException("... id=" + id, e). Залогирует всю цепочку один раз верхний уровень. Пример с дублями в выводе — в разделе Anti-pattern log-and-rethrow.
Checked или unchecked исключение для своего API: как решить быстро?
Задайте вопрос: может ли вызывающий код сделать что-то разумное, кроме прекращения операции? Если да и причина внешняя (сеть, файл, отказ платёжной системы) — checked. Если это ошибка в коде или нарушение контракта — unchecked. В сомнительных случаях современные библиотеки и фреймворки выбирают unchecked, особенно если метод вызывают из лямбд. Подробности — в разделе Checked или unchecked.
Что выбросить при null-аргументе: NullPointerException или IllegalArgumentException?
Принятое в JDK соглашение — NullPointerException, обычно через Objects.requireNonNull(arg, "arg"); так поступают и методы стандартной библиотеки. IllegalArgumentException выбрасывают для значений, которые не равны null, но недопустимы: отрицательное количество, пустая строка, дата в прошлом. Главное — проверить аргумент в начале метода и указать в сообщении имя параметра.
Не противоречит ли правилу «исключения не для управления потоком» парсинг ввода через try-catch?
Нет. Правило касается ситуаций, которые дёшево проверить заранее условием. Для строки от пользователя в JDK нет метода «можно ли её распарсить», поэтому catch (NumberFormatException e) вокруг Integer.parseInt() на границе ввода — нормальная практика. Неправильно было бы ловить исключение в глубине бизнес-логики или в горячем цикле, где данные уже должны быть проверены.
Комментарии