CodeGym /Cours /JAVA 25 SELF /Travail avec de très grands fichiers : chunking, memory m...

Travail avec de très grands fichiers : chunking, memory mapping

JAVA 25 SELF
Niveau 41 , Leçon 3
Disponible

1. Chunking — lecture d’un fichier par morceaux

Comme nous l’avons déjà évoqué dans le cours précédent, le chunking permet de travailler avec des fichiers par morceaux sans les charger entièrement en mémoire. C’est particulièrement important lorsqu’il s’agit de gros volumes de données. Si un fichier fait 10 Mo, il n’y a généralement pas de problème — on peut le charger et travailler avec de n’importe quelle manière. Mais que faire si le fichier atteint 10 Go, que vous n’avez que 8 Go de mémoire vive, avec en plus un navigateur aux dizaines d’onglets et une IDE ouverts ? Tenter de lire un tel fichier en entier se termine souvent mal : OutOfMemoryError, application qui se fige et larmes du développeur.

Des exemples concrets de tels fichiers volumineux sont fréquents : des journaux serveurs sur un mois peuvent représenter des dizaines de gigaoctets, de gros fichiers CSV contiennent des millions de lignes, et les vidéos, archives et dumps de bases de données sont encore plus gros.

L’idée principale reste la même : ne pas essayer « d’avaler l’éléphant d’un coup », mais travailler par morceaux. C’est précisément le chunking qui permet de traiter ces données de façon sûre et efficace en découpant le fichier en parties maîtrisables.

Encore un mot sur le chunking

Chunk (morceau, bloc) — c’est simplement une partie du fichier d’une taille donnée. Au lieu de tout lire d’un coup, on lit, par exemple, par blocs de 4 Mo (ou 64 Ko, ou 1 Mo — selon le contexte).

Principe :

  • Ouvrir un flux pour la lecture du fichier.
  • Créer un tampon — un tableau d’octets de taille fixe.
  • Lire en boucle du fichier vers le tampon jusqu’à atteindre la fin.
  • Traiter chaque « morceau » séparément.

Exemple : copier un gros fichier par morceaux

Supposons que nous ayons un fichier énorme à copier. Écrivons un programme qui le fait « pour de vrai ».

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class BigFileCopy {
    public static void main(String[] args) throws IOException {
        String source = "bigfile.dat";
        String dest = "bigfile_copy.dat";
        int bufferSize = 4 * 1024 * 1024; // 4 Mo

        try (FileInputStream in = new FileInputStream(source);
             FileOutputStream out = new FileOutputStream(dest)) {

            byte[] buffer = new byte[bufferSize];
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                out.write(buffer, 0, bytesRead);
                // Vous pouvez ajouter l’affichage de la progression ou un traitement des données
            }
        }
        System.out.println("Copie terminée !");
    }
}

Pour travailler avec des fichiers en Java, on utilise généralement les flux standards FileInputStream et FileOutputStream. Bonne pratique : utiliser un tampon d’environ 4 Mo — suffisant sur des disques modernes pour lire et écrire efficacement. Dans la boucle, le programme lit des morceaux du fichier et les écrit immédiatement dans le nouveau fichier, sans tenter de garder tout le fichier en mémoire.

Cette approche permet d’économiser la mémoire vive, d’éviter des erreurs du type OutOfMemoryError et de travailler avec des fichiers de pratiquement n’importe quelle taille, même 100 Go et plus.

2. Chunking pour le traitement des données

Souvent, la tâche n’est pas seulement de copier un fichier, mais par exemple de trouver une certaine ligne, compter les occurrences, remplacer quelque chose, etc.

Exemple : recherche d’une ligne dans un grand fichier texte

Si le fichier est texte, il est plus pratique d’utiliser des flux de caractères et une lecture ligne par ligne :

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class BigFileSearch {
    public static void main(String[] args) throws IOException {
        String file = "biglog.txt";
        String keyword = "ERROR";
        int count = 0;

        try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.contains(keyword)) {
                    count++;
                }
            }
        }
        System.out.println("Trouvé " + count + " lignes avec ERROR");
    }
}

Pourquoi cela fonctionne-t-il même pour des fichiers de plusieurs gigaoctets ?

  • BufferedReader lit le fichier par morceaux (tampon de 8 Ko par défaut, mais on peut l’augmenter).
  • En mémoire, on ne conserve à chaque instant qu’une seule ligne.

Taille du tampon : laquelle choisir ?

Règle d’or : un tampon trop petit — beaucoup d’accès disque ; trop grand — de la mémoire gaspillée.

  • Pour des HDD/SSD modernes, un tampon de 64 Ko à 4 Mo fonctionne généralement bien.
  • Pour des SSD très rapides ou en réseau — on peut aller plus loin (8–16 Mo).
  • Pour des fichiers texte — on peut augmenter le tampon dans BufferedReader.

Expérimentez ! Mesurez le temps d’exécution du programme avec différentes tailles de tampon. Parfois, l’augmentation du tampon apporte un gain de 2 à 3 fois, parfois elle a peu d’effet.

3. Memory-mapped files (mappage du fichier en mémoire)

De quoi s’agit-il ?

Le memory mapping est une manière « d’afficher » un fichier directement en mémoire du processus via les mécanismes du système d’exploitation. En Java, on utilise pour cela la classe MappedByteBuffer du paquet java.nio. Le fichier devient comme un grand tableau d’octets avec lequel on peut travailler directement, sans lecture/écriture explicites de chaque morceau.

Cette approche est particulièrement utile avec des fichiers très volumineux. Le système d’exploitation charge lui-même en mémoire les parties nécessaires du fichier, et vous pouvez accéder à n’importe quel endroit du fichier comme s’il s’agissait d’un tableau ordinaire. Les fichiers memory-mapped offrent une grande vitesse d’accès aléatoire. Par exemple, lorsqu’il faut lire rapidement des morceaux répartis dans le fichier sans le charger intégralement.

