CodeGym /Cours /JAVA 25 SELF /Découpage de gros fichiers en chunks

Découpage de gros fichiers en chunks

JAVA 25 SELF
Niveau 59 , Leçon 3
Disponible

1. Pourquoi lire de gros fichiers dans un seul thread — c’est comme porter des briques une par une

Lorsque vous travaillez avec de gros fichiers — des dizaines ou des centaines de mégaoctets, voire des gigaoctets — la lecture ou l’écriture monothread devient vite un goulot d’étranglement. Un seul thread ne tient tout simplement pas la charge : le disque peut fournir des données plus vite que le programme ne parvient à les traiter.

Même avec un SSD rapide, le thread butera non pas sur le disque, mais sur les surcoûts : changements de contexte, gestion des buffers, transformation des données en mémoire. Au final, les performances chutent et le processeur s’ennuie, car ses autres cœurs restent inactifs.

Supposons que vous décidiez de compter le nombre de mots dans un énorme journal. Si vous le faites séquentiellement, un thread va ronger le fichier monotone­ment et vous ne ferez qu’attendre. Mais si vous découpez le fichier en morceaux et confiez le traitement à plusieurs threads, les choses iront beaucoup plus vite : chacun traite sa partie et, au final, vous exploitez presque pleinement le potentiel du disque.

En pratique, cela ressemble à ceci : sur un SSD avec un débit de 2 Go/s, la lecture monothread ne donne qu’environ 300–500 Mo/s. En lisant en parallèle, on peut tirer du support tout ce dont il est capable.

2. Chunking — comment faire travailler le fichier pour vous

Quand un fichier devient trop grand pour être traité d’un seul tenant, le plus raisonnable est de le découper en parties. Cette technique s’appelle le chunking (du mot chunk — « morceau »). L’idée est simple : vous divisez un gros fichier en plusieurs segments logiques et vous affectez à chaque thread sa propre portion.

Chaque thread sait à partir de quel décalage (offset) commencer et où s’arrêter. Il ne lit que son morceau, traite les données, puis les résultats sont rassemblés pour produire le total.

Cette approche permet d’utiliser tous les cœurs du processeur simultanément et accélère nettement le traitement, surtout avec un SSD moderne ou un disque NVMe. Pour des tâches comme compter des lignes, rechercher du texte ou agréger des statistiques, le chunking agit comme un turbo — il ajoute de la vitesse sans grands efforts.

Comment choisir la taille d’un chunk

La taille d’un chunk, c’est un peu comme la taille d’une portion de repas : trop petite — vous passerez votre temps à couper, trop grande — difficile à digérer. Tout dépend de la tâche et des capacités de votre machine.

En moyenne, de bons résultats se situent entre 8–64 Mo par thread. Pour la plupart des cas, 10–20 Mo suffisent, mais il n’y a pas de chiffre magique — on le détermine empiriquement. L’essentiel est que le morceau soit assez grand pour éviter des commutations de threads inutiles, et pas trop grand pour ne pas saturer le cache processeur ni monopoliser la mémoire.

Si vous travaillez avec du texte — par exemple, vous comptez des mots ou cherchez des correspondances — il est important que les chunks ne « coupent » pas les lignes ou les mots au milieu. En général, on règle cela simplement : on crée un petit chevauchement entre les morceaux ou on décale les frontières jusqu’au prochain saut de ligne. Ainsi, le traitement reste précis et le résultat propre et prévisible.

3. Outils pour l’accès positionnel : FileChannel et MappedByteBuffer

FileChannel : E/S positionnées

FileChannel est une classe du paquet java.nio.channels qui permet de travailler avec des fichiers à bas niveau, notamment de lire et d’écrire des données à/depuis une position arbitraire du fichier.

Méthodes clés :

  • position(long newPosition) — définit la position (offset) pour la lecture/écriture.
  • read(ByteBuffer dst, long position) — lit des données du fichier dans le buffer à partir de la position indiquée (ne modifie pas la position courante du canal !).
  • write(ByteBuffer src, long position) — écrit des données dans le fichier à partir de la position indiquée.

Exemple : lecture d’un morceau de fichier

try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
    long chunkSize = 16 * 1024 * 1024; // 16 Mo
    long offset = 0;
    ByteBuffer buffer = ByteBuffer.allocate((int) chunkSize);
    int bytesRead = channel.read(buffer, offset);
    // buffer contient les 16 premiers Mo du fichier
}

Avantages :

  • Lecture/écriture possible depuis n’importe quelle position.
  • Pratique pour le traitement parallèle : chaque thread travaille sur son morceau.

MappedByteBuffer : fichiers mappés en mémoire

MappedByteBuffer est un buffer spécial qui permet de « mapper » (map) une partie d’un fichier en mémoire. Le système d’exploitation se charge de charger/évincer les données entre le disque et la mémoire.

Comment ça marche ?

  • Vous mappez une portion du fichier en mémoire.
  • Vous lisez/écrivez dans le buffer — l’OS charge les pages nécessaires.
  • Pas d’appels explicites à read/write — tout passe par la mémoire.

Exemple :

