CodeGym /Kursy /JAVA 25 SELF /Kanały NIO i ByteBuffer

Kanały NIO i ByteBuffer

JAVA 25 SELF
Poziom 41, Lekcja 1
Dostępny

1. Wprowadzenie do kanałów NIO

W klasycznym Java IO (java.io) wszystko działa według zasady „jeden wątek — jeden plik lub zasób”. Gdy tylko zaczyna się odczyt lub zapis, wątek jest blokowany i czeka na zakończenie operacji. Dla prostych przypadków to wygodne, ale w systemach o dużym obciążeniu takie podejście staje się wąskim gardłem: jeśli połączeń są tysiące, to tysiące wątków spędzają czas na oczekiwaniu.

W NIO (New IO) podejście jest inne. Tutaj I/O może być nieblokujące, a wątek nie musi bezczynnie czekać. Gdy jedne dane jeszcze napływają, może przełączyć się na inne zadanie. To umożliwia obsługę ogromnej liczby połączeń dosłownie kilkoma wątkami.

Różnica widoczna jest także w szczegółach. W „starym” IO praca opiera się na strumieniach, które czytają i zapisują bajty lub znaki, ale zawsze blokują się na czas operacji. W NIO kluczowymi pojęciami stają się kanały (Channels) i bufory (Buffers). Pozwalają one realizować nieblokujące I/O (ważne dla serwerów), a także stosować technikę zero-copy, gdy dane są przekazywane bezpośrednio, z pominięciem zbędnych kopiowań do buforów JVM.

Porównanie: strumienie (Streams) vs kanały (Channels)

Strumienie (InputStream/OutputStream):

  • Czytają/piszą bajty pojedynczo lub całymi tablicami.
  • Brak bezpośredniej kontroli nad pozycją w pliku.
  • Brak wydajnej pracy z bardzo dużymi plikami.

Kanały (Channel):

  • Czytają/piszą dane przez bufory (Buffer).
  • Można sterować pozycją (w tym dostęp losowy).
  • Obsługują asynchroniczność i tryb nieblokujący.
  • Pozwalają używać zero-copy do bardzo szybkiego kopiowania.

2. FileChannel i SeekableByteChannel

Odczyt i zapis danych z użyciem buforów

FileChannel — podstawowy kanał do pracy z plikami. Można go uzyskać z FileInputStream, FileOutputStream lub przez NIO.2 — Files.newByteChannel (zwraca SeekableByteChannel).

Przykład: odczyt pliku przez FileChannel i 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); // bufor 1 kB

            int bytesRead = channel.read(buffer); // czytamy do bufora
            while (bytesRead != -1) {
                buffer.flip(); // przełączamy bufor w tryb odczytu
                while (buffer.hasRemaining()) {
                    System.out.print((char) buffer.get());
                }
                buffer.clear(); // czyścimy bufor przed kolejnym odczytem
                bytesRead = channel.read(buffer);
            }
        }
    }
}

Zapis do pliku:

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: otwarcie kanału przez 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);
}

Pozycjonowanie (position()) i zmiana rozmiaru (truncate())

  • position() — pozwala odczytać lub ustawić bieżącą pozycję w pliku (odpowiednik „kursora”).
  • truncate(long size) — przycina plik do podanego rozmiaru.
channel.position(100);   // przesuwamy się na 100. bajt
channel.truncate(1024);  // przycinamy plik do 1 kB

Dostęp bezpośredni i pozycyjny do plików

  • Dostęp bezpośredni: można czytać/pisać w dowolne miejsce pliku, a nie tylko sekwencyjnie.
  • Dostęp pozycyjny: można czytać/pisać dane w konkretną pozycję, nie zmieniając bieżącej pozycji kanału.
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128);   // czytamy 4 bajty z pozycji 128, nie zmieniając channel.position()

3. ByteBuffer: jak to działa

Podstawowe parametry: capacity, limit, position, mark

  • capacity — maksymalny rozmiar bufora (ustalany przy tworzeniu).
  • limit — granica, do której można czytać/pisać (domyślnie równa capacity).
  • position — bieżąca pozycja (gdzie piszemy/skąd czytamy).
  • mark — „znacznik”, który można ustawić i potem do niego wrócić.

Cykl życia bufora:

  1. Zapisujemy dane do bufora (na przykład czytamy z kanału read()).
  2. flip() — przełączamy bufor w tryb odczytu (position = 0, limit = bieżące position).
  3. Czytamy dane z bufora (get()).
  4. clear() — czyścimy bufor przed następnym zapisem (position = 0, limit = capacity).

