Lire des fichiers en Java avec le mappage mémoire
Avec FileChannel.map(), Java vous permet de lire les octets d’un fichier au moyen d’un MappedByteBuffer. Pour
l’essayer sur une tâche concrète, le programme ci-dessous calcule la somme de contrôle SHA-256 d’un
fichier en utilisant soit le mappage mémoire, soit des lectures tamponnées. Les deux modes traitent
les mêmes octets ; vous pouvez donc vérifier l’exactitude du résultat avant de comparer leurs
performances sur vos fichiers.
Comprendre les fichiers mappés en mémoire
Un mappage expose une région d’un fichier via la mémoire virtuelle. L’accès à ses octets peut obliger le système d’exploitation à charger des pages du fichier en RAM ; mapper un fichier ne signifie pas que chaque octet est déjà résident en mémoire. La documentation des tampons directs de Java décrit les tampons mappés comme des tampons directs dont le contenu réside en dehors du tas Java ordinaire.
Cet exemple hache les octets sans les décoder. Le texte, les caractères UTF-8 et les données
binaires suivent tous le même chemin. Il n’y a ni validation d’encodage ni normalisation des fins
de ligne. Si vous décodez plutôt un mappage entier en String, les caractères décodés et la chaîne
obtenue nécessitent toujours un espace de tas proportionnel à la taille du texte. Le mappage ne
supprime pas cette allocation.
Limitations importantes
Utilisez un fichier ordinaire qui ne change pas et un JDK 21 64 bits. Les commandes ci-dessous utilisent Bash et ont été testées sous Linux avec OpenJDK 21.0.12.1 ; le comportement des fichiers sous Windows et macOS n’a pas été testé ici.
La variante à trois arguments de FileChannel.map()
accepte au plus Integer.MAX_VALUE octets par région : 2 147 483 647 octets, soit 2 GiB moins un
octet. Le mode mappé ci-dessous rejette les fichiers plus volumineux. Son équivalent tamponné peut
lire des fichiers plus volumineux avec un tampon réutilisable de 64 KiB. Plusieurs mappages
constituent une autre option, mais cet exemple utilise une seule région pour que sa durée de vie et
sa limite de taille restent visibles.
Ne modifiez pas et ne tronquez pas le fichier d’entrée pendant l’une ou l’autre lecture. Un mappage
en lecture seule ne fige pas le fichier, et une troncature peut rendre des octets mappés
inaccessibles, comme l’explique le
contrat de MappedByteBuffer.
Aucun des deux modes ne fournit d’instantané d’un fichier en cours d’écriture par un autre processus.
Utiliser Java NIO pour les fichiers mappés en mémoire
Enregistrez ce code sous FileChecksum.java dans un répertoire de travail de votre choix. Il ne nécessite
aucune dépendance en dehors du JDK.
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)
consomme les octets situés entre la position et la limite du tampon. En mode tamponné, flip()
n’expose que les octets qui viennent d’être lus, y compris un dernier bloc plus court, et clear()
prépare le tampon pour sa réutilisation. La branche du fichier vide calcule l’empreinte sans créer
de mappage.
Créez un fichier d’entrée de trois octets contenant abc, sans saut de ligne final, puis compilez
et exécutez les deux modes. Le paramètre noclobber du sous-shell refuse d’écraser un example.txt
existant. La compilation remplace FileChecksum.class dans ce répertoire de travail ; les calculs de somme de
contrôle se contentent de lire le fichier d’entrée et d’écrire sur la sortie standard.
(set -o noclobber; printf 'abc' > example.txt) &&
javac FileChecksum.java &&
java FileChecksum mapped example.txt &&
java FileChecksum buffered example.txt
Les deux commandes devraient afficher cette somme de contrôle :
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
Pour réutiliser un fichier existant, sautez sa création et passez son chemin à l’un ou l’autre
mode, en mettant entre guillemets les chemins qui contiennent des espaces. Avec un fichier vide, le
programme réussit et affiche
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Les fichiers manquants, les arguments invalides et un mappage trop volumineux lèvent une exception :
Java écrit le diagnostic sur la sortie d’erreur standard et se termine avec un code de sortie non
nul. Le programme n’affiche pas de somme de contrôle lorsque la lecture échoue.
Considérations de performance
Le mappage a des coûts de mise en place. La
documentation de FileChannel
indique que lire une petite quantité de données de manière conventionnelle peut coûter moins cher
que créer un mappage. Un calcul séquentiel de somme de contrôle passe aussi du temps à hacher ; une
méthode d’accès aux fichiers plus rapide pourrait donc ne changer que peu la durée d’exécution
totale.
Utilisez le même fichier représentatif et inchangé pour les deux modes, dans la limite de taille du mappage. Vérifiez d’abord que les empreintes correspondent. Dans Bash, ces commandes mesurent la commande entière, y compris le démarrage de la JVM, l’ouverture du fichier, le mappage ou l’allocation du tampon, le hachage et la fermeture du canal :
time java FileChecksum mapped example.txt &&
time java FileChecksum buffered example.txt
Remplacez example.txt dans les deux commandes par votre fichier représentatif. L’exemple de trois
octets sert à vérifier l’exactitude, pas à comparer utilement les vitesses. Répétez les mesures,
inversez l’ordre et gardez identiques le JDK, les options de la JVM, le fichier d’entrée et la
charge de la machine. Comparez la dispersion des temps écoulés, pas seulement l’exécution la plus
rapide. Les lectures précédentes peuvent alimenter le cache de fichiers du système d’exploitation ;
indiquez donc séparément les observations de la première exécution et des exécutions répétées ; une
nouvelle JVM n’implique pas un cache de fichiers froid. Ces mesures indiquent combien de temps prend
cette commande. Elles ne prédisent pas les performances d’un service de longue durée ni d’une charge
de travail à accès aléatoire.
Bonnes pratiques
Le try-with-resources ferme le canal, mais la fermeture d’un canal ne démappe pas son tampon. Le mappage reste valide jusqu’à ce que le tampon soit récupéré par le ramasse-miettes. Dans une application de longue durée, des mappages répétés peuvent donc s’accumuler avant d’être libérés. Cette courte commande met fin à sa JVM après un seul fichier ; elle ne démontre pas un démappage déterministe au sein d’un service.
Ne confondez pas une faible utilisation du tas avec une faible utilisation totale de la mémoire. Le mappage réserve de l’espace d’adressage virtuel, et les pages de fichier consultées consomment de la mémoire physique. Ce code évite un tableau contenant tout le fichier dans le tas, mais l’implémentation du calcul d’empreinte peut quand même copier des blocs en interne. L’espace d’adressage disponible et les ressources système peuvent empêcher le mappage, même en dessous de la limite de région de l’API.
Considérations sur la sûreté des threads
Gardez l’exemple monothread. Bien que les canaux de fichiers prennent en charge une utilisation
concurrente,
les tampons nécessitent une synchronisation lorsqu’ils sont partagés entre threads.
Même un tampon en lecture seule possède une position mutable, que digest.update() fait avancer. Donner à
chaque tâche son propre tampon et son propre objet d’empreinte évite de partager ces objets
mutables ; cela ne protège pas le fichier sous-jacent contre les écritures d’un tiers.
Cas d’usage courants
Pour une somme de contrôle en une seule passe, la lecture tamponnée est un point de départ simple et n’est soumise à aucune limite de taille liée à une région unique. Le mappage mérite d’être étudié lorsqu’un analyseur recherche de façon répétée des enregistrements par décalage d’octets dans un fichier inchangé : un tampon mappé offre un accès indexé sans lecture explicite pour chaque recherche. Mesurez ce schéma d’accès séparément avant de modifier une implémentation qui fonctionne. Une comparaison de sommes de contrôle ne peut à elle seule établir une accélération dans ce cas.
