Обработка исключений в Java: лучшие практики и типичные ошибки - Вопросы

Всего: 5 вопросов

1. 

Что такое 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) всё-таки уместен?

Широкий 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 и как правильно логировать исключения?

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

Страница 1 из 1