Java NIO Überblick
Einführung in Java NIO und NIO.2 — Channels, Buffer, Selektoren und das java.nio.file-Paket.
Die fünfzehn Kapitel vor diesem behandelten java.io — Streams, Reader/Writer, File, Serializable. Diese API ist Javas ursprüngliches I/O-System und wird noch immer intensiv genutzt. NIO ist die Familie von APIs, die Java später hinzugefügt hat, um das abzudecken, was java.io nicht konnte. Sie besteht aus zwei Teilen, die ein Paketpräfix teilen, aber sonst wenig gemeinsam haben:
- NIO (Java 1.4, 2002) —
java.nio.*— Channels, Buffer, Selektoren. Eine andere Form von I/O: bytebasiert, optional nicht-blockierend, konzipiert für Hochdurchsatz-Server. - NIO.2 (Java 7, 2011) —
java.nio.file.*— die KlassenPath,Files,FileSystemundWatchService. Ein benutzerfreundlicherer Ersatz fürjava.io.Fileund ein Ort für Dateisystemfunktionen, diejava.ionie hatte (symbolische Links, erweiterte Attribute, asynchrone Datei-I/O, Verzeichnisüberwachung).
Du hast seit Beginn dieses Teils Teile von NIO.2 verwendet: Path, Files.newBufferedReader, Files.newInputStream stammen alle aus java.nio.file. Dieses Kapitel zoomt heraus und zeigt, wo diese Bausteine hineinpassen und wofür der Rest des Pakets gedacht ist.
Stream vs. Channel: zwei verschiedene Formen
InputStream.read() gibt ein Byte zurück. OutputStream.write(int) schreibt ein Byte. Das gedankliche Modell ist eine Ein-Byte-pro-Schritt-Pipe. Gepufferte Dekoratoren machen es schnell, aber die Abstraktion ist sequenziell und unidirektional.
Ein Channel (java.nio.channels.Channel) ist bidirektional, bytebasiert und unterstützt Operationen, die InputStream nicht ausdrücken kann:
- Lesen in und Schreiben aus einem
ByteBuffer— nicht aus einembyte[]. - Memory-Mapping eines Dateibereichs in den RAM und Lesen/Schreiben als Buffer.
- Scatter eines Lesevorgangs in mehrere Buffer (Header → einer, Nutzdaten → ein anderer).
- Gather eines Schreibvorgangs aus mehreren Buffern (einzelner
write()erzeugt eine zusammenhängende Ausgabe). - Einen Channel als nicht-blockierend markieren und einen
SelectorTausende davon auf einem Thread multiplexen lassen.
Der Preis dafür ist Ausführlichkeit. Channel-Code liest und schreibt durch einen ByteBuffer mit expliziten flip()- und position()-Aufrufen; java.io verbirgt all das hinter read(byte[]). Für typisches Dateilesen bevorzuge die java.io/Files-APIs. Wechsle zu Channels, wenn du eine der Channel-exklusiven Funktionen benötigst.
// channel-shaped read into a 1 KB buffer
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = ch.read(buf); // fills the buffer; updates position
buf.flip(); // switch to "read what was just written"
while (buf.hasRemaining()) {
process(buf.get());
}
}Der flip()-Schritt ist der Moment, in dem man lernt, dass ByteBuffer seinen eigenen kleinen Zustandsautomaten hat.
ByteBuffer: Position, Limit, Kapazität
Ein ByteBuffer ist ein byte[] mit fester Größe (oder ein Stück Off-Heap-Speicher) plus drei Indizes:
position— das nächste zu lesende oder zu schreibende Byte.limit— der Index hinter dem letzten Byte, das du berühren darfst.capacity— die feste Größe des Buffers; kann sich nicht ändern.
0 ─────── position ─────── limit ─────── capacity
(consumed) (active region) (untouchable / empty)Der Buffer befindet sich konventionsgemäß in einem von zwei Modi:
- Schreibmodus (Standard): du fügst
put(byte)s ein.positionrückt vor;limit == capacity. - Lesemodus: du holst
get()Bytes heraus.positionrückt vor;limitist dort, wo du aufgehört hast zu schreiben.
flip() wechselt vom Schreib- in den Lesemodus: es setzt limit = position (markiert, wo die Daten enden) und setzt position = 0 zurück (beginnt vom Anfang zu lesen). clear() wechselt zurück in den Schreibmodus (position = 0, limit = capacity). Fehler hier sind die häufigste Ursache für die Frustration „Ich lese null Bytes; warum?".
Off-Heap-Buffer (ByteBuffer.allocateDirect(n)) umgehen den JVM-Heap und lassen das Betriebssystem sie direkt lesen/schreiben, ohne eine zusätzliche Kopie. Sie sind langsamer bei der Zuweisung, schneller beim I/O und die richtige Wahl nur für I/O-Code auf dem kritischen Pfad.
Selektoren: ein Thread, viele Channels
Vor virtuellen Threads (Java 21) bedeutete das Bedienen von Tausenden gleichzeitiger Netzwerkverbindungen in Java entweder Tausende von Betriebssystem-Threads (einer pro Verbindung — teuer) oder einen einzelnen Thread, der mit einem Selector multiplext:
Selector sel = Selector.open();
serverChannel.register(sel, SelectionKey.OP_ACCEPT);
while (true) {
sel.select(); // blocks until any channel is ready
for (SelectionKey k : sel.selectedKeys()) {
if (k.isAcceptable()) accept(k);
if (k.isReadable()) read(k);
}
}Das Betriebssystem benachrichtigt die JVM, wenn ein registrierter Channel Fortschritt machen kann; die JVM übergibt dir die bereite Menge; du führst ein nicht-blockierendes Lesen oder Schreiben durch und kehrst zu select() zurück. Der Framework-Code unter Netty, gRPC und Spring WebFlux hat genau diese Form.
Mit virtuellen Threads (Thread.startVirtualThread(...)) skaliert das einfachere „ein Thread pro Anfrage"-Muster auf dieselben Verbindungszahlen ohne die Selector-Choreografie — virtuelle Threads blockieren bei blockierendem I/O im Wesentlichen kostenlos. Für neuen Anwendungscode auf Java 21+ ist die Selektor-Schleife zunehmend ein Bibliotheksthema; du schreibst sie normalerweise nicht von Hand. Für Bibliothekscode und Pre-Loom-JVMs ist es das Standardmuster.
java.nio.file: die moderne Datei-API
Das ist die Hälfte von NIO, die du in alltäglichem Code verwenden wirst. Sie ersetzt java.io.File und die meisten dateibezogenen Teile von java.io:
java.io | java.nio.file | Grund für den Ersatz |
|---|---|---|
File | Path | Unveränderlich, betriebssystemunabhängig, keine eingebauten I/O-Methoden |
File.list() | Files.list(Path), Files.walk(Path) | Stream<Path>; schließbar; beachtet symbolische Links |
new FileInputStream(...) | Files.newInputStream(path) | Zeichensatz-bewusste Varianten für Text; eine einheitliche Open-API |
file.delete() gibt bei Fehler false zurück | Files.delete(path) wirft IOException | Fehler sind sichtbar, nicht still |
| kein Äquivalent | Files.walkFileTree, WatchService, symbolischer Link-API, Dateiattribut-Views | Fähigkeiten, die java.io nie hatte |
Die beiden kommenden Kapitel behandeln Path und Files im Detail. Die Faustregel: Für Dateiarbeit in Java 2024+ greife zu java.nio.file. java.io.File ist noch vorhanden, weil alter Code ihn verwendet, aber neuer Code sollte standardmäßig Path verwenden.
Ein durchgearbeitetes Beispiel: Datei via Channel und Buffer hin- und herkopieren
Das folgende Programm kopiert eine kleine Textdatei auf die Channel-und-Buffer-Weise, um position/limit/flip greifbar zu machen. Es öffnet die Quelle als FileChannel, liest in einen ByteBuffer, flippt, schreibt in einen Ziel-FileChannel und gibt den Buffer-Zustand bei jedem Schritt aus, damit du siehst, wie sich die Indizes bewegen.
Was aus dem Lauf zu entnehmen ist:
- Die Schleife gab den Buffer-Zustand bei jedem Schritt aus. Nach einem
read()warpositiondie Anzahl der gelesenen Bytes undlimitwar nochcapacity— das ist der „Schreibmodus": noch Platz am Ende. Nachflip()warposition = 0undlimit = die zuletzt gelesene Zahl— das ist der „Lesemodus": die Bytes liegen zwischen 0 undlimit. Die zwei Indizes kodieren „wo die Daten liegen" ohne sie zu kopieren. - Der Buffer hatte 16 Bytes; die Datei war 44 Bytes groß. Die Schleife lief drei Iterationen: 16, 16, 12. Sobald der Buffer leer war (nachdem
writeihn geleert hatte), setzteclear()ihn zurück in den „Schreibmodus", damit der nächsteread()ihn neu füllen konnte. Das ist das Channel-Muster im Kleinen: füllen, flippen, leeren, bereinigen, wiederholen. transferToerledigte dieselbe Kopie in einer Zeile ohne jeglichenByteBuffer. Unter Linux entspricht das einem einzelnensendfile()-Syscall — die Bytes wandern Kernel-zu-Kernel, ohne die JVM zu passieren. Wenn du Daten zwischen zwei Channels verschiebst und sie dir nicht ansehen musst, ist das das richtige Werkzeug.- Beachte, dass die Quelldatei mit
Files.writeStringerstellt und das Ziel mitFiles.readStringzurückgelesen wurde — beides sindjava.nio.file-Einzeiler, die Channels und Buffer vollständig verbergen. Die detaillierte Channel-Schleife in der Mitte wäre etwas, das du nur schreiben würdest, wenn du direkten Buffer-Zugriff benötigst (benutzerdefiniertes binäres Parsen, Memory-Mapping, Scatter/Gather). Für „eine Datei kopieren" isttransferTooderFiles.copykürzer und mindestens genauso schnell. - Der
FileChannel.open(path, OPTION)-Konstruktor ist das Pendant zuFiles.newInputStream(path). DasStandardOpenOption-Enum (READ, WRITE, CREATE, APPEND, TRUNCATE_EXISTING, ...) steuert das Öffnungsverhalten — es gibt nur eine Stelle, an der man nachschlagen muss. Dieses Open-Options-Enum taucht im nächsten Kapitel immer wieder auf.
Was kommt als Nächstes
Dieses Kapitel hat die Bausteine benannt — Channels, Buffer, Selektoren, java.nio.file. Das nächste Kapitel, Java Path-Klasse, geht auf den benutzerfreundlichsten dieser Bausteine ein — Path — und die Methoden (resolve, relativize, normalize), die du jedes Mal verwendest, wenn du einen Dateisystempfad berührst.