Перейти к основному содержимому

Ввод-вывод и файлы в Java

Разработчику Архитектору

Ввод-вывод и файлы

Ввод-вывод (I/O) в Java строится на потоках — последовательная передача байтов или символов между программой и источником (файл, сеть, консоль). Современный API файловой системы — NIO.2 (java.nio.file, с Java 7).

Исключения при I/O: обработка исключений. Строки и кодировки: строки.


Два уровня API

УровеньПакетыНазначение
Классический I/Ojava.ioInputStream, OutputStream, Reader, Writer
NIO / NIO.2java.nio, java.nio.fileБуферы, каналы, Path, Files

В новом коде для файлов на диске начинайте с Path + Files, для сети и бинарных протоколов — потоки или NIO channels.


Path и Files (NIO.2)

Path — неизменяемый путь (замена устаревшему java.io.File для логики путей):

Path config = Path.of("app", "config", "settings.properties");
Path absolute = config.toAbsolutePath().normalize();

boolean exists = Files.exists(config);
boolean isDir = Files.isDirectory(config);
long size = Files.size(config);

Разбор:

  • Path.of("app", "config", "settings.properties") собирает путь платформенно-нейтрально, без ручной склейки строк через \ или /.
  • toAbsolutePath() превращает относительный путь в абсолютный, а normalize() убирает лишние сегменты (. и ..), чтобы путь был каноничнее для логов и проверок.
  • Files.exists(config) проверяет наличие объекта по пути; это быстрый pre-check перед чтением или записью.
  • Files.isDirectory(config) помогает отделить файл от каталога и заранее предотвратить ошибки вида "ожидали файл, получили директорию".
  • Files.size(config) возвращает размер в байтах и часто используется для валидации входного файла перед обработкой.

Небольшой практический принцип — путь и его проверка (exists, isDirectory, normalize) живут рядом в коде, тогда ошибки формата и окружения всплывают раньше.

Чтение и запись целиком (малые и средние файлы):

Path file = Path.of("notes.txt");

List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
String text = Files.readString(file, StandardCharsets.UTF_8);

Files.writeString(file, "новая строка\n", StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);

Разбор:

  • Files.readAllLines(...) загружает весь файл в List<String>, поэтому удобен для небольших файлов, где нужен построчный доступ в памяти.
  • Files.readString(...) читает файл целиком одной строкой; полезно для шаблонов, JSON и конфигов небольшого размера.
  • Явный StandardCharsets.UTF_8 фиксирует кодировку и делает поведение одинаковым на Linux/Windows/macOS.
  • Files.writeString(...) с CREATE создаёт файл при отсутствии, а APPEND дописывает в конец, не перезаписывая старое содержимое.
  • Комбинация CREATE + APPEND типична для логов и "журналоподобных" текстовых файлов.

Поток строк (большие файлы, ленивая обработка):

try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
long count = lines.filter(l -> !l.isBlank()).count();
}

Разбор:

  • Files.lines(...) возвращает ленивый Stream<String> и читает файл постепенно, а не целиком в память.
  • try-with-resources обязателен: закрывает underlying file handle, когда поток обработан.
  • filter(l -> !l.isBlank()) оставляет только непустые строки и убирает "шум" из пустых/пробельных строк.
  • count() выполняет терминальную операцию: именно в этот момент запускается фактическое чтение данных.
  • Такой подход устойчивее для больших файлов и снижает риск OutOfMemoryError.

Копирование и перемещение:

Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
Files.move(oldPath, newPath);
Files.delete(path);
Files.createDirectories(Path.of("data", "cache"));

Разбор:

  • Files.copy(...) копирует файл/каталог по пути, а REPLACE_EXISTING разрешает перезапись существующей цели.
  • Files.move(...) переносит или переименовывает путь; на одной файловой системе обычно это дешёвая операция.
  • Files.delete(path) удаляет объект и бросает исключение, если путь не существует или удаление запрещено правами.
  • Files.createDirectories(...) создаёт всю цепочку директорий и не падает, если часть каталогов уже есть.
  • Эти операции часто оборачивают в отдельный сервис с логированием пути и типа операции для удобной диагностики.

File и Path — что выбирать

java.io.File встречается в legacy-коде и сторонних библиотеках. В новом коде основной API для ФС:

  • Path для путей;
  • Files для операций с файлами и каталогами;
  • File только как мост при интеграции (path.toFile() / file.toPath()).

Байтовые и символьные потоки

ТипКлассыЕдиница
БайтовыйInputStream, OutputStreambyte
СимвольныйReader, Writerchar (кодировка)

Символьные потоки оборачивают байтовые с указанием charset:

try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8);
Writer writer = Files.newBufferedWriter(out, StandardCharsets.UTF_8)) {
char[] buf = new char[8192];
int n;
while ((n = reader.read(buf)) != -1) {
writer.write(buf, 0, n);
}
}

Разбор:

  • newBufferedReader/newBufferedWriter добавляют буферизацию и уменьшают количество системных вызовов чтения/записи.
  • char[] buf = new char[8192] задаёт буфер фиксированного размера: компромисс между скоростью и памятью.
  • reader.read(buf) возвращает число реально прочитанных символов, поэтому нужно писать ровно n, а не весь буфер.
  • Цикл while (... != -1) — стандартный паттерн для чтения потока до конца.
  • Такой код безопасно копирует текст с сохранением выбранной кодировки.

Буферизация (BufferedInputStream, BufferedReader) снижает число системных вызовов.


Чтение больших файлов без OutOfMemoryError

Для логов и дампов действуйте потоково:

try (BufferedReader br = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
while ((line = br.readLine()) != null) {
process(line);
}
}

