1. Warum große Dateien in einem einzigen Thread zu lesen ist wie Ziegelsteine einzeln zu schleppen
Wenn Sie mit großen Dateien arbeiten – Dutzende oder Hunderte von Megabytes, oder sogar Gigabytes – werden sequentielles Lesen oder Schreiben schnell zum Engpass. Ein einzelner Thread schafft die Last nicht: Das Laufwerk kann Daten schneller liefern, als das Programm sie verarbeiten kann.
Selbst mit einer schnellen SSD stößt der Thread nicht an die Grenzen des Laufwerks, sondern an Overhead – Kontextwechsel, Arbeit mit Puffern, Datenumwandlungen im Speicher. Am Ende sinkt die Leistung, während die CPU unterbeschäftigt ist, denn ihre übrigen Kerne bleiben ungenutzt.
Angenommen, Sie möchten die Anzahl der Wörter in einem riesigen Log zählen. Wenn Sie das sequentiell tun, wird ein Thread monoton die Datei „knabbern“, und Sie warten einfach. Teilen Sie die Datei jedoch in Teile auf und übergeben die Verarbeitung an mehrere Threads, geht es deutlich schneller: Jeder verarbeitet seinen Abschnitt, und Sie nutzen die Leistung des Laufwerks nahezu vollständig aus.
In der Praxis sieht das so aus: Auf einer SSD mit einer Bandbreite von 2 GB/s liefert das sequentielle Lesen nur etwa 300–500 MB/s. Beim parallelen Lesen hingegen kann man dem Laufwerk alles entlocken, wozu es fähig ist.
2. Chunking – so bringt man die Datei für sich zum Arbeiten
Wenn eine Datei zu groß wird, um sie am Stück zu verarbeiten, ist das Vernünftigste, sie in Teile aufzuteilen. Diese Technik heißt Chunking (vom Wort chunk – „Stück“). Die Idee ist einfach: Sie teilen eine große Datei in mehrere logische Segmente und übergeben jedem Thread seinen Abschnitt.
Jeder Thread weiß, von welchem Offset (offset) er beginnen und wo er stoppen soll. Er liest nur seinen Teil, verarbeitet die Daten und anschließend werden die Ergebnisse zu einem Gesamtergebnis zusammengeführt.
Dieser Ansatz ermöglicht es, alle CPU-Kerne gleichzeitig auszulasten und die Verarbeitung deutlich zu beschleunigen, besonders mit modernen SSDs oder NVMe-Laufwerken. Bei Aufgaben wie Zeilen zählen, Textsuche oder Statistikaggregation wirkt Chunking wie ein Turbolader – es bringt einfach mehr Tempo ohne großen Aufwand.
Wie man die Chunk-Größe wählt
Die Chunk-Größe ist fast wie eine Portionsgröße: zu klein – man schneidet sich zu Tode, zu groß – schwer verdaulich. Es hängt von der Aufgabe und der Leistungsfähigkeit Ihrer Maschine ab.
Im Mittel liefern Bereiche von 8–64 MB pro Thread gute Ergebnisse. Für die meisten Aufgaben genügt etwas um 10–20 MB, aber die perfekte Zahl gibt es nicht – das wird experimentell bestimmt. Wichtig ist: Der Abschnitt sollte groß genug sein, um keinen unnötigen Thread-Scheduling-Overhead zu erzeugen, aber nicht so groß, dass er den CPU-Cache verstopft oder den gesamten Speicher belegt.
Wenn Sie mit Texten arbeiten – etwa Wörter zählen oder Übereinstimmungen suchen – ist es wichtig, dass Chunks keine Zeilen oder Wörter in der Mitte „zerreißen“. Üblicherweise löst man das einfach: Man macht eine kleine Überlappung zwischen den Teilen oder verschiebt die Grenzen bis zum nächsten Zeilenumbruch. So bleibt die Verarbeitung korrekt und das Ergebnis sauber und vorhersehbar.
3. Werkzeuge für positionellen Zugriff: FileChannel und MappedByteBuffer
FileChannel: positioniertes I/O
FileChannel ist eine Klasse aus dem Paket java.nio.channels, die das Arbeiten mit Dateien auf niedriger Ebene erlaubt, inklusive Lesen und Schreiben an eine beliebige Position der Datei.
Schlüsselmethoden:
- position(long newPosition) – die Position (Offset) für Lesen/Schreiben setzen.
- read(ByteBuffer dst, long position) – Daten aus der Datei ab der angegebenen Position in den Puffer lesen (ändert die aktuelle Kanalposition nicht!).
- write(ByteBuffer src, long position) – Daten ab der angegebenen Position in die Datei schreiben.
Beispiel: einen Dateiblock lesen
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);
// Der Puffer enthält die ersten 16 MB der Datei
}
Vorteile:
- Lesen/Schreiben ab beliebiger Position.
- Eignet sich für parallele Verarbeitung: Jeder Thread arbeitet mit seinem Abschnitt.
MappedByteBuffer: Memory-mapped Files
MappedByteBuffer ist ein spezieller Puffer, der einen Teil der Datei im Speicher „abbildet“ (mappt). Das Betriebssystem kümmert sich selbst um das Laden der Daten vom Laufwerk in den Speicher und zurück.
Wie funktioniert das?
- Sie mappen einen Dateiausschnitt in den Speicher.
- Sie lesen und schreiben in den Puffer – das OS lädt die benötigten Seiten selbstständig nach.
- Keine expliziten Aufrufe von read/write – alles geschieht über den Speicher.
Beispiel:
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);
// Jetzt verhält sich buffer wie ein Byte-Array, aber die Daten werden beim Zugriff vom Laufwerk nachgeladen
}
Vorteile:
- Sehr hohe Geschwindigkeit (besonders auf SSDs).
- Einfachheit: Lesen/Schreiben wie in ein Array.
Nachteile:
- Verwendet virtuellen Speicher – bei extrem großen Dateien kann der Speicher „zugestopft“ erscheinen.
- Das Entladen aus dem Speicher ist schwer zu steuern (der Puffer kann länger im Speicher bleiben als nötig).
- Nicht immer bequem für sehr große Dateien (über 2–4 GB auf 32‑Bit‑Systemen).
4. Beispiel: Paralleles Lesen und Wörter zählen
Betrachten wir die Aufgabe: die Anzahl der Wörter in einer großen Textdatei (z. B. ein Log mit 10 GB) mithilfe paralleler Verarbeitung zu zählen.
Schritt 1. Datei in Chunks aufteilen
- Dateigröße ermitteln: long fileSize = Files.size(path);
- Chunk-Größe wählen, z. B. 16 MB.
- Für jeden Chunk das Offset berechnen: offset = chunkIndex * chunkSize;
- Der letzte Chunk kann kleiner sein.
Schritt 2. Aufgaben für Threads erstellen
- Für jeden Chunk ein Callable<Integer> (oder Runnable) erstellen, das:
- Seinen Dateiausschnitt über FileChannel.read(ByteBuffer, offset) oder MappedByteBuffer öffnet.
- Die Anzahl der Wörter in seinem Abschnitt zählt.
- Das Ergebnis (Wortanzahl) zurückgibt.
Schritt 3. Aufgaben über ExecutorService starten
- Einen Thread-Pool erstellen: ExecutorService pool = Executors.newFixedThreadPool(N);
- Aufgaben an den Pool übergeben: List<Future<Integer>> results = pool.invokeAll(tasks);
- Ergebnisse einsammeln: Werte aller Futures aufsummieren.
Codebeispiel (vereinfacht):
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);
// Wichtig: Chunk-Grenzen korrekt behandeln, damit kein Wort zerschnitten wird!
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) {
// Einfachste Methode: nach Whitespace splitten und leere Strings herausfiltern
String[] words = text.split("\\s+");
int count = 0;
for (String w : words) {
if (!w.isBlank()) count++;
}
return count;
}
}
Achtung: In realen Aufgaben müssen die Chunk-Grenzen sorgfältig behandelt werden, damit kein Wort oder keine Zeile zwischen zwei Threads zerschnitten wird. Üblicherweise macht man eine kleine Überlappung (z. B. +100 Bytes) und korrigiert Anfang/Ende des Chunks.
5. Fazit und Best Practices
- Für große Dateien Chunking und parallele Verarbeitung verwenden.
- FileChannel für positionellen Zugriff nutzen und MappedByteBuffer für memory-mapped Dateien.
- Die Chunk-Größe experimentell bestimmen; Orientierung: CPU-Cache und Bandbreite des Laufwerks.
- Chunk-Grenzen sorgfältig behandeln (insbesondere bei Texten).
- Für parallele Verarbeitung ExecutorService und einen Thread-Pool verwenden.
- Nicht mit der Thread-Anzahl übertreiben: Üblicherweise genügen 2–4 Threads auf SSDs.
- Den Speicherverbrauch im Blick behalten: MappedByteBuffer kann viel virtuellen Speicher belegen.
6. Typische Fehler beim Arbeiten mit großen Dateien und Chunking
Fehler Nr. 1: Die gesamte Datei in den Speicher einlesen. Bei der Verarbeitung großer Dateien kann das zu OutOfMemoryError führen. Lesen Sie stattdessen die Daten in Teilen (Chunks).
Fehler Nr. 2: Falsche Behandlung der Chunk-Grenzen. Wenn man die Datei ohne Rücksicht auf Zeilen- oder Wortgrenzen zerschneidet, können Daten „zerreißen“ und das Ergebnis wird inkorrekt.
Fehler Nr. 3: Nicht optimale Chunk-Größe. Zu kleine Chunks verursachen unnötigen Overhead beim Thread-Management, zu große nutzen den Speicher ineffizient.
Fehler Nr. 4: Nicht geschlossener FileChannel. Das führt zu Ressourcenlecks. Verwenden Sie try-with-resources, um das Schließen des Kanals zu garantieren.
Fehler Nr. 5: Zu viele Threads. Wenn es zu viele Threads gibt, kann das Laufwerk die Anfragen nicht bedienen und die Leistung sinkt statt zu steigen.
GO TO FULL VERSION