try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
    long chunkSize = 16 * 1024 * 1024; // 16 Mo
    long offset = 0;
    MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, chunkSize);
    // Désormais, buffer se comporte comme un tableau d’octets, mais les données sont lues du disque à la demande
}

Avantages :

  • Très grande vitesse (surtout sur SSD).
  • Simplicité : on lit/écrit comme dans un tableau.

Inconvénients :

  • Utilise la mémoire virtuelle — si le fichier est très grand, on peut « saturer » la mémoire.
  • Contrôler l’éviction est difficile (le buffer peut rester en mémoire plus longtemps que nécessaire).
  • Pas toujours pratique pour des fichiers très volumineux (plus de 2–4 Go sur des systèmes 32 bits).

4. Exemple : lecture parallèle et comptage de mots

Prenons le cas de figure suivant : compter le nombre de mots dans un gros fichier texte (par exemple, un journal de 10 Go) à l’aide d’un traitement parallèle.

Étape 1. Découper le fichier en chunks

  • Récupérer la taille du fichier : long fileSize = Files.size(path);
  • Choisir la taille du chunk, par exemple 16 Mo.
  • Pour chaque chunk, calculer le décalage : offset = chunkIndex * chunkSize;
  • Le dernier chunk peut être plus petit.

Étape 2. Créer les tâches pour les threads

  • Pour chaque chunk, créer un Callable<Integer> (ou un Runnable) qui :
    • Ouvre sa portion du fichier via FileChannel.read(ByteBuffer, offset) ou MappedByteBuffer.
    • Compte le nombre de mots dans sa portion.
    • Retourne le résultat (le nombre de mots).

Étape 3. Lancer les tâches via ExecutorService

  • Créer un pool de threads : ExecutorService pool = Executors.newFixedThreadPool(N);
  • Soumettre les tâches au pool : List<Future<Integer>> results = pool.invokeAll(tasks);
  • Collecter les résultats : sommer les valeurs de tous les futures.

Exemple de code (simplifié) :

import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;

public class ParallelWordCount {
    public static void main(String[] args) throws Exception {
        Path path = Path.of("bigfile.txt");
        long fileSize = Files.size(path);
        int chunkSize = 16 * 1024 * 1024; // 16 Mo
        int chunks = (int) ((fileSize + chunkSize - 1) / chunkSize);

        ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
        List<Future<Integer>> results = new ArrayList<>();

        for (int i = 0; i < chunks; i++) {
            long offset = (long) i * chunkSize;
            long size = Math.min(chunkSize, fileSize - offset);

            results.add(pool.submit(() -> {
                try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
                    MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, size);
                    byte[] bytes = new byte[(int) size];
                    buffer.get(bytes);
                    String text = new String(bytes);
                    // Important : gérer les frontières de chunk pour ne pas couper un mot !
                    return countWords(text);
                }
            }));
        }

        int totalWords = 0;
        for (Future<Integer> f : results) {
            totalWords += f.get();
        }
        pool.shutdown();
        System.out.println("Total words: " + totalWords);
    }

    private static int countWords(String text) {
        // Méthode la plus simple : scinder sur les espaces et filtrer les chaînes vides
        String[] words = text.split("\\s+");
        int count = 0;
        for (String w : words) {
            if (!w.isBlank()) count++;
        }
        return count;
    }
}

Attention : dans des cas réels, il faut gérer soigneusement les frontières des chunks pour éviter de couper un mot ou une ligne entre deux threads. En général, on ajoute un petit chevauchement (par exemple, +100 octets) et on corrige le début/la fin du chunk.

5. Bilan et bonnes pratiques

  • Pour les gros fichiers, utilisez le découpage en chunks et le traitement parallèle.
  • Utilisez FileChannel pour l’accès positionnel et MappedByteBuffer pour les fichiers mappés en mémoire.
  • Choisissez la taille du chunk de façon expérimentale ; tenez compte du cache processeur et du débit du disque.
  • Gérez soigneusement les frontières des chunks (surtout pour le texte).
  • Pour le traitement parallèle, utilisez ExecutorService et un pool de threads.
  • N’abusez pas du nombre de threads : en général, 2–4 suffisent sur un SSD.
  • Surveillez la consommation mémoire : MappedByteBuffer peut occuper beaucoup de mémoire virtuelle.

6. Erreurs typiques avec les gros fichiers et le chunking

Erreur n° 1 : Lire tout le fichier en mémoire. Pour de gros fichiers, cela peut conduire à un OutOfMemoryError. Lisez plutôt les données par portions (chunks).

Erreur n° 2 : Mauvaise gestion des frontières des chunks. Si vous découpez le fichier sans tenir compte des frontières des lignes ou des mots, vous risquez de « couper » les données et d’obtenir un résultat incorrect.

Erreur n° 3 : Taille de chunk non optimale. Des chunks trop petits ajoutent des surcoûts de gestion des threads, tandis que des chunks trop grands utilisent la mémoire de façon inefficace.

Erreur n° 4 : FileChannel non fermé. Cela entraîne des fuites de ressources. Utilisez try-with-resources pour garantir la fermeture du canal.

Erreur n° 5 : Trop de threads. S’il y a trop de threads, le disque n’a pas le temps de répondre aux requêtes et les performances baissent au lieu d’augmenter.

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