Потоки DataInput и DataOutput в Java
Чтение и запись примитивных типов Java в переносимом бинарном формате с помощью DataInputStream и DataOutputStream.
В этой части мы рассмотрели: байтовые потоки (обычные и буферизованные) для произвольных бинарных данных, символьные потоки для текста. Есть третий сценарий использования, который предыдущие главы не охватывают — запись int, double или boolean в файл и чтение их обратно в том же типе, в формате, с которым согласится другая JVM (работающая на другой ОС с другим порядком байт по умолчанию).
Именно для этого существуют DataInputStream и DataOutputStream. Это декораторы, которые располагаются поверх любого байтового потока и добавляют типизированные методы чтения/записи: writeInt, writeDouble, writeUTF, readInt, readDouble, readUTF. Бинарный формат задокументирован, фиксирован, использует порядок big-endian и переносим между всеми JVM.
Что записано — то и прочитано
DataOutputStream предоставляет по одному методу на каждый примитивный тип:
void writeBoolean(boolean v); // 1 byte (0 or 1)
void writeByte(int v); // 1 byte (low 8 bits)
void writeShort(int v); // 2 bytes, big-endian
void writeChar(int v); // 2 bytes, big-endian (UTF-16 code unit)
void writeInt(int v); // 4 bytes, big-endian
void writeLong(long v); // 8 bytes, big-endian
void writeFloat(float v); // 4 bytes, IEEE 754
void writeDouble(double v); // 8 bytes, IEEE 754
void writeUTF(String s); // modified UTF-8 with a 2-byte length prefixDataInputStream имеет соответствующие методы readInt, readLong, readUTF и т. д. Контракт симметричен: записываете int с помощью writeInt, читаете его обратно через readInt — получаете то же число, всегда, на любой JVM, в любой операционной системе.
Три вещи, которые нужно усвоить:
-
Формат не имеет разделителей полей. Файл с
writeInt(42); writeUTF("alice"); writeDouble(3.14)— это4 + 2 + 5 + 8 = 19байт, записанных без каких-либо маркеров между ними. Читать нужно в том же порядке и с теми же типами. Нет схемы, нет самоописания, нет возможности восстановления при ошибке. -
writeUTFиспользует модифицированный UTF-8. Префикс — беззнаковая 16-битная длина (максимум 65 535 байт на строку), аU+0000кодируется двумя байтами (0xC0 0x80) вместо стандартного одного байта. Формат несовместим с обычным UTF-8 — вы не можете прочитать строку, записаннуюwriteUTF, с помощьюReader. Используйте его только когда обе стороны работают на Java. -
Big-endian, всегда. Порядок байт на машине варьируется (x86 — little-endian, сетевые протоколы — big-endian), но
DataOutputStreamвсегда пишет в формате big-endian. Именно это делает формат переносимым. Если вам нужен little-endian для протокола, который вы не контролируете, используйтеjava.nio.ByteBuffer— у него есть настраиваемый порядок байт.
Когда стоит использовать потоки данных
Два случая:
- Вы контролируете обе стороны и хотите простой, компактный, переносимый между языками бинарный формат. «Файл сохранения» для небольшой Java-игры, файл с тестовыми данными, кэш, который не должен переживать смену версии JVM. Формат легко записывать и разбирать; вам не нужна библиотека сериализации.
- Вы читаете файловый формат, который случайно использует разметку Java-потоков данных. Class-файлы (
.class), записи в форматеRandomAccessFile, некоторые индексные файлы.jar. Все они были записаны с помощьюDataOutputStream, потому что JDK сам создаёт этот формат.
Когда нужна межъязыковая совместимость (Python, Go, JS), используйте JSON, Protocol Buffers или MessagePack. Когда нужна версионность и эволюция схемы, ObjectOutputStream ближе к цели — но он тяжелее и имеет свои подводные камни.
Правило конца файла
Там где InputStream.read() возвращает -1 по достижении конца потока, DataInputStream.readInt() (и аналогичные методы) выбрасывает EOFException. Нет внутриполосного сигнала — корректное значение int может быть любым 32-битным значением, включая -1, поэтому единственный способ сигнализировать о конце потока — исключение.
try (DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))) {
try {
while (true) {
int x = in.readInt();
process(x);
}
} catch (EOFException e) {
// normal end of stream
}
}Этот try/catch для нормального завершения — идиоматическая конструкция. Необычно для JDK делать из исключения сигнал управления потоком, но у типизированного API чтения нет другого выхода — нет значения для возврата, которое не было бы одновременно допустимым int.
Для файлов, где вы контролируете формат, лучший паттерн — записать префикс длины в начало:
out.writeInt(n);
for (int i = 0; i < n; i++) out.writeInt(values[i]);Тогда сторона чтения просто выполняет цикл n раз и никогда не ловит EOFException для управления потоком.
Буферизуйте перед декорированием
DataInputStream не буферизует. Каждый readInt превращается в серию вызовов read() на нижележащем потоке. Если этот поток — FileInputStream, каждый readInt — это четыре системных вызова. Всегда оборачивайте BufferedInputStream первым:
// Right
DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)));
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(path)));Это стандартный трёхуровневый стек: файл → буферизованный → данные. Тот же порядок применяется при записи. Пропустите буфер — и заплатите стоимостью системного вызова на байт из главы о буферизованных потоках, умноженной на количество байт на примитив.
Практический пример: простой бинарный формат записей
Программа ниже определяет минимальную бинарную запись — int id, UTF name, double score, boolean active — и записывает несколько записей во временный файл с помощью DataOutputStream. Читает их обратно через DataInputStream, используя паттерн с префиксом-счётчиком и паттерн с EOFException, и наконец демонстрирует сбой при несовпадении формата, когда читатель и писатель расходятся в типах полей.
Что можно извлечь из запуска:
- Размер файла оказался ровно таким, каким вы предсказали бы, сложив типизированные размеры: 4 (счётчик) + для каждой записи (4 + UTF с префиксом длины + 8 + 1). Без выравнивания, без разделителей. Файл потока данных — это байты как они есть, ничего лишнего.
- Оба паттерна чтения дали одинаковые три записи. Паттерн с префиксом-счётчиком предпочтительнее при проектировании формата; паттерн с EOFException используется тогда, когда нельзя изменить писателя, а формат открытый.
- Блок с несовпадением формата записал два
intи прочитал одинlong. Байты на диске (00 00 00 2A 00 00 00 63) были корректны для обеих интерпретаций —DataInputStreamне может их различить. Две интерпретации побайтово согласованы, но семантически неверны. Это цена бинарного формата без схемы: дисциплина на границе — единственная защита. - Каждый поток был обёрнут
Files.newInputStream→BufferedInputStream→DataInputStream(и то же самое на стороне записи). Пропустите буфер — иreadIntпревратится в четыре системных вызова; слой потоков данных выполняет только преобразование формата и не добавляет никакой буферизации. - Для имени использовался
writeUTF. Формат подходит для связи между Java-программами и бесполезен для всего остального — не стоит выбирать его для конфигурационного файла, который когда-нибудь придётся читать на Python. Для варианта «только Java, и я хочу компактность» — это правильный инструмент; для «кто-то ещё может это прочитать» — переходите к JSON или Protobuf.
Что дальше
Потоки данных обрабатывают по одному примитиву за раз и требуют, чтобы читатель знал формат. В следующей главе, Java PrintWriter, мы возвращаемся к символьной стороне и рассматриваем декоратор Writer, добавляющий методы print, println и printf — тот самый API, который вы используете с System.out с первой главы, наконец представленный как полноценный писатель в файл.