сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Java: ядро языка

Исключения в Java

Исключения: где виден продакшен-опыт

Метод из двух строк: в try бросается IllegalStateException, в finally стоит return. Запустил - метод спокойно вернул строку из finally, а исключение исчезло без следа. Ни лога, ни стектрейса, ничего.

По исключениям интервьюер отличает «писал код» от «сопровождал код». Иерархию расскажет любой, а вот что делать с ошибкой - где ловить, что логировать, как не потерять первопричину - знают те, кто дежурил по инцидентам.

// Формулировки: «checked и unchecked - что используешь и почему?», «что не так с return в finally?», «как правильно перебросить исключение?»

Иерархия и вечный спор про checked

Дерево простое: Throwable делится на Error и Exception. Error - это беды виртуальной машины вроде нехватки памяти или переполнения стека, их не ловят. Exception делится дальше: checked компилятор заставляет обработать или объявить в throws, а unchecked (RuntimeException и наследники) летят свободно.

Замысел был такой: checked - для ожидаемых восстановимых ситуаций (файла нет, сеть моргнула), unchecked - для ошибок программирования (null, кривой индекс). На практике checked-объявления расползаются вверх по всем слоям и захламляют сигнатуры: метод репозитория объявил IOException, и о нём знает уже контроллер.

// Поэтому современный тренд - предпочитать unchecked, а чужие checked оборачивать в свои на границе слоя. Kotlin и Spring выбрали эту сторону целиком. На собеседовании ценится не «правильный» ответ, а то, что ты знаешь обе позиции и умеешь аргументировать свою.

checked
компилятор требует обработать или объявить
unchecked
RuntimeException и наследники, летят свободно

try-with-resources и предательский finally

Проверил порядок на двух ресурсах. Открылись A и B, тело бросило исключение, закрылись B и A - в обратном порядке открытия. Причём close у B тоже бросил своё исключение, и оно не потерялось: наружу вылетело исключение из тела, а ошибка закрытия приехала к нему прицепом, в списке подавленных.

Это и есть аргумент за try-with-resources: всё, что реализует AutoCloseable, закрывается само, в правильном порядке и во всех ветках. Ручное закрытие в finally с проверками на null - код до Java 7, сегодня это красный флаг на ревью.

// А finally показывает тёмную сторону, когда в нём стоит return или throw. Он выполняется всегда, поэтому перетирает исключение из try - тот замер из начала темы именно про это. Метод возвращает значение, настоящая ошибка испаряется, и найти её потом нельзя ничем.

try { throw new IllegalStateException("настоящая ошибка"); }
finally { return "значение из finally"; }
// метод вернул строку, исключение исчезло
try-with-resources
автозакрытие в обратном порядке во всех ветках
подавленное исключение
ошибка close, прицепленная к основному исключению

Культура обработки: ловить узко, причину сохранять

Ловить надо узко и осмысленно. Конструкция catch (Exception e) с пустым телом проглатывает и NPE (NullPointerException), и всё, что прилетело от соседнего кода; отладка через неделю идёт вслепую. Обработать нечем - не лови вовсе, пусть летит туда, где решат.

Перебрасываешь - сохраняй причину, и это тоже проверяется замером. Обернул исходную ошибку с передачей cause - getCause показывает исходное IOException с его текстом. Обернул без cause - getCause возвращает null, и первопричина потеряна навсегда вместе со стектрейсом.

// Правило «логируй или пробрасывай, но не одновременно». Логируешь и пробрасываешь на каждом слое - одна ошибка размножается в пять стектрейсов, и дежурный ищет пять багов вместо одного. И ещё: исключения не годятся как поток управления, создание каждого заполняет стектрейс, в горячем цикле это дорого.

cause
исходное исключение внутри обёртки, хранит стектрейс
стектрейс
снимок стека на момент создания исключения

Как отвечать: «Checked и unchecked - в чём разница и что предпочитаешь?»

Checked компилятор заставляет обработать или объявить в throws, unchecked - это RuntimeException и его наследники, они летят свободно. По изначальному замыслу checked предназначались для восстановимых ситуаций вроде отсутствия файла, unchecked - для ошибок программирования. На практике я предпочитаю unchecked, потому что checked расползаются по всем слоям: объявил IOException в репозитории - и о нём знает контроллер, которому это не нужно. Поэтому чужие checked оборачиваю в доменные unchecked прямо на границе слоя, и обязательно с передачей cause. Это не формальность: я проверял, обёртка без cause даёт getCause равный null, то есть первопричина и её стектрейс потеряны навсегда. Ловлю узко и только там, где могу осмысленно обработать, остальное пусть летит к общему обработчику. И держу правило «логируй или пробрасывай, но не оба сразу», иначе одна ошибка превращается в пять стектрейсов в логе.

Сильный ответ: есть учебное различие, собственная позиция с аргументом и практика - оборачивание на границе слоя, обязательный cause, узкий catch. Звучит как опыт, а не пересказ главы.

