1. Problem: Wie verarbeitet man viele Dateien in einem Verzeichnis effizient
In modernen Anwendungen stellt sich häufig die Aufgabe: eine große Anzahl von Dateien in einem Ordner und dessen Unterverzeichnissen zu verarbeiten. Zum Beispiel:
- Die Gesamtzahl der Zeilen in allen ".java"-Dateien des Projekts zählen.
- Alle Dateien finden, die im letzten Monat geändert wurden.
- Dateien nach einem bestimmten Kriterium kopieren oder löschen.
Wenn es nur wenige Dateien gibt, reicht eine normale Schleife. Doch bei Tausenden oder Zehntausenden, insbesondere wenn für jede Datei eine „schwere“ Operation (Lesen, Parsen, Analyse) ausgeführt wird, steigt die Zeit erheblich.
Frage: Wie beschleunigt man die Verarbeitung einer großen Anzahl von Dateien?
Antwort: Parallelisierung nutzen – Dateien gleichzeitig in mehreren Threads verarbeiten.
2. Werkzeuge für das Durchlaufen des Dateisystems
Files.walk()
Seit Java 8 gibt es eine bequeme Möglichkeit, Verzeichnisbäume zu durchlaufen – die Methode Files.walk() aus dem Paket java.nio.file. Sie gibt einen Stream<Path> zurück – alle Dateien und Ordner, beginnend beim angegebenen Verzeichnis.
Beispiel:
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) — gibt einen Stream über alle Dateien und Ordner zurück, einschließlich Unterverzeichnissen.
- Man kann die maximale Tiefe angeben: Files.walk(start, 3).
Files.find()
Wenn man sofort nach einem Kriterium filtern möchte (z. B. nur ".java"-Dateien), verwendet man 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() nimmt einen Filter (BiPredicate<Path, BasicFileAttributes>) entgegen, der den Pfad und die Dateiattribute erhält.
3. Parallele Verarbeitung: parallel() und ForkJoinPool
Parallele Streams: .parallel()
Jeder Stream hat die Methode parallel(). Ruft man sie auf, erfolgt die Verarbeitung der Elemente in mehreren Threads.
Files.walk(start)
.parallel()
.forEach(path -> processFile(path));
Jede Datei wird möglichst parallel verarbeitet, was insbesondere bei „schweren“ Operationen effektiv ist: Lesen, Parsen, Berechnungen.
Wie funktioniert das intern? ForkJoinPool
Parallele Streams verwenden einen gemeinsamen Thread-Pool — ForkJoinPool.commonPool(). Das ist ein „intelligenter“ Pool, der Aufgaben auf Threads verteilt.
- Standardmäßig entspricht die Anzahl der Threads der Anzahl der verfügbaren Prozessoren: Runtime.getRuntime().availableProcessors().
- Das „Fork/Join“-Modell eignet sich gut für unabhängige Aufgaben — wie die Verarbeitung einzelner Dateien.
Wann sollte man .parallel() verwenden?
- Wenn die Verarbeitung jeder Datei unabhängig von den anderen ist.
- Wenn die Operation „schwer“ ist (CPU-lastig oder mit langen IO-Wartezeiten).
- Wenn es viele Dateien gibt (Hunderte, Tausende).
Man sollte parallele Streams nicht verwenden:
- Wenn es nur wenige Dateien gibt (der Overhead der Parallelisierung kann den Nutzen übersteigen).
- Wenn eine strikte Reihenfolge erforderlich ist oder Abhängigkeiten zwischen den Elementen bestehen.
4. Alternativen und Tuning der Parallelität
Wann ist ExecutorService die bessere Wahl?
Parallele Streams sind gut für einfache Fälle. Wenn man jedoch Folgendes braucht:
- Die genaue Anzahl der Threads steuern (für IO-bound-Aufgaben sind oft mehr Threads als Kerne vorteilhaft).
- Warteschlangen, Abbruch, Retries und Fehlerbehandlung steuern.
- Komplexere Aufgabenketten/Pipelines aufbauen.
Dann verwenden Sie 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();
Tuning von ForkJoinPool
Standardmäßig nutzt der gemeinsame Pool so viele Threads wie Prozessoren vorhanden sind. Man kann das über eine Systemeigenschaft ändern (vor der ersten Verwendung paralleler Streams):
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");
- Nach dem Aufruf verwenden alle parallelen Streams bis zu 16 Threads.
CPU-bound vs. IO-bound-Aufgaben
- CPU-bound: belasten die CPU stark (Mathematik, Parsen, Komprimierung). Anzahl der Threads ≈ Anzahl der Kerne.
- IO-bound: viel Wartezeit auf Disk/Netz. Häufig sind mehr Threads als Kerne vorteilhaft.
Parallele Streams sind für IO-bound-Aufgaben nicht immer optimal — oft ist ein eigener ExecutorService mit größerem Pool überlegen.
5. Beispiel: paralleles Suchen und Verarbeiten von Dateien
Wir zählen die Gesamtzahl der Zeilen in allen ".java"-Dateien des Projekts, indem wir den parallelen Durchlauf nutzen.
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() // parallele Verarbeitung!
.filter(p -> p.toString().endsWith(".java"))
.mapToLong(LineCounter::countLines)
.sum();
System.out.println("Gesamtzahl der Codezeilen: " + totalLines);
}
// Methode zum Zählen der Zeilen in einer Datei
private static long countLines(Path path) {
try (Stream<String> lines = Files.lines(path)) {
return lines.count();
} catch (IOException e) {
System.err.println("Fehler beim Lesen der Datei: " + path);
return 0;
}
}
}
Was passiert hier:
- Files.walk(start) — Durchlauf aller Pfade.
- parallel() — parallele Verarbeitung einschalten.
- filter(...) — nur ".java"-Dateien übrig lassen.
- mapToLong(...) — Zeilen in jeder Datei zählen.
- sum() — Ergebnis aufsummieren.
Vorteile: Es werden mehrere Threads genutzt und der Code bleibt dennoch knapp.
6. Wichtige Nuancen und typische Fehler
- Nicht jede Aufgabe wird durch Parallelisierung schneller. Bei kleinen Dateimengen oder sehr schnellen Operationen kann der Overhead das Programm verlangsamen.
- Ressourcen schließen. Beim Arbeiten mit Dateien try-with-resources verwenden — so „lecken“ die Deskriptoren nicht. Zum Beispiel Files.lines(path) in try(...).
- Verschachtelte Parallelität. Parallele Streams innerhalb anderer paralleler Aufgaben (nested parallelism) sind selten effizient und können zu Performanceeinbrüchen führen.
- Seiteneffekte. Schreiben in gemeinsame Strukturen/Dateien ohne Synchronisation vermeiden. Bevorzugen Sie „pure“ Operationen auf den Elementen.
7. Schema: Wie funktioniert der parallele Dateidurchlauf
flowchart TD
A["Files.walk(start)"] --> B["Stream<Path>"]
B --> C{".parallel()?"}
C -- Nein --> D[Sequentieller forEach]
C -- Ja --> E["Paralleler forEach (ForkJoinPool)"]
E --> F[Verarbeitung von Dateien in mehreren Threads]
8. Typische Fehler bei der parallelen Verarbeitung von Dateien
Fehler Nr. 1: Parallele Streams für kleine Aufgaben einsetzen — der Overhead ist größer als der Gewinn.
Fehler Nr. 2: Zu erwarten, dass parallele Streams IO-bound-Aufgaben genauso beschleunigen wie CPU-bound. Für IO braucht man häufig einen ExecutorService mit größerem Pool.
Fehler Nr. 3: Unbehandelte Ausnahmen in Lambdas — ohne Behandlung von IOException kann der Stream abbrechen und das Ergebnis unvollständig sein.
Fehler Nr. 4: Race Conditions beim Schreiben in gemeinsame Variablen oder Dateien — synchronisieren Sie den Zugriff oder vermeiden Sie Seiteneffekte.
Fehler Nr. 5: Ressourcen nicht geschlossen — verwenden Sie try-with-resources für alle Dateioperationen.
Fehler Nr. 6: Versuch, ForkJoinPool.commonPool() nach der ersten Verwendung zu ändern — die Einstellung per System.setProperty(...) muss vorher erfolgen.
Fehler Nr. 7: Parallele Streams innerhalb anderer paralleler Streams verwenden — führt oft zu Performance-Degradation.
GO TO FULL VERSION