FileChannel, ByteBuffer, Memory-mapped I/O, Locks - Feature-Bild

Dateien in Java, Teil 6: FileChannel, ByteBuffer, Memory-mapped I/O, Locks

von Sven Woltmann – 26. Februar 2020

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 JavaTM 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 können einzelne Bereiche einer Datei gesperrt werden?
  • Welche Schreibmethode hat die beste Performance?

Den Code aus diesem Artikel findest du in meinem GitLab-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 / gelesenen Daten innerhalb des eigentlichen Arrays kennen zu müssen.

Die genaue Funktionsweise des ByteBuffers beschreibe ich hier.

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 Threads und Prozesse gesperrt werden („file lock“).
  • Daten können besonders effizient von einem Channel zum anderen übertragen werden.

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, und wie Du das Schreiben vom Cache aufs Speichermedium veranlasst.

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 bspw. 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 Byte von der aktuellen Leseposition zu lesen.

Wir lesen nicht den gesamten Buffer aus, sondern nur eine zufällig 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 genau so viele Bytes, wie wir zuvor aus dem Buffer ausgelesen haben (und entsprechend im Buffer wieder freier Platz zur Verfügung geworden ist).

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. Die Position 0 des FileChannels entspricht dabei nicht der Position 0 innerhalb der Datei sondern der aktuellen Länge der Datei.
  • 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 zehn mal eine zufälllige 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 2^30 – 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.

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 bedeutet einen enormen Performance-Gewinn gegenüber herkömmlichem Lesen und Schreiben. Die Datei wird direkt in den „User Space“ des Arbeitsspeichers gemappt. Hingegen müssen bei der herkömmlichen Variante Daten zwischen „Kernel Space“ und „User Space“ hin- und herkopiert werden.

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.
  • Es kann maximal ein 2 GB großer Bereich in den Speicher gemappt werden. Als der MappedByteBuffer mit Java 1.4 im Jahr 2002 veröffentlicht wurde, konnten sich die Java-Entwickler offenbar nicht vorstellen, dass heute fast jeder Developer-Laptop mit 16 bis 32 GB RAM bestückt ist. Bis einschließlich Java 14 wurde dieses Limit nicht erhöht.
  • 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“. Wenn wir am Ende des Beispiels oben versuchen würden die Datei zu löschen, bekämen wir in den meisten Fällen eine AccessDeniedException. Der MappedByteBuffer wird vom Garbage Collector entfernt, wenn er nicht mehr gebraucht wird. Um die Datei zu „un-mappen“ registriert er einen sogenanten „Cleaner“, der aufgerufen wird, wenn der MappedByteBuffer nur noch „phantom reachable“ ist. Im Code des weiter unten beschriebenen Performance-Tests findet ihr einen Hack mit sun.misc.Unsafe, um die Datei manuell zu „un-mappen“.

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

Das Kommando 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. Einen MapMode.WRITE_ONLY gibt es nicht.

File Locking: Sperren von Dateibereichen

Bei komplexeren Anwendungen (z. B. einem File- oder Datenbankserver) möchte man ggf. von verschiedenen Threads oder auch Prozessen auf ein- und dieselbe Datei zugreifen. Dabei müssen ganze Dateien oder Bereiche von Dateien, in die gerade geschrieben wird, gelockt werden, so dass keine anderen Threads oder Prozesse auf denselben Bereich zugreifen können.

Das Locking wird dabei direkt vom Betriebssystem und dem Filesystem unterstützt, so dass dies auch zwischen mehreren Java-Programmen funktioniert bzw. zwischen Java-Programmen und beliebigen anderen Prozessen auf dem gleichen System – oder bei Verwendung eines Shared Storages – z. B. eines Netzwerklaufwerks – auch zwischen Prozessen auf unterschiedlichen Systemen.

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

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

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 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 Geschwindkeit testen, mit der die Daten ans Betriebssystem übermittelt werden, nicht die Geschwindkeit des Storage-Mediums.

Das Testprogramm kannst du gerne von meinem GitLab-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 Schreibgeschwindkeit 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 kontinuerlich 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-Schreibgeschwindkeit für 1 MB große Dateien
Sequentielle Datei-Schreibgeschwindkeit für 1 MB große Dateien
Sequentielle Datei-Schreibgeschwindkeit für 8 MB große Dateien
Sequentielle Datei-Schreibgeschwindkeit für 8 MB große Dateien
Sequentielle Datei-Schreibgeschwindkeit für 16 MB große Dateien
Sequentielle Datei-Schreibgeschwindkeit für 16 MB große Dateien
Sequentielle Datei-Schreibgeschwindkeit für 128 MB große Dateien
Sequentielle Datei-Schreibgeschwindkeit 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 20 % schneller als der Heap-ByteBuffer. 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-Schreibgeschwindkeit für 1 MB große Dateien
Random Access Datei-Schreibgeschwindkeit für 1 MB große Dateien
Random Access Datei-Schreibgeschwindkeit für 8 MB große Dateien
Random Access Datei-Schreibgeschwindkeit für 8 MB große Dateien
Random Access Datei-Schreibgeschwindkeit für 128 MB große Dateien
Random Access Datei-Schreibgeschwindkeit 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 mindestens 16 KB, maximal 512 KB großem, nativen ByteBuffer.

Selbstverständlich sind das nur grobe Richtwerte, abgeleitet aus Messergebnissen meines Systems. Wenn du die Performance bis auf’s 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. Du hast gelernt, was memory-mapped Files sind und wie man Locks auf bestimmte Bereiche einer Datei setzen kann, so dass diese nicht gleichzeitig aus anderen Prozessen beschrieben werden können.

Damit endet die sechsteilige Serie über Dateien in Java. Wenn dir dieser Artikel (oder die ganze Serie) gefallen hat, teile ihn gerne über einen der Share-Buttons unten. Wenn du über neue Artikel informiert werden möchtest, kannst du dich über das folgende Formular auf meinen E-Mail-Verteiler eintragen.

Ich würde gerne von dir wissen: Welcher Teil dieser Serie hat dir am meisten geholfen oder am besten gefallen? Schreibe mir einen Kommentar!

Die folgenden Artikel könnten dir auch gefallen
Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Pflichtfelder sind markiert.

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}