CodeGym /Kurse /JAVA 25 SELF /Asynchrone Verarbeitung von Textdateien

Asynchrone Verarbeitung von Textdateien

JAVA 25 SELF
Level 56, Lektion 2
Verfügbar

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);
            long position = 0;
            int bytesRead;
            Future<Integer> future = channel.read(buffer, position);

            while ((bytesRead = 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 += bytesRead;
                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.
  • Nach der Verarbeitung geben wir den Puffer frei: buffer.clear(), wenn alle Bytes bereits dekodiert sind, oder buffer.compact(), wenn am Blockende Bytes eines unvollständigen Zeichens übrig sein können.
  • Dieses Beispiel scheitert an Multibyte-Zeichen: Charset.decode() ersetzt ein an der Blockgrenze „zerschnittenes“ Zeichen durch „�“. Warum das so ist und wie man es behebt, sehen Sie weiter unten.

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. Die Bytes merkt er sich allerdings nicht selbst: Steht am Ende des Puffers der Anfang eines Multibyte-Zeichens, lässt decode() diese Bytes im ByteBuffer – die Position des Puffers bleibt direkt davor stehen. Ihre Aufgabe ist es, sie nicht zu verlieren: Rufen Sie nach dem Dekodieren buffer.compact() auf, nicht buffer.clear(). Die Methode compact() verschiebt die nicht dekodierten Bytes an den Anfang des Puffers, und der nächste Lesevorgang schreibt die neuen direkt dahinter.

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

Den „Rest“ der Bytes müssen Sie nicht separat aufbewahren – nach compact() hält ihn der ByteBuffer selbst. Ist die Eingabe zu Ende, rufen Sie decode ein letztes Mal mit endOfInput = true auf und danach decoder.flush(charBuffer) – so verlangt es der Vertrag von CharsetDecoder. Und prüfen Sie das Ergebnis von decode: Bei fehlerhaften Bytes meldet die Methode einen Fehler, statt weiterzukommen.

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.CharacterCodingException;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CoderResult;
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) {
                boolean endOfInput = (result == -1); // -1: Datei bis zum Ende gelesen
                buffer.flip();
                // +1 – Reserve für ein Zeichen, das der Decoder bis zum Ende der Eingabe „zurückhalten“ kann
                CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining() + 1);
                CoderResult cr = decoder.decode(buffer, charBuffer, endOfInput);
                if (!cr.isError() && endOfInput) {
                    cr = decoder.flush(charBuffer); // nach dem letzten decode(..., true)
                }
                if (cr.isError()) {
                    // Fehlerhafte Bytes: Ohne diese Prüfung kommt die Schleife „compact → read → decode“ nicht voran
                    try { cr.throwException(); } catch (CharacterCodingException e) { failed(e, attachment); }
                    return;
                }
                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));
                }

                if (endOfInput) {
                    // Auch die letzte Zeile ohne '\n' zählt als Zeile
                    if (leftover.length() > 0) lines.incrementAndGet();
                    System.out.println("Zeilen in der Datei: " + lines.get());
                    try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
                    return;
                }
                // Nicht dekodierte Bytes (der Anfang eines „zerschnittenen“ Zeichens) sind in buffer geblieben.
                // compact() verschiebt sie an den Pufferanfang, der nächste Lesevorgang schreibt die neuen Bytes direkt dahinter.
                buffer.compact();
                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.
  • Nicht dekodierte Bytes am Blockende bleiben in buffer, und compact() bewahrt sie für den nächsten Lesevorgang auf. leftover dagegen ist der Rest einer Zeile (bereits Zeichen): Er wird nur zum Zählen der Zeilen gebraucht.
  • Wenn read -1 zurückgibt, führen wir ein letztes decode(..., true) und flush() aus. Sind die Bytes fehlerhaft, liefert decode einen Fehler – wir reichen ihn an failed() weiter, sonst dreht sich die Leseschleife endlos im Kreis.
  • 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 ein Zeichen zwischen zwei Blöcken aufgeteilt ist, versuchen Sie nicht, die Bytes manuell zu „verkleben“! CharsetDecoder hält vor dem unvollständigen Zeichen an, und buffer.compact() bewahrt seine Bytes bis zum nächsten Lesevorgang auf.

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).

Der Decoder hält vor einem unvollständigen Zeichen an und lässt dessen Bytes im Eingabepuffer. Zusammensetzen lässt sich das Zeichen nur, wenn Sie diese Bytes aufbewahren (compact()) und an den nächsten Aufruf von decode übergeben.

7. Typische Fehler bei der asynchronen Verarbeitung von Textdateien

Fehler Nr. 1: Verlust nicht dekodierter Bytes zwischen Blöcken. Wenn Sie nach decode buffer.clear() aufrufen, geht der Anfang eines „zerschnittenen“ Multibyte-Zeichens verloren: Der nächste Block beginnt mitten im Zeichen, und der Decoder meldet einen Fehler oder liefert Zeichensalat. Rufen Sie buffer.compact() auf.

Fehler Nr. 2: Falscher Umgang mit dem Puffer. Wenn Sie nach der Verarbeitung weder buffer.clear() noch buffer.compact() 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.

1
Aufgabe
JAVA 25 SELF, Level 56, Lektion 2
Gesperrt
Sekretär-Sprinter: asynchrone Zeilenanzahl in einem riesigen Bericht 📊
Sekretär-Sprinter: asynchrone Zeilenanzahl in einem riesigen Bericht 📊
1
Aufgabe
JAVA 25 SELF, Level 56, Lektion 2
Gesperrt
Automatisierte Datenfabrik: Lesen, Verarbeiten und asynchrone Ausgabe 🤖
Automatisierte Datenfabrik: Lesen, Verarbeiten und asynchrone Ausgabe 🤖
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION