W3docs

Java Thread Pools

Threads wiederverwenden, um viele Aufgaben effizient auszuführen – mit Java Thread Pools und den Konfigurationsparametern des ThreadPoolExecutor.

Das Erstellen eines Threads ist aufwendig. Jedes new Thread() belegt etwa 1 MB nativen Stack, fordert vom Betriebssystem einen neuen Kernel-Thread an und erhöht die GC-Last. Ein Programm, das pro Aufgabe einen Thread erstellt, funktioniert bei zehn Aufgaben gut – bei zehntausend bricht es zusammen. Die Lösung ist ein Thread Pool – eine kleine Menge langlebiger Worker-Threads, die Aufgaben aus einer Warteschlange abarbeiten. Der Pool besitzt die Threads; du besitzt die Aufgaben.

Dieses Kapitel behandelt die konzeptionellen Grundlagen – was ein Pool ist, welche Einstellmöglichkeiten er bietet und welche Fehler auftreten können. Das nächste Kapitel, Executor framework, stellt die Typen Executor/ExecutorService vor, mit denen du mit einem Pool kommunizierst. Beide Kapitel sind eng miteinander verknüpft; dieses Kapitel konzentriert sich auf das Was und Warum, das nächste auf das Wie.

Warum Pooling?

Drei Probleme, die ein Pool löst:

  1. Kosten der Thread-Erstellung. Das Zuweisen eines nativen Stacks und die Anforderung eines neuen Threads beim Betriebssystem dauert in der Größenordnung von Millisekunden. Bestehende Threads wiederzuverwenden dauert Mikrosekunden. Im großen Maßstab entscheidet dieser Unterschied darüber, ob ein Server unter Last standhält oder nicht.
  2. Ressourcenlimits. Ein Plattform-Thread auf einer 64-Bit-JVM belegt ca. 1 MB Stack – bei 64 GB RAM sind das ca. ~64.000 Threads, und das Betriebssystem hat noch einen eigenen Overhead pro Thread. Unbegrenzte Thread-Erstellung bedeutet unbegrenzten RAM-Verbrauch. Ein Pool begrenzt die Anzahl.
  3. Vorhersehbare Parallelität. Ein Pool mit N Workern liefert genau N parallele Aufgaben. Das passt viel besser zu "alle 16 Kerne nutzen" als "pro Anfrage einen Thread erstellen und hoffen."

Der Preis des Poolings: du musst ihn dimensionieren. Zu klein → Aufgaben stauen sich, die Latenz steigt. Zu groß → Context-Switching dominiert und der Durchsatz sinkt. Die Dimensionierungsregeln werden im Kapitel zum Executor-Framework behandelt; dieses Kapitel erklärt, was ein Pool ist.

Aufbau eines Pools

Ein Thread Pool besteht im Wesentlichen aus drei Dingen:

  1. Einer begrenzten Menge von Worker-Threads. Worker führen eine Schleife aus: Aufgabe aus der Warteschlange nehmen, ausführen, nächste nehmen, wiederholen. Sie leben für die Lebensdauer des Pools (oder bis sie zu lange im Leerlauf waren, je nach Konfiguration).
  2. Einer Aufgabenwarteschlange. Wenn du Arbeit einreichst und kein Worker frei ist, landet die Aufgabe hier. Der Warteschlangentyp – LinkedBlockingQueue, ArrayBlockingQueue, SynchronousQueue – beeinflusst, wie der Pool unter Last wächst.
  3. Einer Einreichungs-API. execute(Runnable), submit(Callable), invokeAll(...) – die Möglichkeiten, Arbeit in den Pool zu geben.

In Java ist das alles in java.util.concurrent.ThreadPoolExecutor verpackt, der zugrundeliegenden Klasse für nahezu jeden Pool, dem du begegnen wirst.

Die sieben Parameter von ThreadPoolExecutor

Direkte Konstruktion (die du selten selbst verwendest, aber die Parameter sind das, was jede Factory-Methode intern übergibt):

new ThreadPoolExecutor(
  int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime, TimeUnit unit,
  BlockingQueue<Runnable> workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler
);
ParameterWas er steuert
corePoolSizeMindestanzahl an Workern, die auch im Leerlauf am Leben bleiben. Threads bis zu dieser Zahl werden nicht beendet.
maximumPoolSizeObergrenze für die Gesamtzahl der Worker. Der Pool wächst nur über core hinaus, wenn die Warteschlange voll ist.
keepAliveTimeWie lange ein Worker im Leerlauf über der Core-Größe wartet, bevor er beendet wird.
workQueueWo ausstehende Aufgaben warten. LinkedBlockingQueue (unbegrenzt) vs ArrayBlockingQueue (begrenzt) vs SynchronousQueue (kein Puffer) bestimmt das Verhalten des Pools vollständig.
threadFactoryWie Worker-Threads erstellt werden. Damit lässt sich Name, Daemon-Status, Priorität und ein Handler für unbehandelte Ausnahmen setzen.
handlerWas passiert, wenn sowohl Worker als auch Warteschlange gesättigt sind. Standard: AbortPolicy.

