Moderne Multithreading in Java
Parallelität vs. Gleichzeitigkeit
Parallelität bedeutet, dass mehrere Aufgaben tatsächlich gleichzeitig auf verschiedenen CPU-Kernen ausgeführt werden.
Gleichzeitigkeit bedeutet, dass mehrere Aufgaben scheinbar gleichzeitig durch Wechseln zwischen ihnen ausgeführt werden, aber tatsächlich nacheinander auf demselben Kern.
Warum nicht einfach Threads für alles erstellen?
public Order createOrder(Long clientId) {
Thread threadClient = new Thread(() -> getClient(clientId));
Thread threadItem = new Thread(() -> getItem(clientId));
Thread threadCity = new Thread(() -> getCity(clientId));
threadClient.start();
threadItem.start();
threadCity.start();
threadClient.join();
threadItem.join();
threadCity.join();
return createOrder(clientId, item, city);
}Probleme mit diesem Ansatz:
- Overhead bei Thread-Erstellung: Jeder Thread benötigt Speicher (Standard-Stack ~1 MB)
- Kontextwechsel: CPU-Zeit wird für Wechsel zwischen Threads verschwendet
- Ressourcenverschwendung: Bei vielen Requests = massive Thread-Erstellung
- Schwer zu verwalten: Keine zentrale Kontrolle über Thread-Pool
Wie viele Threads kann ein System erstellen?
Ein einfacher Test:
public class ThreadCapacityTest {
public static void main(String[] args) {
int count = 0;
try {
while (true) {
new Thread(() -> {
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
count++;
if (count % 100 == 0) {
System.out.println("Created threads: " + count);
}
}
} catch (OutOfMemoryError e) {
System.out.println("Max threads created: " + count);
}
}
}Typische Ergebnisse:
- Windows: ~10,000-30,000 Threads
- Linux: ~30,000-100,000 Threads
- Abhängig von: verfügbarem RAM, Stack-Größe pro Thread, OS-Limits
Executor Framework – Besserer Ansatz
public Order createOrder(Long clientId) {
try (ExecutorService executor = Executors.newFixedThreadPool(5)) {
Future<Client> clientFuture = executor.submit(() -> getClient(clientId));
Future<Item> itemFuture = executor.submit(() -> getItem(clientId));
Future<City> cityFuture = executor.submit(() -> getCity(clientId));
return createOrder(
clientId,
itemFuture.get(),
cityFuture.get()
);
}
}Vorteile:
- Thread-Pool-Wiederverwendung: Threads werden wiederverwendet, nicht jedes Mal neu erstellt
- Ressourcenkontrolle: Feste Anzahl von Threads
- Zentrale Verwaltung: Ein Executor verwaltet alle Aufgaben
- Automatisches Herunterfahren: try-with-resources schließt den Pool
Future – Ergebnis abrufen
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "Ergebnis";
});
// Blockiert, bis das Ergebnis verfügbar ist
String result = future.get();
// Mit Timeout
String result = future.get(2, TimeUnit.SECONDS);
// Status prüfen
boolean isDone = future.isDone();
boolean isCancelled = future.isCancelled();
// Abbrechen
future.cancel(true);Einschränkungen von Future:
- Blockierend beim Abrufen des Ergebnisses
- Kann Aufgaben nicht verketten
- Keine Fehlerbehandlung
- Kann nicht mehrere Futures kombinieren
CompletableFuture – Moderne Asynchronität
public Order createOrder(Long clientId) {
CompletableFuture<Client> clientFuture =
CompletableFuture.supplyAsync(() -> getClient(clientId));
CompletableFuture<Item> itemFuture =
CompletableFuture.supplyAsync(() -> getItem(clientId));
CompletableFuture<City> cityFuture =
CompletableFuture.supplyAsync(() -> getCity(clientId));
CompletableFuture<Void> anyWorkFuture =
CompletableFuture.runAsync(() -> doAnyWork());
return CompletableFuture.allOf(
clientFuture, itemFuture, cityFuture, anyWorkFuture
).thenApply(v -> createOrder(
clientId,
itemFuture.join(),
cityFuture.join()
)).join();
}CompletableFuture-Methoden:
Erzeugung:
// Mit Ergebnis
CompletableFuture.supplyAsync(() -> "result");
// Ohne Ergebnis (Runnable)
CompletableFuture.runAsync(() -> doWork());
// Bereits abgeschlossen
CompletableFuture.completedFuture("value");Verkettung:
future.thenApply(result -> transform(result)) // Transformation
.thenAccept(result -> consume(result)) // Konsum
.thenRun(() -> doSomething()) // Aktion
.thenCompose(r -> anotherAsync(r)); // Flache VerkettungKombination:
// Beide warten
future1.thenCombine(future2, (r1, r2) -> combine(r1, r2));
// Erste abgeschlossene
CompletableFuture.anyOf(future1, future2, future3);
// Alle warten
CompletableFuture.allOf(future1, future2, future3);Fehlerbehandlung:
future.exceptionally(ex -> defaultValue)
.handle((result, ex) -> {
if (ex != null) return handleError(ex);
return result;
})
.whenComplete((result, ex) -> cleanup());Async-Varianten:
// Standard-Pool (ForkJoinPool.commonPool())
future.thenApplyAsync(r -> transform(r));
// Benutzerdefinierter Executor
future.thenApplyAsync(r -> transform(r), customExecutor);Praktisches Beispiel: Parallele API-Aufrufe
public OrderDetails getOrderDetails(Long orderId) {
CompletableFuture<Order> orderFuture =
CompletableFuture.supplyAsync(() -> orderService.getOrder(orderId));
CompletableFuture<List<Item>> itemsFuture =
CompletableFuture.supplyAsync(() -> itemService.getItems(orderId));
CompletableFuture<Customer> customerFuture =
orderFuture.thenComposeAsync(order ->
CompletableFuture.supplyAsync(() ->
customerService.getCustomer(order.getCustomerId())
)
);
return CompletableFuture.allOf(orderFuture, itemsFuture, customerFuture)
.thenApply(v -> new OrderDetails(
orderFuture.join(),
itemsFuture.join(),
customerFuture.join()
))
.exceptionally(ex -> {
log.error("Fehler beim Laden von Bestelldetails", ex);
return OrderDetails.empty();
})
.join();
}Virtual Threads (Project Loom) – Java 19+
Platform Threads (traditionell):
- 1:1-Mapping zu OS-Threads
- Teuer (jeder ~1 MB Stack)
- Begrenzt durch OS
- Blockierungsoperationen blockieren den OS-Thread
Virtual Threads:
- Viele-zu-wenige Mapping zu Platform Threads (Carrier Threads)
- Leichtgewichtig (minimaler Overhead)
- Von JVM verwaltet
- Blockierungsoperationen blockieren nur den Virtual Thread
// Virtual Thread erstellen
Thread.ofVirtual().start(() -> {
System.out.println("Hello from virtual thread");
});
// Mit ExecutorService
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "Ergebnis";
});
System.out.println(future.get());
}
// Strukturierte Concurrency (Preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser(userId));
Future<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join();
scope.throwIfFailed();
return new UserWithOrders(user.resultNow(), orders.resultNow());
}Wann Virtual Threads verwenden:
- I/O-intensive Operationen (DB, HTTP, Dateien)
- Viele gleichzeitige Verbindungen (Webserver)
- Blockierende APIs
Wann Platform Threads verwenden:
- CPU-intensive Berechnungen
- Langlebige Threads
- Wenn Pinning ein Problem ist (alte synchronized-Blöcke)
Executor-Typen
// Feste Pool-Größe
ExecutorService fixed = Executors.newFixedThreadPool(10);
// Cached – erstellt bei Bedarf, wiederverwendet wenn möglich
ExecutorService cached = Executors.newCachedThreadPool();
// Single Thread – garantiert Aufgaben-Reihenfolge
ExecutorService single = Executors.newSingleThreadExecutor();
// Scheduled – für verzögerte/periodische Aufgaben
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(5);
scheduled.schedule(() -> task(), 5, TimeUnit.SECONDS);
scheduled.scheduleAtFixedRate(() -> task(), 0, 1, TimeUnit.MINUTES);
// Work Stealing – für rekursive Aufgaben
ExecutorService workStealing = Executors.newWorkStealingPool();
// Virtual Thread Executor
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();Best Practices
-
Verwenden Sie Thread-Pools statt roher Threads
// ❌ Schlecht new Thread(() -> task()).start(); // ✅ Gut executor.submit(() -> task()); -
Schließen Sie Executors ordnungsgemäß
// ❌ Speicherleck ExecutorService executor = Executors.newFixedThreadPool(10); // ✅ Auto-close try (ExecutorService executor = Executors.newFixedThreadPool(10)) { // Verwendung } -
Behandeln Sie Exceptions in asynchronem Code
CompletableFuture.supplyAsync(() -> riskyOperation()) .exceptionally(ex -> { log.error("Fehler", ex); return defaultValue; }); -
Verwenden Sie passende Executor-Typen
- I/O-Aufgaben: cached oder virtual threads
- CPU-Aufgaben: fixed pool (Anzahl der Kerne)
- Sequentielle Aufgaben: single thread
-
Vermeiden Sie gemeinsamen Zustand
// ❌ Race condition private int counter = 0; executor.submit(() -> counter++); // ✅ Atomics verwenden private AtomicInteger counter = new AtomicInteger(0); executor.submit(() -> counter.incrementAndGet());
Fazit
Moderne Java-Anwendungen sollten:
- ExecutorService für Thread-Management verwenden
- CompletableFuture für asynchrone Operationen verwenden
- Virtual Threads für hochgradig gleichzeitige I/O in Betracht ziehen
- Raw Thread-Erstellung vermeiden
- Thread-Pools ordnungsgemäß verwalten
Dies führt zu:
- Besserer Ressourcennutzung
- Einfacherem Code
- Besserer Leistung
- Einfacherer Wartung