JavaRush /Курси /JAVA 25 SELF /Канали NIO та ByteBuffer

Канали NIO та ByteBuffer

JAVA 25 SELF
Рівень 41 , Лекція 1
Відкрита

1. Вступ до каналів NIO

У класичному Java IO (java.io) усе влаштовано за принципом «один потік — один файл або ресурс». Щойно починається читання чи запис, потік блокується та чекає завершення операції. Для простих випадків це зручно, але у навантажених системах такий підхід стає «вузьким місцем»: якщо з’єднань тисячі, то тисячі потоків простоюють в очікуванні.

У NIO (New IO) підхід інший. Тут введення-виведення може бути неблокувальним, і потік не зобов’язаний простоювати. Поки одні дані ще йдуть, він може перемкнутися на інше завдання. Це дає змогу обслуговувати величезну кількість з’єднань буквально кількома потоками.

Різницю видно й у деталях. У «старому» IO робота будується навколо потоків, які читають і пишуть байти або символи, але завжди блокуються на час операцій. У NIO ключовими поняттями стають канали (Channels) і буфери (Buffers). Вони дають змогу реалізувати неблокувальне введення-виведення (важливо для серверів), а також застосовувати прийом zero-copy, коли дані передаються напряму, уникаючи зайвих копіювань у буфери JVM.

Порівняння: потоки (Streams) vs канали (Channels)

Потоки (InputStream/OutputStream):

  • Читають/записують байти по одному або масивами.
  • Немає прямого контролю над позицією у файлі.
  • Немає ефективної роботи з дуже великими файлами.

Канали (Channel):

  • Читають/записують дані через буфери (Buffer).
  • Можна керувати позицією (включно з довільним доступом).
  • Підтримують асинхронність і неблокувальний режим.
  • Дозволяють використовувати zero-copy для надшвидкого копіювання.

2. FileChannel і SeekableByteChannel

Читання та запис даних із використанням буферів

FileChannel — основний канал для роботи з файлами. Його можна отримати з FileInputStream, FileOutputStream або через NIO.2 — Files.newByteChannel (повертає SeekableByteChannel).

Приклад: читання файлу через FileChannel і ByteBuffer

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelReadExample {
    public static void main(String[] args) throws Exception {
        try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
             FileChannel channel = file.getChannel()) {

            ByteBuffer buffer = ByteBuffer.allocate(1024); // буфер 1 КБ

            int bytesRead = channel.read(buffer); // читаємо в буфер
            while (bytesRead != -1) {
                buffer.flip(); // перемикаємо буфер у режим читання
                while (buffer.hasRemaining()) {
                    System.out.print((char) buffer.get());
                }
                buffer.clear(); // очищаємо буфер для наступного читання
                bytesRead = channel.read(buffer);
            }
        }
    }
}

Запис у файл:

try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
     FileChannel channel = file.getChannel()) {

    ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
    channel.write(buffer);
}

NIO.2: відкриття каналу через Files.newByteChannel

import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;

Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
    ByteBuffer buf = ByteBuffer.allocate(256);
    ch.read(buf);
}

Позиціонування (position()) та зміна розміру (truncate())

  • position() — дозволяє дізнатися або встановити поточну позицію у файлі (аналог «курсора»).
  • truncate(long size) — обрізає файл до вказаного розміру.
channel.position(100);   // переміщаємося на 100-й байт
channel.truncate(1024);  // обрізаємо файл до 1 КБ

Прямий і позиційний доступ до файлів

  • Прямий доступ: можна читати/записувати в будь-яке місце файлу, а не лише послідовно.
  • Позиційний доступ: можна читати/записувати дані в конкретну позицію, не змінюючи поточну позицію каналу.
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128);   // читаємо 4 байти з позиції 128, не змінюючи channel.position()

3. ByteBuffer: як це працює

Основні параметри: capacity, limit, position, mark

  • capacity — максимальний розмір буфера (задається під час створення).
  • limit — межа, до якої можна читати/писати (за замовчуванням дорівнює capacity).
  • position — поточна позиція (куди пишемо/звідки читаємо).
  • mark — «закладка», яку можна встановити й потім повернутися до неї.