На чём валят

  • Ставят return или throw в finally. Исключение из try исчезает молча, и спрашивают это кодом «что вернёт метод».
  • Пишут catch (Exception e) с пустым телом. На собеседовании это маркер человека, который прод не сопровождал.
  • Оборачивают без cause. Стектрейс первопричины теряется, getCause возвращает null.
  • Логируют и пробрасывают на каждом слое. Одна ошибка даёт пять стектрейсов, разбор превращается в квест.
  • Ставят catch родителя раньше наследника. Это ошибка компиляции: exception RuntimeException has already been caught.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 18, остальные разбираются в тренажёре.

  1. #exceptions1 / 5
    Выполнится ли блок finally, если в try есть return?
    A)Нет: return немедленно выходит из метода, и до finally управление уже не доходит
    B)Finally выполнится лишь в том случае, если внутри try действительно возникло исключение, а при обычном return этот блок просто пропускается
    C)Да, finally выполняется при return и при исключении; не выполнится лишь при System.exit или падении JVM
    D)Да, но лишь когда метод возвращает void; при return со значением finally пропускается
    показать ответ и разбор
    +C)Да, finally выполняется при return и при исключении; не выполнится лишь при System.exit или падении JVM

    // разбор: finally выполняется практически всегда: при нормальном завершении try, при return из try (return «придерживается», пока не отработает finally) и при выбросе исключения. Не выполнится он лишь в экстренных случаях: System.exit() (принудительно гасит JVM), фатальный сбой/убийство процесса, бесконечный цикл или демон-поток, чей процесс завершился. Именно поэтому в finally освобождают ресурсы. Отдельная ловушка — return внутри самого finally: он перекрывает результат/исключение из try.

  2. #exceptions2 / 5
    Зачем нужен try-with-resources?
    A)Чтобы повторить try при сбое: блок автоматически перезапускается, пока ресурс не откроется
    B)Чтобы ускорить работу с файлами за счёт кэширования открытых потоков между вызовами метода
    C)Чтобы одним блоком catch поймать сразу больше разных типов исключений, не перечисляя их все через вертикальную черту вручную
    D)Чтобы автоматически закрыть ресурсы (AutoCloseable) в обратном порядке даже при исключении — без утечек
    показать ответ и разбор
    +D)Чтобы автоматически закрыть ресурсы (AutoCloseable) в обратном порядке даже при исключении — без утечек

    // разбор: try-with-resources объявляет ресурсы в скобках try(...); каждый должен реализовывать AutoCloseable. По выходу из блока — нормальному ИЛИ через исключение — JVM автоматически вызывает close() для них в порядке, обратном открытию. Это устраняет классические утечки, когда close забыли или он не сработал из-за исключения до finally. Плюс исключение из close не затирает основное — оно добавляется как suppressed. Заметно чище ручного try/finally.

  3. #exceptions3 / 5
    Чем отличаются throw и throws?
    A)Throw объявляет прямо в сигнатуре метода, что тот способен бросить исключение, а throws уже фактически выбрасывает его в теле
    B)throw бросает исключение (оператор в теле); throws объявляет в сигнатуре, что метод может его бросить
    C)Throw — для checked-исключений, throws — для unchecked; в остальном они взаимозаменяемы
    D)Это две формы записи одного и того же; какую выбрать — вопрос стиля и версии Java
    показать ответ и разбор
    +B)throw бросает исключение (оператор в теле); throws объявляет в сигнатуре, что метод может его бросить

    // разбор: throw — оператор, который в конкретной точке кода ВЫБРАСЫВАЕТ экземпляр исключения: throw new IllegalArgumentException(...). throws — часть СИГНАТУРЫ метода, объявляющая, какие checked-исключения он может пробросить наружу: void read() throws IOException. То есть throw — действие, throws — контракт. Путать их — классическая оговорка джуна. Для unchecked throws писать не обязательно, но иногда добавляют для документирования.

  4. #exceptions4 / 5
    Метод бросает исключение в try, но в finally стоит return. Что вернётся наружу?
    A)Исключение из try сильнее return и прервёт выполнение метода
    B)Значение из return в finally; исключение из try при этом молча проглатывается — это антипаттерн
    C)Оба: сначала метод вернёт значение, а затем повторно выбросит отложенное исключение
    D)Ошибка компиляции: делать return внутри finally, когда в try возможно исключение, прямо запрещено спецификацией самого языка
    показать ответ и разбор
    +B)Значение из return в finally; исключение из try при этом молча проглатывается — это антипаттерн

    // разбор: return (или throw) в finally перекрывает всё, что «недосказал» try: если try выбросил исключение, а finally делает return — наружу уходит именно это значение, а исключение теряется БЕЗ следа (не логируется, не пробрасывается). Компилятор такое разрешает, поэтому баг тихий и коварный — потерянные ошибки крайне трудно ловить. Отсюда правило: не делать return/throw в finally; finally оставляют только под освобождение ресурсов.

  5. #exceptions5 / 5
    Чем плох пустой catch: catch (Exception e) {}?
    A)Он ловит слишком мало — Exception не перехватывает RuntimeException, и реальные баги пролетают мимо
    B)Он молча глотает любые исключения, включая ошибки логики (NPE и пр.) — теряется вся диагностика
    C)Пустой catch не компилируется: блок обработки обязан содержать хотя бы логирование или throw
    D)Он замедляет метод, потому что JVM на каждый вызов проверяет, не пуст ли блок обработки
    показать ответ и разбор
    +B)Он молча глотает любые исключения, включая ошибки логики (NPE и пр.) — теряется вся диагностика

    // разбор: catch (Exception e) {} перехватывает почти всё (включая RuntimeException — NPE, IllegalState и т.п.) и НИЧЕГО с этим не делает: ошибка исчезает без лога и без проброса. Программа продолжает работу в некорректном состоянии, а причину потом не найти — это одна из худших практик. Как минимум исключение логируют с контекстом и/или пробрасывают; ловить стоит конкретные типы, а не Exception «на всё». Компилятор пустой catch разрешает, скорость ни при чём.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.