JavaRush /Курсы /Kotlin SELF /Буферизация и chunk‑копирование

Буферизация и chunk‑копирование

Kotlin SELF
43 уровень , 3 лекция
Открыта

1. Копировать по одному байту — это медленно

Если вы когда-нибудь пробовали переносить воду чайной ложкой, когда рядом стоит ведро, то вы уже понимаете идею этой лекции. Чтение и запись по одному байту технически возможны, но почти всегда приводит к огромному количеству обращений к системе ввода‑вывода. А операции I/O — это не «плюсик» в памяти, это поход «наружу» (в ОС, в файловую систему, на диск).

Наивный код типа «читаю байт → сразу пишу байт» чаще всего проигрывает по скорости в разы и даже в десятки раз. Причина не в том, что компьютер «не любит байты», а в том, что он не любит миллионы мелких операций I/O. Мы хотим сделать меньше операций — и переносить данные блоками, например по 8 КБ за раз.

Мини‑набросок «как НЕ надо» (это не «запрещённый приём», это «пожалейте свой SSD»):

import java.io.FileInputStream
import java.io.FileOutputStream

fun main() {
    FileInputStream("in.bin").use { input ->
        FileOutputStream("out.bin").use { output ->
            while (true) {
                val b = input.read()
                if (b == -1) break
                output.write(b)
            }
        }
    }
}

Код корректный по смыслу, но слишком «мелкий» по порциям данных.

2. Буфер и буферизация: ByteArray vs Buffered...Stream

Сейчас будет важный момент, который спасает от половины типичных ошибок в этой теме. Когда мы говорим «буфер», мы можем иметь в виду две разные вещи, которые часто используются вместе, но решают разные задачи.

Первое: наш буфер — это обычный ByteArray, куда мы читаем байты и откуда их пишем. Это просто контейнер фиксированного размера. Здесь работает привычная логика массивов: индексы, size, и так далее. ByteArray — это primitive‑array в Kotlin (без boxing), что особенно логично для данных «байт‑в‑байт».

Второе: буферизация потоков — это обёртки вроде BufferedInputStream и BufferedOutputStream, которые внутри себя держат дополнительный буфер и уменьшают количество обращений к базовому потоку. Это оптимизация «уровнем ниже»: даже если вы читаете блоками, буферизованный поток может сделать это ещё эффективнее.

Удобно удерживать разницу в одной таблице:

Понятие Что это Где живёт Зачем нужно
ByteArray buffer
ваш рабочий массив байтов в вашем коде читать/писать chunks одинакового размера
BufferedInputStream/BufferedOutputStream
обёртка над потоком внутри Java I/O реже дёргать базовый поток (меньше I/O‑операций)

То есть: наш ByteArray — это «миска», а Buffered...Stream — это «официант, который приносит сразу поднос, а не одну котлету».

3. Контракт read(buffer): значение n

Чтобы chunk‑копирование работало корректно, нужно принять простую, но очень важную мысль: вызов read(buffer) не обязан заполнить буфер целиком.

Метод read(buffer) возвращает Int, и это число означает: «сколько байт реально прочитали и положили в buffer». Возвращаемое значение:

  • может быть меньше размера буфера, даже если файл ещё не кончился;
  • будет -1, когда наступил EOF (конец потока).

И вот тут рождается ключевой инвариант копирования: писать в выходной поток нужно ровно n байт, а не весь буфер.

Давайте на очень маленьком примере (без файлов) увидим идею: будем читать из ByteArrayInputStream кусками по 4 байта. Да, это «игрушечный» источник, но он отлично демонстрирует контракт.


import java.io.ByteArrayInputStream

fun main() {
    val data = byteArrayOf(10, 20, 30, 40, 50)
    val input = ByteArrayInputStream(data)

    val buffer = ByteArray(4)
    val n = input.read(buffer)

    println("n=$n")          // n=4
    println(buffer[0])       // 10
}

Если сделать второй read, то он вернёт 1, потому что остался всего один байт. И это нормально. Именно поэтому n нельзя игнорировать.

4. Канонический цикл chunk‑копирования

Сейчас будет «золотой шаблон», который нужно не просто понять, а привыкнуть писать почти автоматически. Он простой:

  1. Создаём ByteArray фиксированного размера (например, 8 * 1024).
  2. В цикле читаем n = input.read(buffer).
  3. Если n == -1 — выходим.
  4. Иначе пишем output.write(buffer, 0, n).

Вот минимальная правильная версия как функция (это удобнее, чем держать копирование прямо в main):

import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.FileInputStream
import java.io.FileOutputStream

