Zum Inhalt springen

FileChannel in Java: ByteBuffer, Memory-Mapped I/O, File Locks

Bündel von Glasfasern, in denen orangefarbene Lichtpunkte laufen

In den bisherigen fünf Teilen dieser Artikelserie ging es um das Lesen und Schreiben von Dateien, die Konstruktion von Verzeichnis- und Dateipfaden, Verzeichnis- und Dateioperationen sowie das Schreiben und Lesen strukturierter Daten.

Im heutigen Teil erkläre ich die in Java 1.4 mit dem JSR 51 („New I/O APIs for the Java™ Platform“) eingeführten NIO-FileChannel und ByteBuffer und zeige, welche Möglichkeiten es gibt, damit Dateien zu lesen und zu schreiben, und was die Vorteile gegenüber den bisher vorgestellten Methoden sind.

Im Einzelnen lernst du:

  • Was sind FileChannel und ByteBuffer und was sind deren Vorteile?
  • Wie schreibt und liest man Dateien mit FileChannel und ByteBuffer?
  • Was sind memory-mapped Files und deren Vorteile?
  • Wie mappst du seit Java 22 auch Dateien über 2 GB in den Speicher?
  • Wie können einzelne Bereiche einer Datei gesperrt werden?
  • Welche Schreibmethode hat die beste Performance?

Den Code aus diesem Artikel findest du in meinem GitHub-Repository.

Begriffserklärungen

Was ist ein FileChannel?

Ein Channel ist eine Kommunikationsverbindung zu einer Datei, zu einem Socket oder einer anderen Komponente, die I/O-Funktionalität zur Verfügung stellt. Im Gegensatz zu einem InputStream oder OutputStream ist der Channel bidirektional, das heißt, er kann zum Schreiben und Lesen verwendet werden.

Ein FileChannel ist ein spezieller Channel für die Verbindung zu einer Datei.

Was ist ein ByteBuffer?

Ein ByteBuffer ist im Grunde ein Byte-Array (auf dem Heap oder im nativen Speicher), kombiniert mit Schreib- und Lesefunktionen. So kann in den ByteBuffer geschrieben bzw. daraus gelesen werden, ohne die Position der geschriebenen bzw. gelesenen Daten innerhalb des zugrunde liegenden Arrays kennen zu müssen.

Wie ein ByteBuffer genau funktioniert, erfährst du im Hauptartikel über ByteBuffer.

Dateizugriff mit FileChannel + ByteBuffer

Um Daten in einen FileChannel zu schreiben oder sie daraus zu lesen, benötigt man einen ByteBuffer.

Zugriff auf eine Datei über FileChannel und ByteBuffer
Zugriff auf eine Datei über FileChannel und ByteBuffer

Daten werden mit put() in den ByteBuffer gelegt und dann mit FileChannel.write(buffer) vom Buffer in die Datei geschrieben. FileChannel.write() ruft dabei auf dem Buffer get() auf, um die Daten zu entnehmen.

Mit FileChannel.read(buffer) werden Daten aus der Datei gelesen. Die read()-Methode legt die Daten mit put() in den ByteBuffer, woraus du sie dann mit get() wieder entnehmen kannst.

Vorteile von FileChannel

Der FileChannel bietet gegenüber den in den ersten zwei Teilen der Serie vorgestellten Klassen FileInputStream und FileOutputStream die folgenden Vorteile:

  • Du kannst an beliebiger Position innerhalb der Datei schreiben und lesen.
  • Du kannst das Betriebssystem veranlassen, geänderte Daten vom Cache auf das Speichermedium zu schreiben („force“).
  • Bereiche der Datei können in den Speicher gemappt werden („memory-mapped file“), was besonders effizienten Datenzugriff erlaubt.
  • Bereiche der Datei können für andere Prozesse gesperrt werden („file lock“).
  • Daten können direkt von einem Channel in einen anderen übertragen werden („transferTo“), ohne den Umweg über einen ByteBuffer.

Dateien lesen und schreiben mit FileChannel und ByteBuffer

In diesem Kapitel zeige ich dir anhand von Code-Beispielen, wie du mit FileChannel und ByteBuffer Daten schreiben und lesen kannst, wie du auf bestimmte Positionen innerhalb der Datei zugreifst, wie du die Dateigröße ermittelst und änderst, wie du das Schreiben vom Cache aufs Speichermedium veranlasst und wie du Daten direkt von einem Channel in einen anderen überträgst.

Wie liest man eine Datei mit FileChannel?

Öffnen eines FileChannels zum Lesen einer Datei

Um eine Datei zu lesen, muss zunächst ein FileChannel geöffnet werden. Die direkteste Variante dafür ist:

Path path = ...;
FileChannel channel = FileChannel.open(path, StandardOpenOption.READ);

(Wie du ein Path-Objekt konstruierst, kannst du im dritten Teil dieser Serie nachlesen.)

Alternativ kannst du einen FileChannel zum Lesen auch über RandomAccessFile erzeugen:

Path path = ...;
RandomAccessFile file = new RandomAccessFile(path.toFile(), "r");
FileChannel channel = file.getChannel();

