Раздел 11. Работа с файлами, I/O и сетью (NIO.2)
Глава 1. Классический Java I/O (java.io)
Байтовые потоки: абстрактные классы InputStream и OutputStream
Байтовые потоки в Java представляют собой фундаментальный слой абстракции для работы с данными в их сыром, двоичном виде. Классы
Это разделение ответственности — транспорт без интерпретации — является ключевым архитектурным решением Java I/O. Байтовые потоки отвечают только за перемещение сырых данных между источником и назначением. Интерпретация этих данных — декодирование текста, парсинг изображений, десериализация объектов — возлагается на более высокие уровни абстракции, которые строятся поверх байтовых потоков через паттерн декоратора.
Использование байтовых потоков обязательно для любых бинарных форматов, где побитовая точность критична. Попытка чтения бинарного файла через символьные потоки (
Путь данных в памяти JVM: от диска до heap
Когда
Уровень 1: Диск и страничный кэш ОС. Файловая система читает данные с блочного устройства в страничный кэш ядра (page cache). Это память ядра операционной системы, недоступная напрямую из Java. Размер страницы обычно 4KB, и чтение меньшего объема все равно загружает целую страницу.
Уровень 2: Нативный буфер и системный вызов. JVM выполняет системный вызов
Уровень 3: Копирование в heap JVM. Данные из нативного буфера копируются в массив
Каждый вызов
Буферизация и сокращение пути данных
При первом вызове
Путь данных с буферизацией:
Первый
Второй
После исчерпания
Это сокращает количество системных вызовов с N до N/8192, где N — размер файла.
#Java #для_новичков #beginner #IO #NIO #InputStream #OutputStream
Глава 1. Классический Java I/O (java.io)
Байтовые потоки: абстрактные классы InputStream и OutputStream
Байтовые потоки в Java представляют собой фундаментальный слой абстракции для работы с данными в их сыром, двоичном виде. Классы
InputStream и OutputStream оперируют единицей данных размером в 8 бит — байтом — и не накладывают никакой семантической интерпретации на прочитанные или записанные значения. Байт может быть частью изображения JPEG, аудиофрейма MP3, сериализованного объекта, зашифрованного пакета или любого другого бинарного формата.Это разделение ответственности — транспорт без интерпретации — является ключевым архитектурным решением Java I/O. Байтовые потоки отвечают только за перемещение сырых данных между источником и назначением. Интерпретация этих данных — декодирование текста, парсинг изображений, десериализация объектов — возлагается на более высокие уровни абстракции, которые строятся поверх байтовых потоков через паттерн декоратора.
Использование байтовых потоков обязательно для любых бинарных форматов, где побитовая точность критична. Попытка чтения бинарного файла через символьные потоки (
Reader/Writer) приведет к повреждению данных, так как символьные потоки применяют кодировку, трансформирующую последовательности байт в Unicode-символы. Для форматов вроде PNG, PDF, ZIP, MP4 или собственных бинарных протоколов единственно корректный выбор — байтовые потоки.Путь данных в памяти JVM: от диска до heap
Когда
FileInputStream.read() читает байт из файла, данные проходят через несколько уровней памяти прежде чем стать доступными Java-коду. Этот путь критичен для понимания производительности и поведения garbage collector.Уровень 1: Диск и страничный кэш ОС. Файловая система читает данные с блочного устройства в страничный кэш ядра (page cache). Это память ядра операционной системы, недоступная напрямую из Java. Размер страницы обычно 4KB, и чтение меньшего объема все равно загружает целую страницу.
Уровень 2: Нативный буфер и системный вызов. JVM выполняет системный вызов
read(), который копирует данные из страничного кэша ядра в нативный буфер в userspace. Это прямое копирование между kernel space и user space управляется DMA (Direct Memory Access) контроллером без участия CPU для самих данных, но требует переключения контекста процессора из userspace в kernelspace.Уровень 3: Копирование в heap JVM. Данные из нативного буфера копируются в массив
byte[], расположенный в heap JVM. Это вторая копия данных, и именно здесь начинает работать garbage collector.// read() возвращает int, но данные прошли путь: диск -> page cache -> нативный буфер -> heap
int byteValue = fileInputStream.read();
Каждый вызов
read() без буферизации порождает этот полный путь для одного байта. Системный вызов и переключение контекста стоят дороже самого чтения, что делает посимвольное чтение катастрофически неэффективным.Буферизация и сокращение пути данных
BufferedInputStream решает проблему, вводя промежуточный буфер в heap JVM:// BufferedInputStream внутренне содержит byte[] buf размером по умолчанию 8192 байт
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("data.bin"))) {
int b;
while ((b = bis.read()) != -1) {
processByte(b);
}
}
При первом вызове
read() BufferedInputStream читает до 8192 байт из underlying потока одним системным вызовом, заполняя внутренний массив buf. Последующие вызовы read() извлекают байты из этого массива без системных вызовов. Данные в buf — это обычный массив в heap, управляемый garbage collector.Путь данных с буферизацией:
Первый
read(): диск -> page cache -> нативный буфер -> buf[8192] в heap -> возврат buf[0]Второй
read(): buf[1] из heap (системный вызов отсутствует)После исчерпания
buf: повторение шага 1Это сокращает количество системных вызовов с N до N/8192, где N — размер файла.
#Java #для_новичков #beginner #IO #NIO #InputStream #OutputStream
👍5
Роль garbage collector
Массив
Его жизненный цикл определяется ссылками:
Внутренний буфер
Объект
При выходе из блока
Если
Важный нюанс: при использовании
Крупные массивы и GC-pressure
При чтении больших файлов размер буфера влияет на поведение garbage collector:
Буфер в 1MB выделяется в heap как единый массив. В G1 GC массивы размером более половины размера региона (обычно > 512KB для региона 1MB) считаются humongous objects и размещаются в специальных humongous-регионах. Эти регионы освобождаются только полным циклом concurrent mark-sweep, что может увеличить паузы GC.
Для приложений с интенсивным I/O рекомендуется переиспользование буферов через пулы объектов, что устраняет накладные расходы на выделение и сборку:
Здесь буфер привязан к потоку через
#Java #для_новичков #beginner #IO #NIO #InputStream #OutputStream
Массив
byte[], используемый как буфер, является обычным объектом в heap JVM.Его жизненный цикл определяется ссылками:
Внутренний буфер
BufferedInputStream удерживается ссылкой из самого объекта BufferedInputStreamОбъект
BufferedInputStream удерживается локальной переменной в стеке потока выполненияПри выходе из блока
try-with-resources вызывается close(), который обнуляет ссылку на underlying потокЕсли
BufferedInputStream становится недостижимым, GC помечает внутренний byte[] как мусорВажный нюанс: при использовании
BufferedInputStream внутри try-with-resources буфер освобождается предсказуемо при выходе из блока. Однако если ссылка на поток сохраняется в поле класса или передается в другие компоненты, GC не может освободить буфер до тех пор, пока объект BufferedInputStream остается достижимым. Утечки памяти в I/O-коде обычно связаны именно с забытыми ссылками на потоки, а не с самими данными.Крупные массивы и GC-pressure
При чтении больших файлов размер буфера влияет на поведение garbage collector:
// Маленький буфер: частые системные вызовы, но быстрое освобождение GC
byte[] smallBuffer = new byte[1024];
// Большой буфер: редкие системные вызовы, но большое выделение памяти
byte[] largeBuffer = new byte[1024 * 1024]; // 1MB
Буфер в 1MB выделяется в heap как единый массив. В G1 GC массивы размером более половины размера региона (обычно > 512KB для региона 1MB) считаются humongous objects и размещаются в специальных humongous-регионах. Эти регионы освобождаются только полным циклом concurrent mark-sweep, что может увеличить паузы GC.
Для приложений с интенсивным I/O рекомендуется переиспользование буферов через пулы объектов, что устраняет накладные расходы на выделение и сборку:
// Переиспользование буфера вместо создания нового при каждой операции
private static final ThreadLocal<byte[]> BUFFER = ThreadLocal.withInitial(() -> new byte[8192]);
public void processFile(String path) throws IOException {
byte[] buffer = BUFFER.get(); // Переиспользование, не создание
try (InputStream in = new FileInputStream(path)) {
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
processChunk(buffer, bytesRead);
}
}
// Буфер остается в ThreadLocal, не подлежит GC между вызовами
}
Здесь буфер привязан к потоку через
ThreadLocal и не создается заново при каждом вызове. Это устраняет аллокации в hot path, но требует осторожности: буфер занимает память на протяжении жизни потока, даже когда не используется.#Java #для_новичков #beginner #IO #NIO #InputStream #OutputStream
👍2
InputStream: чтение байтов
Контракт read(): возврат -1 при конце потока
Контракт метода
Этот контракт определяет стандартный паттерн чтения:
Этот код функционально корректен, но крайне неэффективен: каждый вызов
Правильный подход — буферизованное чтение:
Здесь
#Java #для_новичков #beginner #IO #NIO #InputStream #OutputStream
InputStream — абстрактный класс, определяющий контракт для всех входных байтовых потоков. Его методы реализуют семантику последовательного чтения: данные извлекаются из источника в порядке FIFO, и невозможно вернуться назад без специальных механизмов (mark/reset, доступных не во всех реализациях).int read() — читает один байт из потока. Возвращает значение в диапазоне 0-255 как int, что позволяет различать валидные байты (0-255) от признака конца потока. Когда достигнут конец потока, метод возвращает -1. Этот контракт фундаментален: -1 — единственный способ узнать, что данные в источнике исчерпаны. Возврат int вместо byte обусловлен необходимостью представления 256 возможных значений байта плюс специальное значение -1 для EOF. Тип byte в Java знаковый и имеет диапазон -128..127, что не позволил бы различить байт 0xFF (255 беззнаковый, -1 знаковый) от признака конца потока.int read(byte[] b) — читает байты в предоставленный массив, заполняя его полностью или частично. Возвращает количество фактически прочитанных байт, или -1 при достижении конца потока. Этот метод существенно эффективнее посимвольного чтения, так как уменьшает количество системных вызовов. Однако он не гарантирует заполнение всего массива — при неполных данных возвращается меньшее количество байт, и приложение должно обработать эту ситуацию.int read(byte[] b, int off, int len) — читает до len байт в массив b, начиная с позиции off. Возвращает количество прочитанных байт или -1. Этот метод предоставляет максимальный контроль над буферизацией.
long skip(long n) — пропускает до n байт в потоке. Возвращает количество фактически пропущенных байт, которое может быть меньше n.int available() — возвращает оценку количества байт, которые можно прочитать без блокирования. Для файловых потоков это обычно оставшийся размер файла, для сетевых — количество данных в сокет-буфере. Значение 0 не означает конец потока, а только отсутствие немедленно доступных данных.void close() — закрывает поток и освобождает связанные системные ресурсы: файловые дескрипторы, сокеты, соединения с базой данных. Реализации InputStream не объявляют close() как AutoCloseable, но все конкретные подклассы реализуют этот интерфейс, что позволяет использовать try-with-resources.void mark(int readlimit) и void reset() — поддержка маркировки позиции в потоке для последующего возврата. Доступность проверяется через boolean markSupported(). Большинство потоков, основанных на внешних ресурсах (файлы, сети), не поддерживают эту операцию.Контракт read(): возврат -1 при конце потока
Контракт метода
read() требует особого внимания. Метод блокирует выполнение до тех пор, пока не станет доступен хотя бы один байт, не произойдет IOException, или не будет достигнут конец потока. При нормальном завершении потока возвращается -1, и все последующие вызовы read() также возвращают -1.Этот контракт определяет стандартный паттерн чтения:
// Антипаттерн: чтение байта за байтом без буферизации
public void readByteByByte(InputStream in) throws IOException {
int byteValue;
while ((byteValue = in.read()) != -1) {
processByte(byteValue);
}
}
Этот код функционально корректен, но крайне неэффективен: каждый вызов
read() порождает системный вызов для чтения одного байта. Для файлов это означает обращение к диску, для сети — ожидание пакета.Правильный подход — буферизованное чтение:
// Паттерн: буферизованное чтение с обработкой частичного заполнения
public void readBuffered(InputStream in) throws IOException {
byte[] buffer = new byte[8192]; // 8KB — типичный размер страницы ОС
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
processBuffer(buffer, bytesRead);
}
}
Здесь
read(buffer) читает до 8192 байт за один системный вызов. Возвращаемое значение bytesRead обязательно проверяется: оно может быть меньше размера буфера, особенно при чтении из сети или при достижении конца файла. Признак конца потока -1 завершает цикл.#Java #для_новичков #beginner #IO #NIO #InputStream #OutputStream
👍5
OutputStream: запись байтов
Контракт flush(): гарантия доставки
Метод
Для гарантии физической записи используется
Цепочка оберток: паттерн декоратора
Архитектура Java I/O построена на паттерне декоратора: базовые потоки (
Важное правило: закрывается только внешняя обертка. Вызов
Практический пример: копирование бинарного файла
Этот пример демонстрирует ключевые практики: буферизованное чтение для минимизации системных вызовов, проверку возвращаемого значения
#Java #для_новичков #beginner #IO #NIO #InputStream #OutputStream
OutputStream — абстрактный класс, определяющий контракт для всех выходных байтовых потоков. Семантика записи также последовательная: байты отправляются в назначение в порядке вызова методов write.void write(int b) — записывает один байт. Параметр типа int, но записываются младшие 8 бит. Старшие 24 бита игнорируются. Это согласовано с read(), возвращающим int.void write(byte[] b) — записывает весь массив байт.void write(byte[] b, int off, int len) — записывает len байт из массива b, начиная с позиции off.void flush() — принудительно сбрасывает буферизованные данные в назначение. Многие реализации OutputStream используют внутреннюю буферизацию для повышения производительности: данные накапливаются в памяти и записываются пачками. flush() гарантирует, что все накопленные данные немедленно отправлены. Это критично для сценариев, где задержка недопустима: протоколы запрос-ответ, логирование в реальном времени, интерактивные сессии.void close() — закрывает поток, освобождает ресурсы и неявно вызывает flush() для сброса оставшихся буферизованных данных. После закрытия потока любые последующие операции выбрасывают IOException.Контракт flush(): гарантия доставки
Метод
flush() не гарантирует физическую запись на диск или отправку по сети — он гарантирует только передачу данных из пользовательского буфера в операционную систему. Фактическая запись на носитель контролируется ОС и может быть отложена.Для гарантии физической записи используется
FileChannel.force(true) в NIO.// Пример: flush перед ожиданием ответа
public void sendRequest(OutputStream out, byte[] request) throws IOException {
out.write(request);
out.flush(); // Гарантирует, что запрос отправлен до чтения ответа
// Теперь можно читать ответ, не рискуя deadlock из-за буферизации
}
Цепочка оберток: паттерн декоратора
Архитектура Java I/O построена на паттерне декоратора: базовые потоки (
FileInputStream, SocketInputStream) предоставляют минимальную функциональность, а обертки (BufferedInputStream, DataInputStream, GZIPInputStream) добавляют возможности без изменения базового интерфейса.// Цепочка оберток: буферизация + типизированное чтение + сжатие
try (InputStream fis = new FileInputStream("data.bin");
BufferedInputStream bis = new BufferedInputStream(fis, 8192);
DataInputStream dis = new DataInputStream(bis);
GZIPInputStream gzis = new GZIPInputStream(dis)) {
int magicNumber = dis.readInt(); // Чтение 4-байтового int
long timestamp = dis.readLong(); // Чтение 8-байтового long
double value = dis.readDouble(); // Чтение 8-байтового double
String label = dis.readUTF(); // Чтение строки в модифицированном UTF-8
}
Важное правило: закрывается только внешняя обертка. Вызов
close() на GZIPInputStream проксируется через всю цепочку к FileInputStream, корректно освобождая все ресурсы. try-with-resources обрабатывает это автоматически.Практический пример: копирование бинарного файла
public void copyBinaryFile(String sourcePath, String destPath) throws IOException {
// Буфер 8KB — оптимальный размер для большинства файловых систем
byte[] buffer = new byte[8192];
try (InputStream in = new FileInputStream(sourcePath);
OutputStream out = new FileOutputStream(destPath)) {
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
out.flush(); // Явный flush перед закрытием
}
}Этот пример демонстрирует ключевые практики: буферизованное чтение для минимизации системных вызовов, проверку возвращаемого значения
read для корректной обработки частичного чтения, запись ровно прочитанного количества байт, и явный flush перед закрытием. try-with-resources гарантирует закрытие обоих потоков даже при исключении.#Java #для_новичков #beginner #IO #NIO #InputStream #OutputStream
👍5