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 — «закладка», яку можна встановити й потім повернутися до неї.
Життєвий цикл буфера:
- Пишемо дані в буфер (наприклад, читаємо з каналу read()).
- flip() — перемикаємо буфер у режим читання (position = 0, limit = поточне position).
- Читаємо дані з буфера (get()).
- 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 може бути недоступний — тоді відбудеться звичайне копіювання, і продуктивність буде іншою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