ExecutorService und Thread-Pools: Den richtigen Typ für jede Aufgabe wählen
1. Überblick
new Thread() für jede Aufgabe zu erstellen ist leicht zu schreiben, aber schwer zu kontrollieren, wenn die Last steigt. ExecutorService löst dies durch Wiederverwendung von Threads und Queuing von Aufgaben. Die Klasse Executors bietet verschiedene Pool-Typen an, und die falsche Wahl kann zu OutOfMemoryError führen oder die Anwendung einfrieren.
Dieser Artikel geht durch jeden Pool-Typ, wie ThreadPoolExecutor Aufgaben intern verarbeitet, wie man die Pool-Größe wählt und wie man in Spring Boot 3.x konfiguriert. Nach dem Lesen wissen Sie, welchen Pool Sie für CPU-gebundene Aufgaben, I/O-gebundene Aufgaben oder regelmäßige Aufgaben verwenden sollten.
2. ExecutorService-Grundlagen
Das folgende Beispiel verwendet einen einfachen Pool, um eine Aufgabe mit Rückgabewert auszuführen:
import java.util.concurrent.*;
public class OrderReportApp {
public static void main(String[] args) throws Exception {
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
Future<Integer> total = executor.submit(() -> countOrders("2026-10"));
System.out.println("Bestellungen insgesamt: " + total.get());
}
}
static int countOrders(String month) {
return 128; // Datenbankabfrage simulieren
}
}
Bestellungen insgesamt: 128
Der Code funktioniert folgendermaßen:
submit()nimmt einenCallablean und gibt sofortFuture<Integer>zurück. Die Aufgabe läuft auf einem Thread des Pools.total.get()blockiert den aktuellen Thread, bis das Ergebnis vorliegt.- Ab Java 19 implementiert
ExecutorServiceAutoCloseable. Beim Verlassen destry-Blocks wirdclose()aufgerufen, dasshutdown()aufruft und auf den Abschluss aller Aufgaben wartet.
Hinweis: Wenn Sie den Executor nicht schließen, halten Nicht-Daemon-Threads die JVM am Leben. Das Programm
mainbeendet sich nicht.
3. Pool-Typen in Executors
3.1. newFixedThreadPool(n)
Dieser Pool hat genau n Threads. Zusätzliche Aufgaben werden in einer unbegrenzten LinkedBlockingQueue eingereiht.
ExecutorService invoiceExecutor = Executors.newFixedThreadPool(8);
Dieser Typ eignet sich, wenn Sie die Anzahl gleichzeitig laufender Aufgaben begrenzen möchten, z.B. für CPU-gebundene Aufgaben. Das Risiko liegt in der Queue. Wenn der Producer Aufgaben schneller hinzufügt, als sie verarbeitet werden, wird die Queue unbegrenzt wachsen, bis der Heap voll ist.
3.2. newCachedThreadPool()
Dieser Pool hat keine feste Anzahl von Threads. Er verwendet eine SynchronousQueue, d.h. es gibt keine echte Queue. Jede neue Aufgabe wird einem freien Thread zugewiesen, wenn vorhanden; andernfalls erstellt der Pool einen neuen Thread. Threads, die länger als 60 Sekunden untätig sind, werden beendet.
ExecutorService notificationExecutor = Executors.newCachedThreadPool();
Dieser Pool eignet sich für viele kurze Aufgaben mit niedriger und ungleichmäßiger Last. Bei plötzlicher Last kann er Tausende von Platform-Threads erstellen. Jeder Thread verwendet standardmäßig etwa 1 MB Stack, daher tritt OutOfMemoryError: unable to create native thread leicht auf.
3.3. newSingleThreadExecutor()
Dieser Pool hat nur einen Thread, und Aufgaben laufen sequenziell in der Reihenfolge, in der sie eingereicht wurden.
ExecutorService auditLogExecutor = Executors.newSingleThreadExecutor();
auditLogExecutor.submit(() -> System.out.println("Bestellung #1 erstellt"));
auditLogExecutor.submit(() -> System.out.println("Bestellung #1 bezahlt"));
Bestellung #1 erstellt
Bestellung #1 bezahlt
Die beiden Zeilen werden immer in der korrekten Reihenfolge ausgegeben. Dieser Typ ist nützlich, wenn Sie Logs schreiben, Dateien schreiben oder den Status in der richtigen Reihenfolge aktualisieren möchten, ohne Locks zu verwenden. Wenn der Thread aufgrund einer Exception stirbt, erstellt der Pool einen neuen Thread als Ersatz. Seine Queue ist auch unbegrenzt.
3.4. newScheduledThreadPool(n)
Dieser Typ ist für zeitgesteuerte oder wiederholte Aufgaben vorgesehen:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
() -> System.out.println("Bestand synchronisiert um " + java.time.LocalTime.now()),
0, 5, TimeUnit.SECONDS);
scheduleAtFixedRate führt die Aufgabe alle 5 Sekunden aus, gerechnet vom Anfang der vorherigen Ausführung. Umgekehrt berechnet scheduleWithFixedDelay die 5 Sekunden vom Ende der vorherigen Ausführung. Sie sollten scheduleWithFixedDelay wählen, wenn die Ausführungszeit der Aufgabe variabel ist.
Hinweis: Wenn eine geplante Aufgabe eine Exception wirft, werden alle zukünftigen Ausführungen stillschweigend abgebrochen, ohne dass Logs ausgegeben werden. Bringen Sie den Task-Body immer in
try/catch.
3.5. newWorkStealingPool()
Diese Methode gibt einen ForkJoinPool mit Thread-Anzahl gleich der CPU-Anzahl zurück. Jeder Thread hat seine eigene Queue. Freie Threads “stehlen” Arbeit aus der Queue anderer Threads.
ExecutorService pricingExecutor = Executors.newWorkStealingPool();
Dieser Typ eignet sich für CPU-gebundene Aufgaben, die rekursiv aufgeteilt werden können, z.B. parallele Berechnungen auf großen Datasets. Threads des Pools sind Daemon-Threads, und der Pool garantiert keine Ausführungsreihenfolge.
3.6. newVirtualThreadPerTaskExecutor() (Java 21)
Mit diesem Typ läuft jede Aufgabe auf einem neuen Virtual Thread. Virtual Threads sind sehr günstig, daher können Sie Hunderttausende davon erstellen.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int customerId = i;
executor.submit(() -> {
Thread.sleep(100); // HTTP-Aufruf an CustomerService simulieren
return customerId;
});
}
}
Der obige Code führt 10.000 Aufgaben aus, die jeweils 100 ms blockieren, und wird in wenigen hundert Millisekunden abgeschlossen. Mit einem Fixed Pool von 10 Threads würde die gleiche Arbeit etwa 100 Sekunden dauern.
Dies ist die Standard-Wahl für I/O-gebundene Aufgaben wie HTTP-Aufrufe, Datenbankabfragen oder Dateilesezugriffe. Sie sollten drei Dinge beachten:
- Keinen Virtual Thread Pool bilden. Sie entstehen, um einmal verwendet und weggeworfen zu werden.
- Um die Last auf externe Ressourcen zu begrenzen, verwenden Sie
Semaphore. Wenn die Datenbank beispielsweise nur 20 Verbindungen verarbeiten kann, verlassen Sie sich nicht auf die Pool-Größe zur Beschränkung. - In Java 21 wird ein
synchronized-Block mit einer Blockierungsoperation Virtual Threads an einen Carrier Thread “festmachen”. Der Vorteil von Virtual Threads geht verloren. Bevorzugen SieReentrantLockin solchen Fällen.
Beispiel zum Begrenzen gleichzeitiger Datenbankabfragen mit Semaphore:
Semaphore dbPermits = new Semaphore(20);
executor.submit(() -> {
dbPermits.acquire();
try {
return orderRepository.findByCustomerId(customerId);
} finally {
dbPermits.release();
}
});
4. ThreadPoolExecutor selbst konfigurieren
Die Factory-Methoden in Executors verbergen häufig einen gefährlichen Parameter: unbegrenzte Queue oder unbegrenzte Threadanzahl. Für Production-Systeme sollten Sie ThreadPoolExecutor selbst instanziieren, um alles zu kontrollieren:
ThreadFactory factory = Thread.ofPlatform().name("payment-", 0).factory();
ThreadPoolExecutor paymentExecutor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAlive für Threads über core
new ArrayBlockingQueue<>(100), // Queue mit Limit
factory, // Name: payment-0, payment-1...
new ThreadPoolExecutor.CallerRunsPolicy());
Wenn eine neue Aufgabe ankommt, verarbeitet ThreadPoolExecutor sie in dieser genauen Reihenfolge:
- Wenn die Thread-Anzahl kleiner als
corePoolSizeist, erstellt der Pool einen neuen Thread. - Andernfalls wird die Aufgabe in die Queue eingereiht.
- Wenn die Queue voll ist und die Thread-Anzahl kleiner als
maximumPoolSizeist, erstellt der Pool einen zusätzlichen Thread. - Wenn beide Grenzen erreicht sind, ruft der Pool
RejectedExecutionHandlerauf.
Die Folge ist, dass maximumPoolSize nur wirksam ist, wenn die Queue voll ist. Wenn Sie eine unbegrenzte Queue verwenden, wird der Pool niemals über corePoolSize hinausgehen. Dies ist eine sehr häufige Quelle der Verwirrung.
Java bietet vier vorgefertigte Rejection Policies:
| Policy | Verhalten |
|---|---|
AbortPolicy (Standard) |
Wirft RejectedExecutionException |
CallerRunsPolicy |
Der aufrufende Thread führt die Aufgabe selbst aus, erzeugt Back-Pressure |
DiscardPolicy |
Verwirft die Aufgabe, sagt nichts |
DiscardOldestPolicy |
Verwirft die älteste Aufgabe in der Queue und versucht es erneut |
Das Benennen von Threads über ThreadFactory macht Thread Dumps und Logs viel lesbarer. Der Vergleich von payment-3 zu pool-7-thread-3 zeigt sofort den Unterschied.
5. Die richtige Wahl treffen
5.1. Pool-Größe
Für CPU-gebundene Aufgaben sollte die Thread-Anzahl ungefähr der CPU-Kern-Anzahl entsprechen:
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuExecutor = Executors.newFixedThreadPool(cores);
Mehr Threads als Kerne erhöhen nur den Context-Switch-Overhead.
Für I/O-gebundene Aufgaben auf Platform Threads können Sie diese grobe Formel verwenden:
Thread-Anzahl ≈ Core-Anzahl × (1 + Wartezeit / Berechnungszeit)
Beispiel: Maschine mit 8 Kernen, jede Anfrage wartet 90 ms auf I/O und berechnet 10 ms. Ergebnis: 8 × (1 + 9) = 80 Threads. Dies ist nur ein Ausgangspunkt. Sie müssen die tatsächlichen Werte durch Load-Tests messen.
5.2. Entscheidungstabelle
| Aufgabe | Wahl |
|---|---|
| Intensive CPU-Berechnung | newFixedThreadPool(cores) oder ForkJoinPool |
| Große Anzahl API-Aufrufe, DB-Abfragen | newVirtualThreadPerTaskExecutor() + Semaphore |
| Benötigt sequenzielle Reihenfolge | newSingleThreadExecutor() |
| Regelmäßige Aufgaben | newScheduledThreadPool(n) |
| Production, benötigt Back-Pressure | ThreadPoolExecutor mit limitierter Queue |
| Plötzliche Last, unbegrenzt | Vermeiden Sie newCachedThreadPool() |
6. Thread Pool in Spring Boot 3.x
Zunächst brauchen Sie die Maven-Abhängigkeit:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Dann deklarieren Sie einen separaten Executor für E-Mail-Versand:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "emailExecutor")
public ThreadPoolTaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
return executor;
}
}
@Service
public class EmailService {
@Async("emailExecutor")
public void sendOrderConfirmation(String customerEmail) {
System.out.println(Thread.currentThread().getName() + " sendet E-Mail an " + customerEmail);
}
}
email-1 sendet E-Mail an an.nguyen@example.com
Einige wichtige Punkte:
- Der Standard
queueCapacityvonThreadPoolTaskExecutoristInteger.MAX_VALUE. Wenn Sie diesen Wert nicht setzen, istmaxPoolSizebedeutungslos, wie in Abschnitt 4 erläutert. setWaitForTasksToCompleteOnShutdown(true)stellt sicher, dass E-Mails nicht unterbrochen werden, wenn die Anwendung heruntergefahren wird.- Wenn Sie Virtual Threads für
@Async, Tomcat und@Scheduledverwenden möchten, fügen Sie einfach diese Konfigurationszeile hinzu:
spring.threads.virtual.enabled=true
7. Pool-Verhalten testen
Der folgende Test bestätigt die korrekte Verarbeitungsreihenfolge, die in Abschnitt 4 beschrieben wurde: Core Threads zuerst, dann Queue, dann zusätzliche Threads, dann Ablehnung.
import org.junit.jupiter.api.Test;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class PaymentExecutorTest {
@Test
void shouldRejectWhenThreadsAndQueueAreFull() throws Exception {
CountDownLatch release = new CountDownLatch(1);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 2, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.AbortPolicy());
Runnable blockingPayment = () -> {
try {
release.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
try {
executor.execute(blockingPayment); // 1: erstelle Core Thread
executor.execute(blockingPayment); // 2: in Queue
executor.execute(blockingPayment); // 3: Queue voll -> erstelle zweiten Thread
assertThat(executor.getPoolSize()).isEqualTo(2);
assertThat(executor.getQueue()).hasSize(1);
assertThatThrownBy(() -> executor.execute(blockingPayment))
.isInstanceOf(RejectedExecutionException.class);
} finally {
release.countDown();
executor.shutdown();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
}
CountDownLatch hält alle Aufgaben blockiert, wodurch der Pool-Status stabil bleibt, während Sie ihn überprüfen. Die vierte Aufgabe hat keinen Platz mehr, daher wirft AbortPolicy eine Exception. Der finally-Block gibt den Latch frei und schließt den Pool, sodass der Test keine Rücklauf-Threads hinterlässt.
8. Häufige Fehler
Executors.newFixedThreadPoolfür unkontrollierte Producer verwenden. Die unbegrenzte Queue wird anschwellen und OOM verursachen. Verwenden SieArrayBlockingQueuemit Limit.- Einen neuen Pool in jedem Request erstellen. Der Pool sollte geteilt sein, z.B. als Bean oder
static finalFeld. Das Erstellen und Vergessen zu schließen führt zu Thread-Lecks. - Exceptions schlucken. Mit
submit()wird die Exception inFuturegespeichert. Wenn Sieget()nicht aufrufen, sehen Sie den Fehler nie. Rufen Sieget()auf oder fangen Sie den Fehler sofort in der Aufgabe. - Deadlock durch Task, die Subtask im gleichen kleinen Pool erwartet. Beispiel: Pool mit 2 Threads, beide Parent-Tasks rufen
get()auf einer Subtask auf, die in der Queue wartet. Kein Thread übrig, um die Subtask auszuführen, daher einfrieren. - Schwere
ThreadLocalin Virtual Threads setzen. Sie können Millionen von Virtual Threads haben, jeder mit einer Kopie, daher steigt der Speicher rasant. shutdownNow()aufrufen, ohne dass Aufgaben Interrupts verarbeiten. Die Aufgabe läuft trotzdem weiter. Lange Aufgaben solltenThread.currentThread().isInterrupted()überprüfen.
Hinweis:
ExecutorServicesollte nicht für komplexe mehrstufige Verarbeitungslogiken mit gegenseitigen Abhängigkeiten verwendet werden. In diesem Fall verwenden SieCompletableFuture, das immer noch auf Ihrem konfigurierten Executor laufen kann.
9. Fazit
Jeder Thread-Pool-Typ löst ein eigenes Problem:
- Fixed Pool oder
ForkJoinPoolfür CPU-gebundene Aufgaben. - Virtual Threads mit
Semaphorefür I/O-gebundene Aufgaben auf Java 21. - Single Thread Executor wenn Reihenfolge erforderlich ist.
- Scheduled Pool für regelmäßige Aufgaben.
In Production sollten Sie ThreadPoolExecutor oder ThreadPoolTaskExecutor selbst mit limitierter Queue, klarer Rejection Policy und aussagekräftigen Thread-Namen konfigurieren. Schließlich sollten Sie die Pool-Größe durch Load-Tests überprüfen, anstatt sich nur auf Formeln zu verlassen.