Серия подготовки: к обзору тем Java-собеседования.

Вопросы об исключениях и вводе-выводе Java проверяют, умеете ли вы сохранять причину сбоя, закрывать ресурсы и читать данные без потерь. Ловушки часто скрыты в простом коде: finally меняет возвращаемое значение, ошибка close() заслоняет основную, а один вызов read() получает лишь часть файла.

Разберём 18 вопросов на примерах, совместимых с Java 17 и 21. Все фрагменты учебные: их задача — показать конкретное свойство API. Для текста явно используется UTF-8, для ресурсов — понятный владелец и срок закрытия. Вопросы о вводе-выводе здесь относятся к файлам и потокам данных; Stream API для обработки коллекций — отдельная тема.

Контракты исключений и обработка ошибок

1. Чем checked exceptions отличаются от unchecked?

Для проверяемого исключения компилятор требует подходящую обработку или объявление в throws. Непроверяемыми являются RuntimeException, Error и их подклассы. Это классификация контроля при компиляции, а не обещание, что одни ошибки возникают до запуска, а другие только во время него.

Обе группы представлены объектами Throwable и могут возникнуть при исполнении. Решение о собственном типе исключения зависит от контракта: что вызывающий код должен знать и что способен предпринять. Подвох: catch (Exception e) перехватывает Exception и его подклассы, включая RuntimeException. Объекты Error в эту ветвь иерархии не входят. Попытка поймать вообще всё через Throwable требует объяснить, как программа сможет продолжить после системного сбоя.

2. Чем throw отличается от throws? Что меняется при переопределении?

throw выбрасывает конкретный объект исключения и прерывает обычное выполнение в этой точке. throws объявляет возможные исключения в контракте метода; само объявление ничего не выбрасывает. Один и тот же метод может завершиться успешно при одних входных данных и бросить ошибку при других.

Переопределённый метод не может расширить набор проверяемых исключений относительно доступного вызывающему коду контракта базового метода. Он вправе сузить его или убрать. Для непроверяемых исключений такого ограничения throws нет. Частое уточнение: запись throws Exception не превращает метод в обязательную ошибку и не заменяет понимание того, какие причины сбоя действительно должен различать клиент.

3. Почему порядок catch важен и когда допустим multi-catch?

Выбирается первый подходящий блок catch. Если сначала поставить обработчик общего типа, обработчик его подтипа окажется недостижимым, и компилятор не пропустит такую конструкцию. Поэтому отдельно обрабатываемую конкретную причину ставят перед общим случаем.

Multi-catch объединяет независимые типы с одинаковой обработкой: например, catch (IOException | SQLException e). Нельзя перечислить там тип и его подтип: общий тип уже включает частный. Переменной такого multi-catch нельзя присвоить другое исключение. На интервью важнее объяснить различие реакций: если ошибки должны давать разные сообщения или разные действия, объединение ради сокращения строк ухудшит контракт.

4. Зачем сохранять cause и где оборачивать исключение?

cause сохраняет исходную причину при переходе между уровнями программы. Например, компонент чтения отчёта может сообщить ReportReadException, приложив исходный IOException. Так вызывающий код узнает, какая операция не удалась, а диагностика сохранит детали нижнего уровня.

Новый объект только с e.getMessage() теряет цепочку причин. Однако оборачивание на каждом методе без новой информации создаёт шум. Выбирают границу, где меняется смысл операции или контракт API. Сообщение должно давать полезный контекст: какой этап не завершился. Если после ошибки возвращается пустой отчёт, вызывающая сторона может принять сбой чтения за корректный результат «данных нет» — это уже изменение поведения, которое надо явно проектировать.

Что проверить в операции с ресурсом
  1. Контракт

    Какие данные ожидаем и какие ошибки вызывающий код способен различать?

  2. Владелец

    Кто открыл ресурс и кто обязан его закрыть при любом завершении операции?

  3. Порции данных

    Учитываются ли фактическая длина чтения, конец потока и границы символов?

  4. Причина

    Сохранилась ли основная ошибка, если обработка или закрытие тоже завершились сбоем?

finally и закрытие ресурсов

5. Что вернётся, если return есть и в try, и в finally?

