1. Das Problem des synchronen Codes
Stellen wir uns vor: Sie haben ein Programm, das Daten aus dem Internet laden oder eine große Datei lesen soll. Sie schreiben etwas wie:
String data = readFromFile("bigfile.txt");
System.out.println("Daten: " + data);
Alles wäre gut, aber wenn die Datei groß ist oder das Netzwerk langsam, hängt das Programm einfach an der Lesestelle. Der Benutzer sieht eine „eingefrorene“ Oberfläche, der Server kann keine weiteren Anfragen bedienen, und der Entwickler … ist frustriert.
Diese Situation nennt man Blockierung: Ein Thread (zum Beispiel der Haupt-Thread Ihrer Anwendung) muss warten, bis die Operation abgeschlossen ist. Und wenn es viele solcher Operationen gibt – hallo, Lags und geringe Performance.
Es ist, als würden Sie in ein Café kommen, bestellen und … an der Theke stehen bleiben müssen, bis Ihr Kaffee zubereitet ist. Und die anderen Gäste stehen hinter Ihnen und warten ebenfalls, bis der Barista mit Ihnen fertig ist. Ineffizient, oder?
Asynchronität: wie sie die Welt rettet
Asynchrones Programmieren ist ein Ansatz, bei dem langwierige Operationen (z. B. das Lesen einer Datei, eine Serveranfrage, ein Datenbankzugriff) in einem Hintergrund-Thread ausgeführt werden, während der Haupt-Thread weiterarbeitet: Benutzer bedienen, neue Anfragen annehmen, auf Ereignisse reagieren.
Das heißt, Sie geben die Bestellung auf (starten eine Aufgabe), gehen Ihren anderen Tätigkeiten nach, und wenn der Kaffee fertig ist (die Aufgabe abgeschlossen ist), heißt es einfach: „Fertig!“
In Java war das vor der Einführung von CompletableFuture nicht besonders bequem. Schauen wir uns an, wie sich das entwickelt hat.
2. Historische Ansätze: Future und seine Einschränkungen
In Java 5 erschien das Interface Future – der erste Versuch, die Arbeit mit asynchronen Aufgaben etwas bequemer zu machen. Es erlaubte, eine Aufgabe an einen Thread-Pool zu übergeben und irgendwann das Ergebnis zu erhalten.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Vorsicht: Der Thread blockiert, bis die Aufgabe abgeschlossen ist!
Die Idee klingt gut, aber in der Praxis erwies sich Future wie ein alter Briefkasten: Sie haben den Brief abgeschickt, aber um zu erfahren, ob eine Antwort eingetroffen ist, müssen Sie ständig hineinschauen.
Es benachrichtigt nicht, wenn das Ergebnis bereit ist, unterstützt keine Aktionsketten wie „mach dies und dann das“ und erlaubt keine elegante Fehlerbehandlung. Am Ende läuft alles auf den blockierenden Aufruf get() hinaus, durch den Asynchronität wieder zu Warten wird.
3. Die Einführung von CompletableFuture: ein neuer Stil der Asynchronität
In Java 8 löste ein echter Held der Asynchronität das in die Jahre gekommene Future ab – CompletableFuture. Diese Klasse aus dem Paket java.util.concurrent wurde zum universellen Werkzeug für alle, die es leid waren, Ergebnisse „manuell“ abzuwarten und asynchronen Code schön, kompakt und verständlich schreiben wollten.
CompletableFuture kann fast alles. Es kann Aufgaben in anderen Threads starten, daraus Ketten aufbauen – etwa erst das Ergebnis berechnen, es dann verarbeiten und anschließend noch etwas tun. Es kombiniert mehrere Aufgaben mühelos: Sie können auf den Abschluss aller warten oder nur auf die erste, die fertig wird. Fehler werden ebenfalls elegant behandelt – ohne überflüssige try-catch-Blöcke. Und der Stil rückt näher an die funktionale Programmierung: Statt langweiliger Aufrufe und Wartezeiten gibt es ausdrucksstarke Methoden wie thenApply, thenAccept und andere.
flowchart LR
A[Aufgabe asynchron starten] --> B[Ergebnis verarbeiten]
B --> C[Nächster Schritt]
C --> D[Fehlerbehandlung]
So hat CompletableFuture Asynchronität von einem schweren Handwerk in ein bequemes und flexibles Werkzeug verwandelt, mit dem Code endlich frei durchatmen kann.
4. Ein einfaches Beispiel: der erste Schritt in die Welt von CompletableFuture
Schauen wir uns ein minimales Beispiel für eine asynchrone Aufgabe an:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// Aufgabe asynchron starten
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// Ergebnis abrufen (blockiert den Thread!)
try {
int result = future.get();
System.out.println("Ergebnis: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
Dieser Code führt die Berechnung bereits in einem separaten Thread aus – der Haupt-Thread wird beim Start der Aufgabe nicht blockiert. Der Aufruf get() blockiert den Thread jedoch, bis das Ergebnis bereit ist.
Und wie blockiert man den Thread NICHT?
Ganz einfach: Verwenden Sie Callback-Methoden, die aufgerufen werden, wenn die Aufgabe abgeschlossen ist:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("Ergebnis: " + result));
System.out.println("Ich blockiere nicht und kann noch etwas anderes machen!");
Fazit:
- thenAccept – „auf das Ergebnis reagieren“: Wenn die Aufgabe fertig ist, führe diesen Code aus.
- Der Haupt-Thread wartet nicht auf die Aufgabe, sondern arbeitet weiter.
Visualisierung (Pseudocode der Ereignisse)
[Haupt-Thread] --> [Aufgabe starten]
| |
v v
[Tut etwas] [Hintergrund-Thread berechnet 2+2]
| |
v v
[Gibt "Ich blockiere nicht..." aus]
| |
v v
[Wenn berechnet – thenAccept wird aufgerufen]
5. Wie sieht das in einer Anwendung aus?
Stellen Sie sich vor, Sie entwickeln eine Konsolenanwendung, in der der Benutzer das Laden von Daten anfordern kann (z. B. aus einer Datenbank oder von einem Server), und während die Daten geladen werden, „friert“ das Programm nicht ein, sondern nimmt weiter Befehle entgegen.
Beispiel: Simulation eines langwierigen Vorgangs
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("Wir beginnen mit dem Laden der Daten...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// Imitation eines langen Ladevorgangs
try {
Thread.sleep(2000); // 2 Sekunden
} catch (InterruptedException e) {
return "Ladefehler";
}
return "Daten erfolgreich geladen!";
});
// Auf Ergebnis reagieren
dataFuture.thenAccept(result -> System.out.println("Ergebnis: " + result));
// Das Programm arbeitet weiter
System.out.println("Während die Daten geladen werden, kann ich noch etwas anderes tun!");
// Damit sich das Programm nicht zu früh beendet (nur für die Demo!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
Was Sie in der Konsole sehen werden:
Wir beginnen mit dem Laden der Daten...
Während die Daten geladen werden, kann ich noch etwas anderes tun!
[nach 2 Sekunden]
Ergebnis: Daten erfolgreich geladen!
6. Nützliche Details
Ein wenig über Threads unter der Haube
Wenn Sie CompletableFuture.supplyAsync(...) schreiben, wird die Aufgabe standardmäßig in einem sogenannten ForkJoinPool ausgeführt – das ist ein spezieller Thread-Pool, den Java für parallele Aufgaben verwendet. Wenn Sie mehr Kontrolle benötigen (z. B. einen eigenen ExecutorService), können Sie ihn als zweiten Parameter übergeben:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
Für einfache Aufgaben genügt jedoch der Standardpool.
Ergebnisabfrage: get(), join(), thenAccept
- get() – blockiert den Thread, bis das Ergebnis bereit ist (wirft checked Exceptions).
- join() – blockiert ebenfalls, wirft aber unchecked Exceptions (RuntimeException).
- thenAccept(), thenApply() u. a. – blockieren NICHT, sondern rufen die übergebene Funktion auf, wenn das Ergebnis bereit ist.
In realen asynchronen Anwendungen vermeiden Sie get()/join() im Haupt-Thread!
7. Typische Fehler bei den ersten Schritten mit CompletableFuture
Fehler Nr. 1: Verwendung von get() oder join() im Haupt-Thread.
Damit blockieren Sie die Anwendung wieder und verlieren alle Vorteile der Asynchronität. Verwenden Sie stattdessen thenAccept, thenApply und andere Methoden zur Ergebnisverarbeitung.
Fehler Nr. 2: Fehlerbehandlung vergessen.
Wenn in der asynchronen Aufgabe eine Exception auftritt, „springt“ sie nicht in den Haupt-Thread. Ohne Behandlung über exceptionally oder handle merken Sie gar nicht, dass etwas schiefgelaufen ist.
Fehler Nr. 3: Nicht auf das Programmende gewartet.
In Demo-Beispielen muss man den Haupt-Thread oft per Thread.sleep „anhalten“ – sonst beendet sich das Programm, bevor die Aufgabe fertig ist. In echten Anwendungen (z. B. in Webservern) ist das kein Problem, aber in Konsolen-Demos sollten Sie das berücksichtigen.
Fehler Nr. 4: thenAccept und thenApply verwechselt.
thenAccept – für „Seiteneffekte“ (liefert nichts zurück), thenApply – für die Transformation des Ergebnisses (liefert ein neues Ergebnis zurück).
Fehler Nr. 5: Asynchronen und synchronen Code ohne Not mischen.
Wenn Sie asynchron begonnen haben, ziehen Sie es nicht unnötig wieder in synchronen Code via get()/join(), außer in Ausnahmefällen (z. B. in Tests).
GO TO FULL VERSION