← назад к разделу

Большинство проблем с обработкой ошибок в Java-приложениях сводятся к нескольким повторяющимся антипаттернам. Их легко допустить, но ещё легче не заметить — ошибка молча глотается, и отлаживать приходится уже на продакшне.

У двух самых частых цена одна — запись в логе перестаёт показывать место сбоя: вот один и тот же SQLException при трёх вариантах catch в репозитории.

POST /orders — сбой в JDBC-драйвере, кадр 4 из 4 кадр 1OrderController кадр 2OrderService кадр 3OrderRepository кадр 4JDBC driverSQLExceptionместо сбоя catch (SQLException e) { }репозиторий вернул null, NPE в контроллерев логе — кадр 1, про SQL ни строки3 кадра до места сбоя throw new OrderStorageException(msg)в логе — кадр 3, стектрейс с репозиторияимени таблицы и кода драйвера нет1 кадр до места сбоя throw new OrderStorageException(msg, e)в логе — Caused by: SQLException, кадр 4видно таблицу orders и код драйвера0 — причина в самой записи только cause в конструкторе приводит лог прямо к месту сбоя

Одна и та же ошибка: пустой 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, а перехват внутри транзакции коммитит половину работы

Что почитать дальше