Завершение finally через return заменит подготовленный результат try. Аналогично новое исключение из finally может заслонить исходное. В учебном методе сравниваем два явно указанных значения.

static int choose() {
    try {
        return 10;
    } finally {
        return 20;
    }
}

Метод возвращает 20. Если вместо первого return выбросить исключение, второй return всё равно скроет его. Поэтому управляющие переходы из finally особенно опасны для понятности кода. Другое условие: если finally лишь меняет локальную примитивную переменную, значение уже вычисленного return от этого не пересчитывается. Для возвращаемой ссылки изменение самого объекта — отдельный случай.

6. finally выполняется абсолютно всегда?

При обычном и исключительном выходе из try выполнение проходит через finally. Но обещание «абсолютно всегда» неверно: процесс может быть завершён принудительно, JVM может прекратить работу, а код внутри try может вообще не закончиться.

Практический смысл finally — надёжное завершение в рамках обычной модели исполнения программы, а не защита от отключения питания или внешнего уничтожения процесса. Для данных, которые должны переживать такие события, нужны другие гарантии хранения. На собеседовании не стоит подменять ими простой вопрос о синтаксисе, но полезно обозначить границу: освобождение Java-ресурса, атомарная запись и устойчивость файла к сбою — разные задачи.

7. В каком порядке try-with-resources закрывает ресурсы?

Ресурсы создаются слева направо и закрываются в обратном порядке. Это удобно для обёрток: позднее созданный ресурс может зависеть от ранее открытого. Автоматическое закрытие применяется к AutoCloseable. Если создание второго ресурса не удалось, первый уже созданный ресурс всё равно подлежит закрытию.

Если создающий код открыл ресурс, а затем не успел передать его в корректно организованный блок, сама запись слова try дальше по программе не решит проблему. Граница владения должна совпадать с приобретением ресурса. Переменные, объявленные в заголовке try-with-resources, недоступны после блока. В Java 9+ можно использовать уже существующую final либо effectively final переменную — без последующего присваивания. Она сохраняет свою область видимости после блока, но ресурс к этому моменту уже закрыт.

8. Что такое suppressed exception и чем оно отличается от cause?

Если тело try-with-resources бросило исключение, а закрытие тоже не удалось, основная ошибка тела сохраняется. Ошибки закрытия добавляются в getSuppressed(). cause — причинная связь между уровнями ошибки; suppressed — дополнительные ошибки, не заменившие основную при таком завершении.

Предположим, учебный ресурс Part при закрытии бросает IOException с текстом close- и своим именем. Оба ресурса успешно открылись.

try (Part first = new Part("A"); Part second = new Part("B")) {
    throw new IOException("work");
} catch (IOException error) {
    System.out.println(error.getMessage());
    for (Throwable extra : error.getSuppressed()) {
        System.out.println(extra.getMessage());
    }
}

Вывод: work, close-B, close-A. Если тело завершилось успешно, основной становится первая ошибка закрытия — здесь от B, а следующая добавляется как suppressed. Это помогает прочитать стек и не принять ошибку освобождения за единственную причину сбоя операции.

9. Кто должен закрывать переданный в метод поток?

Это часть контракта владения. Метод, открывший файл для своей работы, обычно и закрывает его. Метод, которому передали общий поток, не должен неожиданно заканчивать его жизнь без соответствующего соглашения. Например, закрытие общей консоли или потока ответа внутри вспомогательной функции может сломать последующую работу владельца.

Закрытие обёртки часто закрывает и вложенный поток, поэтому нельзя считать их независимыми ресурсами. InputStream.transferTo() переносит данные, но не закрывает вход и выход за вызывающую сторону. Правильный ответ объясняет область жизни, а не только перечисляет close(). Когда функция возвращает ленивый поток данных, владелец может перейти вызывающему коду; это нужно выразить в API и способе использования.

Байты, символы и неполные чтения

10. Чем InputStream отличается от Reader и почему нужна кодировка?

InputStream читает байты. Reader читает текстовые кодовые единицы Java char. Для перехода между байтами и текстом нужна кодировка — правило их соответствия. В UTF-8 символ может занимать несколько байтов; преобразование каждого отдельно прочитанного байта в char повреждает такой текст.

