W3docs

Буферизованные потоки Java

Ускорьте ввод/вывод Java с помощью буферизованных потоков — BufferedReader, BufferedWriter, BufferedInputStream, BufferedOutputStream.

В главах о байтовых и символьных потоках честно описывались низкоуровневые API: каждый вызов FileInputStream.read() или FileReader.read() является системным вызовом. Системный вызов занимает порядка микросекунды — быстро в отдельности, катастрофично в тесном цикле. Чтение файла размером 1 МБ побайтово — это миллион системных вызовов; тот же файл с буфером 8 КБ — всего 128. Разница во времени выполнения составляет два-три порядка величины.

Декораторы Buffered* располагаются между вашим кодом и сырым потоком. Они хранят в памяти массив byte[] (или char[]) и обслуживают вызовы read() из него, обращаясь к ОС только когда буфер пуст. На стороне записи они накапливают небольшие записи в буфере и выполняют write() к ОС только при заполнении буфера или при вызове flush/close. Тот же API, совершенно другая стоимость.

Четыре буферизованных класса

КлассОборачивает
BufferedInputStreamЭкземпляр InputStream. Добавляет внутренний буфер byte[].
BufferedOutputStreamЭкземпляр OutputStream. Добавляет внутренний буфер byte[].
BufferedReaderЭкземпляр Reader. Добавляет внутренний буфер char[] и знаменитый метод readLine().
BufferedWriterЭкземпляр Writer. Добавляет внутренний буфер char[] и метод newLine().

Все четыре класса оборачивают любой поток соответствующего типа — файловый, сокетный, канальный, в памяти — не только файловые потоки:

BufferedInputStream  in  = new BufferedInputStream(new FileInputStream(path.toFile()));
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(path.toFile()));
BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
BufferedWriter w = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));

Размер буфера по умолчанию — 8192 байт/символа — выбран для соответствия типичным размерам страниц ОС. Можно передать другой размер во второй конструктор, но значение по умолчанию подходит практически в любом случае. Более крупные буферы не ускоряют работу линейно; они просто используют больше памяти.

Современный API предоставляет эти декораторы уже в готовом виде:

BufferedReader r = Files.newBufferedReader(path);                            // UTF-8 by default
BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8);
InputStream    in  = new BufferedInputStream(Files.newInputStream(path));
OutputStream   out = new BufferedOutputStream(Files.newOutputStream(path));

Files.newBufferedReader / Files.newBufferedWriter уже оборачивают промежуточный класс с нужной кодировкой и BufferedReader/BufferedWriter. Для работы с текстом это однострочная замена трёхуровневого ручного стека.

BufferedReader.readLine()

Именно поэтому BufferedReader является самым используемым классом в java.io:

String readLine() throws IOException;          // a line, terminator stripped, or null at end
Stream<String> lines();                         // Java 8+: line stream

readLine распознаёт \n, \r и \r\n как разделители строк и возвращает строку без разделителя. При достижении конца потока он возвращает null (не пустую строку, не -1) — стандартная идиома чтения строк:

try (BufferedReader r = Files.newBufferedReader(path)) {
  String line;
  while ((line = r.readLine()) != null) {
    process(line);
  }
}

r.lines() возвращает Stream<String> для функциональной обработки. Поток владеет открытым Reader, поэтому try-с-ресурсами вокруг ридера по-прежнему выполняет закрытие — самому lines() собственное закрытие не нужно.

Два важных момента о readLine(). Во-первых, он выделяет объект String на каждую строку. Для тесных циклов обработки журналов, где важно управление памятью, предпочтительнее низкоуровневый read(char[]). Во-вторых, пустая строка — это "" (пустая строка), не null — файл заканчивается только когда readLine() возвращает null.

BufferedWriter.newLine()

Зеркальный удобный метод на стороне записи:

void newLine() throws IOException;             // platform line separator: \n on Unix, \r\n on Windows

newLine() записывает тот разделитель строк, который JVM считает актуальным для текущей платформы. Это удобно, если вы создаёте файлы для чтения человеком на локальной машине; но это баг, если вы создаёте файлы данных, журналы или что-либо предназначенное для другой машины. Интернет работает на \n. Всегда явно пишите \n, когда вывод должен быть переносимым:

w.write("line one\n");                          // portable
w.newLine();                                    // platform-dependent: \n on Unix, \r\n on Windows

То же самое касается PrintWriter.println и спецификатора формата %n — они зависят от платформы. Используйте их только когда вывод предназначен для локального потребления.

Ловушка «хвостовой буфер не сброшен»

Это ошибка, с которой каждая Java-кодовая база сталкивается как минимум один раз:

// WRONG
BufferedWriter w = Files.newBufferedWriter(path);
w.write("hello");
return;                                          // 'hello' is sitting in the buffer; nothing on disk

