Java Byte-Streams
Binärdaten in Java lesen und schreiben mit InputStream, OutputStream, FileInputStream und FileOutputStream.
Kapitel 1 stellte das java.io-Design als einen Stapel von Dekoratoren vor: ein roher Stream am unteren Ende, Funktionsschichten drum herum, die oberste Schicht legt die API frei, die man aufruft. Die ersten sechs Kapitel dieses Teils lebten oben in diesem Stapel — Files.readString, Files.lines, Files.writeString. Dieses Kapitel geht eine Ebene tiefer zur byte-orientierten Abstraktion, auf der der gesamte Stapel aufgebaut ist: InputStream und OutputStream.
Jede Datei, jeder Socket, jede Pipe und jeder In-Memory-Puffer in java.io ist — im Kern — ein Strom von Bytes. Selbst eine UTF-8-Textdatei besteht auf der Festplatte aus Bytes; die Sichtweise „das ist Text" entsteht durch einen Reader, der über einem InputStream aufgebaut ist. Die Byte-API zu kennen ist wichtig, wenn die Daten kein Text sind (Bilder, Audio, Archive, Netzwerkprotokolle), wenn Bytes kopiert werden sollen ohne sie zu dekodieren, und wenn man verstehen möchte, was die höherwertigen APIs wirklich tun.
Der InputStream-Vertrag
InputStream ist eine abstrakte Klasse mit einer einzigen Methode:
public abstract int read() throws IOException;Sie gibt das nächste Byte als int im Bereich 0..255 zurück oder -1, wenn der Stream erschöpft ist. Das int ist kein Fehler: Ein byte in Java ist vorzeichenbehaftet (-128..127), aber der Stream-Vertrag ist vorzeichenlos, daher macht der breitere Rückgabetyp das „Ende des Streams" (-1) von einem echten Byte-Wert unterscheidbar (0xFF liest sich als 255, nicht als -1).
Drei weitere Methoden sind auf read() aufgebaut und werden normalerweise aufgerufen:
int read(byte[] buf); // read up to buf.length bytes; return count or -1
int read(byte[] buf, int off, int len); // same, into a slice
byte[] readAllBytes(); // Java 9+: read everything into a byte[]
long transferTo(OutputStream out); // Java 9+: pipe straight to a sink, no copy loopreadAllBytes() ist die praktische Lösung für kleine Dateien; transferTo ist die praktische Lösung zum Kopieren ohne Dekodierung. Für alles andere gibt es die gepufferte Leseschleife, die die kanonische Form ist:
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n); // n bytes, not buf.length — the last chunk is short
}Zwei Dinge sind zu verinnerlichen. Erstens gibt read(byte[]) zurück, wie viele Bytes tatsächlich gelesen wurden, nicht immer buf.length. Das letzte Lesen ist fast immer unvollständig; den Puffer als voll zu behandeln korrumpiert die Daten. Zweitens sind read() und read(byte[]) blockierend — sie kehren zurück, wenn mindestens ein Byte verfügbar ist oder der Stream endet. Sie kehren nicht vorzeitig bei einer langsamen Festplatte oder einem langsamen Socket zurück.
Überspringen, Vorausschauen und Zurückspulen
InputStream definiert auch drei Methoden, die seltener benötigt werden, aber bekannt sein sollten:
long skip(long n); // discard up to n bytes without copying them anywhere
int available(); // bytes you can read right now without blocking — an estimate, not a length
boolean markSupported();
void mark(int readAheadLimit); // remember this position
void reset(); // jump back to the last markHier lauern zwei Fallen. available() ist nicht die Größe des Streams — bei einer Datei ist es das oft, aber bei einem Socket bedeutet es „bereits gepufferte Bytes", was mitten in einer Übertragung 0 sein kann. Nie new byte[in.available()] schreiben und annehmen, man habe alles gelesen. Und mark/reset funktionieren nur, wenn markSupported() true zurückgibt; ein roher FileInputStream gibt false zurück, also in einem BufferedInputStream (nächstes Kapitel) verpacken, wenn man vorausschauen und zurückgehen muss.
Der OutputStream-Vertrag
Die Spiegelklasse ist OutputStream, ebenfalls mit einer einzigen abstrakten Methode:
public abstract void write(int b) throws IOException;Sie schreibt die unteren 8 Bits von b und ignoriert den Rest. Die praktischen Überladungen sind:
void write(byte[] buf); // write the whole array
void write(byte[] buf, int off, int len); // write a slice — this is the one you usually want
void flush(); // push buffered data to the OS
void close(); // flush + release resourcesflush() ist nur dann wichtig, wenn der Stream puffert. Roher FileOutputStream tut das nicht — jeder write-Aufruf ruft das Betriebssystem auf — daher ist flush ein No-op. BufferedOutputStream (nächstes Kapitel) ist der Ort, wo Pufferung und die Notwendigkeit zum Flushen leben.
close() ruft zuerst flush() auf. Deshalb kürzt „das gepufferte Schreiben vergessen zu schließen" die Datei stillschweigend ab: Der Restpuffer liegt im Speicher und wartet auf ein Flush, das nie kommt.
Konkrete Byte-Streams
Die konkreten Unterklassen, die man tatsächlich instanziiert:
| Klasse | Was sie umschließt |
|---|---|
FileInputStream / FileOutputStream | Eine Datei auf der Festplatte. Öffnet einen Dateideskriptor. |
ByteArrayInputStream / ByteArrayOutputStream | Ein byte[] im Speicher. Nützlich für Tests und zum Erfassen von Ausgaben. |
BufferedInputStream / BufferedOutputStream | Eine gepufferte Sicht auf einen anderen Stream. |
PipedInputStream / PipedOutputStream | Eine Producer/Consumer-Pipe zwischen Threads. |
DataInputStream / DataOutputStream | Über einen Byte-Stream geschichtet, um Primitiven portabel zu lesen/schreiben. |
FileInputStream und FileOutputStream sind die rohen Datei-Streams. Sie sind ungepuffert: Jeder read()/write()-Aufruf ist ein Systemaufruf. Das ist katastrophal bei Byte-für-Byte-Schleifen — Millionen von Systemaufrufen — und nur akzeptabel bei Chunk-Lesevorgängen mit einem 8-KB-Puffer oder größer. Das gepufferte Kapitel ist das, was die Byte-für-Byte-API erschwinglich macht.
// Raw, unbuffered — fine for chunked reads
try (FileInputStream in = new FileInputStream("photo.jpg")) {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) { /* process buf[0..n] */ }
}
// Equivalent one-liner, Java 7+
byte[] all = Files.readAllBytes(Path.of("photo.jpg"));Files.readAllBytes ist der richtige Aufruf für kleine Dateien; für alles, was möglicherweise nicht in den Speicher passt, ist die Chunk-Schleife die sichere Form.
Drei Muster, die man sich merken sollte
Die drei Dinge, die mit Byte-Streams immer wieder gemacht werden:
// 1. Copy a file
try (InputStream in = Files.newInputStream(src);
OutputStream out = Files.newOutputStream(dst)) {
in.transferTo(out); // Java 9+: no manual loop
}
// Java 7+ one-liner: Files.copy(src, dst);
// 2. Read everything into memory
byte[] all = Files.readAllBytes(path); // small-file shortcut
// 3. Build a byte[] you don't know the size of in advance
ByteArrayOutputStream baos = new ByteArrayOutputStream();
in.transferTo(baos);
byte[] bytes = baos.toByteArray();ByteArrayOutputStream ist die „wächst nach Bedarf"-Byte-Senke. So implementiert das JDK selbst readAllBytes() für Streams, deren Länge vorab nicht bekannt ist. Es wirft bei write nie eine Ausnahme (bis der Heap ausgeht) und hat keine nennenswerte close()-Semantik, was es zur Standard-Test-Fixture für „was hat dieser Writer produziert" macht.
Wann man zu Byte-Streams greift
Die ehrliche Antwort: wenn die Daten kein Text sind. Alles Binäre — Bilder, Audio, Video, Archive (.zip, .tar), Executables, Protocol Buffers, benutzerdefinierte Dateiformate — sind Bytes und bleiben Bytes.
Wenn die Daten Text sind, sollte man die Zeichen-Stream-Seite bevorzugen (Reader/Writer, nächstes Kapitel) oder das moderne Files.readString / Files.lines. Eine Textdatei als rohe Bytes zu lesen und manuell zu dekodieren ist der klassische Weg, seinen eigenen Charset-Fehler zu erfinden — UTF-8-Mehrbytezeichen werden über read()-Aufrufe hinweg gesplittet und falsch zusammengesetzt. Die Reader-Schicht existiert genau deshalb, damit man darüber nicht nachdenken muss.
Ein ausgearbeitetes Beispiel: Kopieren, Hashen und Erfassen
Das folgende Programm übt die Byte-Stream-API von Anfang bis Ende. Es schreibt eine kleine Binärdatei (einen Header plus Nutzdaten), liest sie in Chunks für eine Prüfsumme zurück, kopiert sie mit transferTo in eine zweite Datei und erfasst eine weitere Kopie in einem ByteArrayOutputStream, um die In-Memory-Senke in Aktion zu zeigen. Die temporären Dateien bereinigen sich beim Beenden.
Was aus dem Programmlauf zu entnehmen ist:
- Die Schreibseite verwendete
Files.newOutputStream— eineFiles-Fabrikmethode, die ein einfachesOutputStreamzurückgibt. Sobald man es hat, ist die API dieselbe, die Java seit 1.0 hat. Die Fabrik erspart nur das Konstruieren vonFileOutputStreamund das Nachdenken über Öffnungsoptionen. - Die Leseschleife verwendete
n, nichtbuf.length, beim Aufruf voncrc.update. Der Grund steht in der Ausgabezeile: „read in N chunks." Der Puffer war 256 Bytes groß und die Datei war 1004 Bytes, also war das letzte Chunk unvollständig.buf.lengthzu verwenden hätte Müll nach den echten Daten gehasht. in.transferTo(out)ist die getestete Kopierschleife des JDK. Sie ist auf den meisten JVMs messbar schneller als eine handgeschriebene Schleife, weil sie einen 16-KB-Puffer verwenden und die Safepoint-Prüfungen überspringen kann, und ist eine Zeile statt fünf. Man sollte immer darauf zurückgreifen, wenn man sonst einewhile ((n = in.read(buf)) != -1)-Schleife ohne andere Logik darin schreiben würde.ByteArrayOutputStreamwurde direkt intransferToeingesteckt. Es sieht aus wie eine Datei, lebt aber im Speicher — dieselbe API. Diese Symmetrie machtjava.iotestbar: EinenByteArrayInputStreamfür die Quelle übergeben, einenByteArrayOutputStreamfür die Senke, und Code, der „in eine Datei schreibt", lässt sich als Unit-Test ausführen ohne die Festplatte zu berühren.- Der abschließende Block gab
255dann-1aus. Das ist der Vertrag:0xFFist ein gültiger Byte-Wert und liest sich als255;-1ist der außerhalb des Bandes liegende Sentinel, der sagt „keine weiteren Bytes." Das Ergebnis alsbyte(stattint) zu behandeln und mit== -1zu vergleichen würde ein echtes0xFFstillschweigend als Ende des Streams behandeln. Das Ergebnis immer in einemintspeichern und mit-1vergleichen, bevor man es castet.
Was kommt als Nächstes
Bytes sind die richtige Abstraktion für Binärdaten. Das nächste Kapitel, Java Character-Streams, behandelt die parallele Hierarchie für Text — Reader und Writer, Charset-Überbrückung und warum „einfach new FileReader(path)" die klassische Quelle von „funktioniert auf meinem Rechner, kaputt auf dem Server"-Fehlern ist.