1. Fehler mit Puffern: ByteBuffer, Position und Limit
Asynchrone Lese- und Schreibmethoden arbeiten mit einem Objekt ByteBuffer. Im Unterschied zu gewöhnlichen Arrays hat ein Puffer einen „inneren Cursor“ – die Position (position) und das Limit (limit), die festlegen, welche Bytes gelesen oder geschrieben werden. Wenn man diese Eigenschaften falsch verwaltet, erhält man nicht das erwartete Ergebnis oder bricht die Logik komplett.
Wie sieht das im Code aus?
Beispiel für eine falsche Verwendung:
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
// Ups! Wir versuchen sofort, eine Zeichenkette aus dem Puffer zu lesen:
String str = new String(buf.array()); // Das ist falsch!
// ...
}
// ...
});
Was ist falsch daran?
- Nach dem Lesen befindet sich der Puffer im „Schreibmodus“: position zeigt auf das Ende der gelesenen Daten, und limit auf die Größe des Puffers. Wenn Sie sofort aus dem Puffer lesen, erhalten Sie eine Menge Müll (alle 1024 Bytes, selbst wenn tatsächlich nur 10 gelesen wurden).
- Der Aufruf buf.array() gibt das gesamte interne Array zurück und nicht nur den gelesenen Teil. Außerdem wirft array() bei direkten Puffern (allocated via ByteBuffer.allocateDirect) eine UnsupportedOperationException.
Wie macht man es richtig?
Bevor Sie Daten aus dem Puffer lesen, müssen Sie buffer.flip() aufrufen – dadurch wird der Puffer in den „Lesemodus“ versetzt:
public void completed(Integer result, ByteBuffer buf) {
buf.flip(); // Jetzt position = 0, limit = Anzahl der gelesenen Bytes
String str = StandardCharsets.UTF_8.decode(buf).toString();
// ... Verarbeitung der Zeichenkette
}
Wiederverwendung des Puffers
Wenn Sie den Puffer für die nächste Operation wiederverwenden möchten, vergessen Sie nicht, nach der Verarbeitung der Daten buffer.clear() oder buffer.compact() aufzurufen:
- clear() – setzt die Grenzen vollständig zurück: position=0, limit=capacity(), die alten Daten gelten als Müll.
- compact() – verschiebt ungelesene Bytes an den Anfang des Puffers und bereitet ihn für weiteres Schreiben vor.
Hinweis: Wenn result gleich -1 ist, wurde das Dateiende erreicht – es erfolgt keine weitere Verarbeitung.
2. Paralleler Zugriff: Race Conditions und Inkonsistenzen
AsynchronousFileChannel ermöglicht es, mehrere Operationen parallel zu starten. Wenn Sie diesen Prozess nicht steuern, erhalten Sie leicht „korrupte“ Daten oder Programmabstürze.
Problem 1: Gleichzeitiges Lesen in denselben Puffer
// Zwei gleichzeitige Lesevorgänge in denselben Puffer
channel.read(buffer, 0, buffer, handler1);
channel.read(buffer, 1024, buffer, handler2);
Beide Lesevorgänge schreiben in ein und denselben Puffer! Wenn beide fast gleichzeitig abgeschlossen sind, ist der Inhalt des Puffers unvorhersehbar.
Problem 2: Gleichzeitiges Schreiben in dieselbe Datei
Wenn zwei Threads gleichzeitig in denselben Bereich der Datei schreiben, hängt das Ergebnis davon ab, welche Operation zuerst fertig wird. Das ist eine klassische race condition, die zu beschädigten Daten führen kann.
Wie vermeiden?
- Verwenden Sie für jede asynchrone Operation einen separaten Puffer (ByteBuffer) – so greifen sich Threads nicht gegenseitig in den Speicher.
- Starten Sie keine parallelen Schreibvorgänge auf denselben Dateibereich; trennen Sie die Offsets oder synchronisieren Sie den Zugriff.
- Wenn eine strikte Reihenfolge wichtig ist, starten Sie die nächste Operation erst nach Abschluss der vorherigen – z. B. aus completed(...) des CompletionHandler.
3. Ressourcenlecks: Kanal nicht geschlossen
Ein asynchroner Kanal ist eine Systemressource. Wenn Sie ihn nicht schließen (channel.close()), bleibt die Datei im System „belegt“, es können Speicherlecks auftreten und unter Windows – zusätzlich eine Sperrung der Datei für andere Programme.
Typischer Fehler:
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, ...);
// ... Operation gestartet
// channel.close() nach Abschluss aller Operationen vergessen!
Wie macht man es richtig?
Verwenden Sie try-with-resources und warten Sie unbedingt auf den Abschluss aller Operationen, bevor Sie den Block verlassen:
CountDownLatch latch = new CountDownLatch(1);
try (AsynchronousFileChannel channel =
AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(4096);
channel.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
@Override public void completed(Integer r, ByteBuffer b) {
// Verarbeitung...
latch.countDown();
}
@Override public void failed(Throwable ex, ByteBuffer b) {
ex.printStackTrace();
latch.countDown();
}
});
latch.await(); // Auf den Abschluss der asynchronen Operation warten
}
// Schließen erfolgt automatisch
4. Ausnahmebehandlung: Fehler im CompletionHandler nicht ignorieren
In asynchronem Code werden Fehler nicht im Haupt-Thread „ausgelöst“ – sie landen in der Methode failed(...) des Interfaces CompletionHandler. Wenn Sie sie nicht implementieren oder leer lassen, verschwinden Fehler einfach und das Programm verhält sich merkwürdig.
Beispiel für einen „unsichtbaren“ Fehler:
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
// ... Ergebnis verarbeiten
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
// Ups, leer! Der Fehler ging verloren
}
});
Wie macht man es richtig?
@Override
public void failed(Throwable exc, ByteBuffer buf) {
System.err.println("Fehler beim Lesen der Datei: " + exc.getMessage());
exc.printStackTrace();
// Möglich: Kanal schließen, Metriken aktualisieren, Benutzer benachrichtigen usw.
}
5. Verlust von Referenzen auf Future/CompletionHandler
Wenn Sie eine asynchrone Operation über Future gestartet haben, die Referenz aber nicht gespeichert haben, können Sie die Operation nicht abbrechen oder auf ihren Abschluss warten. Ähnlich: Wenn Sie CompletionHandler verwenden, aber den Abschluss aller Operationen nicht synchronisieren, kann das Programm zu früh beendet werden.
Beispiel:
channel.read(buffer, 0, buffer, handler); // handler – anonym, wird nirgends gespeichert
// Das Programm beendete sich sofort, ohne das Ende des Lesens abzuwarten
Wie macht man es richtig?
- Bei der Arbeit mit Future<Integer>: Referenz speichern und future.get() oder bei Bedarf future.cancel(true) verwenden.
- Bei Verwendung von CompletionHandler: Synchronisationsmechanismen (CountDownLatch, Semaphore) einsetzen und den Abschluss aller Operationen vor dem Schließen des Kanals/Programms korrekt abwarten.
6. Fehler bei Zeichencodierungen
Das Lesen und Schreiben von Textdateien erfordert den korrekten Umgang mit Codierungen. Wenn man Bytes „auf die Schnelle“ als Strings liest, kann es zu Kauderwelsch kommen oder Daten gehen verloren, insbesondere beim stückweisen Lesen einer Datei.
Problem:
// Wir lesen die Datei in 1024-Byte-Blöcken und wandeln dann in einen String um
String chunk = new String(buffer.array(), "UTF-8");
Wenn ein Zeichen über zwei Puffer aufgeteilt ist (z. B. verbleibt ein Byte eines UTF-8-Zeichens am Ende eines Puffers und die restlichen am Anfang des nächsten), erhalten Sie fehlerhafte Zeichen oder einen Dekodierungsfehler.
Wie macht man es richtig? Verwenden Sie CharsetDecoder und bewahren Sie „Reste“ unvollständiger Zeichen zwischen den Lesevorgängen auf:
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
ByteBuffer byteBuf = ByteBuffer.allocate(4096);
CharBuffer charBuf = CharBuffer.allocate(4096);
// Bei jedem completed(...):
byteBuf.flip();
CoderResult cr = decoder.decode(byteBuf, charBuf, false); // false – dies ist nicht das Ende der Eingabe
if (cr.isError()) {
cr.throwException();
}
byteBuf.compact();
charBuf.flip();
String text = charBuf.toString();
charBuf.clear();
// Wenn keine Eingabe mehr erfolgt:
decoder.flush(charBuf);
7. Vorzeitiges Beenden des Programms
Asynchrone Operationen werden in anderen Threads ausgeführt. Wenn der Haupt-Thread früher endet als alle Operationen, wird das Programm beendet, ohne das Ergebnis abzuwarten.
Beispiel:
// Asynchrones Lesen starten
channel.read(buffer, 0, buffer, handler);
// Der Haupt-Thread endet sofort – das Programm wurde beendet, die Operation konnte nicht mehr fertiggestellt werden
Wie macht man es richtig?
Verwenden Sie CountDownLatch, Semaphore oder zumindest Thread.sleep(...) (für Demos), um auf den Abschluss aller Operationen zu warten:
CountDownLatch latch = new CountDownLatch(1);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override public void completed(Integer result, ByteBuffer buf) { /* ... */ latch.countDown(); }
@Override public void failed(Throwable exc, ByteBuffer buf) { /* ... */ latch.countDown(); }
});
latch.await(); // Auf den Abschluss der Operation warten
8. Suboptimale Integration mit ExecutorService
AsynchronousFileChannel erlaubt es, einen eigenen ExecutorService für die Ereignisverarbeitung anzugeben. Wenn Sie einen Pool mit wenigen Threads oder sogar nur einem Thread übergeben, werden alle Operationen sequenziell statt parallel ausgeführt. Ist der Pool zu groß, entstehen unnötige Overheads durch Kontextwechsel.
Beispiel:
ExecutorService executor = Executors.newSingleThreadExecutor();
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, options, executor);
// Alle asynchronen Operationen sind im Grunde synchron!
Wie macht man es richtig?
- Passen Sie die Poolgröße an die reale Last und die Anzahl gleichzeitiger Operationen an.
- Für die meisten Aufgaben eignen sich ForkJoinPool.commonPool() oder Executors.newCachedThreadPool().
- Beachten Sie, dass der übergebene ExecutorService die Callback-Aufrufe (completed/failed) steuert, nicht das eigentliche Platten-I/O.
GO TO FULL VERSION