... oder über einen FileInputStream:

Path path = ...;
FileInputStream in = new FileInputStream(path.toFile());
FileChannel channel = in.getChannel();

Welche Variante du wählst, macht in diesem Beispiel keinen Unterschied. Im Endeffekt erstellen die getChannel()-Methoden einen neuen FileChannel anhand der im RandomAccessFile bzw. FileInputStream hinterlegten Datei-Informationen. Obwohl du also z. B. aus einem FileInputStream die Daten nur sequentiell lesen kannst, gilt diese Einschränkung nicht für den über den FileInputStream erstellten FileChannel.

Allerdings werden die readable- und writable-Flags entsprechend gesetzt:

  • Einen über FileInputStream.getChannel() erzeugten FileChannel kannst du nur zum Lesen verwenden.
  • Einen über RandomAccessFile.getChannel() erzeugten FileChannel kannst du zum Lesen und Schreiben verwenden.
  • Wie du einen über FileChannel.open() erstellten FileChannel verwenden kannst, bestimmst du über die im zweiten Parameter übergebenen Optionen. Da wir im Beispiel oben StandardOpenOption.READ übergeben haben, ist in diesem Fall nur Lesezugriff erlaubt. (Eine Übersicht der verfügbaren Optionen findest du im JavaDoc von StandardOpenOption.)

Lesen einer Datei mit FileChannel und ByteBuffer

Wenn du einen FileChannel geöffnet hast, kannst du daraus mit FileChannel.read() in einen ByteBuffer lesen. Das folgende Beispiel liest Blöcke von jeweils 1.024 Bytes, gibt deren jeweilige Länge sowie deren erstes und letztes Byte aus – bis das Ende der Datei erreicht ist:

Path path = Path.of("read-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.READ)) {
  ByteBuffer buffer = ByteBuffer.allocate(1024);

  int bytesRead;
  while ((bytesRead = channel.read(buffer)) != -1) {
    System.out.printf("bytes read from file: %d%n", bytesRead);
    if (bytesRead > 0) {
      System.out.printf("  first byte: %d, last byte: %d%n",
              buffer.get(0), buffer.get(bytesRead - 1));
    }
    buffer.rewind();
  }
}

Mit channel.read(buffer) lesen wir so viele Bytes wie möglich aus der Datei und schreiben sie in den Buffer. Mit buffer.get(index) lesen wir einzelne Bytes aus dem Buffer, ohne dessen Leseposition vorher setzen zu müssen und ohne sie zu verändern. Mit buffer.rewind() müssen wir am Ende der Schleife den Buffer „zurückspulen“, so dass er erneut gefüllt werden kann.

Lesen einer Datei mit ByteBuffer.flip() und compact()

Im folgenden, zweiten Beispiel gehen wir etwas anders vor. Hier lesen wir alle Bytes des Buffers und addieren sie. Anstatt auf die Daten mit buffer.get(index) zuzugreifen, verwenden wir zunächst buffer.flip(), um die Leseposition auf den Anfang des Buffers zu setzen und danach buffer.get(), um einzelne Bytes von der aktuellen Leseposition zu lesen.

Wir lesen nicht den gesamten Buffer aus, sondern nur eine zufällige Anzahl an Bytes, und simulieren damit, dass wir die gelesenen Daten nicht vollständig verarbeiten können. Danach schalten wir mit buffer.compact() in den Buffer-Schreibmodus zurück und lesen weitere Bytes aus dem FileChannel. Ich empfehle zum besseren Verständnis den Artikel Java ByteBuffer: Wie funktionieren flip() und compact() zu lesen.

Path path = Path.of("read-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.READ)) {
  ByteBuffer buffer = ByteBuffer.allocate(1024);

  int bytesRead;
  while ((bytesRead = channel.read(buffer)) != -1) {
    System.out.printf("bytes read from file: %d%n", bytesRead);

    long sum = 0;

    buffer.flip();
    int numBytesToRead =
            ThreadLocalRandom.current().nextInt(buffer.remaining());
    for (int i = 0; i < numBytesToRead; i++) {
      sum += buffer.get();
    }

    System.out.printf("  bytes read from buffer: %d, sum of bytes: %d%n",
            numBytesToRead, sum);
    buffer.compact();
  }
}

In der Ausgabe sehen wir, dass beim ersten Aufruf von channel.read() 1.024 Bytes aus der Datei gelesen werden und bei jedem weiteren Aufruf genauso viele Bytes, wie wir zuvor aus dem Buffer ausgelesen haben (und entsprechend im Buffer wieder Platz frei geworden ist).

Die Bytes, die nach dem letzten channel.read() noch im Buffer liegen, verarbeitet die Demo nicht mehr. In echtem Code würdest du den Buffer nach der Schleife noch einmal mit flip() in den Lesemodus schalten und leer lesen.

Wie schreibt man eine Datei mit FileChannel?

Öffnen eines FileChannels zum Schreiben in eine Datei

Zum Schreiben einer Datei muss zunächst wieder ein FileChannel geöffnet werden. Dies funktioniert analog zum Lesen:

