Geschichte der Frage
Vor Java 5 basierte die Thread-Koordination auf primitiven Methoden wie Thread.suspend (veraltet aufgrund inhärenter Deadlock-Risiken) oder Object.wait/notify, die strenge Monitorbesitzrechte erforderten und unter verlorenen Wakeups litten, wenn die Benachrichtigung vor dem Warten stattfand. Mit der Einführung von java.util.concurrent in Java 5 (JSR 166) wurde LockSupport als ein niedriges Unblocking-Primitiv entwickelt, um die Konstruktion von leistungsstarken Synchronizern wie AbstractQueuedSynchronizer zu ermöglichen, ohne die Belastung durch intrinsische Locks.
Das Problem
In der Nebenläufigkeitsprogrammierung tritt ein klassisches Rennbedingungsproblem auf, wenn ein Signal-Thread den Unpark-Mechanismus vor dem effektiven Parken des Ziel-Threads aufruft. Bei traditionellen Bedingungsvariablen würde dieses Signal verloren gehen, was dazu führt, dass der Ziel-Thread unbegrenzt schläft. Eine naive Lösung könnte einen zählenden Semaphore verwenden, um Berechtigungen zu sammeln, aber dies bringt unnötige Komplexität und potenzielle Ressourcenlecks mit sich, wenn der Produzent schneller ist als der Verbraucher.
Die Lösung
LockSupport verwendet eine nicht ansammelnde, einzelne Erlaubnis, die mit jedem Thread assoziiert ist. Diese Erlaubnis fungiert als ein Einmalpass, der lokal zum Thread gehört:
Da die Erlaubnis nicht kumulativ ist (sie sättigt bei 1), verhindert sie Speicherlecks durch übermäßiges Unparking und gewährleistet gleichzeitig, dass ein Unpark, der vor dem Park aufgerufen wurde, erinnert bleibt, wodurch das Problem des verlorenen Wakeups durch eine Happens-Before-Beziehung beseitigt wird.
import java.util.concurrent.locks.LockSupport; public class PermitExample { public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { System.out.println("Arbeiter: Erstarbeit..."); try { Thread.sleep(100); } catch (InterruptedException e) {} System.out.println("Arbeiter: Versuche zu parken..."); LockSupport.park(); System.out.println("Arbeiter: Erfolgreich unparked!"); }); worker.start(); // Signal, bevor der Arbeiter tatsächlich parkt Thread.sleep(50); System.out.println("Haupt: Unpark aufrufen, bevor der Arbeiter parkt"); LockSupport.unpark(worker); worker.join(); } }
Problembeschreibung
Bei der Gestaltung der Order-Matching-Engine eines Hochfrequenzhandelssystems benötigten wir einen Backpressure-Mechanismus, bei dem Verbraucher-Threads die Verarbeitung anhalten konnten, wenn die eingehende Warteschlange ihre Kapazität erreichte, ohne Schlösser zu halten, die Produzenten daran hindern würden, den Warteschlangenstatus zu überprüfen. Standard ReentrantLock mit Condition erzeugte Konkurrenz um das Schlösschen der Warteschlange während der Signalgebung, und Object.wait/notify litt unter dem Risiko verlorener Wakeups während hoher Rassen.
Verschiedene betrachtete Lösungen
1. Object.wait/notifyAll
Dieser Ansatz verwendete das intrinsische Schloss der Warteschlange. Vorteile: Einfach zu implementieren mit Standardmonitoren. Nachteile: Erforderte, dass der Produzent das Schloss erwerben muss, um notify aufzurufen, wodurch ein Serialisierungsengpass entsteht. Schlimmer noch, wenn ein Produzent während des kurzen Zeitfensters notify aufruft, bevor der Verbraucher die Warteschlangenlänge überprüft und wait aufruft, ging das Signal verloren, was zu einem festen Deadlock des Verbrauchers führte.
2. ReentrantLock mit mehreren Bedingungen
Wir versuchten, separate Bedingungen für die Zustände "voll" und "leer" zu verwenden. Vorteile: Flexibler als intrinsische Schlösser, erlauben selektive Wakeups. Nachteile: Erforderten immer noch den Erwerb des Schlosses für die Signalgebung (signalAll), und die Komplexität, Threads korrekt zwischen Bedingungswarteschlangen zu übertragen, führte zu einem Wartungsaufwand, ohne das grundlegende Locking-Overhead zu lösen.
3. LockSupport mit explizitem atomarem Zustand
Die gewählte Lösung verwendete ein AtomicBoolean, um "Erlaubnis zur Fortsetzung" darzustellen, und LockSupport zum Blockieren. Als die Warteschlange gefüllt war, setzte der Verbraucher atomar ein "needsParking"-Flag und parkte dann. Die Produzenten überprüften nach dem Entfernen eines Elements das Flag und riefen unpark auf, wenn es gesetzt war. Vorteile: Die Signalgebung erforderte keine Schlösser, wodurch die Konkurrenz während der Wakeups eliminiert wurde. Das Ein-Bit-Erlaubnismodell stellte sicher, dass selbst wenn der Produzent unpark Nanosekunden bevor der Verbraucher park aufruft (aufgrund der CPU-Planung), der Wakeup nicht verloren ging.
Ausgewählte Lösung und Ergebnis
Wir wählten den LockSupport-Ansatz. Durch die Entkopplung des Signalmechanismus vom strukturellen Schloss der Warteschlange reduzierten wir die Latenz der Produzenten um 40 % unter hoher Last und eliminierten die verlorenen Wakeup-Szenarien, die während des Stresstests beobachtet wurden. Das explizite Zustandsmanagement (doppeltes Überprüfen der Bedingung nach unpark) stellte die Korrektheit trotz des vertraglich vereinbarten überraschenden Wakeup von park() sicher.
Befreit LockSupport.park den Besitz von Monitoren, die vom Thread gehalten werden?
Nein. Dies ist ein entscheidender Unterschied zu Object.wait(). Wenn ein Thread LockSupport.park aufruft, tritt er in einen Wartestatus ein, behält jedoch den Besitz aller Monitore, die er derzeit hält. Wenn ein anderer Thread versucht, in einen dieser Monitore einzutreten (z. B. ein synchronisierter Block auf demselben Objekt), wird er blockiert, was möglicherweise zu einem Deadlock führt, wenn der parkierte Thread der einzige ist, der ihn freigeben könnte. Kandidaten nehmen oft fälschlicherweise an, dass park wie wait ist und Schlösser freigibt; es ist ein rein thread-lokales Programmierprimitive.
Wie verhält sich LockSupport.park, wenn es auf einem Thread aufgerufen wird, dessen Interrupt-Status gesetzt ist?
Die Methode gibt sofort zurück, ohne zu blockieren, und löscht nicht den Interrupt-Status. Dies unterscheidet sich grundlegend von Object.wait(), das den Interrupt-Status löscht und InterruptedException auslöst. Bei LockSupport muss der Thread den Interrupt-Status explizit überprüfen und löschen (über Thread.interrupted()), wenn er die Unterbrechungskonventionen respektieren möchte. Dieses Design ermöglicht es, park in nicht unterbrechbaren Kontexten zu verwenden oder wo die Unterbrechung als separates Anliegen von der Parkgenehmigung behandelt wird.
Wie behandelt LockSupport spurious wakeups, und wie beeinflusst dies Programmiermuster?
LockSupport.park ist dokumentiert, um "aus keinem erkennbaren Grund" zurückzugeben (spurious wakeup), obwohl dies in der Praxis auf modernen JVMs selten vorkommt. Im Gegensatz zum erlaubnisbasierten Wakeup (unpark) verbrauchen spurious wakeups nicht die Erlaubnis. Daher muss der Aufrufer immer die Bedingung, die das Parken verursacht hat, in einer Schleife erneut überprüfen:
while (!canProceed()) { LockSupport.park(); }
Kandidaten übersehen oft, dass es nicht ausreicht, die Bedingung einmal nach park zu überprüfen; der Thread könnte spurious aufwachen (oder durch einen falschen Interrupt), ohne einen Unpark-Aufruf, was eine Neubewertung des Status erfordert. Die Erlaubnis stellt sicher, dass ein gültiges Unpark nicht verloren geht, aber sie verhindert nicht spurious Rückgaben.