Введение в Java I/O
Обзор Java I/O: байтовые и символьные потоки, буферизованный I/O, java.io и java.nio.file.
Часть 12 завершилась набором понятий, которые вы сможете применить напрямую в этой части: лямбды, Consumer<T> и Supplier<T> как шаблоны для «дай мне строку» и «сделай что-нибудь с этой строкой», try-with-resources для всего, что требует детерминированного закрытия, и конвейер Stream для построчной обработки данных. API ввода-вывода Java были спроектированы именно под эти шаблоны — задолго до того, как появился термин «функциональный интерфейс», у базовых объектов уже был ровно один метод каждый, а фасад, появившийся после Java 8, довёл всё до конца.
В этой части рассматриваются четыре перекрывающихся набора инструментов:
- Потоки
java.io— оригинальный API Java 1.0:InputStream/OutputStreamдля байтов,Reader/Writerдля символов и декораторыBuffered*,Data*,Print*, которые оборачивают их. java.io.File— устаревший класс «эта строка является путём». До сих пор повсеместно встречается в старом коде; для нового кода заменёнjava.nio.file.Path.java.nio.file— современный API (Java 7+):Path,Filesи статические вспомогательные методы (Files.readString,Files.writeString,Files.lines,Files.walk), которые сводят большинство файловых операций к одной строке.- Сериализация — преобразование объектных графов в байты и обратно с помощью
ObjectOutputStream/ObjectInputStream.
Первые шесть глав рассматривают высокоуровневые файловые операции (открытие, создание, чтение, запись, удаление) с использованием как java.io, так и java.nio.file, чтобы вы могли увидеть одну и ту же задачу двумя способами. Средние главы подробно изучают сами классы потоков — байтовые и символьные, буферизацию, данные, вывод. Последние главы посвящены сериализации и API path/walk.
Разделение байт/символ
Каждый API ввода-вывода в java.io имеет одну из двух форм:
InputStream / OutputStream — byte-oriented (raw bytes: int read() returns 0..255 or -1)
Reader / Writer — character-oriented (decoded text: int read() returns a char or -1)Это разделение не косметическое. Байты — это то, что хранят диски и сокеты; символы — это то, что читают люди. .png — это байты; .txt — тоже байты на диске, но обычно вы хотите декодировать их в символы с помощью кодировки. Смешение двух форм без указания кодировки — это самая распространённая причина ошибок «странных символов» в устаревшем Java-коде.
Классы-мосты — InputStreamReader и OutputStreamWriter — выполняют преобразование между ними и принимают аргумент Charset. Используйте StandardCharsets.UTF_8, если у вас нет задокументированной причины использовать что-то другое; формы без аргументов используют кодировку платформы по умолчанию, которая различается в разных операционных системах и является хрестоматийным источником ошибок «работает на моём Mac, сломано на Linux-сервере».
Паттерн декоратор
java.io построен на паттерне декоратор: небольшой набор исходных потоков (FileInputStream, FileOutputStream, FileReader, FileWriter) оборачивается в многоуровневую функциональность (буферизация, построчный текст, примитивные типы, форматированный вывод). Вы компонуете то, что вам нужно, в месте вызова:
// Read a UTF-8 text file line by line:
try (BufferedReader in = new BufferedReader(
new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8))) {
String line;
while ((line = in.readLine()) != null) {
System.out.println(line);
}
}Три уровня, снизу вверх: FileInputStream читает сырые байты; InputStreamReader декодирует их как символы UTF-8; BufferedReader добавляет буфер в памяти и метод readLine(). Каждый уровень — это отдельный класс с одной задачей. Java 11 свела именно этот паттерн к одной строке — Files.newBufferedReader(path) — но декорирование по-прежнему происходит внутри.
try-with-resources — это правило
Каждый поток, читатель, писатель и канал в java.io и java.nio реализует AutoCloseable. Закрытие важно: незакрытый FileOutputStream может потерять хвостовой буфер; незакрытый сокет утечёт файловый дескриптор; незакрытый читатель в Windows удерживает блокировку, которую ОС не освободит. Конструкция try-with-resources (Java 7+) гарантирует вызов close() на каждом успешном и исключительном пути:
try (BufferedReader in = Files.newBufferedReader(path)) {
return in.readLine();
} // close() runs here, even if readLine() throwsВ одном try можно объявить несколько ресурсов; они закрываются в обратном порядке. Старый код try/finally, который вручную вызывает close(), почти всегда неверен — внутреннее исключение поглощает исключение закрытия, или само закрытие забывается на пути ошибки. Используйте try-with-resources для всего, что открывает дескриптор.
java.io против java.nio.file
java.io.File (1996) моделировал путь как String и предлагал несколько операций (exists, isFile, delete, listFiles). Класс до сих пор повсеместно встречается в устаревшем коде, и многие API по-прежнему возвращают или принимают File. Но у него есть ограничения, которые JDK больше не замалчивает:
- Нет способа узнать, почему операция завершилась неудачей —
file.delete()возвращаетfalseдля «файла не существует», «доступ запрещён» и «файл открыт». Вы не можете различить эти случаи. - Нет поддержки символических ссылок, атрибутов файлов, разрешений или атомарных операций.
- Нет способа обойти дерево каталогов без написания рекурсии вручную.
java.nio.file (Java 7) заменяет его. Path — новый тип «это путь», а Files — класс static-утилит примерно с 80 методами для всего, что вы захотите с ним сделать:
Path p = Path.of("data", "users.txt"); // platform-independent path
String text = Files.readString(p, StandardCharsets.UTF_8); // whole file, one call
List<String> lines = Files.readAllLines(p, StandardCharsets.UTF_8);
Files.writeString(p, "hello\n", StandardCharsets.UTF_8);
try (Stream<String> s = Files.lines(p, StandardCharsets.UTF_8)) {
s.filter(l -> !l.isBlank()).forEach(System.out::println);
}Два момента, на которые стоит обратить внимание. Первый: Files.lines(path) возвращает Stream<String> — конвейер потоков, который вы изучили в части 12, читает файлы напрямую. Второй: поток владеет открытым файловым дескриптором, поэтому обёртка try-with-resources обязательна — без неё файл остаётся открытым до следующего GC.
На протяжении всей части 13 мы будем показывать оба API рядом. Новый код должен в первую очередь использовать java.nio.file; устаревшие главы существуют потому, что вы встретите старые шаблоны в каждой кодовой базе, созданной до Java 11.
Что нас ждёт в этой части
- Следующая глава, Класс File в Java, рассматривает устаревший API
java.io.File— его методы запроса, листинг и ограничения, которые мотивировали появлениеjava.nio.file. - Четыре следующие главы (Создание файлов, Чтение файлов, Запись файлов, Удаление файлов) охватывают высокоуровневые операции «сделать одно действие с файлом» с использованием обоих API.
- Главы о байтовых, символьных и буферизованных потоках подробно рассматривают стек декораторов
java.io. - Сериализация, а затем
Path,Filesи API обхода каталогов завершают часть.
Практический пример: одна задача, четыре способа
Программа ниже записывает один короткий текстовый файл четырьмя способами — через современный Files.writeString, классический FileWriter + try-with-resources, декорированный BufferedWriter и PrintWriter для форматированного вывода. Затем она читает файл двумя способами — через Files.readString (весь файл одним вызовом) и через Files.lines как Stream<String>, отфильтрованный Predicate<String>. Пример использует системный временный файл, чтобы работать в любой среде.
Что можно извлечь из выполнения:
- Один и тот же файл был записан четырьмя разными способами.
Files.writeString— это кратчайший путь для «положить эту строку в этот файл»;FileWriter— классический исходный писатель;BufferedWriterдобавляет буфер в памяти (это дёшево, когда вы записываете много мелких фрагментов);PrintWriterдобавляетprintf. Каждый раз перезаписывал предыдущее содержимое, потому что режим открытия по умолчанию — «усечь, затем записать» — для добавления в файл нужно передатьStandardOpenOption.APPEND(рассматривается в главе о записи). - Каждый писатель работал внутри
try-with-resources. Пропуск этого для буферизованного писателя — это ошибка, при которой последние несколько символов никогда не попадают на диск: именноclose()сбрасывает хвостовой буфер. Files.readStringвернул весь файл в виде одногоString— подходит для маленьких файлов, неверный выбор для 4-гигабайтного лога.Files.linesвернулStream<String>, через который можно пропуститьfilter,mapиcount, не удерживая весь файл в памяти. Обязательныйtry-with-resources для потока обусловлен тем, что поток владеет открытым файловым дескриптором.- Строка
Predicate<String> nameLine = l -> l.startsWith("name")— это тот же словарь из части 12: значениеPredicate, передаваемое вStream.filter.Files.lines— это место, где API потоков встречается с API ввода-вывода. Files.deleteIfExists— это удаление без исключений: возвращаетtrue, если файл был удалён,false, если его не было. УстаревшийFile.delete()возвращаетbooleanи для «удалено», и для «не удалось удалить» —Filesразличает их, бросая исключение.
Что дальше
Прежде чем современный API java.nio.file займёт остаток части, следующая глава рассматривает класс, который вы встретите первым в любой старой кодовой базе: Класс File в Java. Это устаревший тип пути и метаданных — ограниченный, но повсеместный — и понимание того, чего он не может делать, даёт основания для использования Path и Files.