Path path = ...;
FileChannel channel = FileChannel.open(path,
    StandardOpenOption.CREATE, StandardOpenOption.WRITE);

Anstatt StandardOpenOption.READ übergeben wir hier StandardOpenOption.WRITE. Zusätzlich gebe ich im Beispiel StandardOpenOption.CREATE mit an, so dass die Datei erstellt wird, falls sie nicht existiert.

Weitere für den Schreibvorgang relevante Optionen sind:

  • StandardOpenOption.CREATE_NEW: Die Datei wird erstellt, wenn sie noch nicht existiert; andernfalls wird eine FileAlreadyExistsException geworfen.
  • StandardOpenOption.APPEND: Daten werden an die Datei angehängt. Vor jedem Schreibvorgang setzt der FileChannel seine Position ans Ende der Datei – auch wenn du sie vorher mit position() woandershin gesetzt hast.
  • StandardOpenOption.TRUNCATE_EXISTING: Die Datei wird vor dem Schreiben komplett geleert. Diese Option kann nicht zusammen mit APPEND verwendet werden.

Analog zum Lesen kannst du einen beschreibbaren FileChannel auch über RandomAccessFile.getChannel() oder FileOutputStream.getChannel() erstellen.

Schreiben in eine Datei mit ByteBuffer.flip() und compact()

Im folgenden Beispiel wird zehnmal eine zufällige Anzahl an Bytes in den ByteBuffer geschrieben und dann von dort in den FileChannel:

Path path = Path.of("write-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

  ByteBuffer buffer = ByteBuffer.allocate(1024);

  for (int i = 0; i < 10; i++) {
    int bytesToWrite =
            ThreadLocalRandom.current().nextInt(buffer.capacity());
    for (int j = 0; j < bytesToWrite; j++) {
      buffer.put((byte) ThreadLocalRandom.current().nextInt(256));
    }

    buffer.flip();
    channel.write(buffer);
    buffer.compact();
  }

  // channel.write() doesn't guarantee all data to be written to the channel.
  // If there are remaining bytes in the buffer, write them now.
  buffer.flip();
  while (buffer.hasRemaining()) {
    channel.write(buffer);
  }
}

Die Schreibposition des Buffers steht zunächst auf 0. Mit buffer.put() füllen wir den ByteBuffer bis zu einer zufälligen Position. Mit buffer.flip() schalten wir in den Buffer-Lesemodus, mit channel.write(buffer) schreiben wir den Inhalt des Buffers in die Datei, und mit buffer.compact() schalten wir den Buffer zurück in den Schreibmodus.

Beim Aufruf von channel.write(buffer) wird nicht garantiert, dass der gesamte Inhalt des Buffers in den Channel geschrieben wird. Deshalb müssen wir am Ende so lange channel.write(buffer) aufrufen, bis buffer.hasRemaining() den Wert false zurückliefert, d. h. der Buffer keine weiteren Daten mehr enthält.

Auch an dieser Stelle empfehle ich noch einmal zum besseren Verständnis des ByteBuffers den Artikel Java ByteBuffer: Wie funktionieren flip() und compact() zu lesen.

Wie liest man Daten von bzw. schreibt Daten an einer bestimmten Position?

Die Lese- bzw. Schreibposition innerhalb eines Channels kann jederzeit mit FileChannel.position() gelesen und mit FileChannel.position(newPosition) neu gesetzt werden. Im folgenden Beispiel wird eine Datei von hinten nach vorne mit den Bytes 0xff bis 0x00 beschrieben. Danach wird der Inhalt an zehn zufälligen Positionen ausgelesen und auf dem Bildschirm ausgegeben.

Path path = Path.of("position-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.WRITE,
        StandardOpenOption.READ)) {

  ByteBuffer buffer = ByteBuffer.allocate(1);

  // Write backwards
  for (int pos = 255; pos >= 0; pos--) {
    buffer.put((byte) pos);
    buffer.flip();
    channel.position(pos);
    while (buffer.remaining() > 0) {
      channel.write(buffer);
    }
    buffer.compact();
  }

  // Read from random positions
  for (int i = 0; i < 10; i++) {
    long pos = ThreadLocalRandom.current().nextLong(channel.size());
    channel.position(pos);
    channel.read(buffer);
    buffer.flip();
    byte b = buffer.get();
    System.out.printf("Byte at position %d: %d%n", pos, b);
    buffer.compact();
  }
}

Im Abschnitt „Memory-mapped Files“ wirst du sehen, wie du dies deutlich eleganter machen kannst.

Wie ermittelt man die Dateigröße?

Die Dateigröße wird wie folgt ermittelt:

long fileSize = channel.size();

Wie ändert man die Dateigröße?

Wie vergrößert man eine Datei?

Beim Schreiben in eine Datei wird die Datei automatisch vergrößert, wenn du über das Ende der Datei hinaus schreibst.

Beispielsweise könnte man wie folgt (vorausgesetzt, sie existiert noch nicht) eine Datei mit 1 GB Größe erstellen, die 230 − 1 Nullen enthält und eine Eins:

