In den ersten vier Teilen dieser Artikelserie haben wir das Lesen und Schreiben von Dateien behandelt, die Konstruktion von Verzeichnis- und Dateipfaden, sowie Verzeichnis- und Dateioperationen.
Gelesen und geschrieben haben wir bisher ausschließlich Byte-Arrays und Strings. In diesem fünften Teil erfährst du, wie du mit DataOutputStream
, DataInputStream
, ObjectOutputStream
und ObjectInputStream
strukturierte Daten schreiben und lesen kannst. Im einzelnen werden folgende Fragen beantwortet:
- Wie speichert man primitive Datentypen (int, long, char, etc...) in Binärdateien und wie liest man sie wieder?
- Welche verschiedenen Arten gibt es Strings in Binärdateien zu schreiben und sie daraus zu lesen?
- Wie speichert man komplexe Java-Objekte in Binärdateien und wie liest man sie wieder?
Die Code-Beispiele aus diesem Artikel findest du in meinem GitHub-Repository.
Strukturierte Daten in Binärdateien schreiben und aus diesen lesen
Mit DataOutputStream
und DataInputStream
ist es möglich primitive Datentypen (byte, short, int, long, float, double, boolean, char) sowie Strings in eine Binärdatei zu schreiben und diese wieder auszulesen. DataOutputStream
und DataInputStream
werden dabei unter Anwendung des Decorator-Patterns um einen OutputStream
(z. B. FileOutputStream
) bzw. um einen InputStream
(z. B. FileInputStream
) gewrappt.
Strukturierte Daten schreiben mit DataOutputStream
Das folgende Beispiel schreibt Variablen aller primitiven Datentypen in die Datei test1.bin
:
public class TestDataOutputStream1 {
public static void main(String[] args) throws IOException {
try (var out = new DataOutputStream(new BufferedOutputStream(
new FileOutputStream("test1.bin")))) {
out.writeByte((byte) 123);
out.writeShort((short) 1_234);
out.writeInt(1_234_567);
out.writeLong(1_234_567_890_123_456L);
out.writeFloat((float) Math.E);
out.writeDouble(Math.PI);
out.writeBoolean(true);
out.writeChar('€');
}
}
}
Code-Sprache: Java (java)
Die Datei test1.bin
enthält nun folgende Bytes:
7b 04 d2 00 12 d6 87 00 04 62 d5 3c 8a ba c0 40 2d f8 54 40 09 21 fb 54 44 2d 18 01 20 ac
Code-Sprache: Klartext (plaintext)
Die Werte wurden also im Big-Endian-Format sequentiell in die Datei geschrieben:
7b
= 12304 d2
= 1.23400 12 d6 87
= 1.234.56700 04 62 d5 3c 8a ba c0
= 1.234.567.890.123.45640 2d f8 54
= 2,718281740 09 21 fb 54 44 2d 18
= 3,14159265358979301
= true20 ac
= '€' (Unicode U-20AC)
Strukturierte Daten lesen mit DataInputStream
Genauso einfach wie wir die Daten geschrieben haben, können wir sie auch wieder lesen:
public class TestDataInputStream1 {
public static void main(String[] args) throws IOException {
try (var in = new DataInputStream(new BufferedInputStream(
new FileInputStream("test1.bin")))) {
System.out.println(in.readByte());
System.out.println(in.readShort());
System.out.println(in.readInt());
System.out.println(in.readLong());
System.out.println(in.readFloat());
System.out.println(in.readDouble());
System.out.println(in.readBoolean());
System.out.println(in.readChar());
}
}
}
Code-Sprache: Java (java)
Das Programm gibt folgendes aus:
123
1234
1234567
1234567890123456
2.7182817
3.141592653589793
true
€
Code-Sprache: Klartext (plaintext)
Das sind exakt die Daten, die wir geschrieben haben.
Abweichende Datentypen bei writeByte() und writeShort()
Wenn du dir die write-Methoden von DateOutputStream
näher anschaust, fällt auf, dass writeByte()
, writeShort()
und auch writeChar()
jeweils ein int
als Parameter erwarten und nicht den jeweiligen Datentyp. Den Grund dafür konnte ich nicht herausfinden; auch der Quellcode der Methoden enthält keine Erklärung. Das ist natürlich fehleranfällig und man sollte wissen, was die Konsequenzen sind, wenn die übergebenen Werte nicht in den genannten Datentyp passen.
Was passiert in dem Fall? Testen wir es für writeByte()
mit folgendem Code. Ich habe hier die resultierenden Bytes direkt als Kommentare in den Code eingefügt, um die Zuordnung zu erleichtern.
public class TestDataOutputStream2 {
public static void main(String[] args) throws IOException {
try (var out = new DataOutputStream(new BufferedOutputStream(
new FileOutputStream("test2.bin")))) {
out.writeByte(1000); // --> e8
out.writeByte(128); // --> 80
out.writeByte(127); // --> 7f (Byte.MAX_VALUE)
out.writeByte(0); // --> 00
out.writeByte(-128); // --> 80 (Byte.MIN_VALUE)
out.writeByte(-129); // --> 7f
out.writeByte(-1000); // --> 18
}
}
}
Code-Sprache: Java (java)
Overflows werden also nicht durch eine Fehlermeldung quittiert. Was wir stattdessen sehen, ist das letzte Byte der jeweiligen int-Darstellung der Zahl, was wir mit folgendem Code zeigen können (ich verwende hier eine Standard-Textbox, um Markierungen zu ermöglichen):
public class TestDataOutputStream3 { public static void main(String[] args) throws IOException { try (var out = new DataOutputStream(new BufferedOutputStream( new FileOutputStream("test2.bin")))) { out.writeInt(1000); // --> 00 00 03 e8 out.writeInt(128); // --> 00 00 00 80 out.writeInt(127); // --> 00 00 00 7f out.writeInt(0); // --> 00 00 00 00 out.writeInt(-128); // --> ff ff ff 80 out.writeInt(-129); // --> ff ff ff 7f out.writeInt(-1000); // --> ff ff fc 18 } } }
Für writeShort()
gilt das analog. Hier habe ich die int-Repräsentation direkt mit in den Kommentar aufgenommen.
public class TestDataOutputStream4 { public static void main(String[] args) throws IOException { try (var out = new DataOutputStream(new BufferedOutputStream( new FileOutputStream("test2.bin")))) { out.writeShort(1000000); // --> 42 40 (int: 00 0f 42 40) out.writeShort(32768); // --> 80 00 (int: 00 00 80 00) out.writeShort(32767); // --> 7f ff (int: 00 00 7f ff) out.writeShort(0); // --> 00 00 (int: 00 00 00 00) out.writeShort(-32768); // --> 80 00 (int: ff ff 80 00) out.writeShort(-32769); // --> 7f ff (int: ff ff 7f ff) out.writeShort(-1000000); // --> bd c0 (int: ff f0 bd c0) } } }
Abweichender Datentyp bei writeChar()
Ein char
wird in Java durch zwei Bytes repräsentiert und kann – ohne Type Casting – einem int
zugewiesen werden. Folgendes ist also vollkommen in Ordnung:
int a = 'a'; // Unicode U+0066
int euro = '€'; // Unicode U+20AC
int word = '字'; // Unicode U+5B57
Code-Sprache: Java (java)
Von daher ist es syntaktisch korrekt, dass writeChar()
ein int
entgegennimmt. Doch was passiert hier, wenn wir Werte übergeben, die größer als zwei Byte oder negativ sind? Probieren wir es aus. In den Kommentaren in folgendem Code-Beispiel siehst Du die resultierenden Bytes und – bei den großen und negativen Zahlen – auch die jeweilige int
-Darstellung. Auch hier sehen wir wieder, dass jeweils die letzten zwei Bytes der int
-Darstellung übernommen werden.
public class TestDataOutputStream5 { public static void main(String[] args) throws IOException { try (var out = new DataOutputStream(new BufferedOutputStream( new FileOutputStream("test5.bin")))) { out.writeChar('a'); // --> 00 61 out.writeChar('€'); // --> 20 ac out.writeChar('字'); // --> 5b 57 out.writeChar(723_790_628); // --> 2b 24 (int: 2b 24 2b 24) out.writeChar(-100); // --> ff 9c (int: ff ff ff 9c) out.writeChar(-16_776_261); // --> 03 bb (int: ff 00 03 bb) } } }
Was bekommen wir, wenn wir die erstellte Datei mit readChar()
wieder einlesen? Hier zunächst der Quellcode dafür:
public class TestDataInputStream5 {
public static void main(String[] args) throws IOException {
try (var in = new DataInputStream(new BufferedInputStream(
new FileInputStream("test5.bin")))) {
System.out.println(in.readChar());
System.out.println(in.readChar());
System.out.println(in.readChar());
System.out.println(in.readChar());
System.out.println(in.readChar());
System.out.println(in.readChar());
}
}
}
Code-Sprache: Java (java)
Und hier die Ausgabe:
a
€
字
⬤
ワ
λ
Code-Sprache: Klartext (plaintext)
Aus der 723.790.628 ist nun beispielsweise über die hexadezimale Darstellung 0x2b242b24 – davon die letzten zwei Bytes 0x2b24 – das Unicode-Zeichen U+2B24 (Schwarzer großer Kreis) geworden. -100 wurde über 0xffffff9c zu U+FF9C (Halbbreiter Katakana-Buchstabe Wa) und -16.776.261 wurde über 0xff0003bb zu U+03BB (Griechischer Kleinbuchstabe Lamda).
Strings schreiben mit DataOutputStream
DataOutputStream
verwirrt mit drei unterschiedlichen Methoden, um Strings zu schreiben:
writeBytes(String s)
writeChars(String s)
writeUTF(String s)
DataInputStream
hingegen bietet neben einer als deprecated markierten readLine()
-Methode, die wir hier nicht weiter betrachten wollen, nur die readUTF()
-Methode, um einen String zu lesen.
Wie unterscheiden sich die drei write
-Methoden? Testen wir es mit einem String, der die unterschiedlichsten Arten von Zeichen enthält, die Unicode zu bieten hat:
public class TestDataOutputStream6 {
static final String STRING = "Hello World äöü ß α € ↖ ?";
public static void main(String[] args) throws IOException {
try (var out = new DataOutputStream(new BufferedOutputStream(
new FileOutputStream("test6.bin")))) {
out.writeBytes(STRING);
out.writeChars(STRING);
out.writeUTF(STRING);
}
}
}
Code-Sprache: Java (java)
Der Atom-Editor zeigt den Inhalt der erstellten Datei – je nach eingestelltem Zeichensatz – wie folgt an:
Wie die Ausgaben vermuten lassen, unterscheiden sich die Methoden durch die Zeichenkodierung, die zur Ausgabe in die Datei verwendet wird:
writeBytes()
schreibt den String im ISO-8859-1-Format, auch bekannt als Latin-1. Hierbei können alle Sonderzeichen nach dem "ß" nicht mehr dargestellt werden.writeChars()
schreibt den String im UTF-16-Format. Hier werden alle Zeichen korrekt dargestellt.writeUTF()
schreibt den String in einem modifizierten UTF-8-Format. Dabei werden "supplementary characters", d. h. alle Zeichen mit einem Code größer als U+FFFF (das Sonderzeichen '?' hat den Code U+1F525) anders als in UTF-8 dargestellt, weshalb Atom hier anstatt des Feuer-Symbol sechs Fragezeichen anzeigt.
Der Inhalt der Datei wird in den folgenden Unterabschnitten erklärt.
Strings schreiben mit DataOutputStream.writeBytes()
writeBytes()
hat die folgenden Bytes in die Datei geschrieben (in der ersten Zeile siehst du die hexadezimale Codierung der Bytes, in der zweiten Zeile das jeweils geschriebene Zeichen):
48 65 6c 6c 6f 20 57 6f 72 6c 64 20 e4 f6 fc 20 df 20 b1 20 ac 20 96 20 3d 25
H e l l o W o r l d ä ö ü ß α € ↖ ?
Code-Sprache: Klartext (plaintext)
writeBytes()
hat für jedes Zeichen ein Byte geschrieben. Jetzt sehen wir auch, was mit den Sonderzeichen passiert ist: Das α-Zeichen beispielsweise hat den Code U+03B1, davon wurde lediglich das untere Byte 0xB1 in die Datei geschrieben. In ISO-8859-1 steht 0xB1 für das Zeichen '±', das wir auch im Editor sehen. Das €-Zeichen hat den Code U+20AC, davon erscheint nur 0xAC in der Datei, was in ISO-8859-1 für '¬' steht. Der Pfeil hat den Code U+2196, dessen unterer Teil 0x96 in ISO-8859-1 nicht belegt ist, weshalb Atom hier eine leere Box anzeigt.
Die Methode writeBytes()
sollte also nicht verwendet werden. Es sei denn, du bist dir hundertprozentig sicher, dass dein Text ausschließlich Zeichen enthält, die durch ISO-8859-1 kodiert werden können.
Interessant ist noch das Feuer-Symbol: Dieses wird als 0x3D 0x25 in die Datei geschrieben – zwei Bytes also. Wie kann das sein, wenn writeBytes()
doch für jedes Zeichen nur ein Byte schreibt?
Die Antwort lautet: das Feuersymbol ist in Java nicht ein Zeichen, sondern zwei! Folgendes ist nicht erlaubt:
char c = '?';
Code-Sprache: GLSL (glsl)
Dieser Code produziert die Fehlermeldung "Too many characters in character literal". Mit folgendem Code untersuchen wir das:
public class TestDataOutputStream7 {
public static void main(String[] args) throws IOException {
String fire = "?";
System.out.println("fire = " + fire);
System.out.println("fire.length() = " + fire.length());
char c0 = fire.charAt(0);
char c1 = fire.charAt(1);
System.out.println("fire.charAt(0) = " + c0 +
" (hex: " + Integer.toHexString(c0) + ")");
System.out.println("fire.charAt(1) = " + c1 +
" (hex: " + Integer.toHexString(c1) + ")");
}
}
Code-Sprache: Java (java)
Folgendes bekommen wir ausgegeben:
fire = ?
fire.length() = 2
fire.charAt(0) = ? (hex: d83d)
fire.charAt(1) = ? (hex: dd25)
Code-Sprache: Klartext (plaintext)
Das Feuer-Symbol besteht also aus zwei Characters mit den Codes U+D83D und U+DD25. Bei diesen Codes handelt es sich nicht um eigenständige Zeichen, sondern um sogenannte Surrogates ("Stellvertreter"), die dafür verwendet werden, um Unicode-Symbole darzustellen mit einem Code größer als U+FFFF, also solche, die mit zwei Bytes nicht repräsentiert werden können.
Strings schreiben mit DataOutputStream.writeChars()
Die Methode writeChars()
hat folgende Bytes in die Datei geschrieben:
00 48 00 65 00 6c 00 6c 00 6f 00 20 00 57 00 6f 00 72 00 6c 00 64 00 20
H e l l o W o r l d
00 e4 00 f6 00 fc 00 20 00 df 00 20 03 b1 00 20 20 ac 00 20 21 96 00 20 d8 3d dd 25
ä ö ü ß α € ↖ ?
Code-Sprache: Klartext (plaintext)
Hier sehen wir für jedes Zeichen zwei Bytes – die jeweilige UTF-16-Big-Endian-Kodierung. Das Feuer-Symbol wird – wie im vorangegangenen Abschnitt erläutert – als zwei mal zwei Bytes geschrieben.
Strings schreiben mit DataOutputStream.writeUTF()
Durch writeUTF()
wurden folgende Bytes in die Datei geschrieben:
00 27 48 65 6c 6c 6f 20 57 6f 72 6c 64 20
H e l l o W o r l d
c3 a4 c3 b6 c3 bc 20 c3 9f 20 ce b1 20 e2 82 ac 20 e2 86 96 20 ed a0 bd ed b4 a5
ä ö ü ß α € ↖ ?
Code-Sprache: Klartext (plaintext)
Hier fällt zuerst auf, dass dem Text zwei Bytes 0x00 0x27 vorangehen. Hierbei handelt es sich um die Länge des Strings als short-Wert. 0x27 ist dezimal 39 – dies entspricht der Anzahl an Bytes, die auf die ersten zwei Bytes folgen.
Beim Feuer-Symbol sehen wir die oben schon erwähnte modifizierte UTF-8-Kodierung. Laut https://www.compart.com/de/unicode/U+1F525 wäre die eigentliche UTF-8-Kodierung 0xF0 0x9F 0x94 0xA5. Java kocht an dieser Stelle sein eigenes Süppchen.
Strings lesen mit DataInputStream
Wie können wir nun unsere Strings wieder einlesen? Für die mit writeBytes()
und writeChars()
geschriebenen Strings gibt es keine entsprechenden read
-Methoden. Ohnehin müssten wir, wenn wir diese Methoden verwenden wollen, zuvor die Länge des Strings in die Datei schreiben – andernfalls wüssten wir gar nicht, wo dieser endet. Hier der dafür entsprechend angepasste Code:
public class TestDataOutputStream8 {
static final String STRING = "Hello World äöü ß α € ↖ ?";
public static void main(String[] args) throws IOException {
try (var out = new DataOutputStream(new BufferedOutputStream(
new FileOutputStream("test8.bin")))) {
out.writeInt(STRING.length());
out.writeBytes(STRING);
out.writeInt(STRING.length());
out.writeChars(STRING);
out.writeUTF(STRING);
}
}
}
Code-Sprache: Java (java)
Die Länge müssten wir dann wieder auslesen, danach die passende Anzahl an Bytes lesen und daraus – und unter Angabe der korrekten Zeichenkodierung – wieder einen String konstruieren:
public class TestDataInputStream8 {
public static void main(String[] args) throws IOException {
try (var in = new DataInputStream(new BufferedInputStream(
new FileInputStream("test8.bin")))) {
// read String written by writeBytes()
int len = in.readInt();
byte[] bytes = new byte[len];
in.read(bytes, 0, len);
String s = new String(bytes, StandardCharsets.ISO_8859_1);
System.out.println(s);
// read String written by writeChars()
len = in.readInt();
bytes = new byte[len * 2];
in.read(bytes, 0, len * 2);
s = new String(bytes, StandardCharsets.UTF_16BE);
System.out.println(s);
// read String written by writeUTF()
s = in.readUTF();
System.out.println(s);
}
}
}
Code-Sprache: Java (java)
Hier die Ausgabe:
Hello World äöü ß ± ¬ =%
Hello World äöü ß α € ↖ ?
Hello World äöü ß α € ↖ ?
Code-Sprache: Klartext (plaintext)
Das Lesen der mit writeBytes()
und writeChars()
geschriebenen Strings ist recht aufwändig. writeBytes()
kann darüber hinaus – wie zuvor schon festgestellt – nicht alle Zeichen kodieren, und writeChars()
belegt für die meisten Texte, mit denen wir hierzulande arbeiten, knapp doppelt so viel Platz wie writeUTF()
.
Von daher lautet meine klare Empfehlung für Strings ausschließlich writeUTF()
und readUTF()
zu verwenden.
Java-Objekte in Dateien schreiben und aus Dateien lesen
Java bietet uns nicht nur die Möglichkeit primitive Datentypen und Strings in Dateien zu schreiben. Wir können ganze Java-Objekte schreiben und lesen. Java stellt uns dafür die Klassen ObjectOutputStream
und ObjectInputStream
zur Verfügung.
Java-Objekte in Dateien schreiben mit ObjectOutputStream
Mit ObjectOutputStream.writeObject()
kannst du beliebige Java-Objekte in eine Datei schreiben. Einzige Vorraussetzung ist, dass das Objekt sowie alle davon – direkt und transitiv – referenzierten Objekte serialisierbar sind (d. h. java.io.Serializable
implementieren). Andernfalls wird eine NotSerializableException
geworfen.
Hier ein Beispiel, in dem wir einen String, eine ArrayList
und eine durch List.of()
erstellte Liste in eine Datei schreiben:
public class TestObjectOutputStream1 {
public static void main(String[] args) throws IOException {
try (var out = new ObjectOutputStream(new BufferedOutputStream(
new FileOutputStream("objects1.bin")))) {
// Write a string
out.writeObject("Hello World äöü ß α € ↖ ?");
// Write an array list
ArrayList<Integer> list = new ArrayList();
list.add(42);
list.add(47);
list.add(74);
out.writeObject(list);
// Write an unmodifiable list
out.writeObject(List.of("Hello", "World"));
}
}
}
Code-Sprache: Java (java)
Die erstellte Datei sieht wie folgt aus:
Wir sehen unseren String und können ein paar Klassennamen erkennen, doch weiteres erschließt sich nicht ohne weiteres. Wir wollen an dieser Stelle auch nicht weiter auf das genaue Binärformat eingehen.
Java-Objekte aus Dateien lesen mit ObjectInputStream
Mit folgendem Code lesen wir die Objekte aus der Datei:
public class TestObjectInputStream1 {
public static void main(String[] args) throws IOException,
ClassNotFoundException {
try (var fis = new FileInputStream("objects1.bin");
var in = new ObjectInputStream(new BufferedInputStream(fis))) {
while (true) {
Object o = in.readObject();
System.out.println("o.class = " + o.getClass() + "; o = " + o);
}
} catch (EOFException ex) {
System.out.println("EOF");
}
}
}
Code-Sprache: Java (java)
Die Ausgabe des Programms lautet:
o.class = class java.lang.String; o = Hello World äöü ß α € ↖ ?
o.class = class java.util.ArrayList; o = [42, 47, 74]
o.class = class java.util.ImmutableCollections$List12; o = [Hello, World]
EOF
Code-Sprache: Klartext (plaintext)
Wie du siehst, müssen wir hier die Struktur der Datei, d. h. welche Objekttypen sie in welcher Reihenfolge enthält, nicht kennen. ObjectOutputStream
schreibt die jeweiligen Klassen mit in die Datei und ObjectInputStream
legt die entsprechenden Objekte wieder an.
Eine Besonderheit müssen wir bei ObjectInputStream
beachten: Im try-with-resources-Block ist es an dieser Stelle essenziell sowohl FileInputStream
als auch ObjectInputStream
je einer Variablen zuzuweisen. Folgendes wäre syntaktisch zwar korrekt, semantisch aber falsch:
var out = new ObjectInputStream(new BufferedInputStream(
new FileInputStream("objects1.bin")));
Code-Sprache: Java (java)
Der Grund ist, dass der ObjectInputStream
-Konstruktor eine IOException
werfen kann. Dies passiert, wenn die Binärdatei nicht von einem ObjectOutputStream
geschrieben wurde, somit also von ObjectInputStream
nicht gelesen werden kann. Im Fall einer Exception würde der (zuvor geöffnete) FileInputStream
nicht automatisch geschlossen werden, da nur Objekte geschlossen werden, die im try-Block einer Variablen zugewiesen werden.
Fortgeschrittene Themen der Objekt-Serialisierung und -Deserialisierung
ObjectOutputStream
und ObjectInputStream
sind noch weitaus mächtiger als hier gezeigt. Ihre Funktion – die Serialisierung und Deserialisierung von Java-Objekten – findet nicht nur im Kontext von Dateioperationen Anwendung, sondern beispielsweise auch bei verteilten In-Memory-Caches oder bei Remote Method Invocation.
Da dies kein Tutorial über die Serialisierung und Deserialisierung von Java-Objekten sein soll, gehe ich hier nicht weiter ins Detail (wie "back references", writeUnshared()
, readUnshared()
, writeObject()
, readObject()
, usw.). Ich werde nach Abschluss der Artikelserie über Dateien ein ausführliches Tutorial zu diesen fortgeschrittenen Serialisierungsthemen schreiben.
Zusammenfassung und Ausblick
In diesem Artikel hast du gesehen, wie du mit DataOutputStream
und DataInputStream
primitive Datentypen und Strings in Dateien schreiben und aus ihnen lesen kannst, und wie du mit ObjectOutputStream
und ObjectInputStream
komplexe Java-Objekte schreiben und lesen kannst.
ObjectOutputStream
und ObjectInputStream
haben wir dabei nur oberflächlich betrachtet. Fortgeschrittenen Serialisierungsthemen werde ich in einem separaten Artikel behandeln.
Im nächsten und letzten Artikel stelle ich dir die in Java 1.4 eingeführten FileChannel und ByteBuffer vor. Diese beschleunigen (bei Verwendung von direkten Buffern) insbesondere das Arbeiten mit sehr großen Dateien, erlauben das Sperren von Dateibereichen ("File locks") und das Mappen von Dateien in den Speicher ("memory-mapped files"), um auf diese so einfach wie auf Byte-Arrays zuzugreifen.
Wenn dir dieser Artikel gefallen hat, dann teile ihn gerne über einen der Share-Buttons unten. Wenn du informiert werden möchtest, wenn der nächste Teil veröffentlicht wird, klicke hier, um dich für den HappyCoders-Newsletter anzumelden.