Разбор:

  • BufferedReader читает файл построчно и держит в памяти только текущую порцию данных.
  • readLine() возвращает null в конце файла, это сигнал завершить цикл.
  • Вызов process(line) выполняет бизнес-логику по мере чтения, без накопления всех строк.
  • Явная UTF-8 кодировка устраняет платформенные расхождения в обработке кириллицы и спецсимволов.
  • Паттерн подходит для логов, CSV и любых длинных текстов, где важна стабильная память.

Преимущество перед readAllLines: память растет стабильно и не зависит линейно от размера файла.


try-with-resources

Ресурсы, реализующие AutoCloseable, закрываются автоматически:

try (InputStream in = Files.newInputStream(path);
OutputStream out = Files.newOutputStream(target)) {
in.transferTo(out); // Java 9+
}

Разбор:

  • newInputStream/newOutputStream открывают байтовые потоки для бинарного копирования данных.
  • in.transferTo(out) переносит все байты из входа в выход до EOF и упрощает ручной цикл чтения/записи.
  • Конструкция try (...) гарантирует закрытие обоих потоков даже при исключении в середине операции.
  • Это хороший базовый шаблон для копирования файлов, выгрузок и загрузок.
  • Для крупных файлов и сетевых сценариев остаётся важным обрабатывать IOException и логировать путь источника/цели.

При исключении в блоке сначала выбрасывается основное, затем подавленное из close() — удобно для диагностики.

Связь с try-with-resources для исключений.


Каталог ресурсов приложения

Важный практический момент: файлы рядом с исходниками и файлы в classpath читаются по-разному.

  • На диске (Path.of(...)) — для пользовательских данных и внешних конфигов.
  • Из classpath (getResourceAsStream) — для файлов, упакованных в JAR.

Это убирает типичный класс ошибок "локально работает, в JAR не находит файл".


Сериализация объектов

ObjectOutputStream / ObjectInputStream сохраняют граф объектов в бинарный поток. Класс должен реализовать Serializable (или Externalizable).

В современных системах чаще используют JSON/XML/Protobuf (см. стандартные библиотеки), а не Java Serialization — из соображений безопасности и совместимости версий.


WatchService — реакция на изменения файлов

Для утилит и локальных демонов можно отслеживать изменения в каталоге:

WatchService ws = FileSystems.getDefault().newWatchService();
Path dir = Path.of("inbox");
dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY);

Разбор:

  • newWatchService() создаёт подписку на события файловой системы конкретной платформы.
  • Path.of("inbox") указывает каталог, за которым будет идти наблюдение.
  • dir.register(...) подписывает каталог на события создания и изменения файлов.
  • ENTRY_CREATE удобно для "подхвата" новых входящих файлов, ENTRY_MODIFY — для реакции на изменения существующих.
  • На практике цикл обработки событий должен учитывать OVERFLOW и повторно валидировать состояние файлов на диске.

WatchService удобен для простых сценариев, но в production требует аккуратной обработки переполнения событий и перезапуска наблюдения.


Работа с каталогами

try (Stream<Path> walk = Files.walk(Path.of("logs"), 2)) {
walk.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".log"))
.forEach(System.out::println);
}

Разбор:

  • Files.walk(Path.of("logs"), 2) обходит дерево каталогов до глубины 2, включая вложенные подпапки.
  • Files::isRegularFile отбрасывает директории, симлинки и специальные файлы.
  • Фильтр endsWith(".log") оставляет только лог-файлы для последующей обработки.
  • forEach(System.out::println) здесь демонстрационный вывод; в реальном коде обычно вызывают парсер, архиватор или uploader.
  • try-with-resources закрывает внутренний поток обхода, чтобы не держать открытыми ресурсы файловой системы.

Files.list(dir) — только один уровень; walk — рекурсия с лимитом глубины.


Типичные ошибки

  1. Не указана кодировкаFileReader без charset использует платформенную; на Windows часто не UTF-8.
  2. Утечка дескрипторов — поток не закрыт (нет try-with-resources).
  3. Чтение гигантского файла в readAllLines — риск OutOfMemoryError; используйте Files.lines или буфер.
  4. Относительные пути — зависят от user.dir; для конфигов лучше явный базовый каталог или переменные окружения.
  5. Пути с пробеламиPath.of корректен; не склеивайте строки вручную через \.

Мини-чек-лист для надежного I/O

  • Charset указан явно (UTF-8) на чтении и записи.
  • Внешние пути валидируются и нормализуются.
  • Любой поток закрывается через try-with-resources.
  • Для больших файлов применяется потоковая обработка.
  • Ошибки I/O логируются с путем и операцией, а не только с текстом исключения.

I/O и многопоточность

Блокирующее чтение в одном потоке занимает поток ОС на всё время ожидания диска или сети. Для множества одновременных соединений смотрите асинхронность и NIO (Selector, неблокирующие каналы) или фреймворки уровня Spring WebFlux.


NIO Channels и Selector когда углубляться

Path и Files покрывают большинство задач работы с файлами на диске.
Неблокирующие Channel и Selector нужны, когда:

  • много одновременных соединений;
  • важно держать малое число потоков ОС;
  • вы строите сетевой сервер, прокси или сервис с очень большим потоком входящих событий.

Базовые элементы:

  • ByteBuffer — буфер байтов с указателями position, limit, capacity.
  • SocketChannel и ServerSocketChannel — сетевые каналы.
  • Selector — механизм, который позволяет одному потоку обслуживать много каналов.

Мини-схема:

Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));
server.register(selector, SelectionKey.OP_ACCEPT);

Когда задача типовая:

  • чтение конфигов;
  • работа с файлами;
  • экспорт и импорт данных;

оставляйте код на Files и buffered I/O.

Переходите к Selector, когда нагрузочный тест показывает, что основное ограничение связано с числом одновременных подключений.

Дополнительно:


Связанные материалы