Большинство проблем с обработкой ошибок в Java-приложениях сводятся к нескольким повторяющимся антипаттернам. Их легко допустить, но ещё легче не заметить — ошибка молча глотается, и отлаживать приходится уже на продакшне.
У двух самых частых цена одна — запись в логе перестаёт показывать место сбоя: вот один и тот же SQLException при трёх вариантах catch в репозитории.
Одна и та же ошибка: пустой catch уводит поиск на три кадра выше места сбоя, обёртка без cause — на один, и только cause оставляет в логе запись с настоящей причиной.
Проглоченное исключение — пустой catch
Самый опасный антипаттерн: catch перехватывает исключение и ничего с ним не делает. Программа продолжает работу как ни в чём не бывало, хотя что-то уже пошло не так.
// плохо
try {
config = objectMapper.readValue(json, Config.class);
} catch (JsonProcessingException e) {
// TODO: разобраться потом
}
После такого блока config остаётся null, и NullPointerException прилетит в совершенно другом месте. Связь с настоящей причиной потеряна.
Правило: в catch должно быть хотя бы одно из трёх — логирование, повторный выброс, значимое действие (fallback, метрика).
// хорошо
try {
config = objectMapper.readValue(json, Config.class);
} catch (JsonProcessingException e) {
throw new ConfigurationException("Не удалось разобрать конфигурацию", e);
}
Слишком широкий catch — Exception и Throwable
Пустой catch хотя бы виден глазами. Гораздо чаще встречается другое: catch не пустой, лог в нём есть, но перехватывает он всё подряд.
// плохо
try {
orderService.place(request);
} catch (Exception e) {
log.error("Не удалось создать заказ", e);
}
Выглядит аккуратно, а в одну ветку попадают три разных случая. Первый — ожидаемый отказ вроде «товара нет на складе», на который нужно ответить клиенту понятной ошибкой. Второй — InterruptedException: это не ошибка заказа, а просьба потоку остановиться, и здесь она гаснет вместе с флагом прерывания. Третий — чужие непроверяемые исключения из глубины: NullPointerException в вашем же коде, IllegalStateException из библиотеки. Все три получают одну и ту же запись «Не удалось создать заказ», и отличить по логу опечатку в коде от нормального отказа уже нельзя.
catch (Throwable t) добавляет к этому Error — семейство, которое не предполагает продолжения работы: OutOfMemoryError, StackOverflowError, NoClassDefFoundError при битой сборке. Перехватив такое, приложение делает вид, что справилось, и продолжает работать в состоянии, в котором продолжать нельзя: памяти по-прежнему нет, класса по-прежнему нет, и следующий запрос упадёт где-то ещё.
// хорошо — разные случаи в разных ветках
try {
orderService.place(request);
} catch (OutOfStockException e) {
return OrderResult.rejected(e.getSku());
} catch (OrderStorageException e) {
log.error("Заказ {} не сохранён", request.id(), e);
throw e;
}
Правило: перехватывайте то, что умеете обработать, и разное — разными ветками. Один широкий catch (Exception e) уместен ровно в двух местах: на самом верху — в @RestControllerAdvice или в теле задачи для пула потоков, чтобы один запрос не уронил приложение молча, — и вокруг обработки одного элемента в цикле, когда сбой на одном элементе не должен останавливать остальные. Внутри такого обработчика всё равно разбирают, что именно прилетело.
Потеря стектрейса — исключение без cause
Когда вы оборачиваете одно исключение в другое, всегда передавайте оригинал как cause. Иначе вся цепочка вызовов, которая привела к ошибке, бесследно исчезает.
// плохо — теряем исходную причину
} catch (SQLException e) {
throw new OrderStorageException("Ошибка БД"); // e пропал
}
// хорошо — причина сохранена
} catch (SQLException e) {
throw new OrderStorageException("Ошибка БД", e);
}
В логах первый вариант покажет только OrderStorageException без какого-либо контекста. Второй — полную цепочку с оригинальным SQLException, именем таблицы, кодом ошибки драйвера.
Подавленное исключение при закрытии ресурса
try-with-resources закрывает ресурс сам, и это правильный способ работать с соединением, файлом или каналом. Но у него есть тихий эффект: если исключение вылетело и в теле блока, и при закрытии, наружу уйдёт только одно — из тела. Второе не пропадает, а прикрепляется к первому как подавленное (suppressed):
живой пример
public class SuppressedDemo {
static class Channel implements AutoCloseable {
void send() { throw new IllegalStateException("таймаут при отправке"); }
public void close() { throw new IllegalStateException("сбой при закрытии канала"); }
}
public static void main(String[] args) {
try (Channel channel = new Channel()) {
channel.send();
} catch (Exception e) {
System.out.println("настоящая причина: " + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("подавленное: " + suppressed.getMessage());
}
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
настоящая причина: таймаут при отправке
подавленное: сбой при закрытии канала
Чаще всего такой порядок и нужен: настоящая причина в теле, сбой при закрытии вторичен. Проблема начинается, когда тело сработало нормально, а упал именно close(). Тогда наружу летит исключение закрытия — «не удалось сбросить буфер», — и операция, которую вы отлаживаете, по логу выглядит успешной ровно до этой строки.
Отсюда три следствия. Не закрывайте ресурс руками в finally «чтобы надёжнее»: там исключение закрытия вытеснит исключение тела полностью, без всякого suppressed. Если пишете свой close(), не бросайте из него исключение там, где хватит записи в лог: закрытие уже идёт по пути отказа. И при разборе непонятной записи смотрите не только на Caused by, но и на строки Suppressed: — стандартный вывод стектрейса их печатает.
return внутри finally
finally выполняется всегда — и именно поэтому в нём нельзя завершать метод. return в finally побеждает всё, что было до него: летящее исключение просто исчезает.
живой пример
public class FinallyDemo {
static int countOrders() {
try {
throw new IllegalStateException("соединение с базой потеряно");
} finally {
return 0;
}
}
public static void main(String[] args) {
System.out.println("метод вернул: " + countOrders());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
метод вернул: 0
Исключения нет ни в логе, ни в стектрейсе: вызывающий получил ноль заказов и уверен, что база пуста. То же делают break, continue и throw из finally — наружу выходит последнее действие, а исходная ошибка теряется по дороге.
Обычная сборка об этом молчит: предупреждение finally clause cannot complete normally javac печатает только с -Xlint:finally (и с -Xlint:all), зато в IDE такое место подсвечивается сразу. Правило простое: в finally — только освобождение ресурсов, никаких выходов из метода. Значение возвращает try, а закрытие лучше вообще отдать try-with-resources.
Исключения как управление потоком
Исключения — дорогой механизм: при создании объекта JVM снимает слепок всего стека вызовов, и платить приходится именно за этот слепок, а не за сам throw. Использовать исключения как goto — неправильно и медленно.
// плохо — исключение вместо if
try {
int value = Integer.parseInt(input);
return value;
} catch (NumberFormatException e) {
return -1; // «не число» — это обычный случай, не ошибка
}
// хорошо — метод сам сообщает о неудаче
public OptionalInt parseQuantity(String input) {
try {
return OptionalInt.of(Integer.parseInt(input));
} catch (NumberFormatException e) {
return OptionalInt.empty();
}
}
Разница между этими двумя кусками не в наличии try/catch, а в том, что метод отдаёт наружу. В плохом варианте неудача превращается в -1 — обычное число, которое вызывающий легко примет за настоящий ответ и понесёт дальше. В хорошем — сам тип возврата говорит «значения может и не быть», и не проверить это уже не получится.
Заманчиво заменить перехват предварительной проверкой регулярным выражением, но с числами это не работает: строка "2147483648" состоит из одних цифр, проходит любую проверку на «это число» — и всё равно не помещается в int. Проверить заранее всё, что умеет разбор, обычно не выходит.
Короткая формула: исключение — для исключительных ситуаций, которых в норме быть не должно. Если «неправильные данные» — ожидаемый случай, метод должен возвращать результат, по которому это видно, а не прятать неудачу за магическим числом.
Бывает и обратный случай: исключение летит часто и по делу — например, как сигнал «повтори попытку» внутри библиотеки. Тогда отключают то, что дорого: у Throwable и его наследников есть защищённый конструктор (message, cause, enableSuppression, writableStackTrace), и с writableStackTrace = false слепок стека не снимается вовсе. Это точечная мера для горячего места, а не разрешение вернуть goto.
Проглоченный InterruptedException
InterruptedException — особый случай: его нельзя просто проглотить. Это сигнал потоку остановиться. Если проигнорировать, поток продолжит работу и никогда не завершится корректно.
// плохо — флаг прерывания потерян
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// ничего не делаем
}
// хорошо — восстанавливаем флаг
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TaskInterruptedException("Задача прервана", e);
}
Если метод не может выбрасывать InterruptedException, минимум — восстановить флаг через Thread.currentThread().interrupt(), чтобы вызывающий код мог его прочитать.
Восстановить флаг — это половина дела. Сам флаг ничего не останавливает: он только помечает поток как прерванный, и следующий блокирующий вызов (sleep, wait, take у очереди) выбросит InterruptedException сразу же. Если после interrupt() метод продолжает крутить цикл, получается худший из вариантов: работа не останавливается, а каждая итерация падает на новом ожидании.
// плохо — флаг восстановили, но из цикла не вышли
while (true) {
try {
process(queue.take());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// хорошо — прерывание останавливает цикл
while (!Thread.currentThread().isInterrupted()) {
try {
process(queue.take());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
В пуле потоков это критично, потому что прерывание — единственный способ остановить задачу извне. shutdownNow() у ExecutorService и cancel(true) у Future не убивают поток, а именно прерывают его, а awaitTermination ждёт, пока задачи отреагируют. Задача, которая глотает прерывание и продолжает цикл, не даст приложению завершиться: остановка сервиса упирается в таймаут, а дальше процесс добивает тот, кто его запускал — контейнер или systemd.
Дублирование логов — log-and-throw
Антипаттерн «залогировал и выбросил» приводит к тому, что одна ошибка попадает в лог несколько раз: каждый слой перехватывает, логирует и бросает дальше.
// плохо — лог в каждом слое
} catch (SQLException e) {
log.error("Ошибка в репозитории", e); // здесь
throw new OrderStorageException("Ошибка БД", e);
}
// ...выше по стеку...
} catch (OrderStorageException e) {
log.error("Ошибка в сервисе", e); // и здесь снова
throw e;
}
В итоге один запрос порождает две одинаковые записи в логе, а с третьим слоем и три — с разными сообщениями, но одним стектрейсом. Найти «настоящую» причину становится сложнее.
Правило: логируйте один раз — либо там, где обрабатываете и не перебрасываете, либо на верхнем уровне (@RestControllerAdvice). Промежуточные слои только оборачивают и бросают дальше.
Стектрейс мимо логов — printStackTrace и getMessage
Две привычки, которые выглядят как логирование, но не логируют.
Первая — e.printStackTrace(). Этот вызов пишет не в логгер, а прямо в System.err. Значит: без уровня (это не ERROR, а просто текст), без времени и имени потока, без сквозного идентификатора запроса из MDC, мимо настроенных фильтров и мимо файла, который забирает сборщик логов. В контейнере System.err попадает в общий поток вывода вперемешку со всем остальным, а если формат логов свой (например, JSON) — такая запись не разберётся и потеряется в поиске.
// плохо
} catch (IOException e) {
e.printStackTrace();
}
// хорошо
} catch (IOException e) {
throw new ReportGenerationException("Не удалось записать отчёт", e);
}
Вторая привычка — логировать текст вместо самого исключения:
// плохо — стектрейса не будет
log.error("Ошибка при отправке: {}", e.getMessage());
// хорошо — исключение последним аргументом и без плейсхолдера
log.error("Ошибка при отправке письма {}", messageId, e);
В первом варианте в логе останется одна строка вида Ошибка при отправке: null — getMessage() у многих исключений пустой, у NullPointerException подробное сообщение появилось только в Java 14 и включено по умолчанию с Java 15 — и ни одного кадра стека. Это та же потеря причины, что и обёртка без cause, только сделанная в момент записи в лог.
В SLF4J исключение передают последним аргументом и без {} для него: тогда логгер печатает сообщение, а под ним полный стектрейс вместе с Caused by и Suppressed. Если поставить под исключение {}, оно подставится как текст toString() — и стектрейс снова не попадёт в лог.
Перехват внутри транзакции — откат, который не случился
Самая дорогая ошибка обработки в Spring-приложении не роняет запрос и не оставляет следа в логе. Она просто коммитит половину работы.
Правило по умолчанию такое: @Transactional откатывает транзакцию только на непроверяемых исключениях — наследниках RuntimeException и Error. Проверяемое исключение (IOException, своё ExportException extends Exception) вылетает из метода, а транзакция при этом коммитится. Для таких исключений откат включают явно:
@Transactional(rollbackFor = ExportException.class)
public void export(long orderId) throws ExportException { ... }
Второй случай — исключение перехвачено внутри транзакции:
// плохо
@Transactional
public void placeOrder(OrderRequest request) {
orderRepository.save(new Order(request));
try {
paymentClient.charge(request.amount());
} catch (PaymentException e) {
log.warn("Оплата не прошла", e);
}
}
Здесь всё выглядит обработанным: сбой записан в лог, метод завершился нормально. Но save уже отправил заказ в базу, а catch не дал транзакции узнать о сбое — на выходе из метода Spring коммитит. В базе остаётся заказ без оплаты, и обнаружится это на сверке через день, а не в логе.
То же ломает вложенный вызов. Если внутренний метод с @Transactional бросил исключение, а внешний его поймал и продолжил работу, транзакция уже помечена как rollback-only: оба метода по умолчанию работают в одной физической транзакции, и на коммите внешнего Spring выбросит UnexpectedRollbackException. Ошибка прилетит в неожиданном месте, далеко от настоящей причины.
Что с этим делать:
- поймали исключение внутри транзакции и не бросаете дальше — либо откатите явно (
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()), либо признайте, что записанное должно остаться, и вынесите этот шаг за границу транзакции; - внешние вызовы (платёж, письмо, чужое API) вообще не держите внутри транзакции: сначала коммит, потом вызов — через таблицу исходящих сообщений или событие после коммита;
- проверяемые исключения, которые обязаны откатывать, перечислите в
rollbackFor— или сделайте их непроверяемыми.
Коротко
- catch не пустой: логирование, повторный выброс или явное действие
- перехватывать то, что умеете обработать;
catch (Exception)— только наверху и вокруг элемента цикла,catch (Throwable)— нигде - оригинальное исключение всегда передаётся как
cause, а в лог уходит само исключение последним аргументом — неe.getMessage()и неprintStackTrace() - в
finallyнетreturn,breakиthrow: они съедают летящее исключение - ошибка при закрытии ресурса прячется в
getSuppressed()— при разборе смотреть и наSuppressed: InterruptedException— восстанавливаем флагThread.currentThread().interrupt()и выходим из цикла, а не продолжаем работу- обычные случаи («неверный формат», «не найдено») — результат, по которому это видно (
Optional,OptionalInt), а не исключение вместоif - лог ошибки — один раз, на верхнем уровне; промежуточные слои не логируют то, что перебрасывают
@Transactionalоткатывает только на непроверяемых: для остальныхrollbackFor, а перехват внутри транзакции коммитит половину работы
Что почитать дальше
- Модель ошибок и иерархия исключений — как выстраивать собственную иерархию исключений в приложении
- Глобальная обработка ошибок —
@RestControllerAdvice, Problem Details и единая точка логирования - Ошибки REST API и Problem Details — как правильно возвращать ошибки клиенту по RFC 9457
- @Transactional: границы транзакции — правило откатов, вложенные вызовы и
UnexpectedRollbackException