Життєвий цикл буфера:

  1. Пишемо дані в буфер (наприклад, читаємо з каналу read()).
  2. flip() — перемикаємо буфер у режим читання (position = 0, limit = поточне position).
  3. Читаємо дані з буфера (get()).
  4. clear() — очищаємо буфер для наступного запису (position = 0, limit = capacity).

Приклад:

ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip();                // тепер можна читати
byte value = buffer.get();    // 42
buffer.clear();               // готовий до нового запису

Створення буферів: allocate() vs allocateDirect()

У ByteBuffer є два основні способи створити буфер, і різниця між ними помітна під час роботи. Метод allocate() розміщує буфер у купі JVM: створюється швидко й підходить для більшості завдань, але під час нативного введення-виведення можливі додаткові копіювання між купою та пам’яттю ОС.

Метод allocateDirect() виділяє пам’ять поза межами купи JVM (у «native memory»). Такий буфер дорожчий у створенні й складніший в управлінні, але під час читання/запису великих файлів або в мережевих операціях часто швидший за рахунок відсутності зайвих копіювань.

Ідея проста: якщо важлива продуктивність на великих обсягах — використовуйте «прямі» буфери. Для дрібних і частих операцій накладні витрати на їхнє створення можуть переважити користь.

ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);

4. Високопродуктивні операції: transferTo() і transferFrom()

Методи transferTo() і transferFrom()

У класі FileChannel є два методи, які дозволяють працювати за принципом «нульового копіювання» — transferTo() і transferFrom(). Їхня ідея в тому, що дані можна передавати напряму між файловими каналами або, наприклад, між файлом і мережею. JVM майже не бере участі: операція виконується силами ОС, а буфери всередині Java не зачіпаються.

У результаті копіювання великих файлів працює помітно швидше: менше копіювань, менше перемикань між user space і kernel space, нижче навантаження на процесор.

Приклад: копіювання файлу через zero-copy

import java.nio.channels.FileChannel;
import java.nio.file.*;

public class ZeroCopyExample {
    public static void main(String[] args) throws Exception {
        try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
             FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

            long size = src.size();
            long transferred = src.transferTo(0, size, dst);
            System.out.println("Скопійовано байтів: " + transferred);
        }
    }
}

Коли zero-copy справді працює?

  • Під час копіювання між файлами на одному диску.
  • Під час надсилання файлів мережею (наприклад, через SocketChannel).
  • Коли ОС підтримує zero-copy (Linux, macOS, Windows — підтримують).

Переваги:

  • Мінімум копіювання: дані не проходять через буфери JVM.
  • Висока швидкість: менше перемикань і менше навантаження на CPU.
  • Менше пам’яті: не потрібні великі користувацькі буфери.

Приклад: копіювання файлу «в один рядок»

Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// Усередині може використовувати zero-copy, якщо можливо

5. Типові помилки

Помилка № 1: забули flip() перед читанням із буфера. Після запису в буфер обов’язково викликайте flip(), інакше читання не спрацює як очікується: position/limit лишатимуться в «режимі запису».

Помилка № 2: використання allocateDirect() для дрібних операцій. Direct-буфери доречні для великих обсягів, але для дрібних запитів їх створення невиправдано дороге. За замовчуванням обирайте allocate().

Помилка № 3: не закрили канал. Завжди використовуйте try-with-resources для каналів і потоків, щоб уникнути витоків дескрипторів.

Помилка № 4: плутаєте position/limit/capacity. Перед читанням/записом переконайтеся, у якому режимі перебуває буфер: після запису потрібен flip(), після читання для нового запису — clear() або compact().

Помилка № 5: очікування, що zero-copy «завжди працює». На деяких конфігураціях (інші пристрої/різні файлові системи/особливі прапори) zero-copy може бути недоступний — тоді відбудеться звичайне копіювання, і продуктивність буде іншою.

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