Исключения ·
‹ Предыдущий Следующий ›
⏱ 5 минут чтения Обновлено: 2026-10-30

Обработка исключений в 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 — почему это важно, разобрано в следующем разделе.

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

Комментарии

Зарегистрируйтесь или войдите, чтобы иметь возможность оставить комментарий.