CodeGym /Corsi /JAVA 25 SELF /Suddivisione di file di grandi dimensioni in chunk

Suddivisione di file di grandi dimensioni in chunk

JAVA 25 SELF
Livello 59, Lezione 3
Disponibile

1. Perché leggere file grandi in un unico thread — è come trasportare mattoni uno alla volta

Quando si lavora con file grandi — decine o centinaia di megabyte, o persino gigabyte — lettura o scrittura a thread singolo diventano rapidamente un collo di bottiglia. Un solo thread semplicemente non regge il carico: il disco può fornire dati più velocemente di quanto il programma riesca a elaborarli.

Anche con un SSD veloce, il limite non sarà il disco, ma l'overhead — context switch, gestione dei buffer, trasformazioni dei dati in memoria. Di conseguenza le prestazioni calano e la CPU resta inattiva: gli altri core non lavorano.

Supponiamo di voler contare le parole in un log enorme. Se lo si fa in modo sequenziale, un singolo thread scorrerà il file e voi dovrete solo aspettare. Se invece si suddivide il file in parti e si affida l'elaborazione a più thread, il lavoro procede molto più rapidamente: ognuno elabora la propria porzione e alla fine si sfrutta quasi per intero il potenziale del disco.

In pratica può andare così: su un SSD con throughput di 2 GB/s la lettura single-thread offre solo circa 300–500 MB/s. Leggendo in parallelo, invece, si può spremere dal drive tutto ciò di cui è capace.

2. Chunking — come far lavorare il file per voi

Quando un file diventa troppo grande per essere elaborato per intero, la cosa più sensata è suddividerlo in parti. Questa tecnica si chiama chunking (dalla parola chunk — «pezzo»). L'idea è semplice: si divide un file grande in più segmenti logici e si affida a ciascun thread la propria sezione.

Ogni thread sa da quale smaterializzazione (offset) iniziare e dove fermarsi. Legge solo la propria porzione, elabora i dati e poi i risultati vengono raccolti nel totale.

Questo approccio consente di utilizzare tutti i core della CPU contemporaneamente e accelera sensibilmente l'elaborazione, soprattutto se avete un SSD moderno o un disco NVMe. In compiti come il conteggio delle righe, la ricerca nel testo o l'aggregazione di statistiche il chunking funziona come un turbo — aggiunge velocità senza grandi sforzi.

Come scegliere la dimensione del chunk

La dimensione del chunk è un po' come la dimensione di una porzione di cibo: troppo piccola — si perde tempo a tagliare; troppo grande — è difficile da digerire. Tutto dipende dal compito e dalle capacità della vostra macchina.

In media funzionano bene intervalli da 8–64 MB per thread. Per la maggior parte dei casi basta scegliere qualcosa intorno a 10–20 MB, ma non esiste un numero perfetto — tutto si determina sperimentalmente. L'importante è che la porzione sia abbastanza grande da non sprecare tempo in switch dei thread e non così grande da saturare la cache della CPU o occupare tutta la memoria.

Se lavorate con testi — ad esempio contate parole o cercate corrispondenze — è importante che i chunk non spezzino righe o parole a metà. Di solito si risolve così: si crea una piccola sovrapposizione tra le porzioni oppure si spostano i confini fino al carattere di newline più vicino. In questo modo l'elaborazione resta precisa e il risultato — pulito e prevedibile.

3. Strumenti per l'accesso posizionale: FileChannel e MappedByteBuffer

FileChannel: I/O posizionale

FileChannel è una classe del package java.nio.channels che consente di lavorare con i file a basso livello, inclusa la lettura e la scrittura da/in una posizione arbitraria del file.

Metodi chiave:

  • position(long newPosition) — imposta la posizione (offset) per lettura/scrittura.
  • read(ByteBuffer dst, long position) — legge i dati dal file nel buffer a partire dalla posizione indicata (non modifica la posizione corrente del canale!).
  • write(ByteBuffer src, long position) — scrive i dati nel file a partire dalla posizione indicata.

Esempio: lettura di una porzione di file

try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
    long chunkSize = 16 * 1024 * 1024; // 16 MB
    long offset = 0;
    ByteBuffer buffer = ByteBuffer.allocate((int) chunkSize);
    int bytesRead = channel.read(buffer, offset);
    // il buffer contiene i primi 16 MB del file
}

Vantaggi:

  • Si può leggere/scrivere da qualsiasi posizione.
  • Comodo per l'elaborazione parallela: ogni thread lavora sulla propria porzione.

MappedByteBuffer: file memory-mapped

MappedByteBuffer è un buffer speciale che consente di «mappare» (map) una porzione di file in memoria. Il sistema operativo si occupa di caricare i dati dal disco in memoria e viceversa.

