Dateien in Java mit Memory Mapping lesen
Mit FileChannel.map() in Java lesen Sie Dateibytes über einen
MappedByteBuffer. Als konkretes Beispiel berechnet das folgende Programm die
SHA-256-Prüfsumme einer Datei entweder per Memory Mapping oder durch gepuffertes Lesen. Beide Modi
verarbeiten dieselben Bytes. So können Sie die Korrektheit prüfen, bevor Sie die Leistung bei Ihren
Dateien vergleichen.
Dateien mit Memory Mapping verstehen
Ein Mapping macht einen Dateibereich über virtuellen Speicher zugänglich. Beim Zugriff auf seine Bytes muss das Betriebssystem möglicherweise Dateiseiten in den RAM laden. Ein Mapping bedeutet nicht, dass jedes Byte bereits im Arbeitsspeicher liegt. Die Dokumentation zu direkten Puffern in Java beschreibt gemappte Puffer als direkte Puffer, deren Inhalt außerhalb des gewöhnlichen Java-Heaps liegt.
Dieses Beispiel berechnet den Hash der Bytes, ohne sie zu decodieren. Text, UTF-8-Zeichen und
Binärdaten werden gleich behandelt. Weder die Zeichencodierung wird validiert noch werden
Zeilenumbrüche normalisiert. Wenn Sie stattdessen ein gesamtes Mapping in einen
String decodieren, benötigen die decodierten Zeichen und die resultierende
Zeichenfolge weiterhin Heap-Speicher proportional zur Textgröße. Mapping vermeidet diese
Speicherzuweisung nicht.
Wichtige Einschränkungen
Verwenden Sie eine unveränderliche reguläre Datei und ein 64-Bit-JDK 21. Die folgenden Befehle nutzen Bash und wurden unter Linux mit OpenJDK 21.0.12.1 getestet. Das Dateiverhalten unter Windows und macOS wurde hier nicht getestet.
Die Variante von FileChannel.map() mit drei Argumenten
akzeptiert höchstens Integer.MAX_VALUE Bytes pro Bereich: 2.147.483.647 Bytes oder 2 GiB
minus ein Byte. Der folgende Mapping-Modus lehnt größere Dateien ab. Sein gepuffertes Gegenstück
kann größere Dateien mit einem wiederverwendbaren Puffer von 64 KiB lesen. Mehrere Mappings sind eine
weitere Möglichkeit. Dieses Beispiel nutzt jedoch einen Bereich, um dessen Lebensdauer und
Größenlimit deutlich zu machen.
Ändern oder kürzen Sie die Eingabedatei während keines der beiden Lesevorgänge. Ein schreibgeschütztes
Mapping friert die Datei nicht ein. Durch Kürzen können gemappte Bytes unzugänglich werden, wie der
Vertrag von MappedByteBuffer erläutert.
Keiner der Modi liefert einen Snapshot einer Datei, in die ein anderer Prozess schreibt.
Java NIO für Dateien mit Memory Mapping verwenden
Speichern Sie dies als FileChecksum.java in einem Arbeitsverzeichnis Ihrer Wahl.
Außer dem JDK sind keine Abhängigkeiten erforderlich.
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public class FileChecksum {
private static byte[] checksum(Path path, boolean mapped)
throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
long size = channel.size();
if (size == 0) {
return digest.digest();
}
if (mapped) {
if (size > Integer.MAX_VALUE) {
throw new IllegalArgumentException(
"Mapped mode supports at most 2147483647 bytes; use buffered mode");
}
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, size);
digest.update(buffer);
} else {
ByteBuffer buffer = ByteBuffer.allocate(64 * 1024);
while (channel.read(buffer) != -1) {
buffer.flip();
digest.update(buffer);
buffer.clear();
}
}
}
return digest.digest();
}
public static void main(String[] args) throws IOException, NoSuchAlgorithmException {
if (args.length != 2 || !(args[0].equals("mapped") || args[0].equals("buffered"))) {
throw new IllegalArgumentException(
"Usage: java FileChecksum <mapped|buffered> <path>");
}
byte[] result = checksum(Path.of(args[1]), args[0].equals("mapped"));
System.out.println(HexFormat.of().formatHex(result));
}
}
MessageDigest.update(ByteBuffer)
verarbeitet die Bytes zwischen Position und Limit des Puffers. Im gepufferten Modus macht
flip() nur die gerade gelesenen Bytes zugänglich, einschließlich eines kurzen
letzten Blocks. clear() bereitet den Puffer auf die Wiederverwendung vor.
Der Zweig für leere Dateien berechnet den Hashwert, ohne ein Mapping anzulegen.
Erstellen Sie eine Eingabedatei mit drei Bytes, die abc ohne abschließenden
Zeilenumbruch enthält. Kompilieren Sie dann das Programm und führen Sie beide Modi aus. Die
Subshell-Einstellung noclobber verhindert das Überschreiben einer vorhandenen
Datei namens example.txt. Die Kompilierung ersetzt
FileChecksum.class in diesem Arbeitsverzeichnis. Prüfsummenläufe lesen die Eingabedatei
nur und schreiben auf die Standardausgabe.
(set -o noclobber; printf 'abc' > example.txt) &&
javac FileChecksum.java &&
java FileChecksum mapped example.txt &&
java FileChecksum buffered example.txt
Beide Befehle sollten diese Prüfsumme ausgeben:
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
Um eine vorhandene Datei zu verwenden, überspringen Sie deren Erstellung und übergeben Sie ihren
Pfad an einen der beiden Modi. Setzen Sie Pfade mit Leerzeichen in Anführungszeichen. Eine leere
Datei wird erfolgreich verarbeitet und liefert
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Fehlende Dateien, ungültige Argumente und ein zu großes Mapping lösen eine Ausnahme aus: Java
schreibt die Diagnose auf die Standardfehlerausgabe und beendet sich mit einem Status ungleich null.
Wenn das Lesen fehlschlägt, gibt das Programm keine Prüfsumme aus.
Leistungsaspekte
Das Einrichten eines Mappings verursacht Aufwand. Die
Dokumentation zu FileChannel
weist darauf hin, dass das konventionelle Lesen kleiner Datenmengen weniger Aufwand verursachen
kann als das Anlegen eines Mappings. Eine sequenzielle Prüfsummenberechnung benötigt auch Zeit für
das Hashing. Ein schnellerer Dateizugriff kann daher die Gesamtlaufzeit nur geringfügig beeinflussen.
Verwenden Sie für beide Modi dieselbe repräsentative, unveränderliche Datei innerhalb des Größenlimits für Mappings. Prüfen Sie zuerst, ob die Hashwerte übereinstimmen. In Bash messen diese Befehle die gesamte Befehlsausführung, einschließlich JVM-Start, Öffnen der Datei, Mapping oder Pufferzuweisung, Hashing und Schließen des Kanals:
time java FileChecksum mapped example.txt &&
time java FileChecksum buffered example.txt
Ersetzen Sie example.txt in beiden Befehlen durch Ihre repräsentative Datei.
Das Beispiel mit drei Bytes prüft die Korrektheit, eignet sich aber nicht für einen aussagekräftigen
Geschwindigkeitsvergleich. Wiederholen Sie die Messungen, kehren Sie die Reihenfolge um und halten
Sie JDK, JVM-Optionen, Eingabe und Rechnerauslastung konstant. Vergleichen Sie die Streuung der
Laufzeiten, nicht nur den schnellsten Lauf. Frühere Lesevorgänge können den Dateicache des
Betriebssystems füllen. Geben Sie deshalb Beobachtungen für den ersten und für wiederholte Läufe
getrennt an. Eine neue JVM bedeutet keinen kalten Dateicache. Diese Messungen zeigen, wie lange
dieser Befehl benötigt. Sie sagen nichts über die Leistung eines dauerhaft laufenden Dienstes oder
einer Arbeitslast mit wahlfreien Zugriffen aus.
Bewährte Verfahren
Try-with-resources schließt den Kanal, aber das Schließen eines Kanals hebt das Mapping seines Puffers nicht auf. Das Mapping bleibt gültig, bis die Garbage Collection den Puffer freigibt. In einer dauerhaft laufenden Anwendung können sich daher durch wiederholte Mappings Speicherbelegungen ansammeln, bevor sie freigegeben werden. Dieser kurze Befehl beendet seine JVM nach einer Datei. Er zeigt nicht, wie Mappings innerhalb eines Dienstes deterministisch aufgehoben werden.
Setzen Sie eine geringe Heap-Nutzung nicht mit einem geringen Gesamtspeicherbedarf gleich. Das Mapping reserviert virtuellen Adressraum, und Dateiseiten, auf die zugegriffen wird, belegen physischen Speicher. Dieser Code vermeidet ein Heap-Array für die gesamte Datei. Die Hash-Implementierung kann jedoch intern weiterhin Blöcke kopieren. Der verfügbare Adressraum und die Systemressourcen können ein Mapping auch unterhalb des Bereichslimits der API verhindern.
Aspekte der Thread-Sicherheit
Belassen Sie das Beispiel bei einem Thread. Dateikanäle unterstützen zwar die gleichzeitige Nutzung,
aber Puffer erfordern Synchronisierung, wenn Threads sie gemeinsam nutzen.
Auch ein schreibgeschützter Puffer hat eine veränderliche Position, die
digest.update() weiterbewegt. Wenn jede Aufgabe ihren eigenen Puffer und ihr eigenes
Hash-Objekt erhält, werden diese veränderlichen Objekte nicht gemeinsam genutzt. Die zugrunde
liegende Datei ist dadurch nicht vor Schreibzugriffen anderer Prozesse geschützt.
Häufige Anwendungsfälle
Für eine Prüfsumme in einem einzigen Durchlauf ist gepuffertes Lesen ein einfacher Ausgangspunkt ohne das Größenlimit eines einzelnen Mapping-Bereichs. Mapping lohnt sich näher zu untersuchen, wenn ein Parser wiederholt Datensätze anhand ihres Byte-Offsets in einer unveränderten Datei nachschlägt: Ein gemappter Puffer bietet indizierten Zugriff ohne expliziten Lesevorgang für jedes Nachschlagen. Messen Sie dieses Zugriffsmuster separat, bevor Sie eine funktionierende Implementierung ändern. Ein Prüfsummenvergleich allein kann dafür keinen Geschwindigkeitsgewinn belegen.