Die nicht offensichtliche Wechselwirkung: Der Pool bevorzugt es, die Warteschlange zu füllen, bevor er neue Threads über core hinaus erstellt. Eine unbegrenzte Warteschlange bedeutet also, dass der Pool niemals über core hinauswächst – er stellt einfach unbegrenzt in die Warteschlange. Eine begrenzte Warteschlange (oder SynchronousQueue) ist es, die den max-Parameter sinnvoll macht.

Die vier Ablehnungsrichtlinien

Wenn submit eine Aufgabe nicht annehmen kann (Warteschlange voll, alle max Worker beschäftigt), entscheidet der RejectedExecutionHandler, was geschieht:

RichtlinieVerhalten
AbortPolicy (Standard)Wirft RejectedExecutionException. Der Aufrufer weiß, dass die Aufgabe verworfen wurde.
CallerRunsPolicyDer aufrufende Thread führt die Aufgabe selbst aus. Verlangsamt den Aufrufer und erzeugt damit Gegendruck.
DiscardPolicyVerwirft die Aufgabe stillschweigend. Nur für "Best-Effort"-Telemetrie geeignet.
DiscardOldestPolicyVerwirft die älteste Aufgabe in der Warteschlange und stellt die neue ein. Nützlich, wenn "nur das Neueste zählt."

Das Werfen bei Standard ist meist die sichere Wahl. CallerRunsPolicy ist ein cleverer Gegendruck-Mechanismus – wenn der Pool überlastet ist, wird der Einreicher verlangsamt, was die Quelle natürlich drosselt.

Die Executors-Factory-Methoden – und warum du sie meistens vermeiden solltest

java.util.concurrent.Executors enthält praktische Factory-Methoden:

Executors.newFixedThreadPool(n);             // core = max = n, unbounded LinkedBlockingQueue
Executors.newCachedThreadPool();             // core = 0, max = Integer.MAX_VALUE, SynchronousQueue, 60s keep-alive
Executors.newSingleThreadExecutor();         // fixed pool with one thread
Executors.newScheduledThreadPool(n);         // for delay/repeat scheduling
Executors.newVirtualThreadPerTaskExecutor(); // Java 21+: one virtual thread per task

Zwei davon haben bekannte Fallstricke:

  • newFixedThreadPool verwendet eine unbegrenzte LinkedBlockingQueue. Bei anhaltender Überlast wächst die Warteschlange ohne Limit – irgendwann kommt es zu einem OOM. Die Pool-Größe ist fest; die sich dahinter aufstauende Arbeit ist es nicht.
  • newCachedThreadPool hat maximum = Integer.MAX_VALUE. Bei einem anhaltenden Arbeitsstoß erstellt er Threads ohne Limit – bis das Betriebssystem-Thread-Limit des Prozesses erschöpft ist und die JVM abstürzt.

Diese sind gut für kleine Jobs, Demos und Einzel-Skripte. Für Produktionscode erstelle einen ThreadPoolExecutor direkt mit einer begrenzten Warteschlange, einem sinnvollen max und einer expliziten Ablehnungsrichtlinie.

Die Ausnahme: newVirtualThreadPerTaskExecutor (Java 21+) gibt virtuelle Threads heraus, die günstig genug sind, dass "einer pro Aufgabe" tatsächlich funktioniert. Das behandeln wir im Kapitel virtual threads.

Lebenszyklus: shutdown vs shutdownNow

Ein Pool läuft weiter, bis du ihm sagst, er soll stoppen. Die zwei Stopp-Modi:

pool.shutdown();                              // stop accepting new work; let queued tasks finish
pool.shutdownNow();                           // stop accepting; interrupt running threads; return queued tasks

boolean terminated = pool.awaitTermination(10, TimeUnit.SECONDS);

shutdown ist die höfliche Variante: keine neuen Einreichungen werden akzeptiert, bestehende Arbeit wird abgeschlossen, dann beendet sich der Pool. shutdownNow ist die grobe Variante: Worker werden unterbrochen, ausstehende Warteschlange wird zurückgegeben. Verwende shutdown für einen sauberen Ausstieg; verwende shutdownNow nach einem shutdown + awaitTermination-Timeout, wenn die Arbeit nicht abgeschlossen wurde.

Das kombinierte Shutdown-Muster aus den JDK-Dokumenten:

pool.shutdown();
try {
  if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
    pool.shutdownNow();
    pool.awaitTermination(5, TimeUnit.SECONDS);
  }
} catch (InterruptedException e) {
  pool.shutdownNow();
  Thread.currentThread().interrupt();
}

Diese genaue Form solltest du in jedem Code verwenden, der einen Pool besitzt. Ohne shutdown hält die JVM die Worker am Leben (standardmäßig nicht-Daemon) und beendet sich nicht.

