1. Datei stückweise lesen: ByteBuffer und Zeichenkodierung
Heutzutage haben wir selten mit kleinen Textdateien zu tun. Üblicher sind riesige Server-Logs, Reports, CSV-Dateien oder gigabytegroße Datendumps. Daher ist es wichtig, eine Datei nicht einfach nur zu lesen, sondern dies effizient und ohne „Einfrieren“ der Anwendung zu tun.
Der asynchrone Ansatz hilft genau dabei: Er blockiert den Haupt-Thread nicht – sei es UI oder Serverlogik –, ermöglicht das parallele Lesen und Schreiben großer Datenmengen und macht die Anwendung skalierbar, wenn gleichzeitig mit mehreren Dateien gearbeitet werden muss.
Wichtig ist: Asynchrone I/O macht das Laufwerk nicht schneller – Wunder gibt es nicht. Es sorgt lediglich dafür, dass Ihr Programm während der Wartezeit, in der das Laufwerk die Operation ausführt, nicht untätig ist und in der Zwischenzeit andere Aufgaben erledigen kann.
Wie funktioniert asynchrones Lesen?
Der asynchrone Kanal (AsynchronousFileChannel) liest keine Strings, sondern Byte-Blöcke in ein ByteBuffer-Objekt. Das ist, als würden Sie Kisten mit Buchstaben schleppen, nicht einzelne Wörter. Nach dem Lesen müssen Sie diese Bytes – unter Berücksichtigung der Kodierung – in Strings umwandeln!
Beispiel: Asynchrones Lesen einer Datei in Blöcken
Schreiben wir ein einfaches Beispiel für das asynchrone Lesen einer Datei in Blöcken zu 4096 Byte und die Ausgabe des Inhalts auf die Konsole.
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();
// Bytes in String umwandeln (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);
}
}
}
}
Wichtige Punkte:
- Wir lesen die Datei stückweise (pufferweise), nicht am Stück.
- Nach dem Lesen werden die Bytes mit Charset in einen String dekodiert.
- Vergessen Sie nicht buffer.clear() – sonst funktioniert der nächste read nicht!
Warum reicht es nicht, Bytes einfach zu dekodieren?
Das Problem: Eine Zeichenfolge kann zwischen zwei Blöcken „zerschnitten“ werden, besonders bei mehrbyteigen Kodierungen (zum Beispiel "UTF-8"). Ist das letzte Byte im Puffer nur die Hälfte eines Zeichens, beginnt der nächste Block mit dem „Rest“. Ohne spezielle Behandlung bekommen Sie Kauderwelsch oder sogar einen Dekodierfehler.
2. Bytes in Strings umwandeln: Trennungen korrekt behandeln
Das Problem zerschnittener Zeichen
Angenommen, Sie haben die Zeichenfolge "Hallo\nWelt\n", doch der Puffer endet bei "Hall", und "o\nWelt\n" gerät in den nächsten Block. Wenn Sie die Strings einfach zusammenkleben, können Zeichen verloren gehen oder die Zeichenfolge wird fehlerhaft.
Lösung: CharsetDecoder verwenden
Java stellt die Klasse CharsetDecoder bereit, die solche Fälle korrekt behandelt. Er „merkt“ sich nicht dekodierte Bytes und stellt Zeichen an Blockgrenzen korrekt wieder her.
Beispiel für die Verwendung von 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 = ... // Ihre Bytes
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// Jetzt enthält charBuffer korrekt dekodierte Zeichen
In einer realen Aufgabe speichern Sie den „Rest“ zwischen den Lesevorgängen und dekodieren unter Berücksichtigung dieses Rests.
3. Asynchrones Schreiben von Textdateien
Lesen ist nur die halbe Miete. Auch das Schreiben erfolgt in Byte-Blöcken, die zunächst aus Strings gewonnen (kodiert) werden müssen.
Beispiel: Asynchrones Schreiben einer Zeichenkette in eine Datei
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 = "Hallo, Welt!\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);
// Zur Demonstration warten wir auf den Abschluss (normalerweise nicht nötig!)
future.get();
System.out.println("Die Daten wurden asynchron geschrieben.");
}
}
}
Kommentar: In echten asynchronen Szenarien sollten Sie future.get() nicht im Haupt-Thread aufrufen – das macht asynchronen Code wieder synchron. Verwenden Sie besser einen CompletionHandler (siehe vorherige Vorlesung).
4. Praxis: Asynchrones Lesen einer großen Textdatei und Zeilenzählung
Implementieren wir eine praktische Aufgabe: eine große Textdatei asynchron lesen und die Anzahl der Zeilen ("\n") zählen. Das Ergebnis – die Anzahl der Zeilen auf der Konsole ausgeben.
Beispiel mit Verwendung von 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) {
// Datei bis zum Ende gelesen
if (leftover.length() > 0) lines.incrementAndGet();
System.out.println("Zeilen in der Datei: " + 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);
// Zeilen zählen
int last = 0;
int idx;
while ((idx = chunk.indexOf('\n', last)) != -1) {
lines.incrementAndGet();
last = idx + 1;
}
// Rest (Teil der Zeile nach dem letzten \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("Lesefehler: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
// Damit das Programm nicht zu früh beendet wird (nur fürs Beispiel!)
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
}
- Wir verwenden CompletionHandler für wirklich asynchronen Code.
- Nach jedem Lesevorgang wird der Puffer mit CharsetDecoder dekodiert.
- Der Rest einer Zeile, die nicht mit "\n" endet, wird in den nächsten Block übernommen.
- Am Dateiende gilt: Wenn im leftover noch etwas steht, zählt das ebenfalls als Zeile.
- Der Einfachheit halber „schläft“ das Beispiel 2000 ms, damit die asynchrone Operation abgeschlossen wird (in realen Anwendungen ist das nicht nötig – üblicherweise gibt es eine Event-Schleife oder ein UI).
5. Asynchrones Schreiben der Ergebnisse in eine Datei
Nehmen wir an, wir möchten das Ergebnis (zum Beispiel die Anzahl der Zeilen) in eine neue Datei schreiben – ebenfalls asynchron.
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 = "Zeilen in der Datei: 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("Ergebnis asynchron geschrieben!");
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("Schreibfehler: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
}
6. Tipps zum Umgang mit Teilfragmenten und Kodierungen
Teilweise Zeilen zwischen Blöcken
Wenn eine Zeile zwischen zwei Blöcken aufgeteilt ist, versuchen Sie nicht, die Bytes manuell zu „verkleben“! Verwenden Sie CharsetDecoder, der fehlende Bytes sauber behandelt und kein Zeichen verliert.
Arbeiten mit verschiedenen Kodierungen
"UTF-8" ist der Standard für moderne Anwendungen, aber wenn die Datei in einer anderen Kodierung vorliegt (zum Beispiel "Windows-1251" oder "UTF-16"), verwenden Sie das entsprechende Charset:
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
Charset charset = Charset.forName("Windows-1251");
CharsetDecoder decoder = charset.newDecoder();
Verwendung von CharsetDecoder und CharsetEncoder
Wenn Sie Daten stückweise lesen oder schreiben, ist der korrekte Umgang mit der Kodierung entscheidend. Ein Zeichen kann zwischen Blöcken „zerschnitten“ werden; ohne zusätzliche Behandlung entsteht dann ein Byte-Salat.
Um dies zu vermeiden, verwendet man CharsetDecoder und CharsetEncoder.
Beim Lesen rufen Sie decode(ByteBuffer, CharBuffer, endOfInput) auf, beim Schreiben – encode(CharBuffer, ByteBuffer, endOfInput).
Sie sorgen dafür, dass ein Zeichen, das zwischen zwei Blöcken geteilt wurde, trotzdem korrekt zusammengesetzt und verarbeitet wird.
7. Typische Fehler bei der asynchronen Verarbeitung von Textdateien
Fehler Nr. 1: Ignorieren von Zeilenresten. Wenn der „Rest“ einer Zeile nicht zwischen den Blöcken vorgehalten wird, können Teile von Zeilen verloren gehen oder falsch dekodiert werden.
Fehler Nr. 2: Falscher Umgang mit dem Puffer. Wenn Sie nach der Verarbeitung buffer.clear() nicht aufrufen, funktioniert der nächste read nicht oder die Daten sind fehlerhaft.
Fehler Nr. 3: Verwendung einer ungeeigneten Kodierung. Wenn die Bytes nicht mit dem Charset dekodiert werden, das beim Schreiben der Datei verwendet wurde, sind „Kauderwelsch“ oder sogar Fehler möglich.
Fehler Nr. 4: Blockieren des Haupt-Threads. Wenn Sie future.get() oder Thread.sleep() im UI-Thread aufrufen, verlieren Sie den Sinn der Asynchronität. Verwenden Sie CompletionHandler und reaktive Ansätze.
Fehler Nr. 5: Kanal nach Abschluss nicht geschlossen. Schließen Sie den Kanal (channel.close()) immer nach dem Ende aller Operationen – auch im Fehlerfall.
GO TO FULL VERSION