Path path = Path.of("1g-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

  ByteBuffer buffer = ByteBuffer.allocate(1);
  buffer.put((byte) 1);
  buffer.flip();

  channel.position((1 << 30) - 1);
  channel.write(buffer);
}

Wie verkleinert man eine Datei?

Um eine Datei zu verkleinern, muss man channel.truncate() aufrufen. Im folgenden Beispiel wird die zuvor erstellte 1-GB-Datei auf 1 KB gekürzt:

Path path = Path.of("1g-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.WRITE)) {
  channel.truncate(1 << 10);
}

Wenn die angegebene neue Größe größer oder gleich der aktuellen Größe ist, hat der Aufruf von truncate() keine Auswirkung.

Wie erzwingt man das Schreiben aufs Speichermedium?

Aus Performance-Gründen cacht das Betriebssystem Änderungen an Dateien und schreibt diese normalerweise nicht sofort auf das Speichermedium.

Mit channel.force(boolean metaData) kann das Betriebssystem veranlasst werden, die Daten unverzüglich zu schreiben. Über den Parameter metaData wird festgelegt, ob auch Metadaten (wie der Zeitpunkt der letzten Änderung und des letzten Zugriffs) auf das Speichermedium geschrieben werden sollen:

  • Gibt man hier true an, veranlasst die Methode auch das sofortige Schreiben der Metadaten, was zusätzliche I/O-Operationen erfordert und damit länger dauert.
  • Übergibt man hier false, wird nur der Inhalt der Datei geschrieben.

Es ist nicht garantiert, dass der Wert von metaData auf allen Betriebssystemen berücksichtigt wird.

Wie überträgt man Daten von einem Channel in einen anderen?

Mit transferTo() und transferFrom() kopierst du Daten direkt von einem Channel in einen anderen, ohne sie durch einen ByteBuffer zu schleusen. Das folgende Beispiel kopiert eine Datei komplett in eine andere:

Path source = Path.of("source.bin");
Path target = Path.of("target.bin");
try (FileChannel in = FileChannel.open(source, StandardOpenOption.READ);
     FileChannel out = FileChannel.open(target,
         StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
  long position = 0;
  long size = in.size();
  while (position < size) {
    position += in.transferTo(position, size - position, out);
  }
}

transferTo(position, count, target) überträgt bis zu count Bytes ab position in den Ziel-Channel und liefert die Anzahl der tatsächlich übertragenen Bytes zurück. Die Schleife ist nötig, denn wie write() garantiert auch transferTo() nicht, alles auf einmal zu übertragen.

Viele Betriebssysteme kopieren die Daten dabei direkt aus dem Dateisystem-Cache in den Ziel-Channel, ohne sie in den Speicher des Programms zu laden – deshalb kann die Methode deutlich effizienter sein als eine Lese-Schreib-Schleife mit ByteBuffer. transferFrom(src, position, count) überträgt in die andere Richtung, aus einem beliebigen Channel in die Datei.

Zum Kopieren einer Datei in eine andere bleibt Files.copy() der kürzere Weg. transferTo() lohnt sich, wenn Quelle oder Ziel keine Datei ist – etwa ein SocketChannel.

Memory-mapped Files: Wie man einen Teil einer Datei in den Speicher mappt

Eine besondere Art von ByteBuffer ist der MappedByteBuffer – dabei wird ein Teil einer Datei direkt in den Arbeitsspeicher gemappt (auf Englisch: „memory-mapped file“). Dies erlaubt einen besonders effizienten Zugriff auf die Datei ohne Umwege über FileChannel.write() und read(). Auf den MappedByteBuffer kann man wie auf ein Byte-Array zugreifen, d. h. an beliebigen Stellen schreiben und von beliebigen Stellen lesen. Änderungen werden im Hintergrund transparent in die Datei geschrieben.

Das direkte Mapping spart bei jedem Zugriff eine Kopie. Beim herkömmlichen Schreiben kopiert channel.write() die Daten aus dem ByteBuffer im User Space (dem Speicher des Programms) in den Page Cache des Betriebssystems im Kernel Space; von dort schreibt das Betriebssystem sie auf das Speichermedium. Ein memory-mapped File blendet die Seiten des Page Cache direkt in den Adressraum des Programms ein – die Kopie zwischen User Space und Kernel Space entfällt:

Herkömmliches Schreiben mit FileChannel.write() kopiert die Daten vom ByteBuffer im User Space in den Page Cache im Kernel Space; ein memory-mapped File blendet den Page Cache direkt in den User Space ein
Ein memory-mapped File spart die Kopie zwischen User Space und Kernel Space

Im Folgenden wird das Beispiel aus dem Abschnitt Wie liest man Daten von bzw. schreibt Daten an einer bestimmten Position?, in dem eine Datei von hinten nach vorne beschrieben und dann an zufälligen Positionen gelesen wurde, mit einem MappedByteBuffer – deutlich eleganter – neu geschrieben:

Path path = Path.of("mapped-byte-buffer-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.WRITE,
        StandardOpenOption.READ)) {
  MappedByteBuffer buffer =
          channel.map(FileChannel.MapMode.READ_WRITE, 0, 256);

  // Write backwards
  for (int pos = 255; pos >= 0; pos--) {
    buffer.put(pos, (byte) pos);
  }

  // Read from random positions
  for (int i = 0; i < 10; i++) {
    int pos = ThreadLocalRandom.current().nextInt((int) channel.size());
    byte b = buffer.get(pos);
    System.out.printf("Byte at position %d: %d%n", pos, b);
  }
}

