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("Готово. size=${dst.length()} байт") // 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 КБ)? Бо це хороший компроміс: блок достатньо великий, щоб не робити надто багато ітерацій, і водночас достатньо малий, щоб не роздувати споживання пам’яті та не ускладнювати роботу з кешами. На практиці часто трапляються 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 — це не «архів у пам’яті», а просто робоча область для однієї ітерації циклу. Ми читаємо блок, записуємо блок і перевикористовуємо той самий масив — знову й знову.

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, для великих — хоча б порівняння розмірів, а ще краще — окремі стратегії перевірки (але це вже інженерна тема, і сьогодні ми в неї не заглиблюємося).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