Dateien schreiben in Java - Feature-Bild

Dateien in Java, Teil 2: Dateien schnell und einfach schreiben

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 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 FileWriterFileOutputStreamOutputStreamReaderBufferedOutputStream und BufferedWriter?
  • Wann verwendet man Files.newOutputStream() und Files.newBufferedWriter()?

Bereits im ersten Teil angesprochen habe ich das Thema Betriebssystem-Unabhängigkeit, d. h. was bei Zeichenkodierungen, Zeilenumbrüche 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, ein String oder eine Liste von Strings 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 zur Konstruktion solcher. Im Beispiel wird die statische – seit Java 11 verfügbare – Methode Path.of() verwendet, um ein Path-Objekt aus einem Dateinamen zu erstellen. Vor Java 11 kannst du stattdessen Paths.get() verwenden. Intern rufen beide Methoden FileSystems.getDefault().getPath() auf.

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

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 eine 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 ist allerdings ein funktionales Interface, dessen einzige Methode iterator() einen Iterator zurückliefert. Daher können wir hier als Iterable eine Methodenreferenz auf lines.iterator() übergeben (welche ja auch einen Iterator zurückliefert). Dies funktioniert, da wir annehmen können, dass 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

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

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 Methode process() zurückgeliefert werden, nach und nach in eine Datei geschrieben werden (solange 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 eine teure Funktionalität. Das Schreiben von 100.000.000 Bytes in eine Testdatei dauert auf diese Weise auf meinem System etwa 230 Sekunden, das sind nur wenig mehr als 0,4 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), benötigt man dafür lediglich 24 Sekunden, also etwas mehr als ein Zehntel der vorherigen Zeit. Schreibt man 1.000.000 mal 100 Bytes, sind es nur noch 2,6 Sekunden, was wiederum etwas mehr als ein Hundertstel der vorherigen Zeit ist.

Relevant ist hier in erster Linie die Anzahl der Schreibvorgänge, nicht die tatsächliche Menge der Daten. Dies liegt daran, dass die Daten blockweise auf den Datenträger geschrieben werden. Dies gilt natürlich nur bis zu einer gewissen Puffergröße. Ein mal zehn Gigabyte zu schreiben ist nicht mehr schneller als zehn mal ein Gigabyte zu schreiben. Der optimale Wert für die Puffergröße hängt von der Hardware als auch der Formatierung des Datenträgers ab. Ich habe mit einem kleinen Testprogramm die Schreibgeschwindigkeit in Abhängigkeit von der Puffergröße gemessen:

FileOutputStream – Schreibgeschwindigkeit in Abhängigkeit von der Puffergröße
FileOutputStream – Schreibgeschwindigkeit in Abhängigkeit von der Puffergröße

Auf meinem System beträgt die optimale Puffergröße 8 KB. Hierbei werden etwa 1.050 MB pro Sekunde erreicht. 8 KB ist auch auf den meisten anderen System die optimale Größe, weshalb Java diesen Wert als Standard nimmt, wie wir im übernächsten Abschnitt sehen werden.

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. Von der Geschwindkeit gibt es sowohl beim Schreiben einzelner Bytes als auch beim Schreiben von Byte-Blöcken auf meinem System keinen relevanten Unterschied gegenüber new FileOutputStream().

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, in dem er die zu schreibenden Bytes zunächst in einem Puffer zwischenspeichert und diesen erst beim Erreichen einer bestimmten Größe auf den Datenträger schreibt. Dieser Puffer ist standardmäßig 8 KB groß, also genau die Größe, die zu einer optimalen Schreibgeschwindigkeit führt.

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 knapp 250 ms. Das sind etwa 400 MB pro Sekunde. Die 1.050 MB/s aus dem vorherigen Test werden hier aufgrund des Overheads durch das Zwischenspeichern nicht erreicht.

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 der eines Puffers und liefert quasi immer optimale Schreibgeschwindigkeiten. 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 auch einen 8 KB großen Puffer, so dass das Schreiben von 100.000.000 Zeichen in eine Textdatei etwa 2,5 Sekunden dauert.

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ührt einen weiteren 8 KB-Puffer für Zeichen hinzu, welche dann in einem Rutsch kodiert werden, sobald der Puffer geschrieben wird (anstatt Zeichen für Zeichen). Damit reduziert sich die Zeit für das Schreiben von 100.000.000 Zeichen auf ca. 370 ms.

