W3docs

Символьные потоки в 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() first

write(String) — это удобный метод, которым вы будете пользоваться чаще всего: большинство текстового ввода-вывода представляет собой небольшое количество крупных записей (тело JSON, сгенерированный отчёт), а не посимвольный вывод.

append существует для совместимости с CharSequenceStringBuilder реализует CharSequence, поэтому Writer может быть целевым объектом для кода, который пишет в любой из них в зависимости от флага. Это тот же метод append, что есть у самого StringBuilder, реализованный через интерфейс.

Конкретные символьные потоки

КлассЧто оборачивает
FileReader / FileWriterФайл на диске, декодируемый как текст.
CharArrayReader / CharArrayWriterМассив char[] в памяти.
StringReader / StringWriterString/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 пересекаются.

java— editable, runs on the server

Что следует запомнить из этого запуска:

  • Файл на диске (23 байта) оказался больше, чем content.length() (20). У String length() == 20 (каждый \n считается за один символ, а эмодзи 🎉 — за два кодовых блока UTF-16, именно так измеряется Java char); 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(), который живёт там.

Практика

Практика
Почему `new FileReader(path)` и `new FileWriter(path)` (без аргумента кодировки) вызывают ошибки типа 'работает у меня, ломается на сервере'?
Почему `new FileReader(path)` и `new FileWriter(path)` (без аргумента кодировки) вызывают ошибки типа 'работает у меня, ломается на сервере'?
Was this page helpful?