À quoi cela ressemble-t-il en code ?

import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MemoryMappedRead {
    public static void main(String[] args) throws Exception {
        String fileName = "bigfile.dat";
        try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
             FileChannel channel = file.getChannel()) {

            long fileSize = channel.size();
            int chunkSize = 1024 * 1024 * 128; // 128 Mo — taille d’un mapping

            long position = 0;
            while (position < fileSize) {
                long size = Math.min(chunkSize, fileSize - position);
                MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);

                // Lire les données depuis buffer comme depuis un tableau
                for (int i = 0; i < size; i++) {
                    byte b = buffer.get(i);
                    // Traitement de l’octet (par exemple, rechercher une valeur donnée)
                }

                position += size;
            }
        }
        System.out.println("Lecture via le memory mapping terminée !");
    }
}

RandomAccessFile et FileChannel permettent d’accéder au fichier à bas niveau. L’appel à channel.map mappe une portion du fichier en mémoire. L’accès aux données se fait via le tampon MappedByteBuffer.

Quels sont les avantages du memory mapping ?

  • Très rapide pour l’accès aléatoire à différentes parties du fichier.
  • Permet de travailler avec des fichiers plus gros que la mémoire disponible (l’OS charge lui-même les pages nécessaires).
  • Utilisé dans des bases de données modernes, des index, de grands journaux.

Et les inconvénients ?

  • Pas toujours adapté à l’écriture (en particulier sur des systèmes de fichiers réseau).
  • Contraintes de taille du mapping (souvent jusqu’à 2 Go par mapping sur des JVM 32 bits).
  • Si on oublie de fermer le fichier, il peut « rester accroché » (notamment sous Windows).
  • Toutes les opérations ne sont pas accélérées — pour une lecture strictement séquentielle, un tampon classique est souvent tout aussi bon.

4. Exemples pratiques

Exemple 1 : recherche d’une sous-chaîne dans un grand fichier via memory mapping

Supposons que nous ayons un fichier de 10 Go et que nous voulions y trouver une séquence d’octets donnée (par exemple la chaîne "SECRET").

import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;

public class MemoryMappedSearch {
    public static void main(String[] args) throws Exception {
        String fileName = "hugefile.bin";
        byte[] target = "SECRET".getBytes(StandardCharsets.UTF_8);

        try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
             FileChannel channel = file.getChannel()) {

            long fileSize = channel.size();
            int chunkSize = 128 * 1024 * 1024; // 128 Mo

            long position = 0;
            while (position < fileSize) {
                long size = Math.min(chunkSize, fileSize - position);
                MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);

                for (int i = 0; i < size - target.length; i++) {
                    boolean found = true;
                    for (int j = 0; j < target.length; j++) {
                        if (buffer.get(i + j) != target[j]) {
                            found = false;
                            break;
                        }
                    }
                    if (found) {
                        System.out.println("Trouvé à la position " + (position + i));
                        // Vous pouvez arrêter la recherche ou continuer
                    }
                }
                position += size;
            }
        }
    }
}

Attention :
Si la sous-chaîne peut être « coupée » entre deux chunks, prévoyez un chevauchement entre les chunks de la longueur de la séquence recherchée.

5. Points utiles

Quand utiliser le chunking et quand le memory mapping ?

  • Chunking — approche universelle pour tous types de fichiers (texte, binaire, journaux, archives). Fonctionne très bien pour un traitement séquentiel.
  • Memory mapping — super efficace pour l’accès aléatoire, le travail avec de grands index, des bases de données, des recherches rapides dans d’énormes fichiers.

Si vous ne savez pas quoi choisir — commencez par le chunking ! Le memory mapping est puissant, mais plus « bas niveau » et requiert de la prudence.

Recommandations

  • Utilisez try-with-resources pour fermer automatiquement les flux et les canaux.
  • N’ouvrez pas trop de fichiers simultanément : l’OS impose des limites sur le nombre de descripteurs ouverts.
  • Ne mappez pas des morceaux trop grands — cela peut conduire à des erreurs (surtout sur des JVM 32 bits).
  • Pour un traitement parallèle, on peut découper le fichier en chunks et les traiter dans des threads séparés (mais attention à ne pas « saturer » le disque ni dépasser la mémoire).

6. Erreurs courantes avec de grands fichiers

Erreur n° 1 : tenter de charger un gros fichier en entier en mémoire.
Problème très fréquent — surtout chez les débutants. Si le fichier dépasse 1–2 Go, utilisez le chunking ou la lecture ligne par ligne, sinon le programme plantera avec OutOfMemoryError.

Erreur n° 2 : tampon trop petit.
Un tampon de 512 octets — ce n’est pas une optimisation, mais un lent suicide pour la performance. Utilisez des tampons à partir de 64 Ko et plus.

Erreur n° 3 : oubli de fermer un flux ou un canal.
Le descripteur de fichier restera ouvert, le fichier ne sera pas supprimé ou libéré avant le redémarrage de la JVM. Utilisez try-with-resources.

Erreur n° 4 : mauvaise utilisation du memory mapping.
Si le fichier est modifié par un autre processus pendant le mapping, vous pouvez obtenir des données incohérentes ou une erreur. N’utilisez pas le memory mapping pour des fichiers qui changent souvent.

Erreur n° 5 : ne pas tenir compte du chevauchement des chunks lors d’une recherche de sous-chaînes.
Si la chaîne recherchée peut se trouver « à la jonction » de deux chunks, faites impérativement un chevauchement égal à la longueur de cette chaîne entre les chunks.

Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION