1. Odczyt pliku partiami: ByteBuffer i kodowanie
Dziś rzadko mamy do czynienia z małymi plikami tekstowymi. Zazwyczaj to ogromne logi serwerów, raporty, pliki CSV lub gigabajtowe zrzuty danych. Dlatego ważne jest nie tylko czytać plik, ale robić to wydajnie i bez „zawieszania” aplikacji.
Podejście asynchroniczne właśnie w tym pomaga: nie blokuje głównego wątku — czy to interfejs użytkownika, czy logika serwerowa — pozwala równolegle czytać i zapisywać duże ilości danych i sprawia, że aplikacja jest skalowalna, gdy trzeba pracować jednocześnie z kilkoma plikami.
Najważniejsze: asynchroniczny I/O nie przyspiesza samego dysku — cudów nie ma. Po prostu pozwala Twojemu programowi nie bezczynnie czekać, gdy dysk wykonuje operację, lecz w tym czasie zajmować się innymi zadaniami.
Jak działa asynchroniczny odczyt?
Asynchroniczny kanał (AsynchronousFileChannel) czyta nie wiersze, lecz bloki bajtów do obiektu ByteBuffer. To tak, jakbyś przenosił pudła z literami, a nie pojedyncze słowa. Po odczycie musisz zamienić te bajty na ciągi znaków — z uwzględnieniem kodowania!
Przykład: asynchroniczny odczyt pliku blokami
Napiszmy prosty przykład odczytu asynchronicznego pliku blokami po 4096 bajtów i wypisania zawartości na konsolę.
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncTextReadExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(4096);
int position = 0;
Future<Integer> future = channel.read(buffer, position);
while (future.get() > 0) {
buffer.flip();
// Przekształcamy bajty w łańcuch znaków (UTF-8)
String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.print(chunk);
buffer.clear();
position += chunk.getBytes(StandardCharsets.UTF_8).length;
future = channel.read(buffer, position);
}
}
}
}
Ważne punkty:
- Czytamy plik partiami (do bufora), a nie w całości.
- Po odczycie bajty są dekodowane do łańcucha znaków przy użyciu Charset.
- Pamiętaj o buffer.clear() — w przeciwnym razie następny read nie zadziała!
Dlaczego samo dekodowanie bajtów nie wystarcza?
Problem w tym, że znak może „rozpaść się” między dwoma blokami, zwłaszcza jeśli używane jest wielobajtowe kodowanie (np. "UTF-8"). Jeśli ostatni bajt w buforze to połowa znaku, to następny blok zacznie się od „reszty” znaku. Bez specjalnej obsługi otrzymasz „krzaczki” albo nawet błąd dekodowania.
2. Przekształcanie bajtów w znaki: obsługa rozcięć
Problem rozcięcia wierszy
Załóżmy, że masz łańcuch "Witaj\nŚwiat\n", a bufor skończył się na "Wit", a "aj\nŚwiat\n" trafiło do kolejnego bloku. Jeśli po prostu skleisz łańcuchy, możesz zgubić znaki lub otrzymać niepoprawny łańcuch.
Rozwiązanie: użyć CharsetDecoder
Java udostępnia klasę CharsetDecoder, która potrafi poprawnie obsługiwać takie przypadki. „Zapamiętuje” niedekodowane bajty i poprawnie odtwarza znaki na styku bloków.
Przykład użycia CharsetDecoder
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.CharBuffer;
import java.nio.ByteBuffer;
CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
ByteBuffer buffer = ... // twoje bajty
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// Teraz charBuffer zawiera poprawnie zdekodowane znaki
W realnym zadaniu będziesz przechowywać „resztę” między odczytami i dekodować z jej uwzględnieniem.
3. Asynchroniczny zapis plików tekstowych
Odczyt to tylko połowa pracy. Zapis również odbywa się blokami bajtów, które najpierw trzeba uzyskać z łańcuchów znaków (zakodować).
Przykład: asynchroniczny zapis łańcucha do pliku
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
public class AsyncTextWriteExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("output.txt");
String text = "Witaj, świecie!\n";
ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
Future<Integer> future = channel.write(buffer, 0);
// Dla demonstracji poczekamy na zakończenie (zwykle nie trzeba tego robić!)
future.get();
System.out.println("Dane zapisano asynchronicznie.");
}
}
}
Komentarz: W rzeczywistych scenariuszach asynchronicznych nie należy wywoływać future.get() w wątku głównym — zamienia to kod asynchroniczny w synchroniczny. Lepiej użyć CompletionHandler (zob. poprzedni wykład).
4. Praktyka: asynchroniczny odczyt dużego pliku tekstowego i zliczanie wierszy
Zaimplementujmy praktyczne zadanie: asynchronicznie odczytać duży plik tekstowy i policzyć liczbę wierszy ("\n"). Wynik — wypisać liczbę wierszy na konsolę.
Przykład z użyciem CompletionHandler
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.CharBuffer;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;
public class AsyncLineCounter {
public static void main(String[] args) throws IOException {
Path path = Path.of("bigfile.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(4096);
AtomicLong position = new AtomicLong(0);
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
StringBuilder leftover = new StringBuilder();
AtomicLong lines = new AtomicLong(0);
channel.read(buffer, position.get(), null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) {
if (result == -1) {
// Plik odczytany do końca
if (leftover.length() > 0) lines.incrementAndGet();
System.out.println("Liczba wierszy w pliku: " + lines.get());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
return;
}
buffer.flip();
CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining());
decoder.decode(buffer, charBuffer, false);
charBuffer.flip();
String chunk = leftover.toString() + charBuffer.toString();
leftover.setLength(0);
// Liczymy wiersze
int last = 0;
int idx;
while ((idx = chunk.indexOf('\n', last)) != -1) {
lines.incrementAndGet();
last = idx + 1;
}
// Reszta (część wiersza po ostatnim \n)
if (last < chunk.length()) {
leftover.append(chunk.substring(last));
}
buffer.clear();
position.addAndGet(result);
channel.read(buffer, position.get(), null, this);
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("Błąd odczytu: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
// Żeby program nie zakończył się zbyt wcześnie (tylko do przykładu!)
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
}
- Używamy CompletionHandler do naprawdę asynchronicznego kodu.
- Po każdym odczycie bufor jest dekodowany przy użyciu CharsetDecoder.
- Pozostała część wiersza, który nie kończy się na "\n", jest przenoszona do następnego bloku.
- Po zakończeniu pliku, jeśli coś zostało w leftover, to również liczymy jako wiersz.
- Dla prostoty przykład „usypia” na 2000 ms, aby operacja asynchroniczna się zakończyła (w rzeczywistych aplikacjach nie jest to potrzebne — zwykle istnieje pętla główna lub UI).
5. Asynchroniczny zapis wyników do pliku
Załóżmy, że chcemy zapisać wynik (np. liczbę wierszy) do nowego pliku — również asynchronicznie.
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncWriteResult {
public static void main(String[] args) throws IOException {
String result = "Liczba wierszy w pliku: 12345\n";
ByteBuffer buffer = ByteBuffer.wrap(result.getBytes(StandardCharsets.UTF_8));
Path path = Path.of("result.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
path, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
channel.write(buffer, 0, null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer written, Object attachment) {
System.out.println("Wynik zapisano asynchronicznie!");
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("Błąd zapisu: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
}
6. Wskazówki dotyczące obsługi danych częściowych i kodowań
Częściowe wiersze między blokami
Jeśli wiersz jest podzielony między dwa bloki, nie próbuj „sklejać” bajtów ręcznie! Użyj CharsetDecoder, który starannie obsłuży brakujące bajty i nie zgubi żadnego znaku.
Praca z różnymi kodowaniami
"UTF-8" to standard dla nowoczesnych aplikacji, ale jeśli plik ma inne kodowanie (np. "Windows-1251" lub "UTF-16"), użyj odpowiedniego Charset:
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
Charset charset = Charset.forName("Windows-1251");
CharsetDecoder decoder = charset.newDecoder();
Użycie CharsetDecoder i CharsetEncoder
Gdy czytasz lub zapisujesz dane partiami, ważna jest poprawna obsługa kodowania. Znak może „rozpaść się” między blokami i bez dodatkowej obsługi powstanie „sałatka” z bajtów.
Aby temu zapobiec, używa się CharsetDecoder i CharsetEncoder.
Przy odczycie wywołuje się decode(ByteBuffer, CharBuffer, endOfInput), a przy zapisie — encode(CharBuffer, ByteBuffer, endOfInput).
Dbają o to, by nawet jeśli znak został rozdzielony między dwa bloki, został mimo to złożony i poprawnie przetworzony.
7. Typowe błędy przy asynchronicznym przetwarzaniu plików tekstowych
Błąd nr 1: Ignorowanie pozostałości wiersza. Jeśli nie przechowujesz „ogona” wiersza między blokami, część wierszy może zginąć albo zostać niepoprawnie zdekodowana.
Błąd nr 2: Nieprawidłowa obsługa bufora. Zapomniano wywołać buffer.clear() po przetworzeniu — następny read nie zadziała lub dane będą niepoprawne.
Błąd nr 3: Użycie niewłaściwego kodowania. Jeśli bajty są dekodowane innym Charset niż ten, który był użyty przy zapisie pliku, możliwe są „krzaczki” lub nawet błędy.
Błąd nr 4: Blokowanie wątku głównego. Jeśli wywołujesz future.get() lub Thread.sleep() w wątku UI, tracisz sens asynchroniczności. Używaj CompletionHandler i podejść reaktywnych.
Błąd nr 5: Niezamknięty kanał po zakończeniu. Zawsze zamykaj kanał (channel.close()) po zakończeniu wszystkich operacji, nawet jeśli wystąpił błąd.
GO TO FULL VERSION