
Nachdem es im ersten Teil der Serie um das Lesen von Dateien in Java ging, stelle ich dir in diesem zweiten Teil die korrespondierenden Methoden zum Schreiben von kleinen und großen Dateien vor.
Im Einzelnen geht es um folgende Fragen:
- Wie schreibt man am einfachsten einen String (oder eine Liste von Strings) in eine Textdatei?
- Wie schreibt man ein Byte-Array in eine Binärdatei?
- Wie hängt man Daten an eine bestehende Datei an?
- Wie schreibt man beim Verarbeiten von großen Datenmengen diese direkt in Dateien (ohne zunächst den kompletten Inhalt der Datei im Speicher aufbauen zu müssen)?
- Wann verwendet man
FileWriter,FileOutputStream,OutputStreamWriter,BufferedOutputStreamundBufferedWriter? - Wann verwendet man
Files.newOutputStream()undFiles.newBufferedWriter()?
Bereits im ersten Teil angesprochen habe ich das Thema Betriebssystem-Unabhängigkeit, d. h. was bei Zeichenkodierungen, Zeilenumbrüchen und Pfadnamen zu beachten ist.
Wie schreibt man in Java am einfachsten in eine Datei?
Bis einschließlich Java 6 gab es keine einfache Möglichkeit, Dateien zu schreiben. Man musste einen FileOutputStream oder einen FileWriter öffnen, diesen ggf. noch mit einem BufferedOutputStream oder BufferedWriter wrappen, in diesen schreiben, und anschließend – auch im Fehlerfall – alle Streams wieder schließen.
In Java 7 kam mit der „NIO.2 File API“ (NIO steht für New I/O) die Utility-Klasse java.nio.file.Files hinzu. Diese enthält Methoden, mit denen ein Byte-Array oder eine Liste von Strings – und seit Java 11 auch ein einzelner String – mit einem einzigen Kommando in eine Datei geschrieben werden kann.
Schreiben eines Byte-Arrays in eine Binärdatei
Ein Byte-Array lässt sich mit folgendem Kommando in eine Datei schreiben:
String fileName = ...;
byte[] bytes = ...;
Files.write(Path.of(fileName), bytes);
Die Methode erwartet an erster Stelle ein Path-Objekt. Dieses beschreibt einen Datei- oder Verzeichnisnamen und bietet Hilfsmethoden, um solche Namen zu konstruieren. Im Beispiel wird die statische – seit Java 11 verfügbare – Methode Path.of() verwendet, um ein Path-Objekt aus einem Dateinamen zu erstellen.
Existiert die Datei bereits, überschreibt Files.write() sie; existiert sie nicht, wird sie angelegt. Wie du stattdessen an eine Datei anhängst, zeige ich dir weiter unten.
Schreiben eines Strings in eine Textdatei
Ebenso einfach lässt sich – allerdings erst seit Java 11 – ein String in eine Datei schreiben:
String fileName = ...;
String text = ...;
Files.writeString(Path.of(fileName), text);
Schreiben einer Liste von Strings in eine Textdatei
Oft schreibt man nicht einen einzelnen String in eine Textdatei, sondern mehrere in Form von Zeilen. Mit folgendem Kommando schreibst du eine String-Liste (bzw. genauer gesagt ein Iterable<? extends CharSequence>) in eine Textdatei:
String fileName = ...;
List<String> lines = ...;
Files.write(Path.of(fileName), lines);
Hinter jeder Zeile schreibt die Methode den Zeilentrenner des Betriebssystems. Die gezeigte Variante ohne Angabe einer Zeichenkodierung gibt es seit Java 8; Java 7 kannte nur die Variante mit Charset-Parameter, zu der ich im Kapitel „Zeichenkodierungen“ komme.
An eine bestehende Datei anhängen
Alle drei Files-Methoden überschreiben eine vorhandene Datei. Um stattdessen an ihr Ende zu schreiben, übergibst du die Option StandardOpenOption.APPEND – und dazu CREATE, damit die Datei angelegt wird, falls es sie noch nicht gibt:
Files.write(Path.of(fileName), lines,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
Files.writeString(Path.of(fileName), text,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
Mit APPEND allein würde Files.write() bei einer fehlenden Datei eine NoSuchFileException werfen. Dieselben Optionen akzeptieren auch Files.newOutputStream() und Files.newBufferedWriter(), die du weiter unten kennenlernst.
Die klassischen Klassen haben dafür einen Konstruktorparameter: new FileWriter(fileName, true) und new FileOutputStream(fileName, true) hängen an die Datei an, statt sie zu überschreiben.
Schreiben von einem String-Stream in eine Textdatei
Es gibt keine 1:1-Entsprechung der Stream-erzeugenden Methode Files.lines(), also keine Methode, die direkt aus einem String-Stream in eine Datei schreibt. Mit einem kleinen Workaround ist es dennoch möglich:
String fileName = ...;
Stream<String> lines = ...;
Files.write(Path.of(fileName), (Iterable<String>) lines::iterator);
Was wird hier gemacht? Die Files.write()-Methode, die hier verwendet wird, ist dieselbe wie im vorherigen Beispiel, also die, die ein Iterable<String> entgegennimmt. Ein Java-8-Stream selbst ist kein Iterable, da man über diesen nicht mehrfach iterieren kann, sondern nur ein einziges Mal. Daher kann man den Stream selbst nicht als Parameter übergeben.
Iterable hat allerdings nur eine einzige abstrakte Methode, iterator(), und ist damit – auch ohne die Annotation @FunctionalInterface – ein funktionales Interface. Daher können wir hier als Iterable eine Methodenreferenz auf lines.iterator() übergeben (welche ja auch einen Iterator zurückliefert). Dies funktioniert, da Files.write() die referenzierte iterator()-Methode nur ein einziges Mal aufruft. Würde die iterator()-Methode ein zweites Mal aufgerufen werden, würde der Stream das mit einer IllegalStateException mit der Nachricht „stream has already been operated upon or closed“ quittieren.
Dateien schreiben mit java.nio.file.Files – Zusammenfassung
In diesem Kapitel hast du die Utility-Methoden der java.nio.file.Files-Klasse kennengelernt. Diese sind geeignet für all diejenigen Use Cases, bei denen die zu schreibenden Daten komplett im Arbeitsspeicher liegen. Schnell sind sie dabei auch: 100 MB aus einem Byte-Array schreibt Files.write() auf meinem System in 26 ms.
Werden die Daten jedoch nach und nach generiert, solltest du diese auch nach und nach in eine Datei schreiben. Du solltest sie nicht erst im Arbeitsspeicher „sammeln“ und anschließend mit einer der zuvor gezeigten Methoden in einem Rutsch in eine Datei schreiben. Es sei denn, die Menge der Daten ist nur wenige Kilobyte, dann ist das auch in Ordnung.
Besser arbeitest du in einem solchen Fall aber (direkt oder indirekt) mit einem FileOutputStream. Wie das funktioniert, erfährst du im folgenden Kapitel.
Wie schreibt man anfallende Daten in eine Datei, ohne deren Inhalt erst komplett im Arbeitsspeicher aufbauen zu müssen?
Um Daten nach und nach in eine Datei zu schreiben, verwendet man einen FileOutputStream (oder damit verwandte Klassen). Diese waren bereits vor Java 7 verfügbar und haben das Schreiben von kleinen Dateien unnötig kompliziert gemacht. Im Folgenden stelle ich die verschiedenen Möglichkeiten vor.
Schreiben einzelner Bytes mit dem FileOutputStream
Die zentrale Klasse ist der FileOutputStream. Dieser schreibt Daten Byte für Byte in eine Datei. Das folgende Beispiel zeigt, wie Bytes, die von der imaginären Methode process() zurückgeliefert werden, nach und nach in eine Datei geschrieben werden (so lange, bis die Methode -1 zurückliefert):
String fileName = ...;
try (FileOutputStream out = new FileOutputStream(fileName)) {
int b;
while ((b = process()) != -1) {
out.write(b);
}
}
Das Schreiben einzelner Bytes ist teuer, denn jeder Aufruf von write() löst einen Systemaufruf aus. Das Schreiben von 100.000.000 Bytes in eine Testdatei dauert auf diese Weise auf meinem System 150 Sekunden. Das sind nur 0,67 MB pro Sekunde.
Schreiben von Byte-Arrays mit dem FileOutputStream
Mit dem FileOutputStream können auch Byte-Arrays geschrieben werden. In folgendem Beispiel liefert die Methode process() Byte-Arrays anstatt einzelner Bytes zurück (und null, wenn keine weiteren Daten folgen):
String fileName = ...;
try (FileOutputStream out = new FileOutputStream(fileName)) {
byte[] bytes;
while ((bytes = process()) != null) {
out.write(bytes);
}
}
Diese Methode ist um ein Vielfaches schneller. Schreibt man 10.000.000-mal 10 Bytes (insgesamt die gleiche Menge wie im vorherigen Abschnitt), benötigt man dafür lediglich 14,9 Sekunden (statt 150 Sekunden). Schreibt man 1.000.000-mal 100 Bytes, sind es nur noch 1,5 Sekunden.
Relevant ist hier in erster Linie die Anzahl der Schreibvorgänge, nicht die tatsächliche Menge der Daten: Jeder Aufruf von write() kostet einen Systemaufruf, egal ob er ein Byte übergibt oder hundert. Dies gilt natürlich nur bis zu einer gewissen Blockgröße. Einmal zehn Gigabyte zu schreiben ist nicht mehr schneller als zehnmal ein Gigabyte zu schreiben. Der optimale Wert hängt von der Hardware und vom Betriebssystem ab.
Das folgende Diagramm zeigt, wie schnell der FileOutputStream auf meinem System schreibt, wenn er pro write()-Aufruf 1 Byte, 2 Bytes, 4 Bytes und so weiter bis 16 MB bekommt – jeweils für dieselben 100 Millionen Bytes:
Jede Verdopplung der Blockgröße halbiert die Zahl der Systemaufrufe – und bis 8 KB halbiert sich damit auch fast die Zeit: von rund 150 Sekunden für 1 Byte pro Aufruf auf 25 ms für 8 KB.
Darüber wird der Gewinn mit jedem Schritt kleiner: 64 KB brauchen 12 ms, 256 KB 8,6 ms. Ab dort ändert sich kaum noch etwas. Am schnellsten ist mein System mit 1 MB pro Aufruf (8,1 ms, das sind 12.346 MB pro Sekunde), bei 16 MB sind es wieder 9,1 ms.
Die 8 KB, die Java für seine Puffer nimmt, liegen genau an dieser Schwelle: Bis dorthin zahlt sich jede Verdopplung fast voll aus, darüber wächst der Speicherbedarf pro Stream schneller als der Gewinn. Statt 100 Millionen sind es mit 8 KB nur noch 12.208 Systemaufrufe. Wie der BufferedOutputStream diesen Puffer nutzt, siehst du im übernächsten Abschnitt.
Schreiben von Binärdaten mit dem NIO.2 OutputStream
In Java 7 kam mit Files.newOutputStream() eine weitere Methode hinzu, um einen OutputStream zu erzeugen:
String fileName = ...;
try (OutputStream out = Files.newOutputStream(Path.of(fileName))) {
int b;
while ((b = process()) != -1) {
out.write(b);
}
}
Diese Methode liefert einen ChannelOutputStream anstatt eines FileOutputStreams, da NIO.2 unter der Haube mit sogenannten Channels arbeitet. Schneller wird das Schreiben dadurch nicht: In meinen Messungen braucht diese Variante für die 100 Millionen einzelnen Bytes 149 Sekunden, der FileOutputStream 150 Sekunden; mit 100-Byte-Blöcken liegen beide bei 1,5 Sekunden.
Schneller schreiben mit dem BufferedOutputStream
Wir haben zuvor festgestellt, dass das Schreiben von Blöcken schneller ist als das Schreiben einzelner Bytes. Diese Tatsache macht sich der BufferedOutputStream zunutze, indem er die zu schreibenden Bytes zunächst in einem Puffer zwischenspeichert und diesen erst beim Erreichen einer bestimmten Größe in die Datei schreibt. Dieser Puffer ist standardmäßig 8 KB groß (in Virtual Threads beginnt er seit Java 19 mit 512 Bytes und wächst bei Bedarf bis 8 KB).
String fileName = ...;
try (FileOutputStream out = new FileOutputStream(fileName);
BufferedOutputStream bout = new BufferedOutputStream(out)) {
int b;
while ((b = process()) != -1) {
bout.write(b);
}
}
Mit dem BufferedOutputStream benötigt mein System für das Schreiben von 100.000.000 einzelnen Bytes 511 ms. Das sind 196 MB pro Sekunde – statt 0,67 MB pro Sekunde ohne Puffer. Die 4.000 MB pro Sekunde, die der 8-KB-Block im Diagramm weiter oben erreicht hat, bleiben dennoch weit entfernt: Jedes Byte gelangt über einen eigenen – synchronisierten – write()-Aufruf in den Puffer, und diese 100 Millionen Methodenaufrufe kosten mehr Zeit als die 12.208 Systemaufrufe, die den Puffer leeren.
Byte-Arrays schreiben mit dem BufferedOutputStream
Ebenso wie der FileOutputStream kann auch der BufferedOutputStream nicht nur einzelne Bytes, sondern auch Byte-Blöcke schreiben:
String fileName = ...;
try (FileOutputStream out = new FileOutputStream(fileName);
BufferedOutputStream bout = new BufferedOutputStream(out)) {
byte[] bytes;
while ((bytes = process()) != null) {
bout.write(bytes);
}
}
Diese Methode vereint die Vorteile des Schreibens von Byte-Arrays mit denen eines Puffers: Kleine Arrays sammelt der Puffer, große schreibt er direkt durch. Mit 100-Byte-Blöcken braucht sie auf meinem System 35 ms für die 100 Millionen Bytes (statt 511 ms für einzelne Bytes). Beim Schreiben von Binärdaten empfehle ich, immer diese Methode zu verwenden.
Schreiben von Textdateien mit dem FileWriter
Um Texte in eine Datei zu schreiben, müssen diese in Binärdaten umgewandelt werden. Das übernimmt der OutputStreamWriter, den du wie folgt um den FileOutputStream legst. Die process()-Methode produziert in folgendem Beispiel einzelne Characters.
String fileName = ...;
try (FileOutputStream out = new FileOutputStream(fileName);
OutputStreamWriter writer = new OutputStreamWriter(out)) {
int c;
while ((c = process()) != -1) {
writer.write(c);
}
}
Komfortabler geht es mit dem FileWriter. Dieser kombiniert FileOutputStream und OutputStreamWriter. Der folgende Code ist äquivalent zu dem vorherigen:
String fileName = ...;
try (FileWriter writer = new FileWriter(fileName)) {
int c;
while ((c = process()) != -1) {
writer.write(c);
}
}
Der OutputStreamWriter verwendet intern einen Puffer für die kodierten Bytes, der bis zu 8 KB groß wird (seit Java 19 beginnt er mit 512 Bytes und wächst bei Bedarf). Das Schreiben von 100.000.000 Zeichen in eine Textdatei dauert mit beiden Varianten 1,4 Sekunden.
Textdateien schneller schreiben mit dem BufferedWriter
Weiter beschleunigen kannst du das Schreiben mit dem BufferedWriter:
String fileName = ...;
try (FileWriter writer = new FileWriter(fileName);
BufferedWriter bufferedWriter = new BufferedWriter(writer)) {
int c;
while ((c = process()) != -1) {
bufferedWriter.write(c);
}
}
Der BufferedWriter fügt einen weiteren 8-KB-Puffer hinzu – dieses Mal für Zeichen. Damit reduziert sich die Zeit für das Schreiben von 100.000.000 Zeichen von 1,4 Sekunden auf 515 ms.
Warum macht ein zweiter Puffer das Schreiben fast dreimal so schnell? Ohne ihn reicht der FileWriter jedes Zeichen einzeln an den Encoder weiter: Für jeden write()-Aufruf legt er ein Array mit einem Element an, verpackt es in einen CharBuffer und ruft den Encoder für dieses eine Zeichen auf.
Der BufferedWriter dagegen legt das Zeichen nur in seinem Array ab und übergibt dem Encoder erst den vollen Puffer: 8.192 Zeichen in einem Aufruf, die der Encoder in einer Schleife kodiert.
Textdateien schneller schreiben mit dem NIO.2 BufferedWriter
In Java 7 kam mit Files.newBufferedWriter() eine neue Methode hinzu, um einen BufferedWriter zu erzeugen; die gezeigte Variante ohne Charset-Parameter gibt es seit Java 8:
String fileName = ...;
try (BufferedWriter bufferedWriter = Files.newBufferedWriter(Path.of(fileName))) {
int c;
while ((c = process()) != -1) {
bufferedWriter.write(c);
}
}
Der so erzeugte BufferedWriter liegt mit 517 ms in derselben Größenordnung wie der „klassisch“ erstellte mit 515 ms.
Übersicht Performance Dateien schreiben
Das folgende Diagramm zeigt noch einmal alle sechs vorgestellten Methoden und die Zeit, die sie jeweils benötigen, um 100.000.000 Bytes oder Zeichen einzeln in eine Datei zu schreiben:
Der Abstand zu den ungepufferten Methoden ist so groß, dass die Balken der vier gepufferten Methoden oben kaum zu erkennen sind. Daher hier ein zweites Diagramm, das nur die gepufferten Methoden zeigt:
Welche Methode solltest du verwenden?
Die Entscheidung hängt von zwei Fragen ab: Liegen die Daten komplett im Speicher, oder entstehen sie nach und nach? Und sind es Binärdaten oder Text? Die folgende Matrix zeigt für jede der vier Kombinationen die Methode, die ich empfehle:
Den ungepufferten FileOutputStream und den FileWriter ohne BufferedWriter brauchst du nur, wenn du ohnehin große Blöcke schreibst – oder als Grundlage für die gepufferten Varianten.
Übersicht FileOutputStream, FileWriter, OutputStreamWriter, BufferedOutputStream, BufferedWriter
Das folgende Diagramm zeigt noch einmal zusammengefasst den Zusammenhang der vorgestellten Klassen zum Schreiben von Binär- und Textdateien aus dem java.io-Paket. Durchgezogene Pfeile stehen für Binärdaten, gestrichelte für Textdaten:
Zeichenkodierungen
Das Thema Zeichenkodierung und die Probleme, die damit einhergehen, wurden im vorherigen Artikel ausführlich besprochen.
Im Folgenden beschränke ich mich daher auf diejenigen Aspekte, die für das Schreiben von Textdateien mit Java relevant sind.
Welche Zeichenkodierung verwendet Java standardmäßig zum Schreiben von Textdateien?
Gibt man beim Schreiben einer Textdatei keine Zeichenkodierung an, verwendet Java eine Standard-Kodierung. Welche das ist, hat sich mit Java 18 geändert.
Seit Java 18 ist die Antwort einfach: Es ist immer UTF-8. JEP 400 hat UTF-8 zur Standard-Kodierung aller Standard-APIs gemacht – unabhängig von Betriebssystem, Locale und Konfiguration.
Bis Java 17 hing die Antwort von der Methode ab:
FileWriterundOutputStreamWriterverwendeten ohne AngabeCharset.defaultCharset(). Diese Kodierung leitete die JVM aus Betriebssystem und Locale ab: auf einem deutschen Windows in der Regel Windows-1252, auf Linux und macOS meist UTF-8. Derselbe Code schrieb also je nach Rechner eine andere Datei.Files.writeString(),Files.write()undFiles.newBufferedWriter()verwendeten dagegen schon immer UTF-8.
Genau diese Uneinheitlichkeit war der Grund für JEP 400. Sie ist auch der Grund, warum du beim Umstieg auf Java 18 oder neuer hinsehen solltest: Schreibt dein Programm über einen FileWriter Dateien, die ein anderes Programm als Windows-1252 liest, passte das auf einem deutschen Windows bis Java 17 – und geht seit Java 18 schief. Für solche Fälle holt die System-Property -Dfile.encoding=COMPAT das alte Verhalten zurück.
Was heißt das für deinen Code? Läuft er auf Java 18 oder neuer, brauchst du die Kodierung nicht mehr anzugeben, um UTF-8-Dateien zu schreiben. Ein StandardCharsets.UTF_8 hinter jedem Aufruf ist dann nur noch Rauschen und sagt nichts, was nicht ohnehin gilt. Angeben musst du die Kodierung in zwei Fällen: wenn dein Code auch auf Java 17 oder älter laufen muss – oder wenn die Datei nicht UTF-8 sein soll, etwa weil ein altes Programm sie in einer anderen Kodierung erwartet.
UTF-8 ist dabei die richtige Wahl für alles, was du neu anlegst: Laut W3Techs verwenden über 99 % aller Websites diese Kodierung.
Wie gibt man die Zeichenkodierung beim Schreiben einer Textdatei an?
Für die beiden genannten Fälle findest du im Folgenden für alle bisher besprochenen Methoden ein Beispiel mit expliziter Angabe der Zeichenkodierung – hier UTF-8:
Files.writeString(path, string, StandardCharsets.UTF_8)Files.write(path, lines, StandardCharsets.UTF_8)new FileWriter(file, StandardCharsets.UTF_8)// erst seit Java 11new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)Files.newBufferedWriter(path, StandardCharsets.UTF_8)
Zusammenfassung und Ausblick
Dieser Artikel hat verschiedene Methoden gezeigt, wie du in Java Byte-Arrays und Strings in Binär- und Textdateien schreiben kannst. Meine Empfehlung in drei Sätzen:
- Liegen die Daten komplett im Speicher, nimm
Files.write()oderFiles.writeString()– eine Zeile, fertig. - Entstehen die Daten nach und nach, schreib sie durch einen
BufferedOutputStreambzw. einenBufferedWriter: Ungepuffert dauert das Schreiben einzelner Bytes auf meinem System 293-mal so lange wie gepuffert. - Die Zeichenkodierung gibst du seit Java 18 nur noch an, wenn die Datei nicht UTF-8 sein soll oder dein Code auch auf Java 17 und älter laufen muss.
Im dritten Teil der Serie lernst du, wie man die Klassen File, Path und Paths nutzt, um Datei- und Verzeichnispfade zu konstruieren.
In weiteren Artikeln dieser Serie werde ich zeigen:
- Wie liest man den Inhalt eines Verzeichnisses ein?
- Wie kopiert, verschiebt und löscht man Dateien, und wie setzt man Links?
- Wie erstellt man temporäre Dateien?
- Wie verwendet man
DataOutputStreamundDataInputStream, um strukturierte Daten zu schreiben und zu lesen?
Im weiteren Verlauf der Serie werden dann fortgeschrittenere Themen behandelt:
- Die in Java 1.4 eingeführten NIO-Channels und Buffer, um insbesondere das Arbeiten mit großen Dateien zu beschleunigen
- Memory-mapped I/O für rasend schnellen Dateizugriff ohne Streams
- File Locking, um parallel – also aus mehreren Threads oder Prozessen – konfliktfrei auf dieselben Dateien zuzugreifen
Hat dieser Artikel deine Fragen beantwortet? Dann freue ich mich über eine Bewertung auf meinem ProvenExpert-Profil – sie hilft anderen Entwickler:innen, diese Inhalte zu finden.