fun copyFileChunked(srcPath: String, dstPath: String) {
    FileInputStream(srcPath).use { rawIn ->
        FileOutputStream(dstPath).use { rawOut ->
            BufferedInputStream(rawIn).use { input ->
                BufferedOutputStream(rawOut).use { output ->
                    val buffer = ByteArray(8 * 1024)

                    while (true) {
                        val n = input.read(buffer)
                        if (n == -1) break
                        output.write(buffer, 0, n)
                    }
                }
            }
        }
    }
}

Обратите внимание на две вещи. Во‑первых, мы закрываем ресурсы через use {} — это идиоматичный Kotlin‑подход для всего, что реализует AutoCloseable: поток закроется даже если внутри случится ошибка. Во‑вторых, мы используем Buffered...Stream, но не заменяем ими наш буфер: мы всё равно читаем в ByteArray.

5. Почему output.write(buffer) — частая ошибка

Этот раздел обычно вызывает у новичков удивление уровня: «Ну я же заполнил буфер, чего не записать его весь?». Давайте разберёмся, почему нельзя так делать.

Если вы пишете output.write(buffer), вы записываете весь массив — то есть buffer.size байт. Но read(buffer) мог прочитать только n байт. Остальные элементы буфера либо остались с прошлого цикла, либо вообще содержат «старый мусор» (в смысле старые значения массива). Итог: в конце файла вы допишете лишние байты, и копия получится повреждённой.

Плохой пример (в нём ошибка специально):

import java.io.FileInputStream
import java.io.FileOutputStream

fun main() {
    FileInputStream("in.bin").use { input ->
        FileOutputStream("out.bin").use { output ->
            val buffer = ByteArray(1024)
            val n = input.read(buffer)
            if (n != -1) output.write(buffer) // ОШИБКА: пишет все 1024 байта
        }
    }
}

Правильный вариант: write(buffer, 0, n).

import java.io.FileInputStream
import java.io.FileOutputStream

fun main() {
    FileInputStream("in.bin").use { input ->
        FileOutputStream("out.bin").use { output ->
            val buffer = ByteArray(1024)
            val n = input.read(buffer)
            if (n != -1) output.write(buffer, 0, n) // пишем только прочитанное
        }
    }
}

Почему ошибка иногда «не палится»? Потому что если размер файла ровно кратен размеру буфера, то последний блок тоже будет полный, и баг спрячется. Это как баг «падает только по пятницам» — особенно любимый жанр у разработчиков.

6. Встраиваем копирование в мини‑утилиту

Простой main без лишней магии

Чтобы примеры не были набором отдельных кусочков, представим, что у нас растёт простая консольная утилита, условно назовём её FileTool. Её цель — выполнить одно действие: скопировать файл из A в B.

Пока без сложных команд, без парсинга аргументов и без богатого UX (у нас сегодня тема не про CLI‑архитектуру). Просто берём два пути и запускаем копирование.

import java.io.File

fun main() {
    val src = File("in.bin")
    val dst = File("out.bin")

    copyFileChunked(src.path, dst.path)
    println("Done. size=${dst.length()} bytes") // Done. size=...
}

В этой версии мы опираемся на то, что copyFileChunked уже корректно закрывает потоки через use. И да, File.length() — это простой первичный контроль: файл скопировался и имеет какой‑то размер. Но «какой‑то размер» — не гарантия корректности данных, это просто быстрый сигнал «мы точно что-то записали».

Быстрая проверка: размер и contentEquals

Очень хочется после копирования сказать «готово» и уйти пить чай. Но иногда полезно хотя бы минимально убедиться, что копия похожа на оригинал.

Самый быстрый и дешёвый тест — сравнить размеры:

import java.io.File

fun main() {
    val src = File("in.bin")
    val dst = File("out.bin")

    println(src.length() == dst.length()) // true/false
}

Но одинаковый размер не доказывает, что байты совпадают: два разных файла вполне могут иметь один и тот же размер. Если файл маленький, можно сравнить содержимое в памяти. Главное — не пытаться так делать для гигабайтных файлов.

Сравнение массивов в Kotlin делается через contentEquals, а не через ==.

import java.io.File

fun main() {
    val a = File("in.bin").readBytes()
    val b = File("out.bin").readBytes()

    println(a.contentEquals(b)) // true/false
}

Эта проверка «байт‑в‑байт», но ценой того, что оба файла загружаются в память. Поэтому она хороша как тест на небольших примерах и в учебных сценариях, но не как универсальная стратегия «для всего на свете».

7. Размер буфера и схема потока данных

Размер буфера: разумные значения

Про размер буфера очень любят спрашивать так, будто есть один «правильный размер на все времена». Спойлер: нет. Есть разумные значения, которые обычно работают хорошо, и есть крайности, которые обычно не дают пользы.

