Буферизованные потоки 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 streamreadLine распознаёт \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 WindowsnewLine() записывает тот разделитель строк, который 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 diskBufferedWriter не отправляет байты в ОС до тех пор, пока буфер не заполнится или не выполнится 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 и демонстрирует ловушку «забыли сбросить» на стороне записи.
Что стоит вынести из запуска:
- Побайтовое копирование без буферизации было на несколько порядков медленнее буферизованного. Тело цикла было идентичным; единственным изменением было оборачивание файловых потоков в
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 на другой ОС.