Worker benennen mit ThreadFactory

Die Standard-Executors.defaultThreadFactory() benennt Threads pool-1-thread-1, pool-1-thread-2 usw. Das ist etwas besser als Thread-7, aber immer noch nicht ideal. Produktionscode verwendet eine benannte Factory:

ThreadFactory factory = r -> {
  Thread t = new Thread(r, "image-worker-" + COUNTER.incrementAndGet());
  t.setDaemon(false);
  t.setUncaughtExceptionHandler((thr, ex) -> log.error("uncaught in " + thr.getName(), ex));
  return t;
};
ExecutorService pool = new ThreadPoolExecutor(
    4, 4, 0, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000),
    factory,
    new ThreadPoolExecutor.CallerRunsPolicy());

Die Factory ist deine Gelegenheit, jede Thread-Eigenschaft zu setzen: Name, Daemon-Flag, Priorität, Handler für unbehandelte Ausnahmen, Thread-Gruppe. In einem 200-Thread-Heap-Dump ist ein Thread namens image-worker-7 ein Thread, den du finden kannst.

Praxisbeispiel: Einen begrenzten Pool mit Gegendruck bauen

Das folgende Programm erstellt einen ThreadPoolExecutor mit 4 Workern, einer begrenzten Warteschlange von 8 und der CallerRunsPolicy-Ablehnungsrichtlinie – sodass der Einreicher verlangsamt wird, wenn der Pool überlastet ist, anstatt eine Ausnahme zu werfen.

java— editable, runs on the server

Was man aus dem Lauf mitnehmen kann:

  • Der Pool hatte ein striktes Limit von 4 Worker-Threads. Bei 40 Aufgaben à 50 ms beträgt die idealisierte serielle Pool-Zeit 40 * 50 / 4 = 500 ms. Die tatsächliche Wanduhrzeit lag nah daran – abzüglich der Kosten, die entfielen, weil CallerRunsPolicy den Einreicher verlangsamte, sobald die Warteschlange voll war.
  • Einige Aufgaben meldeten den Thread-Namen main. Das ist CallerRunsPolicy in Aktion: Wenn die Warteschlange voll und alle Worker beschäftigt waren, führte pool.execute die Aufgabe im aufrufenden Thread aus, anstatt sie einzureihen oder eine Ausnahme zu werfen. Der Einreicher wurde langsamer; das System blieb begrenzt. Das ist Gegendruck, wie er sein sollte.
  • pool.getLargestPoolSize() war 4 – das Maximum blieb gleich dem Core. Der Pool wuchs auch unter anhaltender Last nicht über core hinaus, weil die begrenzte Warteschlange für kurze Ausbrüche Platz hatte. Mit einer unbegrenzten Warteschlange (dem Standard von Executors.newFixedThreadPool) hätte die Warteschlange jede Aufgabe angenommen und largestPoolSize wäre bei 4 geblieben – aber der Speicherbedarf wäre gestiegen, während sich Aufgaben angehäuft hätten.
  • Die Shutdown-Sequenz ist das Produktionsmuster. shutdown() wies den Pool an, keine neuen Einreichungen mehr anzunehmen; awaitTermination(5, SECONDS) wartete bis zu 5 Sekunden auf laufende Arbeit; wenn die Arbeit nicht abgeschlossen wurde, hätte shutdownNow() die verbleibenden Worker unterbrochen. Ohne diese Aufrufe beendet sich die JVM nicht – die Nicht-Daemon-Worker halten sie am Leben.
  • Die Thread-Factory gab jedem Worker einen aussagekräftigen Namen (worker-1 ... worker-4) und einen Handler für unbehandelte Ausnahmen. In einem Produktions-Thread-Dump oder Profiler ist der Unterschied zwischen diesen Namen und generischen Namen der Unterschied zwischen "Ich weiß, welches Subsystem das ist" und "Keine Ahnung." Setze sie für jeden Pool, den du erstellst.

Was kommt als Nächstes

Das nächste Kapitel, Java Executor Framework, stellt die Typhierarchie vor, mit der du mit Thread Pools kommunizierst – Executor, ExecutorService, ScheduledExecutorService – und erklärt, wie du einen Pool für CPU-gebundene und I/O-gebundene Workloads dimensionierst.

Übungen

Übung
Du rufst `Executors.newFixedThreadPool(8)` auf und reichst Aufgaben schneller ein, als der Pool sie verarbeiten kann. Der Pool hat 8 Threads. Was ist das pathologische Versagensmuster bei anhaltender Überlast?
Du rufst `Executors.newFixedThreadPool(8)` auf und reichst Aufgaben schneller ein, als der Pool sie verarbeiten kann. Der Pool hat 8 Threads. Was ist das pathologische Versagensmuster bei anhaltender Überlast?
Was this page helpful?