BufferedWriter не отправляет байты в ОС до тех пор, пока буфер не заполнится или не выполнится close(). Пропустите закрытие — и хвост потеряется: Files.size(path) возвращает 0, и вы не понимаете почему. Исправление — всегда использовать try-с-ресурсами:

try (BufferedWriter w = Files.newBufferedWriter(path)) {
  w.write("hello");
}                                                // close() runs here; tail is flushed

Если данные нужны на диске до закрытия — например, для наблюдателя за хвостом журнала или другого процесса, опрашивающего файл, — явно вызовите flush(). Буфер не сбрасывается автоматически после каждой записи; это и есть цена наличия буфера.

Метка и сброс

BufferedReader и BufferedInputStream поддерживают небольшой API «заглянуть вперёд и откатиться»:

in.mark(1024);                                   // remember this position; allow up to 1024 bytes of lookahead
int b = in.read();
in.reset();                                      // back to the marked position

Это единственный API в java.io, позволяющий прочитать байт/символ и вернуть его обратно. Он лежит в основе кода «заглянуть в первые несколько байт, чтобы определить формат» — обнаружение BOM UTF-8, проверка магических чисел, передача управления парсеру. Без буферизации это невозможно: сырые потоки больше не хранят байты после того, как они были прочитаны.

Когда буферизация не помогает

Два случая, когда добавление декоратора Buffered* ничего не даёт:

  • Источник уже находится в памяти. ByteArrayInputStream и StringReader уже обслуживают read() из byte[]/String в памяти; системных вызовов для амортизации нет.
  • Вы используете Files.readString, Files.readAllBytes, Files.write или transferTo. Эти вызовы выполняют собственный блочный ввод/вывод с большим внутренним буфером. Оборачивание их в BufferedInputStream избыточно — JDK уже выполнил буферизацию.

Случай, когда буферизация помогает, — это исходный: вы читаете или записываете небольшими порциями (один байт, одна строка, вызов printf), а источник/получатель — реальный файл, сокет или канал.

Практический пример: та же нагрузка, с буферизацией и без

Программа ниже копирует один и тот же блок данных размером 32 КБ побайтово из одного временного файла в другой — сначала с сырыми FileInputStream/FileOutputStream, затем с BufferedInputStream/BufferedOutputStream, и наконец с transferTo для сравнения. Время выполнения делает стоимость отсутствующего буфера наглядной. Затем пример читает строки файла через BufferedReader и демонстрирует ловушку «забыли сбросить» на стороне записи.

java— editable, runs on the server

Что стоит вынести из запуска:

  • Побайтовое копирование без буферизации было на несколько порядков медленнее буферизованного. Тело цикла было идентичным; единственным изменением было оборачивание файловых потоков в BufferedInputStream/BufferedOutputStream. В этом и заключается весь смысл существования этих декораторов — тот же API, значительно меньше системных вызовов.
  • transferTo был таким же быстрым, как буферизованная версия (или быстрее). Для задачи «скопировать байты из A в B без преобразования» transferTo — именно то, что нужно: он уже буферизован внутри, и JDK оптимизировал цикл. Обращайтесь к нему прежде, чем писать собственную реализацию.
  • Files.newBufferedReader вернул BufferedReader напрямую. Заметьте, что мы ни разу не написали new BufferedReader(new InputStreamReader(new FileInputStream(...), UTF_8)) — этот трёхуровневый стек и скрывает фабричный метод. readLine() достался нам из этого стека бесплатно.
  • «Дырявый» писатель напечатал 0 bytes до flush(). Эти символы были во внутреннем буфере, а не на диске. Вызов flush() вытолкнул их; без явного сброса (или корректного close()) они бы пропали. Вот почему try-с-ресурсами вокруг буферизованных писателей не опционален — это контракт, делающий запись видимой.
  • Цикл BufferedReader.readLine() — самый распространённый паттерн обработки текста в Java. Запомните форму while ((line = r.readLine()) != null): присваивание внутри условия здесь идиоматично, а null (не пустая строка) является условием завершения цикла.

Что дальше

Буферизация решает проблему стоимости системного вызова на каждый вызов, но не меняет значение байтов. Следующая глава, Потоки DataInput и DataOutput в Java, рассматривает декораторы для чтения и записи примитивов Java в переносимом бинарном формате — уровень, позволяющий записать int в файл и прочитать его обратно как int на другой ОС.

Практика

Практика
Что произойдёт с данными, записанными `w.write('hello')`, если забыть закрыть `BufferedWriter` (и никогда не вызвать `flush()`)?
Что произойдёт с данными, записанными `w.write('hello')`, если забыть закрыть `BufferedWriter` (и никогда не вызвать `flush()`)?
Was this page helpful?