Besonderheiten von Memory-mapped Files

Folgendes ist bei der Verwendung von memory-mapped Files zu beachten:

  • Die Position und Größe des zu mappenden Bereichs muss zu Beginn angegeben werden. Im Beispiel werden die ersten 256 Bytes gemappt. Wenn die Datei nicht existiert, wird eine 256 Byte große Datei erstellt. Existiert die Datei und ist kleiner, wird sie auf 256 Byte vergrößert. Ist die Datei größer, bleibt ihre Größe sowie der Inhalt nach den ersten 256 Bytes unverändert.
  • Über map() lassen sich maximal 2 GB mappen – genauer: Integer.MAX_VALUE Bytes, denn ein ByteBuffer adressiert seinen Inhalt mit einem int. Seit Java 22 gibt es eine zweite Variante von map(), die ein MemorySegment liefert; dessen Größe ist ein long, und das Limit entfällt. Wie das geht, zeige ich im Abschnitt „Memory-mapped Files ab Java 22“.
  • Der MappedByteBuffer implementiert nicht das Closeable-Interface. Deshalb können wir ihn im Beispiel oben auch nicht im try-Block erstellen. Es gibt auch keine Methode, um ihn manuell zu „un-mappen“. Unter Windows lässt sich eine gemappte Datei deshalb nicht löschen – der Versuch endet mit einer AccessDeniedException; Linux und macOS erlauben das Löschen. Un-gemappt wird die Datei erst, wenn der Garbage Collector den MappedByteBuffer entfernt: Beim Erzeugen registriert der MappedByteBuffer einen sogenannten Cleaner, der aufgerufen wird, sobald der Buffer nur noch „phantom reachable“ ist – wann das passiert, entscheidet der Garbage Collector. Wer die Datei deterministisch freigeben will, nimmt seit Java 22 die MemorySegment-Variante. Der frühere Ausweg über sun.misc.Unsafe.invokeCleaner() ist seit Java 23 zur Entfernung markiert.
  • Änderungen am MappedByteBuffer schreibt das Betriebssystem im Hintergrund in die Datei – wann, entscheidet es selbst. Um das Schreiben auf das Speichermedium zu erzwingen, musst du buffer.force() aufrufen; seit Java 13 kannst du mit buffer.force(index, length) einen Teilbereich schreiben. channel.force() hingegen garantiert laut JavaDoc nur für Daten, die über die Schreibmethoden des Channels geschrieben wurden, dass sie das Speichermedium erreichen. Die beiden Methoden rufen unterschiedliche Systemfunktionen auf: channel.force() ruft fsync() für die ganze Datei auf (unter Windows FlushFileBuffers()), buffer.force() ruft msync() für den gemappten Speicherbereich auf (unter Windows FlushViewOfFile()). Ob fsync() auch die über das Mapping geänderten Seiten mitschreibt, hängt vom Betriebssystem ab.

Erstellung eines MappedByteBuffer von einem FileInputStream / FileOutputStream

Wir haben oben gesehen, dass wir auch aus einem FileInputStream oder einem FileOutputStream mit getChannel() einen FileChannel erzeugen können. Was passiert, wenn wir versuchen, diesen in den Speicher zu mappen?

Da der FileChannel unabhängig vom FileInputStream bzw. FileOutputStream ist, ist Folgendes problemlos möglich:

var fis = new FileInputStream(fileName);
var channel = fis.getChannel();
var map = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());

Was allerdings nicht funktioniert, ist Folgendes:

var fos = new FileOutputStream(fileName);
var channel = fos.getChannel();
var map = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());

Der Aufruf channel.map() führt hierbei zu einer NonReadableChannelException, da der durch FileOutputStream.getChannel() erzeugte FileChannel nur ein Schreiben erlaubt – der MapMode.READ_WRITE hingegen auch einen Lesezugriff erfordert.

Es gibt drei Modi: READ_ONLY, READ_WRITE und PRIVATE. Bei PRIVATE (Copy-on-Write) bleiben Änderungen am Buffer im Speicher und landen nicht in der Datei – praktisch, wenn du eine Datei probeweise verändern willst. Einen WRITE_ONLY gibt es nicht; READ_WRITE und PRIVATE verlangen einen lesend und schreibend geöffneten Channel. Für nichtflüchtigen Speicher (NVM) gibt es seit Java 14 zusätzlich die Sync-Modi aus jdk.nio.mapmode.ExtendedMapMode.

Memory-mapped Files ab Java 22: MemorySegment und Arena