Кодировку согласуют с форматом данных и задают явно, например StandardCharsets.UTF_8. Если источник прислал повреждённый UTF-8 и требуется отклонить данные, используют decoder с CodingErrorAction.REPORT, а не молча пробуют другой набор правил. Ещё один подвох: String.length() считает кодовые единицы UTF-16. Некоторые Unicode-символы занимают два char, поэтому длина строки не всегда равна числу видимых знаков.

11. Почему одного read(buffer) недостаточно для чтения файла?

Вызов возвращает фактическое количество прочитанных байтов. Оно может быть меньше размера буфера до достижения конца потока. Для обычного непустого запроса InputStream значение -1 обозначает конец, а не число доступных прямо сейчас байтов.

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

byte[] buffer = new byte[4096];
int count;
while ((count = input.read(buffer)) != -1) {
    output.write(buffer, 0, count);
}

Запись всего массива на последней итерации добавила бы старые байты из хвоста. Для заголовка фиксированного размера удобно readNBytes(n), но полученную длину всё равно сравнивают с ожидаемой: конец потока может наступить раньше. Нельзя превращать недостающие байты обязательного заголовка в корректное значение по умолчанию.

12. available() возвращает размер файла и определяет конец потока?

Нет. Этот метод оценивает количество байтов, которые можно получить без блокирования. Ноль может означать, что данные пока недоступны, а не что вход окончательно закончился. У разных реализаций потока свойства оценки отличаются.

Цикл while (input.available() > 0) поэтому способен остановиться слишком рано. Выделять массив размером available() для всего неизвестного содержимого тоже неверно. Конец определяют по контракту операции чтения либо по правилам протокола. Если нужно узнать размер обычного файла, существует файловый API, но даже известный размер не отменяет возможного изменения файла между проверкой и чтением.

13. Что дают буферизация, flush и close?

Буфер накапливает данные и позволяет уменьшить число мелких обращений к следующему уровню. flush() передаёт накопленное содержимое вниз по цепочке; close() заканчивает работу с ресурсом и обычно включает необходимые действия завершения конкретной реализации. Их точный контракт смотрят у используемого класса.

Подвох: успешный flush() Java-буфера не означает, что данные устойчиво записаны на устройство хранения и переживут отключение питания. Для файлового канала существует force(), но и его гарантии связаны с файловой системой и типом изменений. На интервью отделяйте доставку в нижний программный слой от требований к устойчивому хранению. Добавление нескольких одинаковых буферов вокруг одного потока не доказывает рост скорости.

Files, Path, NIO и сериализация

14. Нужно ли закрывать Stream, который вернул Files.lines?

Да. Он лениво читает данные из открытого файла и держит ресурс до закрытия. Терминальная операция обработки не является общим обещанием автоматически закрыть файл. Аналогичное внимание требуется для Files.list() и Files.walk().

В учебном примере считаем непустые строки UTF-8 файла. Ограниченная область владения выражена напрямую.

try (var lines = Files.lines(path, StandardCharsets.UTF_8)) {
    long count = lines.filter(line -> !line.isBlank()).count();
    System.out.println(count);
}

Для небольшого файла readString() или readAllLines() удобны, но загружают содержимое в память. Для большого входа выбирают обработку по частям и явные ограничения объёма. Подробности ленивых операций и повторного использования потока разобраны в статье о Stream API и Optional.

15. Path.normalize проверяет, существует ли файл?

Нет. Path представляет путь, а normalize() выполняет синтаксическое упрощение его компонентов. Это не чтение файловой системы и не проверка прав. toAbsolutePath() добавляет абсолютную основу, а toRealPath() работает с существующим объектом файловой системы и по умолчанию учитывает символические ссылки.

Сравнение нормализованных строк не всегда описывает реальное расположение файла. При работе с входными путями учитывают абсолютные пути, символьные ссылки и правила конкретной файловой системы. Ещё одна ловушка — проверить существование файла, затем считать последующее открытие гарантированным: между этими действиями файл может исчезнуть или измениться. Саму операцию открытия всё равно нужно корректно обрабатывать.

16. NIO означает, что любое чтение стало неблокирующим?

