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:
- Zapisujemy dane do bufora (na przykład czytamy z kanału read()).
- flip() — przełączamy bufor w tryb odczytu (position = 0, limit = bieżące position).
- Czytamy dane z bufora (get()).
- 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.
GO TO FULL VERSION