Раздел 11. Работа с файлами, I/O и сетью (NIO.2)
Глава 1. Классический Java I/O (java.io)
Символьные потоки: Reader и Writer. Unicode, кодировки и путь данных в памяти
Байтовые потоки (
Символьные потоки (
Unicode — стандарт кодирования символов, охватывающий большинство письменных систем мира. Java приняла Unicode с самого начала: тип
Архитектура Reader и Writer
Ключевое отличие от байтовых потоков: методы
Кодировки: мост между байтами и символами
Символьные потоки не существуют в вакууме. Файлы, сетевые соединения, базы данных хранят и передают байты. Преобразование между байтами и символами выполняется через кодировку (charset) — таблицу соответствия последовательностей байт кодовым точкам Unicode.
UTF-8: доминирующая кодировка
UTF-8 — переменная длина кодирования Unicode:
U+0000..U+007F: 1 байт (ASCII-совместимость)
U+0080..U+07FF: 2 байта
U+0800..U+FFFF: 3 байта (большинство BMP-символов)
U+10000..U+10FFFF: 4 байта (суррогатные пары)
Преимущества UTF-8: ASCII-совместимость, компактность для латинских текстов, самосинхронизация (можно определить границу символа в произвольной позиции). Недостаток: переменная длина усложняет индексацию по символам —
UTF-16: внутреннее представление Java
Java использует UTF-16 для внутреннего представления
Это критично для символьных потоков:
Путь данных в памяти: от байтов файла до char[] в heap
Чтение текстового файла: FileReader
Путь данных при чтении:
Декодирование и память
Буферы
#Java #для_новичков #beginner #IO #NIO #Reader #Writer
Глава 1. Классический Java I/O (java.io)
Символьные потоки: Reader и Writer. Unicode, кодировки и путь данных в памяти
Байтовые потоки (
InputStream/OutputStream) оперируют единицей данных в 8 бит — байтом. Они не интерпретируют содержимое: байт 0x41 может быть буквой 'A' в ASCII, частью многобайтовой последовательности UTF-8, или элементом бинарного протокола. Символьные потоки (
Reader/Writer) оперируют единицей в 16 бит — char Java, который представляет кодовую точку Unicode в диапазоне U+0000..U+FFFF. Это различие коренным образом меняет архитектуру, семантику и путь данных через память JVM.Unicode — стандарт кодирования символов, охватывающий большинство письменных систем мира. Java приняла Unicode с самого начала: тип
char — беззнаковое 16-битное целое, String — неизменяемая последовательность char. Однако Unicode эволюционировал: первоначальная спецификация Unicode 1.0 предполагала 16-битное пространство (65 536 символов), но оказалось недостаточным. Unicode 2.0 ввел дополнительные плоскости за пределами Basic Multilingual Plane (BMP), и для их представления потребовались суррогатные пары — две 16-битные char, кодирующие одну кодовую точку в диапазоне U+10000..U+10FFFF. Это означает, что один "символ" в человеческом понимании может занимать один или два char в Java.Архитектура Reader и Writer
Reader — абстрактный класс для чтения символов. Его методы зеркалируют InputStream, но работают с char вместо byte:public abstract class Reader implements Readable, Closeable {
// Чтение одного символа (char как int для EOF-различения)
public int read() throws IOException
// Чтение в массив char[]
public int read(char[] cbuf) throws IOException
// Чтение в массив с офсетом
public abstract int read(char[] cbuf, int off, int len) throws IOException
// Пропуск символов
public long skip(long n) throws IOException
// Готовность к чтению без блокировки
public boolean ready() throws IOException
// Поддержка mark/reset
public boolean markSupported()
public void mark(int readAheadLimit) throws IOException
public void reset() throws IOException
// Закрытие
public abstract void close() throws IOException
}
Writer — абстрактный класс для записи символов:public abstract class Writer implements Appendable, Closeable, Flushable {
// Запись одного символа
public void write(int c) throws IOException
// Запись массива char[]
public void write(char[] cbuf) throws IOException
// Запись части массива
public abstract void write(char[] cbuf, int off, int len) throws IOException
// Запись строки
public void write(String str) throws IOException
// Запись части строки
public void write(String str, int off, int len) throws IOException
// Добавление (append)
public Writer append(CharSequence csq) throws IOException
// Сброс
public abstract void flush() throws IOException
// Закрытие
public abstract void close() throws IOException
}Ключевое отличие от байтовых потоков: методы
read() и write(int) работают с char (16 бит), а не с byte (8 бит). Возвращаемое значение read() — int в диапазоне 0..65535 для валидных символов и -1 для EOF. Это сохраняет контракт различения валидных данных от признака конца потока, но для 16-битных char вместо 8-битных byte.Кодировки: мост между байтами и символами
Символьные потоки не существуют в вакууме. Файлы, сетевые соединения, базы данных хранят и передают байты. Преобразование между байтами и символами выполняется через кодировку (charset) — таблицу соответствия последовательностей байт кодовым точкам Unicode.
UTF-8: доминирующая кодировка
UTF-8 — переменная длина кодирования Unicode:
U+0000..U+007F: 1 байт (ASCII-совместимость)
U+0080..U+07FF: 2 байта
U+0800..U+FFFF: 3 байта (большинство BMP-символов)
U+10000..U+10FFFF: 4 байта (суррогатные пары)
Преимущества UTF-8: ASCII-совместимость, компактность для латинских текстов, самосинхронизация (можно определить границу символа в произвольной позиции). Недостаток: переменная длина усложняет индексацию по символам —
str.charAt(n) — O(1), но str.codePointAt(n) требует проверки суррогатов.UTF-16: внутреннее представление Java
Java использует UTF-16 для внутреннего представления
String и char[]. Каждый char — 16 бит. Символы BMP (U+0000..U+FFFF) представляются одним char. Символы за пределами BMP — суррогатной парой: старший суррогат (U+D800..U+DBFF) + младший суррогат (U+DC00..U+DFFF).// Пример: эмодзи U+1F600 (😀) — суррогатная пара в Java
String emoji = "😀";
System.out.println(emoji.length()); // 2 — два char
System.out.println(emoji.codePointAt(0)); // 128512 — U+1F600
// Итерация по кодовым точкам, а не char
for (int cp : emoji.codePoints().toArray()) {
System.out.println("Code point: U+" + Integer.toHexString(cp));
}
Это критично для символьных потоков:
Reader.read() возвращает int с кодовой точкой, корректно обрабатывая суррогатные пары. read(char[]) заполняет массив char, где суррогатные пары занимают два элемента.Путь данных в памяти: от байтов файла до char[] в heap
Чтение текстового файла: FileReader
try (FileReader reader = new FileReader("text.txt")) {
int charValue;
while ((charValue = reader.read()) != -1) {
processChar((char) charValue);
}
}Путь данных при чтении:
[Диск: файл в байтах UTF-8]
-> [Page Cache ОС: байты файла]
-> [FileInputStream (underlying FileReader): чтение байтов]
-> [InputStreamReader: декодирование байт -> char]
-> [CharsetDecoder UTF-8: конечный автомат для мультибайтовых последовательностей]
-> [char[] буфер декодера в heap]
-> [FileReader.read(): возврат char как int]
-> [Java-код: processChar()]
FileReader — конкретный подкласс InputStreamReader, который автоматически открывает FileInputStream и использует кодировку платформы по умолчанию. Это опасно: кодировка по умолчанию зависит от ОС и locale, что делает поведение непредсказуемым при переносе между системами.// Антипаттерн: зависимость от кодировки платформы
FileReader reader = new FileReader("text.txt"); // Неявная кодировка!
// Правильно: явное указание кодировки
InputStreamReader reader = new InputStreamReader(
new FileInputStream("text.txt"), StandardCharsets.UTF_8);
Декодирование и память
InputStreamReader использует CharsetDecoder из java.nio.charset. Декодер поддерживает внутренний буфер байт для неполных мультибайтовых последовательностей (например, первые 2 байта 3-байтового UTF-8 символа в конце блока чтения) и буфер char для выходных символов.// Упрощенная логика декодирования
ByteBuffer in = ByteBuffer.allocate(8192); // Входные байты из потока
CharBuffer out = CharBuffer.allocate(8192); // Выходные символы
// Чтение байт из FileInputStream
int bytesRead = fileInputStream.read(in.array());
in.position(0).limit(bytesRead);
// Декодирование
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
CoderResult result = decoder.decode(in, out, false);
// Извлечение символов
char[] chars = new char[out.position()];
out.flip().get(chars);
Буферы
ByteBuffer и CharBuffer — объекты в heap. CharsetDecoder — тоже объект в heap. При каждом создании InputStreamReader выделяется память для этих структур. При массовом открытии текстовых файлов без закрытия это создает давление на GC.#Java #для_новичков #beginner #IO #NIO #Reader #Writer
👍4
Путь данных при записи: от char[] в heap до байтов файла
Запись текстового файла: FileWriter
Путь данных при записи:
plain
Кодирование UTF-8 из char требует:
ASCII (U+0000..U+007F): 1 байт на символ
BMP non-ASCII (U+0080..U+FFFF): 2-3 байта
Суррогатные пары: 4 байта
Строка "Hello, 世界! 🌍" в Java:
'H', 'e', 'l', 'l', 'o', ',', ' ', '!': 8 символов ASCII → 8 байт UTF-8
'世' (U+4E16): 3 байта UTF-8
'界' (U+754C): 3 байта UTF-8
'🌍' (U+1F30D): суррогатная пара, 4 байта UTF-8
Итого: 8 + 3 + 3 + 4 = 18 байт UTF-8 для 11 "символов" в человеческом понимании, 12
Роль garbage collector
Жизненный цикл объектов кодирования
При обработке тысяч файлов этот код создает тысячи временных объектов. Они короткоживущие и эффективно собираются minor GC, но при высокой частоте могут вызвать частые паузы young generation.
Оптимизация: переиспользование Reader/Writer
Для потоковой обработки текста из одного источника переиспользование буферов снижает нагрузку на GC:
Проблема: большие строки и heap
BufferedReader и BufferedWriter: буферизация символьных потоков
Аналогично байтовым потокам, символьные потоки имеют буферизованные обертки:
java
Каждая
#Java #для_новичков #beginner #IO #NIO #Reader #Writer
Запись текстового файла: FileWriter
try (FileWriter writer = new FileWriter("output.txt")) {
writer.write("Hello, 世界! 🌍");
}Путь данных при записи:
plain
[Java-код: String "Hello, 世界! 🌍"]
-> [String.getBytes() или прямая работа с char[]]
-> [FileWriter (наследник OutputStreamWriter)]
-> [CharsetEncoder UTF-8: кодирование char -> байты]
-> [ByteBuffer буфер кодера в heap]
-> [FileOutputStream (underlying): запись байт]
-> [Page Cache ОС: dirty pages]
-> [Фоновая запись на диск]
Кодирование UTF-8 из char требует:
ASCII (U+0000..U+007F): 1 байт на символ
BMP non-ASCII (U+0080..U+FFFF): 2-3 байта
Суррогатные пары: 4 байта
Строка "Hello, 世界! 🌍" в Java:
'H', 'e', 'l', 'l', 'o', ',', ' ', '!': 8 символов ASCII → 8 байт UTF-8
'世' (U+4E16): 3 байта UTF-8
'界' (U+754C): 3 байта UTF-8
'🌍' (U+1F30D): суррогатная пара, 4 байта UTF-8
Итого: 8 + 3 + 3 + 4 = 18 байт UTF-8 для 11 "символов" в человеческом понимании, 12
char в JavaРоль garbage collector
Жизненный цикл объектов кодирования
public void processTextFiles(List<String> paths) throws IOException {
for (String path : paths) {
// Каждая итерация создает:
// - FileInputStream (heap)
// - InputStreamReader (heap)
// - CharsetDecoder (heap)
// - ByteBuffer/CharBuffer внутренние (heap)
// - char[] для чтения (heap)
try (InputStreamReader reader = new InputStreamReader(
new FileInputStream(path), StandardCharsets.UTF_8)) {
char[] buffer = new char[4096];
int charsRead;
while ((charsRead = reader.read(buffer)) != -1) {
process(buffer, charsRead);
}
} // close() освобождает ресурсы, объекты становятся недостижимыми
// GC собирает все созданные объекты при следующей minor collection
}
}При обработке тысяч файлов этот код создает тысячи временных объектов. Они короткоживущие и эффективно собираются minor GC, но при высокой частоте могут вызвать частые паузы young generation.
Оптимизация: переиспользование Reader/Writer
Для потоковой обработки текста из одного источника переиспользование буферов снижает нагрузку на GC:
// Переиспользуемый буфер
private static final ThreadLocal<char[]> CHAR_BUFFER =
ThreadLocal.withInitial(() -> new char[8192]);
public void processStream(InputStream in) throws IOException {
char[] buffer = CHAR_BUFFER.get();
try (InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
int charsRead;
while ((charsRead = reader.read(buffer)) != -1) {
process(buffer, charsRead);
}
}
// reader и его декодер собираются GC
// buffer остается в ThreadLocal, не подлежит GC
}
Проблема: большие строки и heap
// Антипаттерн: чтение всего файла в одну строку
String content = new String(Files.readAllBytes("huge.txt"), StandardCharsets.UTF_8);
Files.readAllBytes возвращает byte[] размером с файл. new String(...) создает char[] размером с количество символов (для ASCII — тот же размер, для UTF-8 с мультибайтом — меньше). Для файла 100MB это 100MB в byte[] + ~100MB в char[] = 200MB heap на один вызов. Если файл содержит преимущественно ASCII, byte[] и char[] примерно равны. Если содержит много 3-байтовых UTF-8 символов, char[] будет в 3 раза меньше byte[].BufferedReader и BufferedWriter: буферизация символьных потоков
Аналогично байтовым потокам, символьные потоки имеют буферизованные обертки:
try (BufferedReader br = new BufferedReader(
new InputStreamReader(
new FileInputStream("text.txt"), StandardCharsets.UTF_8), 8192)) {
String line;
while ((line = br.readLine()) != null) {
processLine(line);
}
}
BufferedReader содержит char[] cb в heap — буфер символов. readLine() накапливает символы до \n или \r\n, создавая новую String при каждом вызове. Эти строки — долгоживущие объекты, которые GC не собирает до потери ссылки.java
// Путь данных readLine():
// 1. Чтение блока символов в cb[8192] из underlying Reader
// 2. Поиск \n в cb
// 3. new String(cb, start, end - start) — создание String в heap
// 4. Возврат String
// 5. Следующий вызов: повторение
Каждая
String — отдельный объект в heap с собственным char[] (или byte[] в современных JDK с компакт-строками). При чтении файла 100MB по строкам в 100 символов создается ~1 000 000 объектов String. Это создает массивное давление на GC.#Java #для_новичков #beginner #IO #NIO #Reader #Writer
👍4