Нет. NIO включает каналы, буферы и файловые API; FileChannel не становится неблокирующим только из-за имени пакета. Неблокирующий режим и selector относятся к подходящим selectable channels, например сетевым. Выбор API зависит от задачи, а не от буквы N в названии.

У ByteBuffer важны position и limit. После заполнения из канала flip() готовит буфер к чтению уже записанных данных. Очистка через clear() меняет указатели, а не затирает все байты. Запись в канал тоже может перенести не весь оставшийся объём: при копировании проверяют hasRemaining() и продолжают по контракту выбранного канала. Состояние буфера нужно объяснять после каждой операции.

В учебном буфере вместимостью восемь байтов записаны только три значения. Без flip() позиция осталась бы после них, а последующее чтение началось бы с другой части буфера.

ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put(new byte[] {10, 20, 30});
buffer.flip();
while (buffer.hasRemaining()) {
    System.out.println(buffer.get());
}
buffer.clear();
System.out.println(buffer.get(0)); // 10: clear не затёр данные

Цикл выводит 10, 20, 30, а не все восемь ячеек. После clear() буфер снова готов к заполнению с начала; последний вывод показывает, что прежний байт физически остался. Это различие важно и для правильной длины записи, и для понимания слова «очистить» в контракте API.

17. Files.copy или move делают запись целиком атомарной?

Обычное копирование не является транзакцией: при сбое целевой файл может остаться частично записанным. Для перемещения можно запросить StandardCopyOption.ATOMIC_MOVE, но поддержка зависит от файлового провайдера и расположения источника и назначения. При невозможности выполнить это требование операция сообщает ошибку.

Если приложению нужна именно атомарная смена версии файла, эту гарантию включают в проектирование и проверяют в выбранном окружении. Незаметная замена на обычное перемещение нарушила бы заявленный контракт. Атомарная видимость замены также не равна гарантии сохранения всех данных после сбоя питания. Эти вопросы стоит разделять, иначе слово «атомарно» скроет несколько разных требований.

18. Serializable — безопасный способ принимать любые объекты?

Нет. Стандартная Java-сериализация восстанавливает граф объектов и может вызывать предусмотренные методы классов. Сам интерфейс Serializable не проверяет, что вход доверенный, ограниченного размера и соответствует бизнес-правилам. Для внешнего обмена выбирают формат и схему с понятными ограничениями.

Если приходится работать с существующей Java-десериализацией, проверяют происхождение данных, допустимые классы, размер и глубину графа; ObjectInputFilter предоставляет механизм таких ограничений. Он не отменяет остальные проверки. serialVersionUID связан с совместимостью сериализованной формы, а transient исключает поле из обычной сериализации, но не является шифрованием и не доказывает защищённость значения в других каналах.

Утверждение, которое нужно уточнить на интервью
УтверждениеУточнениеПрактическое следствие
read заполнил массивВозвращена фактическая длина порцииИспользовать count, проверять EOF и длину обязательных данных
flush сохранил файлДанные переданы следующему уровнюОтдельно определить требования устойчивости хранения
normalize проверил путьВыполнено синтаксическое преобразованиеНе считать это проверкой существования, прав или символьных ссылок
Ошибка закрытия заменила ошибку работыTry-with-resources сохраняет основную и добавляет suppressedПроверять обе группы причин при разборе сбоя

Практическая задача для самопроверки

Спроектируйте чтение небольшого UTF-8 отчёта: формат содержит обязательную строку заголовка, затем записи. Сначала определите, кто открывает и закрывает файл. Затем опишите реакцию на неверную кодировку, отсутствующий заголовок, ошибку посреди чтения и сбой закрытия. Пустой файл и техническая ошибка должны оставаться разными исходами.

Для проверки двоичного копирования используйте учебный поток, который выдаёт за одно чтение не больше двух байтов, даже если буфер намного больше. Сравните исходный и полученный массивы. Затем объясните, что сломается при записи всего буфера вместо фактической порции. Этот пример помогает заметить ошибку, которую короткий локальный файл иногда случайно скрывает.

Перед встречей проследите три маршрута: как данные доходят до результата, как исключение доходит до обработчика и как ресурс доходит до закрытия. В каждом назовите контракт и его границу. Так вы сможете ответить и на вопрос о синтаксисе, и на уточнение о поведении реальной программы.