Seit Java 22 gibt es eine zweite Variante von map(), die zur Foreign Function & Memory API gehört: channel.map(mode, offset, size, arena). Sie liefert keinen MappedByteBuffer, sondern ein MemorySegment. Das löst die beiden Einschränkungen aus dem Abschnitt „Besonderheiten von Memory-mapped Files“:

  • Die Größe ist ein long – du kannst also auch eine 3 GB große Datei am Stück mappen.
  • Das Segment gehört zu einer Arena, die die Lebensdauer des Mappings bestimmt. Schließt du die Arena, wird die Datei sofort un-gemappt – ohne auf den Garbage Collector zu warten.

Hier das MappedByteBuffer-Beispiel von oben mit MemorySegment und Arena:

Path path = Path.of("memory-segment-demo.bin");
try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.WRITE,
        StandardOpenOption.READ);
     Arena arena = Arena.ofConfined()) {
  MemorySegment segment =
          channel.map(FileChannel.MapMode.READ_WRITE, 0, 256, arena);

  // Write backwards
  for (int pos = 255; pos >= 0; pos--) {
    segment.set(ValueLayout.JAVA_BYTE, pos, (byte) pos);
  }

  // Read from random positions
  for (int i = 0; i < 10; i++) {
    int pos = ThreadLocalRandom.current().nextInt((int) channel.size());
    byte b = segment.get(ValueLayout.JAVA_BYTE, pos);
    System.out.printf("Byte at position %d: %d%n", pos, b);
  }
}

Die Arena steht als zweite Ressource im try-Block. Beim Verlassen des Blocks wird sie geschlossen, und das Mapping ist aufgehoben – die Datei lässt sich danach sofort löschen oder verschieben. Statt MappedByteBuffer.put() und get() verwendest du MemorySegment.set() und get() mit einem ValueLayout, das den Datentyp angibt; für andere Typen als Byte gibt es z. B. ValueLayout.JAVA_INT und ValueLayout.JAVA_LONG. Und wie beim MappedByteBuffer erzwingt segment.force() das Schreiben auf das Speichermedium.

Die FFM API ist weitaus umfangreicher als ein ByteBuffer. Arena.ofConfined() erlaubt beispielsweise den Zugriff nur aus dem Thread, der die Arena erzeugt hat – den Zugriff durch mehrere Threads erlaubt Arena.ofShared(). Wie Arena, MemorySegment und ValueLayout zusammenhängen, erkläre ich im Artikel über die Foreign Function & Memory API.

File Locking: Sperren von Dateibereichen

Bei komplexeren Anwendungen (z. B. einem File- oder Datenbankserver) greifen mehrere Prozesse auf ein und dieselbe Datei zu. Dann müssen ganze Dateien oder Bereiche von Dateien, in die gerade geschrieben wird, gelockt werden, so dass andere Prozesse nicht gleichzeitig in denselben Bereich schreiben.

Das Locking wird direkt vom Betriebssystem und dem Dateisystem unterstützt. Es funktioniert deshalb auch zwischen mehreren Java-Programmen, zwischen Java-Programmen und beliebigen anderen Prozessen auf demselben System – und bei einem Shared Storage, z. B. einem Netzwerklaufwerk, auch zwischen Prozessen auf unterschiedlichen Systemen.

Für Threads innerhalb eines Programms sind File Locks dagegen nicht gedacht: Ein Lock gehört der gesamten JVM. Fordert ein zweiter Thread – oder derselbe Thread über einen zweiten FileChannel – ein Lock auf einen überlappenden Bereich an, wirft die JVM eine OverlappingFileLockException. Zum Synchronisieren von Threads nimmst du die Mittel aus java.util.concurrent, etwa ein ReentrantReadWriteLock.

Man unterscheidet zwischen shared Locks („read locks“) und exclusive Locks („write locks“). Hält ein Prozess ein exklusives Lock auf einen Dateibereich, bekommt kein anderer Prozess ein Lock auf denselben oder einen überlappenden Bereich – weder ein exklusives noch ein shared Lock. Hält ein Prozess ein shared Lock, können andere Prozesse ebenfalls ein shared Lock auf denselben oder einen überlappenden Bereich erhalten.

Die folgende Tabelle zeigt, welches Lock ein zweiter Prozess bekommt, wenn ein erster bereits eines auf denselben Bereich hält:

Erster Prozess hält …… zweiter fordert shared Lock an… zweiter fordert exklusives Lock an
shared Lock✓✗
exklusives Lock✗✗

Mit folgenden Methoden kannst du ein Lock setzen:

  • FileChannel.lock(position, size, shared) – die Methode wartet so lange, bis für den durch position und size angegebenen Bereich ein Lock des gewünschten Typs (shared = true → shared; shared = false → exclusive) gesetzt werden kann.
  • FileChannel.lock() – die Methode wartet, bis für die gesamte Datei ein exklusives Lock gesetzt werden kann.
  • FileChannel.tryLock(position, size, shared) – die Methode versucht, für den angegebenen Bereich ein Lock des gewünschten Typs zu setzen. Ist dies nicht möglich, wartet sie nicht, sondern gibt null zurück.
  • FileChannel.tryLock() – die Methode versucht, für die gesamte Datei ein exklusives Lock zu setzen. Ist dies nicht möglich, gibt sie null zurück.