Przykład:

ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip();                // teraz można czytać
byte value = buffer.get();    // 42
buffer.clear();               // gotowy na nowy zapis

Tworzenie buforów: allocate() vs allocateDirect()

ByteBuffer ma dwa podstawowe sposoby tworzenia bufora, a różnica między nimi jest zauważalna w praktyce. Metoda allocate() umieszcza bufor na stercie JVM: powstaje szybko i nadaje się do większości zadań, ale przy natywnym I/O możliwe są dodatkowe kopiowania między stertą a pamięcią systemu operacyjnego.

Metoda allocateDirect() alokuje pamięć poza stertą JVM (w „pamięci natywnej”). Taki bufor jest droższy w utworzeniu i trudniejszy w zarządzaniu, ale przy odczycie/zapisie dużych plików lub w operacjach sieciowych bywa szybszy dzięki brakowi zbędnych kopiowań.

Idea jest prosta: jeśli liczy się wydajność przy dużych wolumenach danych — używaj buforów „bezpośrednich”. Dla drobnych i częstych operacji narzut ich tworzenia może przewyższyć zysk.

ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);

4. Operacje wysokowydajne: transferTo() i transferFrom()

Metody transferTo() i transferFrom()

Klasa FileChannel ma dwie metody, które pozwalają pracować w trybie „zerowego kopiowania” — transferTo() i transferFrom(). Ich idea polega na tym, że dane można przesyłać bezpośrednio między kanałami plików lub, na przykład, między plikiem a siecią. JVM prawie w tym nie uczestniczy: operację wykonuje system operacyjny, a bufory w Javie nie są dotykane.

W efekcie kopiowanie dużych plików działa zauważalnie szybciej: mniej kopiowań, mniej przełączeń między przestrzenią użytkownika i jądra, niższe obciążenie procesora.

Przykład: kopiowanie pliku „jedną linijką”

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("Skopiowano bajtów: " + transferred);
        }
    }
}

Kiedy zero-copy faktycznie działa?

  • Przy kopiowaniu między plikami na tym samym dysku.
  • Przy wysyłaniu plików przez sieć (np. przez SocketChannel).
  • Gdy system operacyjny wspiera zero-copy (Linux, macOS, Windows — wspierają).

Zalety:

  • Minimum kopiowania: dane nie przechodzą przez bufory JVM.
  • Wysoka szybkość: mniej przełączeń i mniejsze obciążenie CPU.
  • Mniej pamięci: nie są potrzebne duże bufory użytkownika.

Przykład: kopiowanie pliku „jedną linijką”

Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// W środku może użyć zero-copy, jeśli to możliwe

5. Typowe błędy

Błąd nr 1: zapomniano o flip() przed odczytem z bufora. Po zapisaniu do bufora koniecznie wywołaj flip(), w przeciwnym razie odczyt nie zadziała zgodnie z oczekiwaniami: position/limit pozostaną w „trybie zapisu”.

Błąd nr 2: użycie allocateDirect() do drobnych operacji. Bufory direct są dobre dla dużych wolumenów, ale dla drobnych żądań ich tworzenie bywa nieuzasadnione. Domyślnie wybieraj allocate().

Błąd nr 3: nie zamknięto kanału. Zawsze używaj try-with-resources dla kanałów i strumieni, aby uniknąć wycieków deskryptorów.

Błąd nr 4: mylenie position/limit/capacity. Przed odczytem/zapisem upewnij się, w jakim trybie jest bufor: po zapisie potrzebny jest flip(), po odczycie dla nowego zapisu — clear() lub compact().

Błąd nr 5: oczekiwanie, że zero-copy „zawsze działa”. W niektórych konfiguracjach (inne urządzenia/różne systemy plików/szczególne flagi) zero-copy może być niedostępne — wtedy nastąpi zwykłe kopiowanie, a wydajność będzie inna.

1
Zadanie
JAVA 25 SELF, poziom 41, lekcja 1
Niedostępne
Chirurgia danych: Rzeźbienie binarnego zwoju ✂️
Chirurgia danych: Rzeźbienie binarnego zwoju ✂️
1
Zadanie
JAVA 25 SELF, poziom 41, lekcja 1
Niedostępne
Cyfrowy kurier: Natychmiastowe dostarczanie kopii 📦
Cyfrowy kurier: Natychmiastowe dostarczanie kopii 📦
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION