W3docs

Java Character Streams

Text in Java lesen und schreiben mit Reader, Writer, FileReader, FileWriter und Zeichenkodierungen.

Das vorherige Kapitel behandelte Byte Streams — die rohe Schicht, in der alles ein byte ist. Diese Schicht ist für Binärdaten geeignet, nicht aber für Text. Ein UTF-8-Zeichen kann ein, zwei, drei oder vier Bytes umfassen; UTF-16 verwendet Zwei-Byte-Codeeinheiten mit Surrogate-Paaren für alles jenseits der Basic Multilingual Plane; selbst ASCII-Text erfordert irgendwo die Entscheidung „das ist ASCII". InputStream.read() auf Text aufzurufen und das Ergebnis in char zu casten funktioniert nur mit Glück, wenn die Datei ein Byte pro Zeichen hat — und sobald jemand „é", „日" oder „🎉" schreibt, korrumpiert diese glückliche Variante die Daten.

Die Character-Stream-Hierarchie existiert, um diese Dekodierung aus dem Code herauszuhalten. Reader und Writer arbeiten mit char, nicht mit byte. Die Bridge-Klassen — InputStreamReader und OutputStreamWriter — nehmen einen Charset entgegen und führen die Konvertierung durch. Wird der Charset an der Bridge korrekt gesetzt, arbeitet jede darüber liegende Schicht mit dekodiertem Text.

Der Reader-Vertrag

Reader ist das Pendant zu InputStream, mit einem abstrakten Methodenpaar (read(char[], int, int) und close()) sowie darüber liegenden Hilfsmethoden:

int read();                            // next char as int 0..65535, or -1 at end
int read(char[] buf);                  // read up to buf.length chars; return count or -1
int read(char[] buf, int off, int len); // into a slice
String readLine();                       // only on BufferedReader — not on Reader itself
long transferTo(Writer out);             // Java 10+: pipe straight to a sink

Zwei subtile Unterschiede zur Byte-Seite. Erstens ist die Einheit char (eine 16-Bit-UTF-16-Codeeinheit), nicht byte. Zweitens gibt read() 0..65535 für eine Codeeinheit und -1 am Ende des Streams zurück — derselbe Sentinel-Trick wie bei InputStream, aber der gültige Bereich ist größer.

Ein char ist nicht immer ein „Zeichen" — Zeichen außerhalb der Basic Multilingual Plane (U+10000 und höher: die meisten Emoji, alte Schriftsysteme) verwenden zwei UTF-16-Codeeinheiten (ein Surrogate-Paar). Wenn man an char-Grenzen aufteilt (z.B. 100 Zeichen auf einmal liest und in Blöcken verarbeitet), kann man ein Surrogate-Paar auf zwei Lesevorgänge aufteilen. Bei zeilenorientiertem Text ist das selten ein Problem; bei der zeichenweisen Verarbeitung beliebigen Unicodes sollte man in Codepunkten arbeiten (String.codePoints()).

Der Writer-Vertrag

Writer ist das Pendant zu OutputStream:

void write(int c);                          // low 16 bits
void write(char[] buf);
void write(char[] buf, int off, int len);
void write(String s);                        // convenience — encodes a whole String
void write(String s, int off, int len);
Writer append(CharSequence csq);             // chainable: w.append("a").append("b")
void flush();
void close();                                // calls flush() first

write(String) ist die Hilfsmethode, die am häufigsten verwendet wird: Die meisten Text-Ein-/Ausgaben bestehen aus wenigen großen Schreibvorgängen (ein JSON-Body, ein generierter Bericht) statt aus zeichenweiser Ausgabe.

append existiert für die CharSequence-Interoperabilität — StringBuilder implementiert CharSequence, sodass ein Writer das Ziel von Code sein kann, der je nach Flag entweder in einen Writer oder einen StringBuilder schreibt. Es ist dieselbe append-Methode, die StringBuilder selbst per Interface bereitstellt.

Konkrete Character Streams

KlasseWas sie kapselt
FileReader / FileWriterEine Datei auf der Festplatte, als Text dekodiert.
CharArrayReader / CharArrayWriterEin char[] im Arbeitsspeicher.
StringReader / StringWriterEin String/StringBuilder im Arbeitsspeicher.
BufferedReader / BufferedWriterEine gepufferte Sicht auf einen anderen Reader/Writer.
InputStreamReader / OutputStreamWriterBridge-Klassen: ein Reader/Writer über einem darunterliegenden Byte-Stream mit einem Charset.
PrintWriterEin Writer-Decorator, der print, println und printf hinzufügt.

Die Bridge-Klassen sind der strukturelle Dreh- und Angelpunkt der gesamten Hierarchie. Jeder Character Stream, der mit einer Datei, einem Socket oder einer Pipe kommuniziert, ist — im Kern — ein Byte-Stream plus ein Charset. FileReader ist ein dünner Wrapper um InputStreamReader(new FileInputStream(...)); FileWriter entsprechend um OutputStreamWriter(new FileOutputStream(...)).

Die Charset-Falle

Der klassische Java-I/O-Fehler:

// WRONG in any code that might run on more than one machine
try (FileReader in = new FileReader("data.txt")) { ... }
try (FileWriter out = new FileWriter("data.txt")) { ... }

Die Konstruktoren ohne Charset verwenden den Standard-Charset der JVM, der beim Start aus dem OS-Locale ermittelt wird. Auf einem Entwickler-Mac ist das fast immer UTF-8. Auf einem Linux-Server mit C-Locale kann es US-ASCII sein. Unter Windows mit einer englischen Installation ist es Cp1252. Der Fehler „funktioniert auf meinem Mac, kaputt auf dem Produktionsserver" ist genau dieser Konstruktor.

Den Charset explizit angeben:

// Right
try (FileReader in = new FileReader("data.txt", StandardCharsets.UTF_8)) { ... }
try (FileWriter out = new FileWriter("data.txt", StandardCharsets.UTF_8)) { ... }

(Die zweiargumentigen Formen mit einem Charset wurden in Java 11 hinzugefügt. Davor musste man auf die Bridge-Klassen zurückgreifen — new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8) — und die verkettete Decorator-Zeile ist einer der Gründe, warum Files.newBufferedReader(path) eingeführt wurde: Es verwendet seit Java 18 standardmäßig UTF-8 und war vorher immer charset-explizit.)

Die moderne Files-API hat diese Voreinstellung sicherer gemacht:

String text = Files.readString(path);                // UTF-8 by default (Java 18+)
BufferedReader r = Files.newBufferedReader(path);    // UTF-8 by default (always was)

Wer neu anfängt, sollte die Files-Factories verwenden. Wer Legacy-Code mit FileReader/FileWriter anfasst, behebt den Fehler am einfachsten durch Hinzufügen des zweiten Arguments StandardCharsets.UTF_8.

Die Bridge-Klassen direkt

InputStreamReader und OutputStreamWriter werden immer dann benötigt, wenn die Quelle keine Datei ist — ein ZipEntry, ein Socket, ein HTTP-Response-Body, System.in, ein Inflater-umhüllter Stream — und man Text daraus lesen möchte:

// Read text from System.in as UTF-8
try (BufferedReader stdin = new BufferedReader(
        new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
  String line = stdin.readLine();
}

// Write the response of an HttpURLConnection as text
try (BufferedReader resp = new BufferedReader(
        new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
  resp.lines().forEach(System.out::println);
}

Die Struktur ist immer gleich: Byte-Stream → InputStreamReader(stream, charset) → optionaler BufferedReader → eigener Code.

Ein ausgearbeitetes Beispiel: Text in drei Formen

Das folgende Programm schreibt eine kleine UTF-8-Textdatei mit ASCII, akzentuierten Zeichen und einem Mehrbyte-Emoji und liest sie dann auf vier Arten zurück: als String, zeichenweise, zeilenweise über einen BufferedReader und über den Legacy-Konstruktor FileReader(charset). Das Beispiel zeigt außerdem, wie die Bridge-Klassen-Struktur über einen ByteArrayInputStream funktioniert, sodass man sehen kann, wo Reader und InputStream aufeinandertreffen.

java— editable, runs on the server

Was man der Ausgabe entnehmen kann:

  • Die Datei auf der Festplatte (23 Bytes) war größer als content.length() (20). Der String hat length() == 20 (wobei jedes \n gezählt wird und das 🎉-Emoji als zwei UTF-16-Codeeinheiten zählt — das ist, was ein Java-char misst); UTF-8 kodiert das Emoji als vier Bytes und é als zwei, daher ist die Byte-Anzahl größer. In Codepunkten gibt es nur 19 — das Emoji ist ein Codepunkt, aber zwei Chars. Derselbe logische Text ist eine Zahl in Chars, eine andere in Bytes, eine weitere in Codepunkten. Zu wissen, welche man meint, ist die Hälfte aller Charset-Fehler.
  • Die zeichenweise Schleife hat denselben String wieder zusammengesetzt. Die Reader-API hat die UTF-8-Dekodierung übernommen: Ein einzelnes Emoji erscheint als zwei (char) read()-Aufrufe aufgrund von UTF-16-Surrogaten, aber man musste nie über Byte-Grenzen nachdenken.
  • BufferedReader.readLine() lieferte drei Zeilen: hello, café, 🎉 party. Das ist das textorien­tierte Vokabular — zeilenweise, terminatorbewusst (behandelt \n, \r und \r\n) und auf der Bridge-Klasse aufgebaut. Jeder API-Aufruf in diesem und dem nächsten Kapitel reduziert sich letztlich auf „Bytes durch einen Charset dekodieren und Zeichen liefern."
  • Der direkte InputStreamReader(new ByteArrayInputStream(raw), UTF_8)-Block zeigt die strukturelle Form: Byte-Quelle innen, Charset an der Bridge, Character-API außen. Tauscht man ByteArrayInputStream gegen socket.getInputStream() aus, ist der Rest identisch — deshalb konvergieren HTTP- und JDBC-Clients auf dasselbe Idiom.
  • Der letzte Block hat dieselben Bytes mit dem falschen Charset dekodiert. Das akzentuierte é und das Emoji kamen beide als Zeichensalat heraus — der Lehrbuch-Mojibake-Fehler. Die Bytes auf der Festplatte waren in Ordnung; der Charset an der Bridge war falsch. Deshalb ist das explizite Angeben des Charsets die nützlichste Gewohnheit bei Java-Text-I/O.

Was als Nächstes kommt

Sowohl Byte- als auch Character-Streams arbeiten standardmäßig mit Einzeloperationen, und bei einem rohen Datei-Stream ist jeder Aufruf ein Syscall. Das nächste Kapitel, Java Buffered Streams, behandelt die Buffered*-Decorators — einen In-Memory-Puffer zwischen dem Code und dem Betriebssystem — sowie die readLine()-API, die dort zu finden ist.

Übungen

Übung
Warum verursachen `new FileReader(path)` und `new FileWriter(path)` (ohne Charset-Argument) den Fehler 'funktioniert auf meinem Rechner, kaputt auf dem Server'?
Warum verursachen `new FileReader(path)` und `new FileWriter(path)` (ohne Charset-Argument) den Fehler 'funktioniert auf meinem Rechner, kaputt auf dem Server'?
Was this page helpful?