Praktische Best Practices für Multithreading: Java-Edition — Konventionen für das Zeitalter der virtuellen Threads
· Go Komura · Multithreading, Java, Geschäftsanwendungen, Fehleruntersuchung, Design
„Wir möchten einen Batch-Job in einem Geschäftssystem mit Java parallelisieren.“ „Der gemeinsame Cache in unserer Spring-Webanwendung geht gelegentlich kaputt.“ „Wir haben eine alte Swing-Anwendung übernommen, die vor new Thread nur so strotzt.“ — Java hat Multithreading seit JDK 1.0 fest in die Sprache eingebaut, ist mit java.util.concurrent zu einem ausgereiften Werkzeugkasten herangewachsen und hat mit den virtuellen Threads in JDK 21 die gängigen Vorstellungen von nebenläufiger Programmierung noch einmal neu geschrieben. Gerade weil das Werkzeugrepertoire so reichhaltig ist, wird die Entscheidung, welches Werkzeug man wählt, selbst zur Entwurfsqualität.
Dieser Artikel ist die Java-Edition der Multithreading-Praxisserie. Er richtet sich an Entwickler, die in Java Geschäftssysteme, Batch-Jobs und Serveranwendungen schreiben, überträgt die Entwurfsprinzipien des Multithreadings — Threads nicht direkt erstellen, gemeinsam genutzten veränderlichen Zustand reduzieren, Sperrdisziplin, zuerst entwerfen, wie es angehalten wird — auf die Werkzeuge von Java (im Wesentlichen die LTS-Version JDK 21 und neuer) und fasst dabei die Unterscheidungen des Zeitalters der virtuellen Threads sowie javaspezifische Fallstricke zusammen, gestützt auf Primärquellen mit Stand August 2026. Er ist so geschrieben, dass er auch für sich allein gelesen werden kann. Dieselben Prinzipien, auf andere Sprachen übertragen, finden Sie in der „.NET-Edition“, der „C++-Edition“ und der „C-Edition“.
1. Das Wichtigste zuerst
new Threadgehört auch in Java nicht in Geschäftscode. Geben Sie Aufgaben anExecutorServiceweiter und überlassen Sie die Verwaltung des Thread-Lebenszyklus der Bibliothek.1- Leiten Sie überwiegend auf I/O wartende Aufgaben an virtuelle Threads. Die in JDK 21 offiziell eingeführten virtuellen Threads werden nach dem Prinzip „ein virtueller Thread pro Aufgabe“ verwendet und niemals gepoolt. Die Begrenzung der Nebenläufigkeit erfolgt nicht über einen Pool, sondern über
Semaphore.23 - Virtuelle Threads sind ein Werkzeug für den Durchsatz, kein Werkzeug, um Berechnungen zu beschleunigen. Für CPU-gebundene Parallelisierung sind wie bisher Plattform-Threads in Kernzahl-Größenordnung zuständig (fester Pool oder parallel stream).3
- Sperren Sie über ein
private final-Sperrobjekt oder ein dediziertesReentrantLock.synchronized(this)und das Sperren eines öffentlich sichtbaren Objekts können mit externem Code kollidieren. Brauchen Sie eine zeitlich begrenzte Erlangung (tryLock), verwenden SieReentrantLock. - In JDK 21–23 gab es das Problem, dass Blockierungen innerhalb von synchronized virtuelle Threads pinnen, aber JDK 24 (JEP 491) hat das behoben. Unterscheiden Sie zwischen alten Warnhinweisen und der aktuellen Realität.34
volatilegarantiert Sichtbarkeit und Ordnung, nicht Atomarität. Für Zähler verwenden SieAtomicInteger/LongAdder, für zusammengesetzten Zustand eine Sperre.5- Kooperative Abbruchsteuerung über Interrupts ist die einzig richtige Antwort für das Stoppen.
Thread.stop/suspend/resumelösen heuteUnsupportedOperationExceptionaus. Verschlucken SieInterruptedExceptionnicht, sondern stellen Sie den Status wieder her oder werfen Sie sie weiter.6 - Stoppen Sie
ExecutorServicemit dem zweistufigen Muster „shutdown → awaitTermination → shutdownNow“.shutdownNowist Best-Effort (in der Standardimplementierung über Interrupts), setzt also voraus, dass die Aufgaben selbst auf Interrupts reagieren.1 - Die UI von Swing gehört ausschließlich dem EDT (Event Dispatch Thread). Von einem anderen Thread aus erfolgt der Zugriff als Auftrag über
SwingUtilities.invokeLater.7
2. Warum Multithreading schwierig ist — Race Conditions, Deadlocks und das Speichermodell
Letztlich führt Multithreading, unabhängig von der Sprache, zwei Arten von Problemen ein.
Eine Race Condition ist ein Fehler, bei dem sich das Ergebnis danach richtet, in welcher Reihenfolge mehrere Threads eine bestimmte Codestelle erreichen. Das klassische Beispiel ist ein gemeinsam genutzter Zähler: Der eine Ausdruck count++ zerfällt tatsächlich in drei Schritte – „lesen → addieren → zurückschreiben“. Treten zwei Threads gleichzeitig in diese drei Schritte ein, wird die Addition des einen durch das Zurückschreiben des anderen überschrieben und geht verloren. Das Ergebnis ändert sich bei jedem Lauf, und welches Ergebnis herauskommt, ist nicht vorhersagbar.
sequenceDiagram
participant A as Thread A
participant M as Gemeinsame Variable count
participant B as Thread B
Note over M: count = 10
A->>M: Lesen (10)
B->>M: Lesen (10)
A->>A: Lokal addieren (11)
B->>B: Lokal addieren (11)
A->>M: Zurückschreiben (11)
B->>M: Zurückschreiben (11)
Note over M: count = 11 trotz zweier Inkremente<br/>Das Inkrement von Thread A ging verloren
Abbildung 1: Eine typische Race Condition, bei der ein Inkrement auf einem gemeinsam genutzten Zähler verloren geht. Unterbricht ein anderer Thread die drei Schritte von count++, überschreibt derjenige, der zuletzt zurückschreibt, den anderen
Ein Deadlock ist ein Zustand, in dem zwei Threads jeweils auf die vom anderen gehaltene Sperre warten und keiner von beiden weiterkommt. Thread A hält Sperre 1 und wartet auf Sperre 2, Thread B hält Sperre 2 und wartet auf Sperre 1 — das allein genügt, damit beide für immer stehen bleiben.
flowchart LR
A["Thread A<br/>hält Sperre 1"] -->|"wartet auf Freigabe von Sperre 2"| B["Thread B<br/>hält Sperre 2"]
B -->|"wartet auf Freigabe von Sperre 1"| A
Abbildung 2: Die zirkuläre Wartesituation eines Deadlocks. Sobald die Wartepfeile einen Ring bilden, bleiben alle Threads in diesem Ring für immer stehen
Beides ist zeitabhängig: Eine Ausführungsreihenfolge, die auf einer Entwicklungsmaschine nur einmal unter Zehntausenden von Läufen zutrifft, kann auf einem Produktionsserver mit anderer Kernzahl und anderer Last täglich auftreten. Auch „es reproduziert sich nicht, wenn man den Debugger anhängt“ oder „es verschwand, als ich Logging hinzufügte“ liegt daran, dass die Beobachtung selbst das Timing verändert – ein typisches Verhalten von Race-Bugs. Genau deshalb weisen alle Prinzipien dieses Artikels in eine Richtung: Vor „richtig synchronisieren“ steht „die Stellen reduzieren, an denen synchronisiert werden muss“.
2.1. Eine javaspezifische Grundlage — das Speichermodell und happens-before
Javaspezifisch kommt hinzu, dass die Sichtbarkeit gemeinsam genutzter Daten durch die happens-before-Beziehungen des Java Memory Model (JMM) definiert wird.
Lesen und Schreiben einer gemeinsam genutzten Variable ohne Synchronisierung führt zwar nicht zu „undefiniertem Verhalten“ wie in C++, aber es kann rechtmäßig dazu kommen, dass veraltete Werte weiterhin sichtbar bleiben oder die Reihenfolge der Schreibvorgänge vertauscht erscheint. Eine Speicherkonsistenzfehler, bei dem „eine Schleife ein boolean-Flag beobachtet, der von einem anderen Thread geänderte Wert aber nie sichtbar wird“, ist ein vom JMM erlaubtes Verhalten, kein Fehler der JVM.5 Die Werkzeuge, die davor schützen, sind die Mechanismen, die happens-before-Beziehungen erzeugen – synchronized, volatile sowie die Klassen aus java.util.concurrent. Verwenden Sie die nebenläufigen Collections korrekt, garantiert Ihnen die Bibliothek die „happens-before-Beziehung zwischen einer Aktualisierungsoperation und einem nachfolgenden Abruf“.8
Die praktische Richtlinie für Java lässt sich also so zusammenfassen: Versuchen Sie sich nicht mit einer rohen gemeinsamen Variable zu behelfen. Verwenden Sie für die gemeinsame Nutzung die Werkzeuge aus java.util.concurrent und lassen Sie happens-before von der Bibliothek herstellen.
3. Wie man Threads erstellt — ExecutorService und virtuelle Threads
3.1. Trennung von Aufgabe und Ausführung
In Java trägt ExecutorService das Prinzip „Threads nicht selbst erstellen“. Es trennt die Arbeit (Runnable / Callable) von der Frage, wie sie ausgeführt wird (mit wie vielen Threads, über welche Warteschlange), und überlässt Erstellung, Wiederverwendung und Verwerfen von Threads der Bibliothek.1
Ab JDK 21 ist die Wahl des Ausführungsmittels zu einer einfachen Zweierwahl geworden.23
flowchart TB
S["Es gibt eine Aufgabe, die nebenläufig laufen soll"] --> Q1{"Was dominiert die Aufgabe?"}
Q1 -->|"Überwiegend Warten auf I/O<br/>HTTP-Aufrufe, DB, Dateien"| VT["Virtuelle Threads<br/>Executors.newVirtualThreadPerTaskExecutor()<br/>einer pro Aufgabe. Nicht poolen"]
Q1 -->|"CPU-gebundene Berechnung"| PT["Fester Pool aus Plattform-Threads<br/>Executors.newFixedThreadPool(etwa Kernzahl)<br/>oder parallel stream"]
VT --> LIMIT["Begrenzung gleichzeitiger Zugriffe auf<br/>externe Dienste über Semaphore, nicht über den Pool"]
Abbildung 3: Die Wahl des Ausführungsmittels ab JDK 21. Ziehen Sie zuerst die Linie „bei I/O die Art des Wartens ändern, bei CPU parallelisieren“ und teilen Sie dann I/O-gebundene Arbeit virtuellen Threads und CPU-gebundene Arbeit dem klassischen Pool zu
Auf der CPU-gebundenen Seite ist ein Vorbehalt zu beachten. Executors.newFixedThreadPool begrenzt zwar die Thread-Anzahl, aber die Warteschlange ist unbegrenzt. Bei einem dauerhaft laufenden Dienst, bei dem der Zulauf die Verarbeitung ständig übersteigt, ist nur die Thread-Anzahl auf die Kernzahl begrenzt – die in der Warteschlange aufgestauten Aufgaben samt ihren Daten fressen weiterhin Speicher. Konfigurieren Sie in einem solchen Aufbau entweder direkt über ThreadPoolExecutor eine kapazitätsbegrenzte Warteschlange plus eine Ablehnungsrichtlinie, oder setzen Sie auf der einreichenden Seite eine Zugangsbeschränkung wie Semaphore ein, damit sich Backpressure erzeugen lässt (dasselbe Prinzip wie bei der Warteschlange in Kapitel 4).
3.2. Virtuelle Threads nicht falsch einsetzen
Virtuelle Threads sind leichtgewichtige, von OS-Threads entkoppelte Threads, die während einer blockierenden Operation der JDK (I/O, Sperren, sleep und Ähnliches in der Standardbibliothek) ihren OS-Thread freigeben, sodass sich mehrere Millionen von ihnen in einer einzigen JVM ausführen lassen. Allerdings gilt das nicht für „jede Art von Blockierung“. Blockiert ein virtueller Thread während der Ausführung von nativem Code (JNI) oder einer Foreign Function, bleibt er an seinen Carrier-Thread gepinnt. Was JDK 24 (das unten besprochene JEP 491) behoben hat, ist das Pinning durch synchronized; das Pinning an nativen Grenzen bleibt bestehen, sodass ein Vorgang, der über einen JNI-Treiber oder eine Geräte-API lange blockiert, bei Verwendung vieler virtueller Threads die Carrier-Threads erschöpft. Wie der offizielle Leitfaden aber betont, sind es keine „schnelleren Threads“. Die Ausführungsgeschwindigkeit des Codes ändert sich nicht – was sie bieten, ist Skalierung (Durchsatz).3
Es gibt drei Regeln der Disziplin für ihre Verwendung.3
- Nicht poolen. Virtuelle Threads sind billig und zum Wegwerfen bestimmt; „Anzahl der Aufgaben = Anzahl der virtuellen Threads“ ist der korrekte Zustand. Virtuelle Threads in einen
newFixedThreadPoolzu stecken, ist ein Fehler – verwenden Sie die Formtry (var executor = Executors.newVirtualThreadPerTaskExecutor()). - Begrenzen Sie die Nebenläufigkeit mit
Semaphore. Drücken Sie eine Beschränkung wie „höchstens 10 gleichzeitige Verbindungen zu einer externen API“ über ein Semaphor aus, nicht über die Poolgröße. - Nicht für CPU-gebundene Arbeit verwenden. Für die Parallelisierung von Berechnungen bleiben wie bisher Plattform-Threads in Kernzahl-Größenordnung die richtige Wahl.
Beachten Sie, dass innerhalb eines virtuellen Threads ganz gewöhnlicher synchroner Code läuft. Statt den Code wie beim async/await von .NET umzuschreiben, besteht die Entwurfsphilosophie virtueller Threads darin, „einfachen Code nach dem Muster ein Request, ein Thread“ unverändert in großer Zahl laufen zu lassen.2
3.3. Ein häufiges Missverständnis — „kein Pool nötig“ gilt nur für virtuelle Threads
Lesen Sie aus der Regel „nicht poolen“ bitte nicht heraus, dass „Java keinen Thread-Pool-Mechanismus besitzt (oder er ineffizient wäre)“. Tatsächlich ist das Gegenteil der Fall: Javas Pools sind seit JDK 5 (2004) ein ausgereifter Bestandteil der Standardbibliothek. Der fein konfigurierbare Allzweckpool ThreadPoolExecutor (erstellt über die verschiedenen Executors-Factorys), der Work-Stealing-Pool ForkJoinPool (dessen gemeinsame Instanz commonPool() das Standardziel für parallel stream und CompletableFuture ist) sowie ScheduledThreadPoolExecutor für periodische Ausführung – bei CPU-gebundener Arbeit spielen diese nach wie vor die Hauptrolle.
Ein Pool ist im Grunde eine Optimierung nach dem Prinzip „Erstellung und Vorhaltung eines OS-Threads sind teuer, also wiederverwenden“. Virtuelle Threads beseitigen diese Voraussetzung, indem sie die Erstellungskosten praktisch auf null senken, sodass es keinen Grund mehr zur Wiederverwendung gibt — die zutreffende Sichtweise ist nicht, dass Pooling ineffizient geworden wäre, sondern dass Threads so leichtgewichtig geworden sind, dass die Optimierung durch Pooling überflüssig ist. Und unter der Oberfläche der virtuellen Threads betreibt der Scheduler der JDK als Work-Stealing-ForkJoinPool eine Menge von Carrier-Threads (OS-Threads) in Kernzahl-Größenordnung.2 Das Grundmuster „eine kleine Anzahl von OS-Threads bewältigt eine riesige Menge an Nebenläufigkeit“ bleibt also erhalten – nur die Verwaltung dieses Pools ist aus der Hand des Entwicklers an die JVM übergegangen. Man kann sagen, dass Java auf demselben Zielpunkt landet wie das async/await von .NET, das seinen Thread zum Zeitpunkt des await an den Pool zurückgibt – nur ohne dass sich die Form des Codes ändert.
4. Gemeinsam genutzten veränderlichen Zustand reduzieren — Aufteilen, Unveränderlichkeit, nebenläufige Collections und Warteschlangen
Ein Konflikt entsteht nur, wenn „mehrere Threads“ und „gemeinsam genutzte veränderliche Daten“ zusammenkommen. Die Anzahl der Threads wird durch die Anforderungen bestimmt, sodass sich im Entwurf nur die gemeinsame Nutzung reduzieren lässt. Es gibt drei Mittelfamilien – „Aufteilen“, „Unveränderlichmachen“ und „Weiterreichen“ –, die sich in Java so schreiben lassen.
Aufteilen. Lassen Sie bei der parallelen Aggregation nicht jeden Thread in eine gemeinsame Summenvariable schreiben, sondern jeden Thread ein Teilergebnis bilden und diese am Ende zusammenführen. reduce / collect von parallel stream bieten genau diese Form als Rahmenwerk, und auch das weiter unten besprochene LongAdder ist eine Implementierung dieser Aufteilungsstrategie: Intern werden Zellen aufgeteilt, um Konflikte zu streuen, und beim Lesen zusammengerechnet. Die Häufigkeit der Schreibzugriffe auf gemeinsamen Zustand zu reduzieren, kommt vor dem korrekten Schreiben von Synchronisierung.
Unveränderlich machen. Bilden Sie mit record und unveränderlichen Collections (List.copyOf / Map.copyOf) Daten, die nach der Konstruktion nicht mehr überschrieben werden, lassen sich diese ohne Synchronisierung teilen. Für Konfiguration und Stammdaten ist die Standardform: „beim Ersetzen ein neues Objekt bauen und eine volatile-Referenz umhängen“. Allerdings sind „sieht schreibgeschützt aus“ und „ist unveränderlich“ zwei verschiedene Dinge. Die Zugriffsmethoden eines record geben die Referenzen seiner Komponenten unverändert zurück, und auch die Kopie von List.copyOf ist flach (sie dupliziert die Elementobjekte nicht) – sind die Elemente veränderlich, kann jeder, der einen Alias besitzt, den Inhalt überschreiben, und der Konflikt bleibt bestehen. Ohne Synchronisierung teilen dürfen Sie nur dann, wenn der gesamte Objektgraph einschließlich der Elemente unveränderlich ist. Enthält er veränderliche Elemente, übergeben Sie entweder eine tiefe Kopie, oder machen Sie auch die Elemente zu record- bzw. unveränderlichen Typen.
Die zusammengesetzten Operationen nebenläufiger Collections nutzen. Für das „falls nicht vorhanden, anlegen und einfügen“ von ConcurrentHashMap verwenden Sie computeIfAbsent. Diese Methode wird als Ganzes atomar ausgeführt, und ist der Schlüssel nicht vorhanden, wird die Mapping-Funktion innerhalb dieses einen Aufrufs genau einmal aufgerufen.8 Dass sich die Garantie von .NETs ConcurrentDictionary.GetOrAdd unterscheidet (dessen Factory bei Konkurrenz mehrfach laufen kann), ist ein Punkt, den Menschen, die zwischen beiden Sprachen wechseln, leicht verwechseln. Es gilt jedoch nicht „genau einmal über die gesamte Lebensdauer des Schlüssels“. Gibt die Funktion null zurück oder wirft sie eine Ausnahme, wird keine Zuordnung registriert, und die Funktion wird beim nächsten Aufruf erneut ausgeführt (dasselbe gilt, wenn der Eintrag nach der Registrierung entfernt wird). Entwerfen Sie eine Initialisierung, die keine doppelten Seiteneffekte verträgt, so, dass die Funktion nicht-null zurückgibt und erfolgreich abschließt. Als Preis der Atomarität werden während der Berechnung jedoch einige Aktualisierungen anderer Threads blockiert, weshalb Sie die Mapping-Funktion kurz halten und diese Map innerhalb der Funktion nicht selbst aktualisieren dürfen (eine erkannte rekursive Aktualisierung kann zu IllegalStateException führen).8
// Der Standardansatz für einen Häufigkeitszähler: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
Über eine Warteschlange weiterreichen. Leiten Sie den Datenfluss zwischen Threads über BlockingQueue. Bei einer ArrayBlockingQueue mit angegebener Kapazität blockiert put, wenn sie voll ist, was zu natürlicher Backpressure führt – dasselbe Bild wie beim bounded Channel aus der .NET-Edition. Auch im Zeitalter der virtuellen Threads bleibt dieser Entwurf, der die Grenze zwischen Producer und Consumer klar zieht, wirksam.
5. Sperrdisziplin — synchronized und ReentrantLock
5.1. Worauf man sperrt und was man während des Sperrens unterlässt
Denken Sie bei der Einheit der Sperre nicht in „Codeabschnitten“, sondern in „Daten“. Ordnen Sie jeder Menge zu schützender veränderlicher Daten genau ein Sperrobjekt zu, und nehmen Sie an allen Stellen, die diese Daten berühren, dieselbe Sperre — ein zusammenbrechendes Zuordnungsschema genau dieser Art ist in der Praxis die Ursache von Race-Bugs. Vermeiden Sie synchronized(this) und synchronized(SomeClass.class), da externer Code dasselbe Objekt sperren kann, und ordnen Sie stattdessen ein nicht nach außen sichtbares private final Object lock = new Object(); eins-zu-eins den zu schützenden Daten zu.
Dazu zwei weitere Disziplinregeln. Erstens: Tun Sie während des Sperrens nichts Zeitaufwendiges oder Externes. I/O, das Aufrufen von Listenern oder das Ausführen unbekannten Codes bei gehaltener Sperre verlängert nicht nur die Haltezeit, sondern schafft auch einen Pfad, auf dem der aufgerufene Code versucht, eine andere Sperre zu nehmen, und so die zirkuläre Wartesituation aus Abbildung 2 erzeugt. Zweitens: Legen Sie die Erwerbsreihenfolge mehrerer Sperren fest. Machen Sie an Stellen, an denen zwei oder mehr Sperren genommen werden, zur Regel, dass alle Threads dieselbe Reihenfolge einhalten, und richten Sie dort, wo Sie die Reihenfolge nicht garantieren können, mit dem unten beschriebenen tryLock(timeout) einen Pfad „falls nicht erlangbar, aufgeben und erneut versuchen“ ein.
synchronized genügt für „kurzen, einfachen Ausschluss“. Wechseln Sie zu ReentrantLock, sobald Sie Folgendes benötigen.
- Zeitlich begrenzte Erlangung über
tryLock(timeout)(verwandelt einen endlosen Hang in einen Fehler, den Sie protokollieren und behandeln können) - Fairness-Policy, mehrere
Condition-Objekte, oder wenn Sie Erlangen und Freigeben der Sperre auf verschiedene Methoden aufteilen möchten
Verwenden Sie ReentrantLock, brechen Sie niemals das Muster auf, direkt nach lock() ein try zu setzen und unlock() in einem finally-Block aufzurufen (Java hat keine dem RAII von C++ entsprechende Syntax, sodass dieses Muster die gesamte Disziplin ausmacht).
5.2. Virtuelle Threads und Pinning — was sich mit JDK 24 geändert hat
Bei der ursprünglichen Einführung virtueller Threads (JDK 21–23) gab es die Einschränkung, dass eine Blockierung innerhalb eines synchronized-Blocks den virtuellen Thread an seinen OS-Thread pinnt (er kann den OS-Thread nicht freigeben, wodurch der Skalierungsvorteil verloren geht), weshalb bei häufigen oder lang andauernden Blockierungen der Umstieg auf ReentrantLock empfohlen wurde.3 Diese Einschränkung wurde behoben, als JEP 491 in JDK 24 die Monitor-Implementierung neu schrieb, und synchronized pinnt virtuelle Threads seither nicht mehr.4 Verwenden Sie JDK 24 oder neuer, ist ein mechanischer Austausch als Gegenmaßnahme gegen Pinning nicht mehr nötig. Es lohnt sich zu prüfen, ob interne Richtlinien noch auf dem Warnstand von JDK 21 stehen geblieben sind.
5.3. Wo Atomics und volatile hingehören
Atomare Aktualisierungen einer einzelnen Variable übernehmen AtomicInteger / AtomicLong / AtomicReference (bei Statistikwerten, die nur mit hoher Frequenz erhöht werden, das gegen Konkurrenz robuste LongAdder). volatile garantiert Sichtbarkeit und Ordnung (happens-before), nicht die Atomarität zusammengesetzter Operationen.5 Dieselbe Schlussfolgerung wie in der .NET- und der C++-Edition gilt auch für Java: Flags und einzelne Werte gehören zu Atomics, zusammengesetzter Zustand zu Sperren, und volatile sollte es nicht allein richten müssen.
6. Entwurf des Anhaltens — Interrupts als gemeinsame Sprache
6.1. Die Etikette von interrupt
Das Stoppen und Abbrechen in Java ist einheitlich auf Interrupts ausgerichtet. t.interrupt() setzt den Interrupt-Status des Ziel-Threads, und blockiert das Ziel gerade in sleep / wait / join oder Ähnlichem, wirft es InterruptedException, um es sofort zu wecken (dabei wird der Interrupt-Status zurückgesetzt).6 Die einstigen erzwingenden Mittel Thread.stop / suspend / resume sind grundsätzlich unsicher, weshalb ein Aufruf heute UnsupportedOperationException auslöst.6
flowchart TB
OWNER["Die stoppende Seite: t.interrupt()"] --> ST["Interrupt-Status wird gesetzt"]
ST --> A["Thread mitten in der Berechnung:<br/>prüft Thread.interrupted() in einer Schleife"]
ST --> B["Blockiert in sleep / wait / join:<br/>InterruptedException fliegt und weckt sofort<br/>(Status wird zurückgesetzt)"]
A --> E["Räumt auf und endet selbst"]
B --> C{"Was tut der catch-Block?"}
C -->|"Kann selbst beenden"| E
C -->|"Kann nicht beenden (z. B. innerhalb einer Bibliothek)"| R["Thread.currentThread().interrupt()<br/>stellt den Status wieder her und hinterlässt das Signal"]
R --> E
Abbildung 4: Kooperative Abbruchsteuerung über Interrupts. Wird InterruptedException verschluckt, verschwindet das Stopp-Signal — nach dem Fangen bleibt nur die Wahl zwischen “beenden” und “wiederherstellen”
In der Praxis genügt eine einzige Regel: Schreiben Sie keinen Code, der InterruptedException fängt und nichts tut. Können Sie im Rahmen Ihrer eigenen Verantwortung beenden, tun Sie das dort; können Sie es nicht, stellen Sie mit Thread.currentThread().interrupt() den Status wieder her und geben das Signal an den Aufrufer weiter (siehe FAQ).
6.2. Das zweistufige Herunterfahren von ExecutorService
Die Stopp-APIs von ExecutorService bauen auf dem Interrupt-Modell auf. shutdown() stoppt die Annahme neuer Aufgaben und lässt bereits eingereichte Aufgaben vollständig durchlaufen, shutdownNow() versucht, laufende Aufgaben zu stoppen. Als Schnittstellenspezifikation ist das Best-Effort, und es ist ausdrücklich festgehalten, dass die Standardimplementierung (etwa ThreadPoolExecutor) typischerweise über Thread.interrupt() abbricht — das heißt, Aufgaben, die nicht auf Interrupts reagieren, stoppen auch mit shutdownNow nicht, und verwenden Sie eine eigene Executor-Implementierung, müssen Sie deren Abbruchmethode (ob überhaupt ein Interrupt gesendet wird) in der Dokumentation der Implementierung nachschlagen.1 Das von der offiziellen Dokumentation gezeigte Standardmuster für das Stoppen ist das folgende zweistufige Muster.1
/** true, sobald das Herunterfahren abgeschlossen ist. Bei false nicht mit der Freigabe gemeinsamer Ressourcen fortfahren. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // Stufe 1: Annahme neuer Aufgaben stoppen und auf den Abschluss warten
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// Stufe 2: Abbruch anfordern. Aufgaben, die vor der Ausführung verworfen wurden,
// werden zurückgegeben - deren Futures als abgebrochen markieren, um auf get() wartende Aufrufer zu wecken
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
return false; // Herunterfahren nicht abgeschlossen. Von Erfolg unterscheidbar melden
}
}
return true;
} catch (InterruptedException ex) {
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
Thread.currentThread().interrupt(); // Auch den eigenen Interrupt-Status wiederherstellen
return false; // Auch auf diesem Pfad ist das Herunterfahren womöglich unvollständig
}
}
flowchart TB
S["shutdown()<br/>stoppt die Annahme neuer Aufgaben"] --> W1{"awaitTermination<br/>wartet auf den Abschluss"}
W1 -->|"innerhalb der Frist abgeschlossen"| DONE["Herunterfahren abgeschlossen"]
W1 -->|"Zeitüberschreitung"| NOW["shutdownNow()<br/>sendet Interrupt an laufende Aufgaben<br/>(ob sie reagieren, hängt von der Aufgabe ab)"]
NOW --> W2{"awaitTermination<br/>wartet erneut"}
W2 -->|"abgeschlossen"| DONE
W2 -->|"noch nicht beendet"| LOG["als Anomalie protokollieren<br/>(verdächtig sind Aufgaben, die nicht auf Interrupts reagieren)"]
Abbildung 5: Das zweistufige Herunterfahren von ExecutorService. Ein gestuftes Vorgehen nach dem Muster “höflich warten → über Interrupt anfordern → wenn immer noch nicht beendet, als Anomalie beobachten”
Beachten Sie außerdem eine Grenze beim Abbrechen der von shutdownNow() zurückgegebenen Runnable-Objekte. Zurückgegeben wird das Objekt, das in der Ausführungswarteschlange stand – bei einem einfachen submit ist das genau der FutureTask, der dem Aufrufer übergeben wurde, aber bei Aufgaben, die über einen Wrapper wie ExecutorCompletionService eingereicht wurden, ist es der Wrapper in der Warteschlange, ein anderes Objekt als das Future des Aufrufers. In diesem Aufbau bringt der obige Abbruch das Future des Aufrufers nicht zum Abschluss. Entwerfen Sie das System daher so, dass Sie bei der Einreichung selbst eine Liste der Future-Objekte führen und diese beim Herunterfahren abbrechen (oder die verworfenen Aufgaben an ihren Besitzer zurückgeben).
close() (AutoCloseable), verfügbar ab JDK 19, verpackt „shutdown aufrufen und auf den Abschluss warten“ in eine Form, die sich mit try-with-resources schreiben lässt, und try (var executor = ...) in Kombination mit dem virtuellen newVirtualThreadPerTaskExecutor ist die heute übliche Grundform.1 close() ist jedoch kein Ersatz für das obige zweistufige Muster. Da es ohne Timeout auf den Abschluss wartet, blockiert der Thread, der zu schließen versucht, für immer, sobald auch nur eine Aufgabe nicht auf Interrupts reagiert oder nie endet. Es ist ein Werkzeug für Situationen, in denen die Aufgaben im Geltungsbereich endlich sind und ihr vollständiger Abschluss garantiert ist (an Ort und Stelle einreichen und an Ort und Stelle warten); verwenden Sie für Stellen wie den Shutdown-Pfad einer Anwendung, an denen Sie „unbedingt in endlicher Zeit fertig werden“ müssen, das befristete zweistufige Muster. Das Abbrechen einzelner Aufgaben erfolgt ebenfalls über Interrupts, mit Future.cancel(true).
7. Der UI-Thread — Swings EDT
Für Desktopanwendungen gilt unabhängig von Sprache oder Framework das Gesetz, dass die UI ausschließlich dem sie verwaltenden Thread gehört. In Swing ist dieser exklusive Thread der Event Dispatch Thread (EDT): Die Methoden von Swing-Komponenten sind grundsätzlich nicht threadsicher, und der Zugriff von mehreren Threads aus zieht Thread-Interferenzen und Speicherkonsistenzfehler nach sich. Aktualisierungen der Anzeige von einem anderen Thread aus werden über SwingUtilities.invokeLater beim EDT beauftragt, und da umgekehrt lange Verarbeitungen auf dem EDT die UI einfrieren lassen, lagern Sie aufwendige Arbeit über SwingWorker oder Ähnliches an einen Worker-Thread aus.7 Bei JavaFX ist die Struktur dieselbe: UI-Aktualisierungen werden über Platform.runLater beim Anwendungs-Thread beauftragt.
8. Verifikation und Debugging — Thread-Dumps als Waffe
Sie können nicht erwarten, dass Race-Bugs durch Tests gefunden werden. Gewöhnliche Tests zählen einen Lauf, bei dem „zufällig kein Konflikt aufgetreten ist“, als Erfolg. Denken Sie Ihre Vorbereitung in drei Schichten.
Die erste Verteidigungslinie ist der Entwurf. Prüfen Sie im Review tabellarisch: „Welche veränderlichen Daten werden geteilt?“, „Welche Sperre schützt jeweils welche (die Zuordnung aus 5.1)?“, „Ist die Erwerbsreihenfolge der Sperren eindeutig?“, „Gibt es einen catch-Block, der InterruptedException verschluckt?“ und „Erreicht der Stopp-Pfad (shutdown/Interrupt) alle Aufgaben?“
Zweitens setzen Sie Thread-Dumps gekonnt ein. Java besitzt ein Standardwerkzeug, um „den Zustand aller Threads in diesem eingefrorenen Moment“ zu erfassen: jstack (oder jcmd <pid> Thread.print) gibt Stacktraces aus, und die Option -l liefert zusätzlich Informationen zu Sperren.9 Beachten Sie, dass dieses klassische Dump-Format für Plattform-Threads gedacht ist und die virtuellen Threads Ihrer Anwendung nicht enthält. Verfolgen Sie eine blockierte Anfrage in einem Aufbau, der virtuelle Threads verwendet (Kapitel 3), nutzen Sie jcmd <pid> Thread.dump_to_file -format=json <Datei>, das auch virtuelle Threads mit erfasst.2 Bei der Untersuchung eines Hangs ist es das übliche Vorgehen, alle paar Sekunden zwei bis drei Dumps zu erfassen und abzugleichen, auf welche Sperre die untätigen Threads jeweils warten und wer diese Sperre hält. Protokollieren Sie Zeitüberschreitungen von tryLock(timeout) (5.2), lässt sich sogar der Anlass zum Erfassen eines Dumps automatisieren.
Drittens rütteln Sie unter Last daran. Stresstests – mit mehr Parallelität als Kernen über längere Zeit laufen lassen, die Verarbeitungsreihenfolge randomisieren, künstliche Verzögerungen einfügen – sind ein realistisches Mittel, um es auf der Entwicklungsmaschine wahrscheinlicher zu machen, einen Konflikt zu „treffen“. Führen Sie vor der Veröffentlichung mindestens einmal einen Test mit produktionsnaher Datenmenge und Thread-Anzahl durch.
9. Die Zukunft der Java-Nebenläufigkeit — strukturierte Nebenläufigkeit
Zum Abschluss noch ein kurzer Blick nach vorn. Auf der Grundlage virtueller Threads befindet sich Structured Concurrency (StructuredTaskScope) in der Entwicklung – ein Konzept, das „mehrere Teilaufgaben als eine Arbeitseinheit behandelt und die Weitergabe von Fehlern sowie den Abbruch strukturiert“ –, das mit Stand August 2026 noch ein Preview-Feature ist. In der fünften Preview von JDK 25 (JEP 505) wurde es zu einer API-Form über StructuredTaskScope.open() umgestaltet und setzt sich im aktuellen JDK 26 als sechste Preview (JEP 525) fort.1011 Die unveränderliche Kontextfreigabe Scoped Values, die die Probleme von ThreadLocal löst, wurde dagegen in JDK 25 finalisiert.12 Die Prinzipien dieses Artikels – klare Aufgabengrenzen, unveränderliche gemeinsame Nutzung, kooperatives Stoppen – decken sich auch mit der Richtung, in die diese neuen APIs zielen.
10. Zusammenfassung — die Checkliste für Java
- Ist im Geschäftscode noch
new Threadübrig geblieben (oder setzt er aufExecutorService/ virtuelle Threads)? - Werden I/O-gebundene und CPU-gebundene Arbeit unterschiedlichen Ausführungsmitteln zugewiesen (die Verzweigung aus Abbildung 3)?
- Werden virtuelle Threads nicht gepoolt, und wird die Begrenzung der Nebenläufigkeit über
Semaphoreausgedrückt? - Sind gemeinsam genutzte Daten unveränderlich (
record/List.copyOf) oder auf den Werkzeugen vonjava.util.concurrentaufgebaut? - Gibt es kein
synchronized(this)und keine Sperre auf ein öffentlich sichtbares Objekt? - Werden die zusammengesetzten Operationen von
ConcurrentHashMap(computeIfAbsentusw.) verwendet und die Mapping-Funktion kurz gehalten? - Wird von
volatilekeine Atomarität erwartet (verwenden Zähler die Atomic-Klassen /LongAdder)? - Gibt es keinen einzigen catch-Block, der
InterruptedExceptionverschluckt? - Folgt das Stoppen von
ExecutorServicedem befristeten zweistufigen Muster (und ist die Verwendung vonclose()auf Bereiche beschränkt, in denen der vollständige Abschluss der Aufgaben garantiert ist)? - Sind die UI-Aktualisierungen von Swing/JavaFX auf dem EDT bzw. dem Anwendungs-Thread gebündelt?
Java ist eine der Sprachen mit dem am besten ausgestatteten Werkzeugkasten für Nebenläufigkeit, und mit dem Aufkommen der virtuellen Threads hat sich auch ein Weg eröffnet, einfachen synchronen Code unverändert zu skalieren. Gerade deshalb ist das korrekte Verständnis der Arbeitsteilung der Werkzeuge — welches für Durchsatz zuständig ist, welches für Ausschluss, und was das Signal zum Stoppen ist — der eigentliche Kern des Multithreading-Designs in Java.
Verwandte Artikel
- Praktische Best Practices für Multithreading: .NET-Edition
- Praktische Best Practices für Multithreading: C++-Edition
- Praktische Best Practices für Multithreading: C-Edition
- C# async/await-Praxis-Entscheidungstabelle – Task.Run und ConfigureAwait
Verwandte Beratungsleistungen
Die KomuraSoft LLC übernimmt Multithreading-Design-Reviews für Geschäftssysteme und Batch-Verarbeitung in Java, die Ursachenuntersuchung nebenläufigkeitsbedingter Fehler wie beschädigten gemeinsamen Zustands oder „stoppt gelegentlich nicht / friert ein“ (Thread-Dump-Analyse) sowie technische Beratung zur Einführung virtueller Threads.
Referenzlinks
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). Dazu, dass shutdown() bereits eingereichte Aufgaben vollständig durchlaufen lässt und gleichzeitig die Annahme neuer Aufgaben stoppt; dazu, dass shutdownNow() versucht, laufende Aufgaben zu stoppen, und eine Liste wartender Aufgaben zurückgibt, wobei die typische Implementierung über Thread.interrupt() abbricht und keine über Best-Effort hinausgehende Garantie bietet, sodass eine nicht auf Interrupts reagierende Aufgabe nicht endet; dazu, dass sich mit awaitTermination auf den Abschluss warten lässt; dazu, dass close() (AutoCloseable, ab Java 19) shutdown aufruft und auf den Abschluss wartet und sich mit try-with-resources verwenden lässt; sowie dazu, dass das zweistufige Herunterfahren shutdown → awaitTermination → shutdownNow als Anwendungsbeispiel gezeigt wird. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
OpenJDK, JEP 444: Virtual Threads. Dazu, dass virtuelle Threads in JDK 21 zu einem offiziellen Feature wurden; dazu, dass es sich um leichtgewichtige Threads handelt, die den Aufwand für das Schreiben, Warten und Beobachten hochdurchsatzstarker nebenläufiger Anwendungen erheblich senken; zur Entwurfsphilosophie, einfachen synchronen Code nach dem Muster „ein Request, ein Thread“ unverändert zu skalieren; dazu, dass der Scheduler der JDK für virtuelle Threads ein im FIFO-Modus arbeitender Work-Stealing-
ForkJoinPoolist, dessen Standardparallelität der Anzahl verfügbarer Prozessoren entspricht; sowie dazu, dass ein neues Thread-Dump-Format, das virtuelle Threads einschließt, alsjcmd Thread.dump_to_file(im Klartext- und im JSON-Format) hinzugefügt wurde, während klassische Thread-Dumps keine virtuellen Threads enthalten. ↩ ↩2 ↩3 ↩4 ↩5 -
Oracle Java SE Core Libraries, Virtual Threads. Dazu, dass virtuelle Threads leichtgewichtige, von der Java-Laufzeit implementierte Threads sind, die bei blockierendem I/O ihren OS-Thread freigeben; dazu, dass es sich um ein Feature für Skalierung (Durchsatz) statt Geschwindigkeit (Latenz) handelt, das für CPU-intensive Verarbeitung ungeeignet ist; dazu, dass virtuelle Threads niemals gepoolt, sondern pro Aufgabe einzeln verwendet werden sollten (newVirtualThreadPerTaskExecutor); dazu, dass für die Begrenzung der Nebenläufigkeit Semaphore statt eines Thread-Pools verwendet werden sollte; dazu, dass eine Blockierung innerhalb von synchronized zum Stand von JDK 21 ein Pinning an den OS-Thread verursacht, weshalb bei häufigen oder lang andauernden Fällen der Umstieg auf ReentrantLock empfohlen wurde; sowie dazu, dass sich Pinning mit -Djdk.tracePinnedThreads erkennen lässt. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. Dazu, dass die Monitor-Implementierung der JVM in JDK 24 für die Unterstützung virtueller Threads neu geschrieben wurde, sodass eine Blockierung innerhalb eines synchronized-Blocks oder einer synchronized-Methode virtuelle Threads nicht mehr an ihren Carrier-Thread pinnt; sowie dazu, dass die aus der JDK-21-23-Ära stammende Gegenmaßnahme, synchronized durch ReentrantLock zu ersetzen, dadurch grundsätzlich überflüssig wird. ↩ ↩2
-
Oracle, The Java Tutorials, Memory Consistency Errors. Dazu, dass Speicherkonsistenzfehler entstehen, wenn mehrere Threads eine inkonsistente Sicht auf dieselben Daten haben; dazu, dass der Schlüssel zur Vermeidung die happens-before-Beziehung ist (die Garantie, dass ein Speicherschreibvorgang einer Anweisung für eine andere Anweisung sichtbar ist); sowie dazu, dass synchronized, volatile sowie Thread.start / join unter anderem happens-before-Beziehungen erzeugen. ↩ ↩2 ↩3
-
Oracle, Thread (Java SE 21 & JDK 21 API). Dazu, dass Thread.stop / suspend / resume grundsätzlich unsicher sind (Sperren werden in einem inkonsistenten Zustand freigegeben, wodurch beschädigte Objekte sichtbar werden; suspend kann zu Deadlocks führen) und daher zur Entfernung vorgesehen als veraltet gelten, wobei ein Aufruf heute UnsupportedOperationException auslöst; dazu, dass interrupt() den Interrupt-Status setzt und einen in sleep / wait / join blockierten Thread durch Werfen von InterruptedException weckt (wobei der Interrupt-Status dabei zurückgesetzt wird); sowie zum Unterschied, wie interrupted() und isInterrupted() mit diesem Status umgehen. ↩ ↩2 ↩3
-
Oracle, The Java Tutorials, The Event Dispatch Thread. Dazu, dass der Ereignisverarbeitungscode von Swing auf dem Event Dispatch Thread (EDT) läuft; dazu, dass die Methoden der meisten Swing-Objekte nicht threadsicher sind und Aufrufe von mehreren Threads aus Thread-Interferenzen und Speicherkonsistenzfehler nach sich ziehen, weshalb der Zugriff auf Swing-Komponenten grundsätzlich auf dem EDT erfolgen sollte; dazu, dass von anderen Threads aus Aufgaben über SwingUtilities.invokeLater / invokeAndWait beim EDT beauftragt werden; sowie dazu, dass Aufgaben auf dem EDT kurz gehalten werden sollten. ↩ ↩2
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). Dazu, dass der gesamte Methodenaufruf von computeIfAbsent atomar ausgeführt wird und die Mapping-Funktion bei fehlendem Schlüssel genau einmal aufgerufen wird; dazu, dass während der Berechnung ein Teil der Aktualisierungsoperationen anderer Threads blockiert wird, weshalb die Berechnung kurz und einfach gehalten werden sollte; dazu, dass die Mapping-Funktion diese Map nicht selbst verändern darf und eine erkannte rekursive Aktualisierung zu IllegalStateException führt; sowie dazu, dass eine Abrufoperation (get) nicht blockiert und zwischen einer Aktualisierung für einen bestimmten Schlüssel und einem nachfolgenden Abruf eine happens-before-Beziehung besteht. ↩ ↩2 ↩3
-
Oracle, The jstack Command (Java SE 21 Tools Reference). Dazu, dass jstack die Stacktraces (Klassenname, Methodenname, Zeilennummer) aller Threads eines angegebenen Java-Prozesses ausgibt; dazu, dass sich mit der Option -l eine Detailansicht mit zusätzlichen Informationen zu Sperren erstellen lässt; sowie dazu, dass es zusammen mit anderen Diagnosewerkzeugen wie jcmd verwendet wird. ↩
-
OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Dazu, dass die API für strukturierte Nebenläufigkeit eine Gruppe zusammengehöriger Teilaufgaben als eine Arbeitseinheit behandelt und Fehlerweitergabe sowie Abbruch strukturiert; dazu, dass StructuredTaskScope zu einer Form umgestaltet wurde, die über eine statische Factory-Methode (open) geöffnet wird; sowie dazu, dass es sich zum Stand von JDK 25 um die fünfte Preview handelt und nicht um ein finalisiertes Feature. ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Dazu, dass strukturierte Nebenläufigkeit auch in JDK 26 als sechste Preview fortgesetzt wird — das heißt, mit Stand August 2026 im aktuellen JDK weiterhin ein Preview-Feature ist, dessen Nutzung die Aktivierung von Preview-Features erfordert. ↩
-
OpenJDK, JEP 506: Scoped Values. Dazu, dass Scoped Values in JDK 25 finalisiert wurden; sowie dazu, dass es sich um einen Mechanismus handelt, um unveränderliche Kontextdaten innerhalb und zwischen Threads sicher und effizient zu teilen, der eine Lösung für die Probleme von ThreadLocal (Veränderlichkeit, Lebenszyklusverwaltung, Vererbungskosten) darstellt. ↩
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Praktische Multithreading-Best-Practices: C-Edition — Sicher schreiben nach Win32-API-Konvention
Bei Multithreading in C mit Win32 sind Thread-Erstellung über _beginthreadex, SRW-Locks und Bedingungsvariablen, Interlocked-Funktionen s...
Praktische Multithreading-Best-Practices: C++-Edition — Unfälle mit RAII und jthread strukturell beseitigen
In C++ ist Multithreading eine Welt, in der eine Datenrace zu undefiniertem Verhalten wird. Dieser Artikel behandelt die Falle des std::t...
Praktische Best Practices für Multithreading: .NET-Edition — Was Sie entscheiden sollten, bevor Sie weitere Threads hinzufügen
Eine praxisnahe Übersicht der Entwurfsregeln, die verhindern, dass Multithreading-Code in .NET/C# gelegentlich abstürzt oder hängen bleib...
Volumeschattenkopie (VSS): Funktionsweise und Praxis ── Warum sich Dateien in Benutzung sichern lassen
Dateien in Benutzung lassen sich wegen Freigabekonflikten eigentlich nicht kopieren – wie schafft es Backup-Software trotzdem? Dieser Art...
Wenn man eine Windows-App besser nicht ins Web migriert: Entscheidungstabelle und die pragmatische Lösung der Aufteilung
Der Wunsch, interne Windows-Anwendungen ins Web zu migrieren, nimmt stetig zu. Doch bei Apps mit Gerätekopplung, lokaler Dateiverarbeitun...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Fehleranalyse und Langzeitprobleme
Sporadische Fehler, Kommunikationsdiagnose, Langzeitabstürze und Tests von Fehlerpfaden.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Technische Beratung und Design-Review
Klärung von Änderungsstrategie, Entwurf und Umgang mit bestehenden Assets.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Brauche ich mit virtuellen Threads überhaupt noch einen Thread-Pool (ExecutorService)?
- Das hängt vom Einsatzzweck ab. Virtuelle Threads sind ein Mechanismus, um riesige Mengen überwiegend auf I/O wartender Aufgaben laufen zu lassen – sie machen Code nicht schneller, sondern erhöhen den Durchsatz. Verwenden Sie für I/O-gebundene Arbeit pro Aufgabe einen virtuellen Thread (Executors.newVirtualThreadPerTaskExecutor), und poolen Sie virtuelle Threads niemals. Für die Parallelisierung von Berechnungen, die die CPU voll auslasten, eignet sich dagegen wie bisher ein auf etwa die Kernzahl begrenzter Pool aus Plattform-Threads (oder ein parallel stream). Wenn Sie außerdem die Anzahl gleichzeitiger Zugriffe auf einen externen Dienst begrenzen möchten, empfiehlt sich im Zeitalter der virtuellen Threads eine Begrenzung über Semaphore statt über die Poolgröße.
- Sollte ich synchronized oder ReentrantLock verwenden?
- Für kurzen, einfachen Ausschluss reicht synchronized völlig aus, und der Code bleibt knapp. Greifen Sie zu ReentrantLock, wenn Sie Funktionen wie eine zeitlich begrenzte Erlangung über tryLock, eine Fairness-Policy oder mehrere Conditions benötigen. In Kombination mit virtuellen Threads gibt es einen historischen Vorbehalt: In JDK 21–23 gab es das Problem, dass eine Blockierung innerhalb eines synchronized-Blocks den virtuellen Thread an seinen OS-Thread pinnte, weshalb bei häufigen oder lang andauernden Blockierungen der Umstieg auf ReentrantLock empfohlen wurde. In JDK 24 (JEP 491) wurde die Monitor-Implementierung neu geschrieben, wodurch diese Einschränkung entfällt. Ab JDK 24 ist ein Austausch allein wegen des Pinnings nicht mehr nötig.
- Macht das Hinzufügen von volatile etwas threadsicher?
- Nein. Javas volatile erzeugt eine happens-before-Beziehung zwischen dem Schreiben und dem Lesen dieser Variablen und garantiert damit Sichtbarkeit (dass andere Threads den neuesten Schreibvorgang sehen) und Ordnung, nicht aber die Atomarität einer zusammengesetzten Operation wie „lesen, berechnen, zurückschreiben“. Führen mehrere Threads ++ auf einem volatile int-Zähler aus, gehen Additionen verloren. Verwenden Sie für Zähler AtomicInteger / AtomicLong (bei hochfrequenter Aggregation LongAdder) und für das gemeinsame Schützen mehrerer Variablen eine Sperre. volatile eignet sich praktisch nur für den Fall eines einfachen Zustands-Flags, bei dem ein Thread schreibt und die anderen nur lesen.
- Darf ich InterruptedException fangen und ignorieren?
- Nein. Interrupts sind Javas Standardsignal für Stoppen und Abbrechen, und sie zu verschlucken erzeugt einen „Thread, der sich nicht stoppen lässt“. Zum Zeitpunkt, an dem InterruptedException geworfen wird, ist der Interrupt-Status bereits zurückgesetzt. Können Sie die Arbeit nicht selbst beenden, stellen Sie den Status mit Thread.currentThread().interrupt() wieder her, um das Signal für den Aufrufer zu hinterlassen, oder werfen Sie die Ausnahme unverändert weiter. Ein leerer catch-Block, der nichts tut, ist eine klassische Ursache für Fehler wie ein wirkungsloses Shutdown oder ein ignoriertes shutdownNow.
- Kann ich einen Thread nicht mit Thread.stop anhalten?
- Nein, das geht nicht. Thread.stop ist grundsätzlich unsicher (Sperren werden in einem inkonsistenten Zustand freigegeben, sodass beschädigte Objekte für andere Threads sichtbar werden) und gilt daher schon lange als veraltet; im heutigen Java löst ein Aufruf UnsupportedOperationException aus. Dasselbe gilt für Thread.suspend / resume. Das einzig legitime Mittel, einen Thread zu stoppen, ist die kooperative Abbruchsteuerung über interrupt. Verwenden Sie ExecutorService, stoppt shutdown lediglich die Annahme neuer Aufgaben und wartet auf deren Abschluss, ohne laufenden Aufgaben ein Interrupt zu senden. Erst shutdownNow versucht, laufende Aufgaben zu stoppen, und das ist Best-Effort (in der Standardimplementierung über Interrupts).
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.