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
undByteBuffer
und was sind deren Vorteile? - Wie schreibt und liest man Dateien mit
FileChannel
undByteBuffer
? - 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 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 / gelesenen Daten innerhalb des eigentlichen 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
.
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);
Code-Sprache: Java (java)
(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();
Code-Sprache: Java (java)
... oder über einen FileInputStream
:
Path path = ...;
FileInputStream in = new FileInputStream(path.toFile());
FileChannel channel = in.getChannel();
Code-Sprache: Java (java)
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()
erzeugtenFileChannel
kannst du nur zum Lesen verwenden. - Einen über
RandomAccessFile.getChannel()
erzeugtenFileChannel
kannst du zum Lesen und Schreiben verwenden. - Wie du einen über
FileChannel.open()
erstelltenFileChannel
verwenden kannst, bestimmst du über die im zweiten Parameter übergebenen Optionen. Da wir im Beispiel obenStandardOpenOption.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();
}
}
Code-Sprache: Java (java)
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();
}
}
Code-Sprache: Java (java)
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);
Code-Sprache: Java (java)
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 eineFileAlreadyExistsException
geworfen.StandardOpenOption.APPEND
: Daten werden an die Datei angehängt. Die Position 0 desFileChannel
s 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 mitAPPEND
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);
}
}
Code-Sprache: Java (java)
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();
}
}
Code-Sprache: Java (java)
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();
Code-Sprache: Java (java)
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);
}
Code-Sprache: Java (java)
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);
}
Code-Sprache: Java (java)
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);
}
}
Code-Sprache: Java (java)
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 dasCloseable
-Interface. Deshalb können wir ihn im Beispiel oben auch nicht imtry
-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 eineAccessDeniedException
. DerMappedByteBuffer
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 derMappedByteBuffer
nur noch "phantom reachable" ist. Im Code des weiter unten beschriebenen Performance-Tests findet ihr einen Hack mitsun.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());
Code-Sprache: Java (java)
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());
Code-Sprache: Java (java)
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 durchposition
undsize
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 gibtnull
zurück.FileChannel.tryLock()
– die Methode versucht für die gesamte Datei ein exklusives Lock zu setzen. Ist dies nicht möglich, gibt sienull
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);
}
Code-Sprache: Java (java)
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 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 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.
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.
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, klicke hier, um dich für den HappyCoders-Newsletter anzumelden.
Ich würde gerne von dir wissen: Welcher Teil dieser Serie hat dir am meisten geholfen oder am besten gefallen? Schreibe mir einen Kommentar!