Байтовые потоки в Java
Чтение и запись двоичных данных в Java с помощью InputStream, OutputStream, FileInputStream и FileOutputStream.
В главе 1 архитектура java.io была описана как стек декораторов: сырой поток в основании, слои функциональности поверх него, а верхний слой предоставляет API, которым вы пользуетесь. Первые шесть глав этой части работали на вершине стека — Files.readString, Files.lines, Files.writeString. Эта глава опускается на уровень ниже — к байт-ориентированной абстракции, на которой построен весь стек: InputStream и OutputStream.
Каждый файл, сокет, канал и буфер в памяти в java.io — это в самом низу поток байт. Даже текстовый файл в формате UTF-8 — это байты на диске; «текстовое» представление появляется благодаря Reader, надстроенному поверх InputStream. Знание байтового API необходимо тогда, когда данные не являются текстом (изображения, аудио, архивы, сетевые протоколы), когда нужно копировать байты без их декодирования, и когда вы хотите понять, что делают высокоуровневые API на самом деле.
Контракт InputStream
InputStream — абстрактный класс с одним методом:
public abstract int read() throws IOException;Он возвращает следующий байт как int в диапазоне 0..255 или -1 при исчерпании потока. Тип int — не ошибка: byte в Java знаковый (-128..127), но контракт потока подразумевает беззнаковое значение, поэтому расширенный возвращаемый тип позволяет отличить «конец потока» (-1) от реального значения байта (0xFF возвращается как 255, а не как -1).
На основе read() определены ещё три метода, которые вы обычно и вызываете:
int read(byte[] buf); // read up to buf.length bytes; return count or -1
int read(byte[] buf, int off, int len); // same, into a slice
byte[] readAllBytes(); // Java 9+: read everything into a byte[]
long transferTo(OutputStream out); // Java 9+: pipe straight to a sink, no copy loopreadAllBytes() — удобный метод для небольших файлов; transferTo — для копирования без декодирования. Для всего остального используется цикл буферизованного чтения, который имеет каноническую форму:
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n); // n bytes, not buf.length — the last chunk is short
}Две вещи, которые нужно запомнить. Во-первых, вызовы read(byte[]) возвращают фактическое количество прочитанных байт, а не всегда buf.length. Последнее чтение почти всегда неполное; обращение с буфером как с полным искажает данные. Во-вторых, read() и read(byte[]) являются блокирующими — они возвращают управление, когда доступен хотя бы один байт или поток завершён. Они не возвращают управление досрочно на медленном диске или медленном сокете.
Пропуск, просмотр вперёд и перемотка
InputStream также определяет три метода, к которым обращаются реже, но которые стоит знать:
long skip(long n); // discard up to n bytes without copying them anywhere
int available(); // bytes you can read right now without blocking — an estimate, not a length
boolean markSupported();
void mark(int readAheadLimit); // remember this position
void reset(); // jump back to the last markЗдесь есть две ловушки. available() — это не размер потока: для файла он часто совпадает, но для сокета это «уже буферизованные байты», которых может быть 0 во время передачи. Никогда не пишите new byte[in.available()], рассчитывая прочитать всё целиком. А mark/reset работают, только если markSupported() возвращает true; сырой FileInputStream возвращает false, поэтому оберните его в BufferedInputStream (следующая глава), если нужно заглядывать вперёд и возвращаться назад.
Контракт OutputStream
Зеркальный класс — OutputStream, тоже с одним абстрактным методом:
public abstract void write(int b) throws IOException;Он записывает младшие 8 бит b и игнорирует остальные. Удобные перегрузки:
void write(byte[] buf); // write the whole array
void write(byte[] buf, int off, int len); // write a slice — this is the one you usually want
void flush(); // push buffered data to the OS
void close(); // flush + release resourcesflush() имеет значение только если поток буферизует данные. Сырой FileOutputStream не буферизует — каждый write обращается к ОС — поэтому flush является заглушкой. Буферизация и необходимость в flush появляются в BufferedOutputStream (следующая глава).
close() вызывает flush() первым. Вот почему «забытое закрытие буферизованного потока» молча обрезает файл: хвостовой буфер находится в памяти в ожидании flush, который так и не происходит.
Конкретные байтовые потоки
Конкретные подклассы, которые вы будете инстанциировать напрямую:
| Класс | Что оборачивает |
|---|---|
FileInputStream / FileOutputStream | Файл на диске. Открывает файловый дескриптор. |
ByteArrayInputStream / ByteArrayOutputStream | byte[] в памяти. Полезно для тестов и захвата вывода. |
BufferedInputStream / BufferedOutputStream | Буферизованное представление другого потока. |
PipedInputStream / PipedOutputStream | Канал производитель/потребитель между потоками. |
DataInputStream / DataOutputStream | Надстройка над байтовым потоком для переносимого чтения/записи примитивов. |
FileInputStream и FileOutputStream — сырые файловые потоки. Они небуферизованы: каждый вызов read()/write() — это один системный вызов. Это катастрофически плохо для циклов побайтового чтения (миллионы системных вызовов), но вполне приемлемо для чтения блоками размером 8 КБ и более. Буферизованная глава — это то, что делает побайтовый API приемлемым.
// Raw, unbuffered — fine for chunked reads
try (FileInputStream in = new FileInputStream("photo.jpg")) {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) { /* process buf[0..n] */ }
}
// Equivalent one-liner, Java 7+
byte[] all = Files.readAllBytes(Path.of("photo.jpg"));Files.readAllBytes — правильный выбор для небольших файлов; для всего, что может не поместиться в памяти, безопасна форма с блочным циклом.
Три шаблона, которые стоит запомнить
Три вещи, которые вы делаете с байтовыми потоками снова и снова:
// 1. Copy a file
try (InputStream in = Files.newInputStream(src);
OutputStream out = Files.newOutputStream(dst)) {
in.transferTo(out); // Java 9+: no manual loop
}
// Java 7+ one-liner: Files.copy(src, dst);
// 2. Read everything into memory
byte[] all = Files.readAllBytes(path); // small-file shortcut
// 3. Build a byte[] you don't know the size of in advance
ByteArrayOutputStream baos = new ByteArrayOutputStream();
in.transferTo(baos);
byte[] bytes = baos.toByteArray();ByteArrayOutputStream — это «растущий по мере необходимости» байтовый приёмник. Именно так JDK реализует readAllBytes() для потоков, длина которых заранее неизвестна. Он никогда не генерирует исключение при write (пока не кончится куча) и не имеет значимой семантики close(), что делает его стандартным тестовым инструментом для «захвата того, что записал этот writer».
Когда использовать байтовые потоки
Честный ответ: когда данные не являются текстом. Всё двоичное — изображения, аудио, видео, архивы (.zip, .tar), исполняемые файлы, protocol buffers, пользовательские форматы файлов — это байты, и они остаются байтами.
Когда данные являются текстом, предпочтительнее символьная сторона потоков (Reader/Writer, следующая глава) или современные Files.readString / Files.lines. Чтение текстового файла как сырых байт и ручное декодирование — это стандартный способ создать собственный баг с кодировкой: многобайтовые символы UTF-8 могут быть разделены между вызовами read(), и вы неправильно их соберёте. Слой Reader существует именно для того, чтобы вам не пришлось об этом думать.
Практический пример: копирование, хеширование и захват
Программа ниже демонстрирует байтовый API потоков от начала до конца. Она записывает небольшой двоичный файл (заголовок и полезная нагрузка), читает его обратно по блокам для вычисления контрольной суммы, копирует в другой файл с помощью transferTo и захватывает ещё одну копию в ByteArrayOutputStream, чтобы продемонстрировать приёмник в памяти в действии. Временные файлы удаляются при выходе.
Что стоит вынести из этого запуска:
- Сторона записи использовала
Files.newOutputStream— фабричный метод в стилеFiles, возвращающий обычныйOutputStream. Как только он получен, API — тот самый, что был в Java с версии 1.0. Фабрика просто избавляет от необходимости конструироватьFileOutputStreamи думать об опциях открытия. - В цикле чтения использовалось
n, а неbuf.length, при вызовеcrc.update. Причина видна в строке вывода: «read in N chunks». Буфер был размером 256 байт, а файл — 1004 байта, поэтому последний блок оказался неполным. Использованиеbuf.lengthхешировало бы мусор за пределами реальных данных. in.transferTo(out)— это проверенный JDK-цикл копирования. Он заметно быстрее написанного вручную цикла на большинстве JVM, поскольку может использовать буфер на 16 КБ и пропускать точки безопасности, и при этом занимает одну строку вместо пяти. Используйте его всякий раз, когда иначе написали бы циклwhile ((n = in.read(buf)) != -1)без другой логики внутри.ByteArrayOutputStreamнапрямую подключился кtransferTo. Он выглядит как файл, но живёт в памяти — тот же API. Эта симметрия делаетjava.ioтестируемым: передайтеByteArrayInputStreamв качестве источника иByteArrayOutputStreamв качестве приёмника — и можно юнит-тестировать код, «записывающий в файл», не касаясь диска.- Последний блок вывел
255, затем-1. Это и есть контракт:0xFF— допустимое значение байта, которое возвращается как255;-1— это внешнеполосный сигнал о том, что «байт больше нет». Если хранить результат вbyte(вместоint) и сравнивать с== -1, реальный0xFFбудет молча восприниматься как конец потока. Всегда сохраняйте результат вintи сравнивайте с-1перед приведением типа.
Что дальше
Байты — правильная абстракция для двоичных данных. В следующей главе, Java Character Streams, рассматривается параллельная иерархия для текста — Reader и Writer, сопряжение кодировок и почему «просто new FileReader(path)» — классический источник ошибок «работает у меня, сломано на сервере».