Почему часто выбирают 8 * 1024 (8 КБ)? Потому что это хороший компромисс: достаточно большой chunk, чтобы не делать слишком много итераций, и достаточно маленький, чтобы не раздувать память и не усложнять работу с кешами. В реальности часто встречаются 4 КБ, 8 КБ, 16 КБ, 64 КБ — все эти значения могут быть нормальными.

Если сделать буфер слишком маленьким (например, 1 байт или 16 байт), вы проиграете по скорости из-за количества read/write. Если сделать буфер слишком большим (например, десятки мегабайт), вы начнёте тратить память, а выигрыша можете не получить: всё упрётся в скорость диска/файловой системы, плюс гигантский массив не так дружелюбен к кешам и сборке мусора.

Полезная привычка: оформить размер буфера как константу, чтобы он был «в одном месте» и нормально читался.

private const val DEFAULT_BUFFER_SIZE_BYTES = 8 * 1024

fun makeBuffer(): ByteArray = ByteArray(DEFAULT_BUFFER_SIZE_BYTES)

И дальше использовать makeBuffer() в копировании. Код станет чуть длиннее, зато смысл «почему 8192» станет очевиднее.

Схема потока данных в chunk‑копировании

Чтобы в голове не было ощущения «я просто переписал шаблон и молюсь», полезно один раз увидеть картинку процесса.

flowchart TD
    A[Файл-источник] --> B[FileInputStream]
    B --> C[BufferedInputStream]
    C --> D[ByteArray buffer]
    D --> E[BufferedOutputStream]
    E --> F[FileOutputStream]
    F --> G[Файл-приёмник]

Важная мысль: ByteArray buffer — это не «архив в памяти», это просто рабочая область для одной итерации цикла. Мы читаем chunk, записываем chunk, и переиспользуем тот же массив снова и снова.

8. Типичные ошибки при chunk‑копировании

Ошибка №1: игнорировать n и писать весь буфер.
Это самая частая проблема: read(buffer) прочитал 137 байт, а вы записали 8192. В результате в конец файла прилетает «хвост» из старых значений буфера. Ошибка особенно коварна тем, что на некоторых файлах она не проявится, если размер файла кратен буферу, а на других сломает копию гарантированно.

Ошибка №2: считать, что одного read(buffer) достаточно для копирования.
Потоки не обязаны отдавать весь файл за один вызов read. Даже если файл маленький, корректная модель — «читаем порцию, пока не пришёл EOF». Один read — это максимум «прочитал начало».

Ошибка №3: путать «наш буфер» и BufferedInputStream.
Иногда новички думают, что если они обернули поток в BufferedInputStream, то ByteArray уже не нужен. Но BufferedInputStream не отменяет вашу задачу читать кусками и писать кусками. Он лишь делает это эффективнее внутри, уменьшая количество обращений к базовому потоку.

Ошибка №4: не закрывать потоки (или закрывать «как получится»).
Файловые потоки — это ресурсы, и закрывать их нужно гарантированно. В Kotlin для этого есть use {}: внутри блока вы работаете с ресурсом, а после выхода он закрывается автоматически, даже если случится исключение. Если потоки не закрывать, вы можете получить незаписанные данные, утечки дескрипторов и очень странные «иногда работает, иногда нет».

Ошибка №5: проверять равенство массивов через ==.
Если вы делаете тесты и хотите сравнить два ByteArray, используйте contentEquals. В Kotlin == для массивов проверяет не содержимое, а «это один и тот же объект или нет». Для сравнения содержимого как раз и существует contentEquals.

Ошибка №6: пытаться проверять корректность копии через «посмотрел в блокноте».
Бинарный файл в текстовом редакторе почти всегда выглядит как набор кракозябр, и это нормально. Визуальный контроль «открыл, вроде похоже» тут не работает. Для маленьких файлов используйте contentEquals, для больших хотя бы сравнение размеров, а ещё лучше — отдельные стратегии проверки (но это уже отдельная инженерная тема, и сегодня мы в неё не уходим).

1
Задача
Kotlin SELF, 43 уровень, 3 лекция
Недоступна
Два захвата буфера
Два захвата буфера
1
Задача
Kotlin SELF, 43 уровень, 3 лекция
Недоступна
Копия по чанкам
Копия по чанкам
1
Задача
Kotlin SELF, 43 уровень, 3 лекция
Недоступна
Хвост без порчи
Хвост без порчи
1
Задача
Kotlin SELF, 43 уровень, 3 лекция
Недоступна
Счётчик чтений
Счётчик чтений
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