Обработка исключений в Java: лучшие практики и типичные ошибки - Вопросы
Всего: 5 вопросов
1. Что такое exception swallowing («проглатывание» исключения) и чем оно опасно?
Что такое exception swallowing («проглатывание» исключения) и чем оно опасно?
Exception swallowing — перехват исключения без реакции на него: пустой catch (часто с комментарием // TODO), catch только с e.printStackTrace() или подмена ошибки значением null или -1. Такой код компилируется без предупреждений, но программа продолжает работать с неверным состоянием, а причина сбоя всплывает позже и в другом месте.
- Пустой catch: ошибка превращается в тишину — например, цикл сохранения заказов сообщает «ошибок нет», хотя один заказ из трёх не сохранился.
- Только printStackTrace(): выполнение идёт дальше (клиент получает письмо о несохранённом заказе), а вывод уходит в
System.errв обход логгера — без времени, уровня и контекста, и на сервере часто теряется. - return null или -1: «данных нет» и «хранилище недоступно» становятся неразличимы; вызывающий код получит
NullPointerExceptionпозже, а-1бывает допустимым значением. Отсутствие результата выражают черезOptional, сбой — исключением.
Правильно: не перехватывать исключение там, где с ним нечего делать; если ошибку одного элемента нужно пережить, зафиксировать её (собрать в отчёт или записать в лог с объектом исключения). Если игнорирование осознанное, назовите параметр ignored и объясните причину в комментарии, а в Java 22+ можно написать catch (NumberFormatException _).
2. Почему не стоит ловить Exception и Throwable и в каком месте программы catch (Exception e) всё-таки уместен?
Почему не стоит ловить Exception и Throwable и в каком месте программы catch (Exception e) всё-таки уместен?
Широкий catch перехватывает не только ожидаемую ошибку, но и всё остальное:
catch (Exception e)ловит всеRuntimeException— баги вродеNullPointerException,ClassCastException,IndexOutOfBoundsException. МетодparseOrDefault(null, 0)с таким блоком вернёт значение по умолчанию вместо падения, и ошибка программиста замаскируется под «строку не того формата». Заодно перехватываетсяInterruptedException, и поток теряет флаг прерывания.catch (Throwable t)добавляетOutOfMemoryErrorиStackOverflowError; после них состояние программы не гарантировано, продолжать работу опасно.
Правило: ловите самый конкретный тип, который умеете обработать (NumberFormatException вместо Exception), несколько ожидаемых типов перечисляйте через multi-catch.
catch (Exception e) уместен на границе приложения — в самом верхнем месте, где одна сбойная операция не должна останавливать остальные: метод main, обработчик HTTP-запроса, цикл потока-воркера, обрабатывающего очередь задач. Там исключение логируют вместе со стеком и переходят к следующей задаче или возвращают клиенту ошибку 500. В Spring такую границу обычно оформляют одним @ControllerAdvice. Throwable допустимо ловить разве что на верхнем уровне потока или фреймворка, чтобы записать ошибку в лог, после чего Error пробрасывают дальше.
3. Что такое anti-pattern log-and-rethrow и как правильно логировать исключения?
Что такое anti-pattern log-and-rethrow и как правильно логировать исключения?
Log-and-rethrow — когда каждый слой (репозиторий, сервис, контроллер) пишет исключение в лог и пробрасывает его дальше. Одна ошибка даёт несколько записей уровня ERROR/SEVERE и несколько одинаковых стеков: мониторинг насчитает несколько инцидентов, а по логу трудно понять, где ошибка возникла впервые.
Как правильно:
- Логировать один раз — там, где исключение окончательно обрабатывается (обычно граница приложения). Если в
catchестьthrow, метод ошибку не обрабатывает, и запись в лог перед ним почти всегда лишняя. - Промежуточный слой либо ничего не делает с исключением, либо оборачивает его в исключение своего слоя с контекстом и причиной:
throw new OrderStorageException("Не удалось сохранить заказ id=" + id, e). Без второго аргумента обрывается цепочкаCaused by. - Передавать в логгер сам объект исключения, а не
e.getMessage():log.log(Level.SEVERE, "Не удалось сохранить заказ id=" + orderId, e)вjava.util.loggingилиlog.error("... id={}", orderId, e)в SLF4J. Сообщение не содержит стека и причин, а уnew IllegalStateException()оно вообщеnull. - Писать контекст: что пытались сделать и с какими данными, без паролей, номеров карт и токенов. Не использовать
printStackTrace()иSystem.outвместо логгера.
4. Checked или unchecked исключение выбрать для своего API и почему Spring и другие фреймворки выбирают unchecked?
Checked или unchecked исключение выбрать для своего API и почему Spring и другие фреймворки выбирают unchecked?
Критерий — может ли вызывающий код сделать что-то разумное, кроме прекращения операции. Checked выбирают для ожидаемых внешних ситуаций, на которые можно отреагировать: файл не найден, платёж отклонён банком, сервис не ответил вовремя. Unchecked — для ошибок программиста и нарушения контракта: null или недопустимый аргумент, вызов не вовремя. Практическая проверка: если почти каждый вызывающий пишет catch (XException e) { throw new RuntimeException(e); }, исключение должно было быть непроверяемым.
Почему фреймворки выбирают unchecked:
- Checked плохо масштабируются по слоям:
SQLExceptionиз репозитория требуетthrowsв сервисе и контроллере, детали JDBC протекают через всё приложение, а вынужденныеcatchчасто превращаются в «проглатывание». Поэтому Spring переводитSQLExceptionв непроверяемую иерархиюDataAccessException, а JPA и Hibernate бросают непроверяемоеPersistenceException. - Лямбды и Stream API:
Function,Consumer,ToIntFunctionне объявляютthrows, и checked-исключение приходится оборачивать внутри каждой лямбды (дляIOExceptionестьUncheckedIOException). - @Transactional по умолчанию откатывает транзакцию только при
RuntimeExceptionиError; при checked-исключении данные будут закоммичены, если не указатьrollbackFor = Exception.class— частый вопрос с подвохом на собеседовании.
В Kotlin, Scala и C# проверяемых исключений нет вовсе.
5. Как правильно обрабатывать InterruptedException и почему нельзя оставлять его catch пустым?
Как правильно обрабатывать InterruptedException и почему нельзя оставлять его catch пустым?
InterruptedException — проверяемое исключение, которое означает, что другой поток попросил текущий остановиться (вызвал interrupt()). Выбрасывая его, Thread.sleep(), wait(), BlockingQueue.take() сбрасывают флаг прерывания. Если флаг установлен ещё до вызова, sleep() выбрасывает исключение сразу.
Поэтому пустой catch стирает просьбу остановиться без следа: цикл while (!Thread.currentThread().isInterrupted()) в воркере продолжит крутиться, а ExecutorService.shutdownNow(), который останавливает задачи именно через interrupt(), не сможет завершить пул.
Правильные варианты:
- пробросить исключение дальше через
throws InterruptedException; - если пробросить нельзя (например, метод
run()уRunnable), восстановить флаг:catch (InterruptedException e) { Thread.currentThread().interrupt(); }— после этогоisInterrupted()снова вернётtrue.
Тот же эффект, что и пустой catch, даёт catch (Exception e) вокруг кода с sleep() или take(): перед ним нужен отдельный catch (InterruptedException e), восстанавливающий флаг.