Java Buffered Streams
Java I/O beschleunigen mit gepufferten Streams — BufferedReader, BufferedWriter, BufferedInputStream, BufferedOutputStream.
Die Kapitel zu Byte- und Zeichen-Streams haben die rohen APIs ehrlich beschrieben: Jeder Aufruf von FileInputStream.read() oder FileReader.read() ist ein Syscall. Ein Syscall kostet in der Größenordnung einer Mikrosekunde — für sich allein schnell, in einer engen Schleife katastrophal. Eine 1-MB-Datei Byte für Byte zu lesen entspricht einer Million Syscalls; dieselbe Datei mit einem 8-KB-Puffer sind 128. Der Unterschied in der Wanduhrzeit beträgt zwei bis drei Größenordnungen.
Die Buffered*-Dekoratoren sitzen zwischen Ihrem Code und dem rohen Stream. Sie halten ein byte[] (oder char[]) im Arbeitsspeicher und bedienen read()-Aufrufe daraus — zum Betriebssystem wird erst zugegangen, wenn der Puffer leer ist. Auf der Schreibseite sammeln sie kleine Schreibvorgänge in einem Puffer und übertragen sie erst ans Betriebssystem, wenn der Puffer voll ist oder Sie flush/close aufrufen. Dieselbe API, völlig andere Kosten.
Die vier gepufferten Klassen
| Klasse | Umhüllt |
|---|---|
BufferedInputStream | Einen InputStream. Fügt einen internen byte[]-Puffer hinzu. |
BufferedOutputStream | Einen OutputStream. Fügt einen internen byte[]-Puffer hinzu. |
BufferedReader | Einen Reader. Fügt einen internen char[]-Puffer und die bekannte Methode readLine() hinzu. |
BufferedWriter | Einen Writer. Fügt einen internen char[]-Puffer und eine newLine()-Methode hinzu. |
Alle vier umhüllen jeden Stream des passenden Typs — Datei, Socket, Pipe, In-Memory — nicht nur Datei-Streams:
BufferedInputStream in = new BufferedInputStream(new FileInputStream(path.toFile()));
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(path.toFile()));
BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
BufferedWriter w = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));Die Standard-Puffergröße beträgt 8192 Bytes/Zeichen — gewählt, um gängigen OS-Seitengrößen zu entsprechen. Sie können eine andere Größe an den zweiten Konstruktor übergeben, aber der Standard ist in nahezu allen Fällen ausreichend. Größere Puffer beschleunigen Abläufe nicht linear; sie verbrauchen nur mehr Speicher.
Die moderne API liefert Ihnen diese Dekoratoren bereits fertig zusammengesetzt:
BufferedReader r = Files.newBufferedReader(path); // UTF-8 by default
BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8);
InputStream in = new BufferedInputStream(Files.newInputStream(path));
OutputStream out = new BufferedOutputStream(Files.newOutputStream(path));Files.newBufferedReader / Files.newBufferedWriter umhüllen bereits die Bridge-Klasse mit dem richtigen Zeichensatz und einem BufferedReader/BufferedWriter. Für Text ist das der einzeilige Ersatz für den dreistufigen manuellen Stack.
BufferedReader.readLine()
Der Grund, warum BufferedReader die am häufigsten verwendete Klasse in java.io ist:
String readLine() throws IOException; // a line, terminator stripped, or null at end
Stream<String> lines(); // Java 8+: line streamreadLine erkennt \n, \r und \r\n als Zeilentrennzeichen und gibt die Zeile ohne den Trenner zurück. Es gibt null (nicht einen leeren String, nicht -1) am Streamende zurück — das standardmäßige Read-Line-Idiom:
try (BufferedReader r = Files.newBufferedReader(path)) {
String line;
while ((line = r.readLine()) != null) {
process(line);
}
}r.lines() gibt einen Stream<String> für die funktionale Pipeline-Form zurück. Der Stream besitzt den offenen Reader, daher übernimmt das try-with-resources um den Reader das Schließen — lines() selbst benötigt kein eigenes Close.
Zwei wichtige Hinweise zu readLine(). Erstens alloziert es einen String pro Zeile. Für enge Log-Verarbeitungsschleifen, bei denen Allokierung eine Rolle spielt, ist das niedrigstufigere read(char[]) die bessere Wahl. Zweitens ist eine leere Zeile "" (ein leerer String), nicht null — die Datei endet erst, wenn readLine() null zurückgibt.
BufferedWriter.newLine()
Die entsprechende Komfortmethode auf der Schreibseite:
void newLine() throws IOException; // platform line separator: \n on Unix, \r\n on WindowsnewLine() schreibt das, was die JVM als Zeilentrennzeichen der aktuellen Plattform betrachtet. Das ist ein Feature, wenn Sie Dateien für menschliche Augen auf dem lokalen Rechner erzeugen; es ist ein Bug, wenn Sie Datendateien, Log-Dateien oder etwas erstellen, das für eine andere Maschine bestimmt ist. Das Internet läuft auf \n. Schreiben Sie \n immer explizit, wenn die Ausgabe portabel sein soll:
w.write("line one\n"); // portable
w.newLine(); // platform-dependent: \n on Unix, \r\n on WindowsDieselbe Empfehlung gilt für PrintWriter.println und den %n-Formatbezeichner — sie sind plattformabhängig. Verwenden Sie sie nur, wenn die Ausgabe für den lokalen Einsatz bestimmt ist.
Die Falle "Tail-Puffer nie geflusht"
Das ist der Bug, dem jede Java-Codebasis mindestens einmal begegnet:
// WRONG
BufferedWriter w = Files.newBufferedWriter(path);
w.write("hello");
return; // 'hello' is sitting in the buffer; nothing on diskEin BufferedWriter schiebt Bytes erst dann ans Betriebssystem, wenn entweder der Puffer voll ist oder close() ausgeführt wird. Das Close weglassen und der Tail geht verloren — Files.size(path) ist 0 und Sie haben keine Ahnung warum. Die Lösung ist try-with-resources, ausnahmslos:
try (BufferedWriter w = Files.newBufferedWriter(path)) {
w.write("hello");
} // close() runs here; tail is flushedWenn Sie die Daten vor dem Close auf der Festplatte benötigen — ein Log-Tail-Watcher oder ein anderer Prozess, der die Datei abfragt — rufen Sie flush() explizit auf. Der Puffer wird nicht nach jedem Schreibvorgang automatisch geleert; das ist der Preis für einen Puffer überhaupt.
Mark und Reset
BufferedReader und BufferedInputStream unterstützen beide eine kleine "Vorausschauen und Zurückspulen"-API:
in.mark(1024); // remember this position; allow up to 1024 bytes of lookahead
int b = in.read();
in.reset(); // back to the marked positionDas ist die einzige java.io-API, mit der Sie ein Byte/Zeichen lesen und es anschließend zurücklegen können. Sie bildet die Grundlage für Code, der "die ersten paar Bytes liest, um das Format zu ermitteln" — UTF-8-BOM-Erkennung, Magic-Number-Sniffing, Parser-Übergabe. Ohne Pufferung ist das nicht möglich: Die rohen Streams haben die Bytes nicht mehr, sobald sie gelesen wurden.
Wenn Pufferung nicht hilft
Zwei Fälle, in denen das Hinzufügen eines Buffered*-Dekorators nichts bringt:
- Die Quelle befindet sich bereits im Arbeitsspeicher.
ByteArrayInputStreamundStringReaderbedienenread()bereits aus einembyte[]/Stringim Speicher; es gibt keine Syscalls zu amortisieren. - Sie verwenden
Files.readString,Files.readAllBytes,Files.writeodertransferTo. Diese Aufrufe führen ihre eigene blockweise I/O mit einem großen internen Puffer durch. Diese inBufferedInputStreameinzuwickeln ist redundant — das JDK hat bereits gepuffert.
Der Fall, bei dem Pufferung hilft, ist der ursprüngliche: Sie lesen oder schreiben kleine Blöcke (ein einzelnes Byte, eine einzelne Zeile, einen printf-Aufruf) und die Quelle/das Ziel ist eine echte Datei, ein Socket oder eine Pipe.
Ein Praxisbeispiel: dieselbe Last, mit und ohne
Das folgende Programm kopiert denselben 32-KB-Blob Byte für Byte von einer temporären Datei in eine andere — einmal mit rohem FileInputStream/FileOutputStream, einmal mit BufferedInputStream/BufferedOutputStream, einmal mit transferTo zum Vergleich. Die ausgegebenen Wanduhrzeiten machen die Kosten des fehlenden Puffers sichtbar. Anschließend liest das Beispiel die Zeilen der Datei über einen BufferedReader und demonstriert die "Flush vergessen"-Falle auf der Schreibseite.
Was aus dem Durchlauf mitgenommen werden sollte:
- Die rohe Byte-für-Byte-Kopie war um Größenordnungen langsamer als die gepufferte. Der Schleifenkörper war identisch; die einzige Änderung war das Einwickeln der Datei-Streams in
BufferedInputStream/BufferedOutputStream. Das ist der einzige Grund, warum diese Dekoratoren existieren — dieselbe API, drastisch weniger Syscalls. transferTowar genauso schnell wie die gepufferte Version (oder schneller). Für "Bytes von A nach B kopieren ohne Transformation" isttransferTodie richtige Wahl — es puffert intern, und das JDK hat die Schleife optimiert. Greifen Sie darauf zurück, bevor Sie Ihre eigene schreiben.Files.newBufferedReadergab direkt einenBufferedReaderzurück. Beachten Sie, dass wir nienew BufferedReader(new InputStreamReader(new FileInputStream(...), UTF_8))geschrieben haben — diesen dreistufigen Stack verbirgt die Factory.readLine()kam aus diesem Stack ohne weiteren Aufwand.- Der undichte Writer zeigte
0 bytesvorflush(). Diese Zeichen befanden sich im In-Memory-Puffer, nicht auf der Festplatte. Das Aufrufen vonflush()schob sie heraus; ohne das explizite Flush (oder ein ordnungsgemäßesclose()) wären sie verloren gegangen. Deshalb isttry-with-resources um gepufferte Writer nicht optional — es ist der Vertrag, der den Schreibvorgang sichtbar macht. - Die
BufferedReader.readLine()-Schleife ist die gebräuchlichste Form der Textverarbeitung in Java. Prägen Sie sich die Formwhile ((line = r.readLine()) != null)ein: Die Zuweisung in der Bedingung ist hier idiomatisch, und dasnull-Sentinel (nicht ein leerer String) ist die Endbedingung der Schleife.
Wie es weitergeht
Pufferung löst die Kosten eines Syscalls pro Aufruf, ändert aber nicht, was die Bytes bedeuten. Das nächste Kapitel, Java DataInput and DataOutput Streams, behandelt die Dekoratoren, die Java-Primitive in einem portablen Binärformat lesen und schreiben — die Schicht, mit der Sie einen int in eine Datei schreiben und ihn als int auf einem anderen Betriebssystem zurücklesen können.