Textdateien schneller schreiben mit dem NIO.2 BufferedWriter

In Java 7 kam mit Files.newBufferedWriter() eine neue Methode hinzu, um einen BufferedWriter zu erzeugen:

String fileName = ...;
try (BufferedWriter bufferedWriter = Files.newBufferedWriter(Path.of(fileName))) {
    int c;
    while ((c = process()) != -1) {
        bufferedWriter.write(c);
    }
}

Die Schreibgeschwindigkeit entspricht auf meinem System der des „klassisch“ erstellten BufferedWriters.

Übersicht Performance Dateien schreiben

Im folgenden Diagramm siehst du noch einmal zusammengefasst, wie lange die vorgestellten Methoden benötigen, um 100.000.000 Bytes bzw. Zeichen in eine Binär- bzw. Textdatei zu schreiben:

Vergleich der Zeiten zum Schreiben von 100 Millionen Bytes / Zeichen in eine Datei
Vergleich der Zeiten zum Schreiben von 100 Millionen Bytes / Zeichen in eine Datei

Aufgrund des großen Abstands von ungepuffertem zu gepuffertem Schreiben heben sich die gepufferten Methoden hier kaum ab. Das folgende Diagram zeigt daher als Ausschnitt nur die Methoden, die einen Puffer verwenden:

Vergleich der Zeiten zum Schreiben von 100 Millionen Bytes / Zeichen in eine Datei (buffered)
Vergleich der Zeiten zum Schreiben von 100 Millionen Bytes / Zeichen in eine Datei (buffered)

Ü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 Linien stehen für Binärdaten, gestrichtelte für Textdaten. FileWriter kombiniert FileOutputStream und OutputStreamWriter.

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-Codierung. Doch aufgepasst: Welche das ist, hängt zum einen von der verwendeten Methode ab, zum anderen von der eingesetzten Java-Version.

  • Die Klassen FileWriter und OutputStreamWriter verwenden intern StreamEncoder.forOutputStreamWriter(). Wird diese Methode ohne Zeichenkodierung aufgerufen, verwendet sie Charset.defaultCharset(). Diese Methode wiederum liest die Zeichenkodierung aus der System-Property „file.encoding“. Ist diese nicht angegeben, wird bis Java 5 standardmäßig ISO-8859-1 verwendet und seit Java 6 UTF-8.
  • Die Methoden Files.writeString(), Files.write() und Files.newBufferedWriter() verwenden alle grundsätzlich UTF-8 als Standardkodierung, ohne die o. g. System Property zu lesen.

Aufgrund dieser Inkonsistenzen sollte man immer die Zeichenkodierung mit angeben. Ich empfehle grundsätzlich UTF-8 zu verwenden. Diese Kodierung wird laut Wikipedia auf 94,3 % aller Webseiten verwendet und kann von daher als De-facto-Standard angesehen werden. Eine Ausnahme besteht natürlich, wenn mit alten Dateien gearbeitet werden muss, die in einer anderen Kodierung geschrieben wurden.

Wie gibt man die Zeichenkodierung beim Schreiben einer Textdatei an?

Im Folgenden findest du für alle bisher besprochenen Methoden ein Beispiel mit Angabe von UTF-8 als Zeichenkodierung:

  • Files.writeString(path, string, StandardCharsets.UTF_8)
  • Files.write(path, lines, StandardCharsets.UTF_8)
  • new FileWriter(file, StandardCharsets.UTF_8) // diese Methode gibt es erst seit Java 11
  • new InputStreamWriter(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.

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:

Im weiteren Verlauf der Serie werden dann fortgeschritteren Themen behandelt:

  • Die in Java 1.4 eingeführten NIO-Channels und Buffer, um insbesondere das Arbeiten mit großen Dateien zu beschleunigen
  • File Locking, um parallel – also aus mehreren Threads oder Prozessen – konfliktfrei auf dieselben Dateien zuzugreifen
  • Memory-mapped I/O für rasend schnellen Dateizugriff ohne Streams

Möchtest du informiert werden, wenn weitere Artikel veröffentlicht werden? Dann kannst du dich im folgenden Formular für meinen Newsletter anmelden. Wenn dir der Artikel gefallen hat, freue ich mich auch, wenn du ihn über einen der Buttons am Ende teilst.

4 Kommentare zu „Dateien in Java, Teil 2: Dateien schnell und einfach schreiben“

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.