Come funziona?

  • Si mappa una porzione di file in memoria.
  • Si legge/scrive nel buffer — l'OS carica automaticamente le pagine necessarie.
  • Niente chiamate esplicite a read/write — tutto avviene tramite la memoria.

Esempio:

try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
    long chunkSize = 16 * 1024 * 1024; // 16 MB
    long offset = 0;
    MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, chunkSize);
    // Ora il buffer si comporta come un array di byte, ma i dati vengono letti dal disco on-demand
}

Pro:

  • Velocità molto elevata (soprattutto su SSD).
  • Semplicità: si legge/scrive come in un array.

Contro:

  • Usa memoria virtuale — se il file è molto grande, si può saturare la memoria.
  • Il controllo dello svuotamento dalla memoria è difficile (il buffer può restare mappato più a lungo del necessario).
  • Non sempre adatto per file molto grandi (oltre 2–4 GB su sistemi a 32 bit).

4. Esempio: lettura parallela e conteggio delle parole

Consideriamo il compito: contare il numero di parole in un grande file di testo (ad esempio, un log da 10 GB) usando l'elaborazione parallela.

Passo 1. Suddividiamo il file in chunk

  • Otteniamo la dimensione del file: long fileSize = Files.size(path);
  • Scegliamo la dimensione del chunk, ad esempio 16 MB.
  • Per ogni chunk calcoliamo l'offset: offset = chunkIndex * chunkSize;
  • L'ultimo chunk può essere più piccolo.

Passo 2. Creiamo i task per i thread

  • Per ogni chunk creiamo un Callable<Integer> (o un Runnable) che:
    • Apre la propria porzione di file tramite FileChannel.read(ByteBuffer, offset) o MappedByteBuffer.
    • Conta il numero di parole nella propria porzione.
    • Restituisce il risultato (numero di parole).

Passo 3. Avviamo i task tramite ExecutorService

  • Creiamo un thread pool: ExecutorService pool = Executors.newFixedThreadPool(N);
  • Inviamo i task al pool: List<Future<Integer>> results = pool.invokeAll(tasks);
  • Raccogliamo i risultati: sommiamo i valori di tutte le future.

Esempio di codice (semplificato):

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 MB
        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);
                    // Importante: gestire i confini del chunk per non spezzare una parola!
                    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) {
        // Modo più semplice: dividere per spazi e filtrare le stringhe vuote
        String[] words = text.split("\\s+");
        int count = 0;
        for (String w : words) {
            if (!w.isBlank()) count++;
        }
        return count;
    }
}

Attenzione: nei casi reali bisogna trattare con cura i confini dei chunk per non spezzare una parola o una riga tra due thread. Di solito si introduce un piccolo overlap (ad esempio, +100 byte) e si correggono in seguito inizio/fine del chunk.

5. Conclusioni e best practice

  • Per file grandi usate la suddivisione in chunk e l'elaborazione parallela.
  • Usate FileChannel per l'accesso posizionale e MappedByteBuffer per i file memory-mapped.
  • Scegliete la dimensione del chunk in modo sperimentale; riferimento — cache della CPU e throughput del disco.
  • Trattate con cura i confini dei chunk (soprattutto per testi).
  • Per l'elaborazione parallela usate ExecutorService e un thread pool.
  • Non esagerate con il numero di thread: in genere bastano 2–4 thread su SSD.
  • Attenzione al consumo di memoria: MappedByteBuffer può occupare molta memoria virtuale.

6. Errori tipici con file grandi e chunking

Errore n. 1: leggere l'intero file in memoria. Con file grandi questo può portare a OutOfMemoryError. Invece, leggete i dati a porzioni (chunk).

Errore n. 2: gestione errata dei confini dei chunk. Se si taglia il file senza considerare i confini di righe o parole, si possono spezzare i dati e il risultato sarà scorretto.

Errore n. 3: dimensione del chunk non ottimale. Chunk troppo piccoli introducono overhead inutile di gestione dei thread, mentre chunk troppo grandi usano la memoria in modo inefficiente.

Errore n. 4: FileChannel non chiuso. Questo porta a leak di risorse. Usate try-with-resources per garantire la chiusura del canale.

Errore n. 5: numero eccessivo di thread. Se i thread sono troppi, il disco non riesce a servire le richieste e le prestazioni calano invece di aumentare.

1
Compito
JAVA 25 SELF, livello 59, lezione 3
Bloccato
Lettura di una porzione del file tramite MappedByteBuffer
Lettura di una porzione del file tramite MappedByteBuffer
1
Compito
JAVA 25 SELF, livello 59, lezione 3
Bloccato
Conteggio parallelo delle righe in un file di grandi dimensioni
Conteggio parallelo delle righe in un file di grandi dimensioni
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION