1. Wann Multithreading hilft
Multithreading ist sinnvoll, wenn es viel Arbeit gibt, die sich gleichzeitig erledigen lässt. Wenn Sie zum Beispiel dutzende Dateien verarbeiten sollen — sie kopieren, neu berechnen oder analysieren — ist es einfacher, unterschiedliche Teile verschiedenen Threads zu überlassen, als alles nacheinander zu tun. Das ist, als würden Sie statt eines Freundes, der Ihr Fotoarchiv durchgeht, gleich fünf Freunde bitten: Die Arbeit geht schneller und macht mehr Spaß.
Besonders nützlich ist es bei der Stapelverarbeitung von Dateien, beim Herunterladen oder Kopieren großer Dateien in Teilen oder wenn nach dem Einlesen der Daten parallel für verschiedene Bereiche etwas berechnet werden soll.
Aber Multithreading hilft nicht immer. Wenn Sie nur eine kleine Datei haben, ist es sinnlos, dafür ein Dutzend Threads zu starten. Wenn Festplatte oder Netzwerk bereits am Limit sind, verlangsamen zusätzliche Threads den Prozess nur. Und wenn mehrere Threads gleichzeitig ohne Synchronisierung in dieselbe Datei schreiben, entsteht echtes Chaos mit beschädigten Daten.
Kurz gesagt, Multithreading ist ein Werkzeug. Wie ein Hammer: Man kann damit einen Nagel einschlagen, aber auch den Finger treffen. Das Wichtigste ist zu wissen, wann und wie man es einsetzt.
2. Java‑Werkzeuge für nebenläufiges I/O
Sie wissen bereits, dass es in Java mehrere Möglichkeiten gibt, Aufgaben parallel zu starten:
- Klassischer Thread — manuelles Erstellen von Threads.
- Thread-Pool über ExecutorService — moderne, flexible und bequeme Variante.
- CompletableFuture und parallele Streams (Stream API) — für fortgeschrittene Aufgaben (mehr dazu in den nächsten Vorlesungen).
Beginnen wir mit dem Einfachsten: Verarbeitung mehrerer Dateien in verschiedenen Threads.
Beispiel 1: Klassischer Thread
public class FileCopyTask extends Thread {
private final Path source;
private final Path target;
public FileCopyTask(Path source, Path target) {
this.source = source;
this.target = target;
}
@Override
public void run() {
try {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Datei kopiert: " + source);
} catch (IOException e) {
System.err.println("Fehler beim Kopieren von " + source + ": " + e.getMessage());
}
}
}
// Start mehrerer Kopien in separaten Threads
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
new FileCopyTask(file, Path.of("backup_" + file.getFileName())).start();
}
Vorteile: Einfach, verständlich.
Nachteile: Viele Threads manuell zu verwalten, ist unbequem; keine Kontrolle über die Anzahl gleichzeitig laufender Threads.
Beispiel 2: ExecutorService — Thread‑Pool
ExecutorService ermöglicht es, Aufgaben an einen Thread-Pool zu delegieren, der selbst entscheidet, wie viele Threads gleichzeitig verwendet werden.
import java.util.concurrent.*;
public class MultiFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4); // maximal 4 Threads
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Files.copy(file, Path.of("backup_" + file.getFileName()), StandardCopyOption.REPLACE_EXISTING);
System.out.println("Kopiert: " + file);
} catch (IOException e) {
System.err.println("Fehler: " + file + " " + e.getMessage());
}
});
}
executor.shutdown(); // nehmen keine Aufgaben mehr an
executor.awaitTermination(1, TimeUnit.MINUTES); // warten auf den Abschluss aller Aufgaben
}
}
Vorteile:
- Leicht skalierbar (die gewünschte Anzahl Threads lässt sich festlegen).
- Bequeme Kontrolle über den Abschluss von Aufgaben (Methoden shutdown(), awaitTermination(...)).
- Eignet sich für die Verarbeitung von Hunderten und Tausenden Dateien.
3. Probleme und Einschränkungen von nebenläufigem I/O
Konkurrenz um Ressourcen
Wenn Sie ohne Synchronisierung versuchen, gleichzeitig aus mehreren Threads dieselbe Datei zu lesen oder in sie zu schreiben, ist das Ergebnis unvorhersehbar. Das ist, als würden zwei Personen gleichzeitig auf derselben Buchseite schreiben: Es wird ein Durcheinander. Zur Koordination verwenden Sie zum Beispiel synchronized, explizite Locks oder einen separaten Schreib-Thread.
Einschränkungen des Dateisystems und des Betriebssystems
- Nicht alle Dateisysteme unterstützen gleichzeitiges Schreiben in eine Datei gut.
- Das Betriebssystem kann die Anzahl gleichzeitig geöffneter Dateien begrenzen.
- Eine Festplatte (insbesondere HDD) arbeitet schlecht bei vielen zufälligen Zugriffen.
Synchronisierung beim Schreiben in eine gemeinsame Ressource
Wenn mehrere Threads in eine Datei (z. B. ein Log) schreiben, müssen Sie den Zugriff synchronisieren (z. B. über synchronized, Locks oder spezielle Schreib-Threads).
Ineffizienz bei kleinen Dateien
Bei kleinen Dateien können die Overheads für das Erzeugen von Threads und das Umschalten zwischen ihnen größer sein als der Gewinn durch Parallelität.
4. Praktische Beispiele
Paralleles Kopieren von Dateien
Nehmen wir an, wir haben einen Ordner mit vielen Logs, die in ein Archivverzeichnis kopiert werden sollen.
import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;
public class ParallelFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4);
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
// ... fügen Sie beliebig viele Dateien hinzu
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Path target = Path.of("archive", file.getFileName().toString());
Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Kopiert: " + file);
} catch (IOException e) {
System.err.println("Fehler: " + file + " " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
}
}
Kommentar:
- Wir verwenden einen Pool aus 4 Threads — das reicht normalerweise aus, um die Festplatte zu beschäftigen, ohne das System zu überlasten.
- Für 1000 Dateien kann der Pool auf 8 erhöht werden, aber zu groß sollte er nicht sein.
Parallele Verarbeitung der Zeilen einer Datei mit dem Stream API
Seit Java 8 kann man parallele Streams zur Verarbeitung des Datei-Inhalts verwenden:
import java.nio.file.*;
import java.io.IOException;
public class ParallelLineProcessing {
public static void main(String[] args) throws IOException {
Path path = Path.of("biglog.txt");
// Files.lines liefert einen Stream<String> — einen Stream der Zeilen der Datei
Files.lines(path)
.parallel() // in einen parallelen Stream umwandeln
.filter(line -> line.contains("ERROR"))
.forEach(line -> System.out.println("Fehler: " + line));
}
}
Wichtig:
- Parallele Streams beschleunigen die Verarbeitung, wenn sie rechenintensiv ist (CPU-bound), nicht jedoch den eigentlichen Lesevorgang (IO-bound).
- Wenn die Zeilenverarbeitung einfach ist (z. B. nur Ausgabe über System.out.println), gibt es möglicherweise keinen Gewinn.
Lesen/Schreiben verschiedener Teile einer großen Datei
Java erlaubt das gleichzeitige Lesen oder Schreiben verschiedener Bereiche einer Datei mithilfe von FileChannel und Positionsmethoden. Das ist bereits ein fortgeschrittenes Thema, aber das Prinzip ist einfach: Jeder Thread arbeitet mit seinem eigenen Dateibereich.
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.io.*;
import java.nio.ByteBuffer;
public class FileChunkReader implements Runnable {
private final Path path;
private final long position;
private final long size;
public FileChunkReader(Path path, long position, long size) {
this.path = path;
this.position = position;
this.size = size;
}
@Override
public void run() {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate((int) size);
channel.read(buffer, position);
System.out.println("Chunk ab Position " + position + " mit Größe " + size + " gelesen");
// Hier kann der Buffer verarbeitet werden
} catch (IOException e) {
System.err.println("Fehler beim Lesen des Chunks: " + e.getMessage());
}
}
}
// Beispielstart: Datei mit 1 MB in 4 Threads lesen
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1 MB
int chunks = (int) Math.ceil((double) fileSize / chunkSize);
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < chunks; i++) {
long position = i * chunkSize;
long size = Math.min(chunkSize, fileSize - position);
executor.submit(new FileChunkReader(file, position, size));
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
Kommentar:
- Jeder Thread liest seinen eigenen Dateibereich, ohne andere zu stören.
- Dieser Ansatz wird z. B. in Torrents und Download-Managern verwendet.
Synchronisierung beim Schreiben in eine gemeinsame Datei
Wenn mehrere Threads in dieselbe Datei schreiben (z. B. ein Log), müssen Sie den Zugriff synchronisieren, damit kein Wirrwarr aus Zeilen entsteht:
import java.io.*;
public class SafeLogger {
private final Writer writer;
public SafeLogger(String filename) throws IOException {
this.writer = new BufferedWriter(new FileWriter(filename, true));
}
public synchronized void log(String message) throws IOException {
writer.write(message);
writer.write(System.lineSeparator());
writer.flush();
}
public void close() throws IOException {
writer.close();
}
}
Kommentar:
- Die Methode log ist als synchronized markiert, damit immer nur ein Thread gleichzeitig in die Datei schreibt.
- Das funktioniert, kann aber bei vielen Threads zum Flaschenhals werden — besser in verschiedene Dateien schreiben und anschließend zusammenführen.
5. Wann man auf Multithreading verzichten sollte
Multithreading wirkt verlockend: mehr Threads, also muss doch alles fliegen! In der Praxis ist das nicht immer so. Wenn Sie ein paar kleine Dateien verarbeiten, ist es einfacher und zuverlässiger, dies sequentiell zu tun. Die Zeit, die Sie für das Starten von Threads und deren Koordination aufwenden, zahlt sich einfach nicht aus.
Manchmal liegt das Problem gar nicht an der Festplatte, sondern am Netzwerk — dann beschleunigt das Hinzufügen von Threads nichts, weil sich das Bottleneck an ganz anderer Stelle befindet. Eine weitere Falle ist das parallele Schreiben in dieselbe Datei. Wenn Sie wenig Erfahrung mit Synchronisierung haben, versuchen Sie es besser gar nicht erst: Die Wahrscheinlichkeit ist groß, dass Daten beschädigt werden.
Und schließlich: Wenn Ihre Festplatte oder Ihr Dateisystem es nicht mögen, von Dutzenden Threads gleichzeitig beansprucht zu werden, hilft Multithreading nicht, sondern verschlimmert die Lage.
Kurz gesagt: Wenn es so scheint, „je mehr Threads, desto besser“, ist das meist nicht der Fall. Manchmal erledigt ein einzelner ruhiger Thread die Arbeit sauberer, schneller und zuverlässiger als ein Dutzend hastiger.
6. Kurzer Überblick über FileChannel für fortgeschrittene Aufgaben
FileChannel aus dem Paket java.nio.channels ist ein Werkzeug für die niedrigstufige Dateiverarbeitung und erlaubt Lesen und Schreiben an beliebigen Positionen. Damit lässt sich z. B. paralleles Laden oder die Verarbeitung großer Dateien in Chunks umsetzen.
Beispiel:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.bin"), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 0;
int bytesRead = channel.read(buffer, position); // 1024 Bytes ab Position 0 lesen
// Verarbeitung des Buffers
}
Wichtig:
- FileChannel ist nicht synchronisiert — wenn mehrere Threads mit demselben Channel arbeiten, müssen Sie die Synchronisierung selbst implementieren.
- Für paralleles Arbeiten ist es einfacher, für jeden Thread einen eigenen Channel zu öffnen.
7. Typische Fehler bei nebenläufigem I/O
Fehler Nr. 1: Ungesynchronisierter Schreibzugriff auf eine einzelne Datei.
Das Ergebnis sind beschädigte Daten, seltsame Zeichen, manchmal ist die Datei gar nicht mehr lesbar. Synchronisieren Sie den Zugriff immer oder schreiben Sie in separate Dateien.
Fehler Nr. 2: Zu viele Threads.
Wenn Sie 1000 Threads nur zum Kopieren von 1000 Dateien öffnen, könnte Ihr Rechner „beleidigt“ sein (OutOfMemoryError, Ruckler, Abstürze). Verwenden Sie einen Thread-Pool (ExecutorService) und begrenzen Sie die Anzahl.
Fehler Nr. 3: Streams/Dateien werden nicht geschlossen.
Jeder geöffnete Stream ist eine OS-Ressource. Wenn man sie nicht schließt, kann die Fehlermeldung „Too many open files“ auftreten. Verwenden Sie try-with-resources oder vergessen Sie nicht, close() aufzurufen.
Fehler Nr. 4: Vorzeitiges Beenden des Programms.
Wenn man nicht auf das Ende aller Threads wartet (z. B. executor.awaitTermination(...) nicht aufruft), kann das Programm enden, bevor alle Dateien kopiert sind.
Fehler Nr. 5: Paralleles Schreiben in denselben Dateibereich ohne Positionsmanagement.
Wenn mehrere Threads in denselben Bereich der Datei schreiben, vermischen sich die Daten. Verwenden Sie für positionsbezogenes Schreiben Channels und klar abgegrenzte Bereiche.
GO TO FULL VERSION