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.
GO TO FULL VERSION