Ein shared Lock setzt voraus, dass der Channel zum Lesen geöffnet ist, ein exklusives Lock, dass er zum Schreiben geöffnet ist – sonst gibt es eine NonReadableChannelException bzw. NonWritableChannelException.

Bei erfolgreichem Setzen des Locks geben die Methoden ein FileLock-Objekt zurück, über dessen release()- oder close()-Methode das Lock wieder freigegeben werden kann. Hier ein einfaches Beispiel, das ein exklusives Lock auf die komplette Datei setzt und dann 1.000 zufällige Bytes schreibt:

Path path = Path.of("lock-demo.bin");

byte[] bytes = new byte[1000];
ThreadLocalRandom.current().nextBytes(bytes);

try (FileChannel channel = FileChannel.open(path,
        StandardOpenOption.CREATE, StandardOpenOption.WRITE);
     FileLock lock = channel.lock()) {
  ByteBuffer buffer = ByteBuffer.wrap(bytes);
  channel.write(buffer);
}

Performance-Tests

Ich habe ein Programm geschrieben, um die Performance der verschiedenen Schreibmethoden zu messen – bei unterschiedlichen Buffer- und Dateigrößen.

Um ein möglichst seiteneffektfreies Ergebnis zu erhalten, habe ich jeden Test 32-mal wiederholt und dann den Median ermittelt. Dabei habe ich Dateien von 1 MB bis 1 GB Größe erstellt und für den ByteBuffer zwischen 1 KB und 1 MB zur Verfügung gestellt.

Alle Tests werden ohne force() durchgeführt. Ich möchte die Geschwindigkeit testen, mit der die Daten ans Betriebssystem übermittelt werden, nicht die Geschwindigkeit des Storage-Mediums.

Das Testprogramm kannst du gerne von meinem GitHub-Repository clonen und auf deinem System laufen lassen.

Im Quellcode des Testprogramms kannst du sehen, dass die Tests auch für solche FileChannel ausgeführt werden, die über RandomAccessFile.getChannel() und FileOutputStream.getChannel() erzeugt werden. Da die Ergebnisse jeweils fast identisch zu denen von FileChannel.open() sind, zeige ich sie im Folgenden nicht mit an.

Testergebnisse

Die Testergebnisse sind zu umfangreich, um sie hier komplett abzudrucken. Du kannst sie dir gerne in diesem Google Document anschauen.

In erster Linie hängt die Schreibgeschwindigkeit von der Art des Zugriffs ab – sequentiell oder Random Access. Interessant ist, dass auch Buffer- und Dateigröße einen erheblichen Einfluss auf das Resultat haben.

Testergebnisse sequentieller Schreibzugriff

Bei sequentiellem Schreibzugriff steigt die Geschwindigkeit bis zu einer Dateigröße von 128 MB kontinuierlich an, danach stagniert oder sinkt sie. Ich vermute, dass ab dieser Größe das Betriebssystem beginnt, die Daten auf das Storage-Medium zu schreiben, ab hier also dessen Geschwindigkeit in die Messergebnisse mit eingeht. Ich werde daher im Folgenden nur Messwerte bis zur Dateigröße von 128 MB darstellen.

Im Folgenden siehst du vier Diagramme, die für die Dateigrößen 1 MB, 8 MB, 16 MB und 128 MB und jeweils vier Schreibmethoden die Schreibgeschwindigkeit in Abhängigkeit von der Buffergröße zeigen.

Sequentielle Datei-Schreibgeschwindigkeit für 1 MB große Dateien
Sequentielle Datei-Schreibgeschwindigkeit für 1 MB große Dateien
Sequentielle Datei-Schreibgeschwindigkeit für 8 MB große Dateien
Sequentielle Datei-Schreibgeschwindigkeit für 8 MB große Dateien
Sequentielle Datei-Schreibgeschwindigkeit für 16 MB große Dateien
Sequentielle Datei-Schreibgeschwindigkeit für 16 MB große Dateien
Sequentielle Datei-Schreibgeschwindigkeit für 128 MB große Dateien
Sequentielle Datei-Schreibgeschwindigkeit für 128 MB große Dateien

Testergebnisse sequentieller Schreibzugriff – Analyse

Bei Dateien bis zu 8 MB Größe sind memory-mapped Files – unabhängig von der Buffergröße – am schnellsten.

Bei einer Dateigröße von 16 MB gilt dies nur noch bis zu einer Buffergröße von 16 KB. Ab einer Buffergröße von 32 KB ist der FileChannel mit einem nativen ByteBuffer schneller. Bei einer Dateigröße von 128 MB ist der FileChannel bereits ab einer Buffergröße von 16 KB schneller.

Der native ByteBuffer ist bis zu 17 % schneller als der Heap-ByteBuffer – bei der 1-GB-Datei, die in den Diagrammen fehlt, sogar bis zu 34 %. Je größer Datei und Buffer sind, desto größer ist auch der Performance-Gewinn durch den nativen Buffer.

