1. Problema: come elaborare in modo efficiente molti file in una directory
Nelle applicazioni moderne capita spesso il compito: elaborare un gran numero di file in una cartella e nelle sue sottodirectory. Per esempio:
- Calcolare il numero totale di righe in tutti i file ".java" del progetto.
- Trovare tutti i file modificati nell'ultimo mese.
- Copiare o eliminare file in base a un determinato criterio.
Se i file sono pochi, basta un ciclo normale. Ma con migliaia e decine di migliaia, soprattutto quando su ogni file viene eseguita un'operazione «pesante» (lettura, parsing, analisi), il tempo cresce sensibilmente.
Domanda: come velocizzare l'elaborazione di un gran numero di file?
Risposta: usare il parallelismo — elaborare i file contemporaneamente in più thread.
2. Strumenti per attraversare il file system
Files.walk()
In Java 8+ è apparso un modo comodo per attraversare l'albero delle directory — il metodo Files.walk() dal package java.nio.file. Restituisce uno Stream<Path> — tutti i file e le cartelle, a partire dalla directory specificata.
Esempio:
import java.nio.file.*;
import java.util.stream.Stream;
Path start = Paths.get("src");
try (Stream<Path> stream = Files.walk(start)) {
stream.forEach(System.out::println);
}
- Files.walk(start) — restituisce uno stream di tutti i file e cartelle, incluse le sottodirectory.
- È possibile specificare la profondità massima di attraversamento: Files.walk(start, 3).
Files.find()
Se è necessario filtrare subito per un criterio (ad esempio solo i file ".java"), usa Files.find():
import java.nio.file.*;
import java.util.stream.Stream;
Path start = Paths.get("src");
try (Stream<Path> stream = Files.find(
start,
Integer.MAX_VALUE,
(path, attr) -> path.toString().endsWith(".java"))) {
stream.forEach(System.out::println);
}
- Files.find() accetta un filtro (BiPredicate<Path, BasicFileAttributes>) che riceve il percorso e gli attributi del file.
3. Elaborazione parallela: parallel() e ForkJoinPool
Stream paralleli: .parallel()
Ogni Stream ha il metodo parallel(). Se lo si invoca, l'elaborazione degli elementi avverrà su più thread.
Files.walk(start)
.parallel()
.forEach(path -> processFile(path));
Ogni file verrà elaborato in parallelo (quando possibile), il che è particolarmente efficace per operazioni «pesanti»: lettura, parsing, calcoli.
Come funziona internamente? ForkJoinPool
Gli stream paralleli utilizzano un pool di thread comune — ForkJoinPool.commonPool(). È un pool «intelligente» che distribuisce i task tra i thread.
- Per impostazione predefinita il numero di thread è uguale al numero di processori disponibili: Runtime.getRuntime().availableProcessors().
- Il modello parallelo «fork/join» è adatto a task indipendenti — come l'elaborazione di singoli file.
Quando usare .parallel()?
- Quando l'elaborazione di ciascun file è indipendente dagli altri.
- Quando l'operazione è «pesante» (impegna la CPU o attende a lungo l'I/O).
- Quando i file sono molti (centinaia, migliaia).
Non conviene usare gli stream paralleli:
- Se i file sono pochi (i costi del parallelismo possono superare i benefici).
- Se è richiesto un ordine rigoroso o ci sono dipendenze tra gli elementi.
4. Alternative e ottimizzazione del parallelismo
Quando è meglio usare ExecutorService?
Gli stream paralleli sono ottimi per casi semplici. Ma se serve:
- Controllare il numero esatto di thread (per i task IO-bound conviene avere più thread che core).
- Gestire code, cancellazioni, retry, gestione degli errori.
- Costruire pipeline di task più complesse.
Allora usa ExecutorService:
import java.nio.file.*;
import java.util.concurrent.*;
ExecutorService executor = Executors.newFixedThreadPool(8);
Files.walk(start)
.filter(Files::isRegularFile)
.forEach(path -> executor.submit(() -> processFile(path)));
executor.shutdown();
Ottimizzazione di ForkJoinPool
Per impostazione predefinita il pool comune usa un numero di thread pari al numero di processori. È possibile modificarlo tramite una proprietà di sistema (prima del primo utilizzo degli stream paralleli):
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");
- Dopo questa impostazione tutti gli stream paralleli useranno fino a 16 thread.
CPU-bound vs IO-bound task
- CPU-bound: caricano intensamente la CPU (calcoli, parsing, compressione). Numero di thread ≈ numero di core.
- IO-bound: molto tempo di attesa su disco/rete. Spesso conviene avere più thread che core.
Gli stream paralleli non sono sempre ottimali per i task IO-bound — spesso conviene un ExecutorService dedicato con un pool aumentato.
5. Esempio: ricerca ed elaborazione parallela dei file
Calcoliamo il numero totale di righe in tutti i file ".java" del progetto, utilizzando l'attraversamento parallelo.
import java.nio.file.*;
import java.util.stream.*;
import java.io.IOException;
public class LineCounter {
public static void main(String[] args) throws IOException {
Path start = Paths.get("src");
long totalLines = Files.walk(start)
.parallel() // elaborazione parallela!
.filter(p -> p.toString().endsWith(".java"))
.mapToLong(LineCounter::countLines)
.sum();
System.out.println("Totale righe di codice: " + totalLines);
}
// Metodo per contare le righe in un file
private static long countLines(Path path) {
try (Stream<String> lines = Files.lines(path)) {
return lines.count();
} catch (IOException e) {
System.err.println("Errore di lettura del file: " + path);
return 0;
}
}
}
Cosa succede:
- Files.walk(start) — attraversamento di tutti i percorsi.
- parallel() — abilitiamo l'elaborazione parallela.
- filter(...) — manteniamo solo i file ".java".
- mapToLong(...) — contiamo le righe in ogni file.
- sum() — sommiamo il risultato.
Vantaggi: si sfruttano più thread e il codice rimane conciso.
6. Dettagli importanti ed errori comuni
- Non tutti i task traggono beneficio dal parallelismo. Per insiemi piccoli di file o operazioni veloci, l'overhead può rallentare il programma.
- Chiudete le risorse. Quando lavorate con i file usate try-with-resources — così i descrittori non andranno in «leak». Per esempio, Files.lines(path) nel try(...).
- Parallelismo annidato. Avviare stream paralleli all'interno di altri task paralleli (nested parallelism) raramente è efficace e può portare a degrado delle prestazioni.
- Effetti collaterali. Evitate di scrivere in strutture/file condivisi senza sincronizzazione. Preferite operazioni «pure» sugli elementi.
7. Schema: come funziona l'attraversamento parallelo dei file
flowchart TD
A["Files.walk(start)"] --> B["Stream<Path>"]
B --> C{".parallel()?"}
C -- No --> D[forEach normale]
C -- Sì --> E["forEach parallelo (ForkJoinPool)"]
E --> F[Elaborazione dei file su più thread]
8. Errori tipici durante l'elaborazione parallela dei file
Errore n. 1: Uso degli stream paralleli per task piccoli — l'overhead è maggiore del guadagno.
Errore n. 2: Aspettarsi che gli stream paralleli accelerino i task IO-bound come quelli CPU-bound. Per l'I/O spesso serve un ExecutorService con un pool più grande.
Errore n. 3: Eccezioni non gestite nelle lambda — senza gestione di IOException lo stream può interrompersi e il risultato può essere incompleto.
Errore n. 4: Race condition durante la scrittura in variabili o file condivisi — sincronizzate l'accesso o evitate effetti collaterali.
Errore n. 5: Dimenticare di chiudere le risorse — usate try-with-resources per tutte le operazioni sui file.
Errore n. 6: Tentare di modificare ForkJoinPool.commonPool() dopo il primo utilizzo — l'impostazione tramite System.setProperty(...) va fatta in anticipo.
Errore n. 7: Uso di stream paralleli all'interno di altri stream paralleli — spesso porta a degrado delle prestazioni.
GO TO FULL VERSION