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 einen Callable an und gibt sofort Future<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 ExecutorService AutoCloseable. Beim Verlassen des try-Blocks wird close() aufgerufen, das shutdown() 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 main beendet 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 Sie ReentrantLock in 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:

  1. Wenn die Thread-Anzahl kleiner als corePoolSize ist, erstellt der Pool einen neuen Thread.
  2. Andernfalls wird die Aufgabe in die Queue eingereiht.
  3. Wenn die Queue voll ist und die Thread-Anzahl kleiner als maximumPoolSize ist, erstellt der Pool einen zusätzlichen Thread.
  4. Wenn beide Grenzen erreicht sind, ruft der Pool RejectedExecutionHandler auf.

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 queueCapacity von ThreadPoolTaskExecutor ist Integer.MAX_VALUE. Wenn Sie diesen Wert nicht setzen, ist maxPoolSize bedeutungslos, 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 @Scheduled verwenden 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.newFixedThreadPool für unkontrollierte Producer verwenden. Die unbegrenzte Queue wird anschwellen und OOM verursachen. Verwenden Sie ArrayBlockingQueue mit Limit.
  • Einen neuen Pool in jedem Request erstellen. Der Pool sollte geteilt sein, z.B. als Bean oder static final Feld. Das Erstellen und Vergessen zu schließen führt zu Thread-Lecks.
  • Exceptions schlucken. Mit submit() wird die Exception in Future gespeichert. Wenn Sie get() nicht aufrufen, sehen Sie den Fehler nie. Rufen Sie get() 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 ThreadLocal in 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 sollten Thread.currentThread().isInterrupted() überprüfen.

Hinweis: ExecutorService sollte nicht für komplexe mehrstufige Verarbeitungslogiken mit gegenseitigen Abhängigkeiten verwendet werden. In diesem Fall verwenden Sie CompletableFuture, das immer noch auf Ihrem konfigurierten Executor laufen kann.

9. Fazit

Jeder Thread-Pool-Typ löst ein eigenes Problem:

  • Fixed Pool oder ForkJoinPool für CPU-gebundene Aufgaben.
  • Virtual Threads mit Semaphore fü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.

javaconcurrencyexecutorservicethread-poolspring-boot