1. ExecutorService: Threads wie die Profis verwalten
Warum man Threads nicht einfach mit new Thread erstellen sollte
Am Anfang wirkt Nebenläufigkeit ganz einfach:
Thread t = new Thread(() -> {
// etwas tun
});
t.start();
Dieser Ansatz funktioniert, wird aber schnell zur Last, wenn es viele Aufgaben gibt. Jeder Aufruf von new Thread() erzeugt einen neuen Thread, und Dutzende oder Hunderte Threads überlasten das System. Außerdem ist die Verwaltung unhandlich: Sie müssen verfolgen, wann sie enden, was bei Fehlern zu tun ist, wie man sie stoppt und wiederverwendet.
Hier kommt der ExecutorService ins Spiel – ein intelligenter Thread-Dispatcher. Sie geben ihm einfach Aufgaben, und er entscheidet selbst, mit welchem Thread und wann sie ausgeführt werden. Das Ergebnis: alles läuft schneller, stabiler und ohne Kopfzerbrechen.
Wie der ExecutorService funktioniert
ExecutorService arbeitet nach einem einfachen, aber effizienten Prinzip.
- Er enthält einen Thread-Pool – einen vorab erstellten Satz von Worker-Threads (fix oder dynamisch).
- Aufgaben landen in einer Warteschlange und werden von freien Threads aufgenommen.
- Der Service verwaltet den Lebenszyklus: Sie können auf Abschluss warten, den Pool sauber beenden und Ressourcen freigeben.
ExecutorService erstellen
Der häufigste Weg – die Factory-Methoden der Klasse Executors zu verwenden:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(4); // 4 Threads
- newFixedThreadPool(N) – ein Pool aus N Threads (geeignet für die meisten Aufgaben).
- newCachedThreadPool() – ein dynamischer Pool, erstellt Threads bei Bedarf (Vorsicht: bei einer Aufgabenflut kann man an Speichergrenzen stoßen).
- newSingleThreadExecutor() – ein Thread (sequenzielle Ausführung).
Beispiel: Runnable über ExecutorService starten
executor.submit(() -> {
System.out.println("Hallo aus dem Thread-Pool!");
});
Nachdem Sie die Arbeit mit dem ExecutorService beendet haben, müssen Sie ihn sauber herunterfahren:
executor.shutdown(); // Verbietet neue Aufgaben, wartet auf Abschluss der laufenden
Wichtig: Wenn Sie shutdown() nicht aufrufen, kann sich das Programm nicht beenden – die Threads im Pool warten auf neue Aufgaben.
2. Runnable vs Callable: Aufgaben gibt es in verschiedenen Arten
Bis Java 5, wenn Sie etwas in einem Thread ausführen wollten, schrieben Sie eine Implementierung des Interfaces Runnable. Das ist eine Aufgabe, die nichts zurückgibt und keine geprüften Ausnahmen wirft.
Runnable task = () -> {
System.out.println("Ich arbeite einfach, gebe nichts zurück!");
};
executor.submit(task);
Callable: Aufgabe mit Ergebnis (und Ausnahmen)
Manchmal soll eine Aufgabe nicht nur „etwas tun“, sondern ein Ergebnis zurückgeben – zum Beispiel eine Summe, ein Berechnungsergebnis oder Daten vom Server. Dafür gibt es das Interface Callable<T>.
import java.util.concurrent.Callable;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
- Die Methode call() gibt ein Ergebnis vom Typ T zurück.
- Die Methode call() darf geprüfte Ausnahmen werfen.
Analogie: Runnable – „geh und spüle das Geschirr“ (das Ergebnis ist unwichtig), Callable – „geh und bring Tee und sag, welche Temperatur er hat“ (das Ergebnis ist wichtig).
Callable starten: Um das Ergebnis zu erhalten, verwenden Sie executor.submit(...). Das liefert ein Future<T> zurück.
3. Future: ein Versprechen auf ein Ergebnis
Future ist ein „Versprechen“, in der Zukunft ein Ergebnis zu liefern. Wenn Sie eine Aufgabe an den ExecutorService senden, erhalten Sie ein Future, aus dem Sie später das Ergebnis holen, prüfen können, ob die Aufgabe abgeschlossen ist, oder sie abbrechen können.
Wichtige Methoden von Future
- T get() – Ergebnis abrufen (wartet, bis die Aufgabe abgeschlossen ist).
- boolean isDone() – ist die Aufgabe abgeschlossen.
- boolean cancel(boolean mayInterruptIfRunning) – versucht, die Aufgabe abzubrechen.
- boolean isCancelled() – wurde die Aufgabe abgebrochen.
Beispiel: Callable starten und Ergebnis abrufen
import java.util.concurrent.*;
public class ParallelSumApp {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
Future<Integer> future = executor.submit(sumTask);
System.out.println("Aufgabe gestartet, Sie können inzwischen etwas anderes tun...");
// Ergebnis abrufen (blockiert den Thread, falls die Aufgabe noch nicht fertig ist)
Integer result = future.get();
System.out.println("Rechenergebnis: " + result);
executor.shutdown();
}
}
- Die Aufgabe wird an den Thread-Pool gesendet.
- Während die Aufgabe läuft, kann der Haupt-Thread andere Dinge tun.
- Wenn das Ergebnis benötigt wird, rufen wir future.get() auf – der Thread wartet, falls die Aufgabe noch läuft.
- Sobald die Aufgabe endet, wird das Ergebnis zurückgegeben.
4. Praxis: mehrere Aufgaben, auf Abschluss warten
Oft müssen mehrere Aufgaben gleichzeitig gestartet und abgewartet werden, bis alle fertig sind. Zum Beispiel verarbeiten Sie ein Datenfeld, teilen es in Teile auf und berechnen die Summe jedes Teils in einer separaten Aufgabe.
Beispiel: Summe der Feldelemente in Teilbereichen
import java.util.*;
import java.util.concurrent.*;
public class ParallelArraySum {
public static void main(String[] args) throws Exception {
int[] array = new int[1000];
Arrays.setAll(array, i -> i + 1); // Mit Zahlen von 1 bis 1000 füllen
ExecutorService executor = Executors.newFixedThreadPool(4);
int chunkSize = array.length / 4;
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int from = i * chunkSize;
int to = (i == 3) ? array.length : (i + 1) * chunkSize;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int j = from; j < to; j++) sum += array[j];
System.out.println("Summe von " + from + " bis " + (to - 1) + " = " + sum);
return sum;
};
futures.add(executor.submit(sumTask));
}
int totalSum = 0;
for (Future<Integer> f : futures) {
totalSum += f.get(); // Wir warten die Aufgaben nacheinander ab
}
System.out.println("Gesamtsumme: " + totalSum);
executor.shutdown();
}
}
Hier wird das Array in 4 Teile aufgeteilt. Für jeden Teil wird eine Aufgabe (Callable) erstellt, die die Summe berechnet. Alle Aufgaben werden an den ExecutorService übergeben, es werden Future zurückgegeben. Am Ende sammeln wir die Ergebnisse aller Aufgaben und addieren sie.
In realen Anwendungen ist es praktisch, invokeAll zu verwenden, um auf alle Aufgaben gleichzeitig zu warten.
5. Fehlerbehandlung bei der Arbeit mit Future
Wenn Sie future.get() aufrufen und die Aufgabe mit einer Ausnahme endete, wird sie als ExecutionException weitergereicht. Wichtig: Wenn in der Aufgabe etwas schiefgelaufen ist, erfahren Sie das erst beim Aufruf von get().
Beispiel: Ausnahmebehandlung
Callable<Integer> errorTask = () -> {
throw new IllegalArgumentException("Etwas ist schiefgelaufen!");
};
Future<Integer> badFuture = executor.submit(errorTask);
try {
badFuture.get();
} catch (ExecutionException e) {
System.out.println("Die Aufgabe wurde mit einem Fehler beendet: " + e.getCause());
}
- Innerhalb der Aufgabe wird eine Ausnahme geworfen.
- Beim Aufruf von get() wird sie in eine ExecutionException „eingewickelt“.
- Die eigentliche Ursache kann über getCause() ermittelt werden.
6. Nützliche Feinheiten
Wie man eine Aufgabe abbricht
Future<?> f = executor.submit(() -> {
while (true) {
// Endlose Arbeit
if (Thread.currentThread().isInterrupted()) {
System.out.println("Ich wurde um Beendigung gebeten!");
break;
}
}
});
Thread.sleep(100); // Warten wir kurz
f.cancel(true); // Versuchen wir, die Aufgabe abzubrechen
- cancel(true) versucht, die Aufgabe abzubrechen, sofern sie noch nicht fertig ist.
- Innerhalb der Aufgabe sollte Thread.currentThread().isInterrupted() geprüft und sauber beendet werden.
shutdown vs shutdownNow
shutdown() – sanftes Anhalten: verbietet neue Aufgaben und lässt die laufenden in Ruhe fertig werden. Wird am häufigsten verwendet.
shutdownNow() – hartes Anhalten: versucht, aktive Threads zu unterbrechen, und gibt eine Liste der Aufgaben zurück, die nicht mehr starten konnten. Vorsichtig einsetzen.
invokeAll und invokeAny
invokeAll(Collection<Callable<T>> tasks) startet alle übergebenen Aufgaben und wartet, bis sie alle abgeschlossen sind. Gibt eine Liste von Future zurück.
invokeAny(Collection<Callable<T>> tasks) wartet nur auf die erste erfolgreich abgeschlossene Aufgabe, gibt deren Ergebnis zurück und bricht die übrigen ab. Praktisch, wenn die erste erfolgreiche Antwort zählt.
7. Typische Fehler im Umgang mit ExecutorService, Callable und Future
Fehler Nr. 1: Den ExecutorService nicht schließen. Wenn Sie shutdown() vergessen, kann das Programm nach dem Ende von main „hängen“, weil die Threads im Pool auf neue Aufgaben warten.
Fehler Nr. 2: Das Ergebnis sofort nach dem Absenden der Aufgabe erwarten. Wenn Sie direkt nach submit() get() aufrufen, nutzen Sie die Asynchronität nicht – der Thread wird trotzdem warten. Erledigen Sie in der Zwischenzeit sinnvolle Arbeit und holen Sie das Ergebnis erst, wenn es wirklich benötigt wird.
Fehler Nr. 3: Ausnahmen in Aufgaben ignorieren. Wenn ExecutionException beim Aufruf von get() nicht behandelt wird, können wichtige Fehler, die in der Aufgabe passiert sind, unbemerkt bleiben.
Fehler Nr. 4: Gemeinsame veränderliche Variablen ohne Synchronisation verwenden. Wenn mehrere Aufgaben mit denselben Daten arbeiten, sind Synchronisation oder thread-sichere Collections erforderlich.
Fehler Nr. 5: Zu viele Threads erstellen. Erstellen Sie keinen Pool mit deutlich mehr Threads als CPU-Kernen – das kann die Ausführung sogar verlangsamen.
Fehler Nr. 6: Das Abbrechen von Aufgaben vergessen. Wenn eine Aufgabe nicht mehr benötigt wird, brechen Sie sie über cancel() ab, um keine Ressourcen zu verschwenden.
GO TO FULL VERSION