Символьные потоки в Java
Чтение и запись текста в Java с помощью Reader, Writer, FileReader, FileWriter и учёт кодировок символов.
В предыдущей главе рассматривались байтовые потоки — низкоуровневый слой, где всё работает с типом byte. Этот слой подходит для двоичных данных, но не для текста. Символ UTF-8 может занимать один, два, три или четыре байта; UTF-16 использует двухбайтовые кодовые единицы с суррогатными парами для символов вне базовой многоязычной плоскости; даже текст в ASCII где-то требует явного решения «это ASCII». Вызов InputStream.read() на текстовых данных и приведение результата к char работает только случайно — когда файл однобайтовый на символ. Как только кто-то пишет «é», «日» или «🎉», такой подход приводит к искажению данных.
Иерархия символьных потоков существует именно для того, чтобы убрать это декодирование из вашего кода. Reader и Writer работают с типом char, а не byte. Классы-мосты — InputStreamReader и OutputStreamWriter — принимают Charset и выполняют преобразование. Укажите кодировку правильно на уровне моста — и все вышестоящие слои будут работать с декодированным текстом.
Контракт Reader
Reader является зеркалом InputStream: одна абстрактная пара методов (read(char[], int, int) и close()) с вспомогательными методами поверх них:
int read(); // next char as int 0..65535, or -1 at end
int read(char[] buf); // read up to buf.length chars; return count or -1
int read(char[] buf, int off, int len); // into a slice
String readLine(); // only on BufferedReader — not on Reader itself
long transferTo(Writer out); // Java 10+: pipe straight to a sinkДва тонких отличия от байтовой стороны. Во-первых, единицей является char (16-битная кодовая единица UTF-16), а не byte. Во-вторых, read() возвращает 0..65535 для кодовой единицы и -1 при конце потока — та же сигнальная уловка, что и у InputStream, но допустимый диапазон шире.
char — это не всегда один «символ»: символы вне базовой многоязычной плоскости (U+10000 и выше: большинство эмодзи, древние письменности) используют две кодовые единицы UTF-16 (суррогатную пару). Если разбивать по границам char (например, читать по 100 символов за раз и обрабатывать чанками), можно разделить суррогатную пару между двумя чтениями. Для построчной обработки это редко имеет значение; при посимвольной обработке произвольного Unicode работайте с кодовыми точками (String.codePoints()).
Контракт Writer
Writer является зеркалом OutputStream:
void write(int c); // low 16 bits
void write(char[] buf);
void write(char[] buf, int off, int len);
void write(String s); // convenience — encodes a whole String
void write(String s, int off, int len);
Writer append(CharSequence csq); // chainable: w.append("a").append("b")
void flush();
void close(); // calls flush() firstwrite(String) — это удобный метод, которым вы будете пользоваться чаще всего: большинство текстового ввода-вывода представляет собой небольшое количество крупных записей (тело JSON, сгенерированный отчёт), а не посимвольный вывод.
append существует для совместимости с CharSequence — StringBuilder реализует CharSequence, поэтому Writer может быть целевым объектом для кода, который пишет в любой из них в зависимости от флага. Это тот же метод append, что есть у самого StringBuilder, реализованный через интерфейс.
Конкретные символьные потоки
| Класс | Что оборачивает |
|---|---|
FileReader / FileWriter | Файл на диске, декодируемый как текст. |
CharArrayReader / CharArrayWriter | Массив char[] в памяти. |
StringReader / StringWriter | String/StringBuilder в памяти. |
BufferedReader / BufferedWriter | Буферизованное представление другого Reader/Writer. |
InputStreamReader / OutputStreamWriter | Классы-мосты: Reader/Writer поверх байтового потока с указанием Charset. |
PrintWriter | Декоратор Writer, добавляющий методы print, println и printf. |
Классы-мосты — это структурная точка всей иерархии. Каждый символьный поток, работающий с файлом, сокетом или каналом, является — в глубине — байтовым потоком плюс кодировкой. FileReader — это тонкая обёртка вокруг InputStreamReader(new FileInputStream(...)); FileWriter — аналогично вокруг OutputStreamWriter(new FileOutputStream(...)).
Ловушка кодировки
Классическая ошибка в Java I/O:
// WRONG in any code that might run on more than one machine
try (FileReader in = new FileReader("data.txt")) { ... }
try (FileWriter out = new FileWriter("data.txt")) { ... }Конструкторы без указания кодировки используют кодировку JVM по умолчанию, которая определяется при запуске из локали ОС. На Mac разработчика это почти всегда UTF-8. На Linux-сервере с локалью C это может быть US-ASCII. На Windows с английской установкой — Cp1252. Баг «работает на моём Mac, ломается на продакшене» — это именно этот конструктор.
Передавайте кодировку явно:
// Right
try (FileReader in = new FileReader("data.txt", StandardCharsets.UTF_8)) { ... }
try (FileWriter out = new FileWriter("data.txt", StandardCharsets.UTF_8)) { ... }(Двухаргументные формы с Charset были добавлены в Java 11. До этого приходилось опускаться до классов-мостов — new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8) — и именно эта цепочка декораторов стала одной из причин добавления Files.newBufferedReader(path): по умолчанию он использует UTF-8 начиная с Java 18 и всегда был явным в отношении кодировки.)
Современный API Files сделал это поведение по умолчанию более безопасным:
String text = Files.readString(path); // UTF-8 by default (Java 18+)
BufferedReader r = Files.newBufferedReader(path); // UTF-8 by default (always was)Если начинаете с нуля — используйте фабрики Files. Если работаете с унаследованным кодом, использующим FileReader/FileWriter, самое простое исправление — добавить второй аргумент StandardCharsets.UTF_8.
Классы-мосты напрямую
InputStreamReader и OutputStreamWriter нужны тогда, когда источник — не файл: ZipEntry, сокет, тело HTTP-ответа, System.in, поток через Inflater — и вы хотите получить из него текст:
// Read text from System.in as UTF-8
try (BufferedReader stdin = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
String line = stdin.readLine();
}
// Write the response of an HttpURLConnection as text
try (BufferedReader resp = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
resp.lines().forEach(System.out::println);
}Форма всегда одна и та же: байтовый поток → InputStreamReader(stream, charset) → опциональный BufferedReader → ваш код.
Практический пример: текст в трёх формах
Программа ниже записывает небольшой текстовый файл в UTF-8, содержащий ASCII, символы с диакритикой и многобайтовый эмодзи, а затем читает его четырьмя способами: как String, посимвольно, построчно через BufferedReader и через устаревший конструктор FileReader(charset). Пример также демонстрирует форму работы с классом-мостом поверх ByteArrayInputStream, чтобы было видно, где Reader и InputStream пересекаются.
Что следует запомнить из этого запуска:
- Файл на диске (23 байта) оказался больше, чем
content.length()(20). УStringlength() == 20(каждый\nсчитается за один символ, а эмодзи 🎉 — за два кодовых блока UTF-16, именно так измеряется Javachar); UTF-8 кодирует эмодзи как четыре байта, аé— как два, поэтому байт больше. В кодовых точках их всего 19 — эмодзи является одной кодовой точкой, но двумяchar. Один и тот же логический текст имеет одно число вchar, другое в байтах, третье в кодовых точках. Понимание того, что именно вы имеете в виду, — это половина всех ошибок с кодировками. - Посимвольный цикл восстановил точно такую же строку. API
Readerвыполнил декодирование UTF-8 за вас: один эмодзи появляется в виде двух вызовов(char) read()из-за суррогатов UTF-16, но вам не нужно было думать о границах байтов. BufferedReader.readLine()вернул три строки:hello,café,🎉 party. Это текстовый словарь — построчный, с учётом разделителей (обрабатывает\n,\rи\r\n), построенный поверх класса-моста. Каждый вызов API в этой и следующей главах в конечном счёте сводится к «декодируй байты через кодировку и выдай символы».- Блок с прямым
InputStreamReader(new ByteArrayInputStream(raw), UTF_8)показывает структурную форму: байтовый источник внутри, кодировка на мосту, символьный API снаружи. ЗаменитеByteArrayInputStreamнаsocket.getInputStream()— и остальное останется идентичным. Именно поэтому HTTP- и JDBC-клиенты сходятся к одной и той же идиоме. - Последний блок декодировал те же байты с неправильной кодировкой. Символ
éс диакритикой и эмодзи оба превратились в мусор — классическая ошибка mojibake. Байты на диске были в порядке; кодировка на мосту была неправильной. Именно поэтому явное указание кодировки — самая полезная привычка в Java-обработке текста.
Что дальше
Как байтовые, так и символьные потоки по умолчанию работают в режиме одного вызова за раз, и на «сыром» файловом потоке каждый вызов является системным вызовом. Следующая глава, Буферизованные потоки Java, рассматривает декораторы Buffered* — буфер в памяти между вашим кодом и ОС — и API readLine(), который живёт там.