Bis zu einer Buffergröße von 8 KB ist der FileOutputStream mit dem BufferedOutputStream schneller als der FileChannel. Ab 8 KB Buffergröße sind Stream und Channel mit Heap-Buffer etwa gleich schnell. Die Grenze von 8 KB ist auf den internen 8 KB großen Buffer des BufferedOutputStream zurückzuführen. Dieser füllt erst den Buffer, bevor er die Daten in die Datei schreibt.

Ab 1 MB Buffergröße geht die Geschwindigkeit bei allen Schreibmethoden und Dateigrößen wieder zurück.

Testergebnisse Random Write Access

Es folgen drei Diagramme, die für die Dateigrößen 1 MB, 8 MB und 128 MB und jeweils drei Schreibmethoden die Random-Access-Schreibgeschwindigkeit in Abhängigkeit von der Buffergröße zeigen. Größere Dateien habe ich nicht geschrieben, da die Random Write Access Tests im Allgemeinen deutlich länger dauern als die Tests des sequentiellen Schreibzugriffs.

Random Access Datei-Schreibgeschwindigkeit für 1 MB große Dateien
Random Access Datei-Schreibgeschwindigkeit für 1 MB große Dateien
Random Access Datei-Schreibgeschwindigkeit für 8 MB große Dateien
Random Access Datei-Schreibgeschwindigkeit für 8 MB große Dateien
Random Access Datei-Schreibgeschwindigkeit für 128 MB große Dateien
Random Access Datei-Schreibgeschwindigkeit für 128 MB große Dateien

Testergebnisse Random Write Access – Analyse

Bei Random Write Access ist der Schreibzugriff – unabhängig von Datei- und Buffergröße – mit memory-mapped Files am schnellsten. FileChannel folgt mit großem Abstand, der Performancegewinn durch native Buffer kann auch hier bis zu 20 % erreichen.

Schlussfolgerung

Bei Random Write Access sollte die Wahl immer auf memory-mapped Files fallen.

Bei sequentiellem Schreibzugriff kann man bei Dateigrößen bis zu 8 MB ebenfalls grundsätzlich mit memory-mapped Files arbeiten. Bei größeren Dateien erreicht man die beste Performance mit FileChannel und einem mindestens 64 KB, maximal 512 KB großen, nativen ByteBuffer.

Selbstverständlich sind das nur grobe Richtwerte, abgeleitet aus Messergebnissen meines Systems. Wenn du die Performance bis aufs letzte MB/s tunen willst, empfehle ich dir, für deinen speziellen Use Case verschiedene Schreibmethoden und Buffergrößen zu testen.

Zusammenfassung

Im heutigen Artikel habe ich dir gezeigt, was FileChannel und ByteBuffer sind und wie du damit Dateien schreibst und liest – an beliebiger Position, mit force() bis aufs Speichermedium und mit transferTo() direkt von einem Channel in einen anderen.

Memory-mapped Files sparen die Kopie zwischen User Space und Kernel Space. Als MappedByteBuffer sind sie auf 2 GB begrenzt; seit Java 22 mappst du mit MemorySegment und Arena auch größere Dateien und gibst das Mapping deterministisch wieder frei.

File Locks sperren Dateibereiche gegen andere Prozesse – nicht gegen Threads derselben JVM, und unter Linux und macOS nur gegenüber Prozessen, die selbst Locks anfordern.

Aus den Messungen leite ich zwei Faustregeln ab: Bei Random Access nimm memory-mapped Files. Beim sequentiellen Schreiben großer Dateien nimm einen FileChannel mit einem nativen ByteBuffer von 64 bis 512 KB.

Damit endet die sechsteilige Serie über Dateien in Java.

Wenn dir der Artikel weitergeholfen hat, würde ich mich sehr über eine positive Bewertung auf meinem ProvenExpert-Profil freuen. Dein Feedback hilft mir, meine Inhalte weiter zu verbessern und motiviert mich, neue informative Artikel zu schreiben.

👉 Bewertung abgeben

Du möchtest über alle neuen Java-Features auf dem Laufenden sein? Dann klicke hier, um dich für den HappyCoders-Newsletter anzumelden.

👉 Newsletter-Anmeldung

Lust auf noch mehr Wissen?

In meinem Blog findest du viele Artikel zu Java, Softwarearchitektur und Performance von grundlegenden Konzepten bis hin zu fortgeschrittenen Patterns.

Wenn du tiefer einsteigen willst, schau dir meine Trainings an: praxisnah, verständlich und direkt auf euren Projektalltag übertragbar. Statt Theorie vermittle ich Prinzipien, die euch helfen, Code langfristig besser, wartbarer und performanter zu schreiben.

Zu den Java-Schulungen

Werde ein:e bessere:r Java-Entwickler:in

Mit meinem kostenlosen Newsletter bleibst du vorn. Modernes Java: neue Versionen & Features, Performance und JVM-Insights – 1x im Monat.

Suche