paint-brush
Der wesentliche Leitfaden zum Umlernen von Java-Thread-Primitivenvon@shai.almog
736 Lesungen
736 Lesungen

Der wesentliche Leitfaden zum Umlernen von Java-Thread-Primitiven

von Shai Almog8m2023/04/11
Read on Terminal Reader
Read this story w/o Javascript

Zu lang; Lesen

Synchronized war revolutionär und hat immer noch großartige Einsatzmöglichkeiten. Es ist jedoch an der Zeit, auf neuere Thread-Primitive umzusteigen und möglicherweise unsere Kernlogik zu überdenken.
featured image - Der wesentliche Leitfaden zum Umlernen von Java-Thread-Primitiven
Shai Almog HackerNoon profile picture

Ich habe seit der ersten Beta in Java programmiert; Schon damals standen Threads ganz oben auf meiner Liste meiner Lieblingsfeatures. Java war die erste Sprache, die Thread-Unterstützung in der Sprache selbst einführte; Es war damals eine umstrittene Entscheidung.


Im letzten Jahrzehnt hat sich jede Sprache rasant darum bemüht, async/await zu integrieren, und sogar Java hatte Unterstützung von Drittanbietern dafür … Aber Java ging im Zickzack statt im Zickzack und führte die weitaus besseren virtuellen Threads ein (Projekt Loom) . In diesem Beitrag geht es nicht darum.


Ich finde es wunderbar und beweist die Kernleistung von Java. Nicht nur als Sprache, sondern als Kultur. Eine Kultur der bewussten Veränderung, statt sich auf den modischen Trend einzulassen.


In diesem Beitrag möchte ich die alten Methoden des Threadings in Java noch einmal Revue passieren lassen. Ich bin es gewohnt, „ synchronized “, wait “, notify “ usw. zu verwenden. Aber es ist schon lange her, dass sie der überlegene Ansatz für Threading in Java waren.


Ich bin Teil des Problems; Ich bin immer noch an diese Ansätze gewöhnt und es fällt mir schwer, mich an einige APIs zu gewöhnen, die es seit Java 5 gibt. Es ist eine Gewohnheit. T


Es gibt viele großartige APIs für die Arbeit mit Threads, die ich in den Videos hier bespreche, aber ich möchte über Sperren sprechen, die grundlegend und dennoch wichtig sind.

Synchronisiert vs. ReentrantLock

Eine Abneigung, die ich beim Verlassen von Synchronized hatte, ist, dass die Alternativen nicht viel besser sind. Der Hauptgrund dafür, heute damit aufzuhören, besteht darin, dass die Synchronisierung derzeit dazu führen kann, dass Threads in Loom fixiert werden, was nicht ideal ist.


JDK 21 könnte dies beheben (wenn Loom auf GA umgestellt wird), aber es macht immer noch Sinn, es beizubehalten.


Der direkte Ersatz für synchronisiert ist ReentrantLock. Leider hat ReentrantLock gegenüber der Synchronisierung nur sehr wenige Vorteile, sodass der Nutzen der Migration bestenfalls zweifelhaft ist.


Tatsächlich hat es einen großen Nachteil; Um einen Eindruck davon zu bekommen, schauen wir uns ein Beispiel an. So würden wir synchronisiert verwenden:

 synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }


Der erste Nachteil von ReentrantLock ist die Ausführlichkeit. Wir benötigen den try-Block, da die Sperre bestehen bleibt, wenn innerhalb des Blocks eine Ausnahme auftritt. Synchronized erledigt das nahtlos für uns.


Es gibt einen Trick, den manche Leute anwenden, um das Schloss mit AutoClosable zu umschließen, der ungefähr so aussieht:

 public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }


Beachten Sie, dass ich die Lock-Schnittstelle nicht implementiert habe, was ideal gewesen wäre. Das liegt daran, dass die lock-Methode die automatisch schließbare Implementierung anstelle von void zurückgibt.


Sobald wir das getan haben, können wir prägnanteren Code wie diesen schreiben:

 try(LOCK.lock()) { // safe code }


Mir gefällt die reduzierte Ausführlichkeit, aber das ist ein problematisches Konzept, da try-with-resource zum Zweck der Bereinigung konzipiert ist und wir Sperren wiederverwenden. Es ruft close auf, aber wir werden diese Methode erneut für dasselbe Objekt aufrufen.


Ich denke, es wäre schön, den Versuch mit der Ressourcensyntax zu erweitern, um die Sperrschnittstelle zu unterstützen. Aber solange das nicht geschieht, dürfte dieser Trick kein lohnenswerter Trick sein.

Vorteile von ReentrantLock

Der Hauptgrund für die Verwendung ReentrantLock ist die Loom-Unterstützung. Die anderen Vorteile sind nett, aber keines davon ist ein „Killer-Feature“.


Wir können es zwischen Methoden statt in einem kontinuierlichen Block verwenden. Dies ist wahrscheinlich eine schlechte Idee, da Sie den Sperrbereich minimieren möchten und ein Fehler ein Problem darstellen kann. Ich halte diese Funktion nicht für einen Vorteil.


Es besteht die Möglichkeit der Fairness. Dies bedeutet, dass der erste Thread, der bei einer Sperre angehalten hat, zuerst bedient wird. Ich habe versucht, mir einen realistischen, unkomplizierten Anwendungsfall vorzustellen, bei dem dies von Bedeutung sein wird, und ich ziehe Lücken.


Wenn Sie einen komplexen Scheduler schreiben, bei dem sich viele Threads ständig in der Warteschlange für eine Ressource befinden, kann es zu einer Situation kommen, in der ein Thread „ausgehungert“ ist, weil ständig andere Threads eingehen. Solche Situationen werden jedoch wahrscheinlich besser durch andere Optionen im Concurrency-Paket bedient .


Vielleicht übersehe ich hier etwas ...


lockInterruptibly() können wir einen Thread unterbrechen, während er auf eine Sperre wartet. Dies ist eine interessante Funktion, aber auch hier ist es schwierig, eine Situation zu finden, in der sie realistischerweise einen Unterschied machen würde.


Wenn Sie Code schreiben, der sehr schnell auf Unterbrechungen reagieren muss, müssen Sie die API lockInterruptibly() verwenden, um diese Fähigkeit zu erhalten. Aber wie lange verbringt man durchschnittlich mit der lock() Methode?


Es gibt Randfälle, in denen dies wahrscheinlich von Bedeutung ist, aber die meisten von uns werden nicht darauf stoßen, selbst wenn sie fortgeschrittenen Multithread-Code erstellen.

ReadWriteReentrantLock

Ein viel besserer Ansatz ist ReadWriteReentrantLock . Die meisten Ressourcen folgen dem Prinzip häufiger Lesevorgänge und weniger Schreibvorgängen. Da das Lesen einer Variablen threadsicher ist, ist keine Sperre erforderlich, es sei denn, wir schreiben gerade in die Variable.


Dies bedeutet, dass wir das Lesen extrem optimieren und gleichzeitig die Schreibvorgänge etwas langsamer machen können.


Vorausgesetzt, dies ist Ihr Anwendungsfall, können Sie viel schneller Code erstellen. Wenn wir mit einer Lese-/Schreibsperre arbeiten, haben wir zwei Sperren; eine Lesesperre, wie wir im folgenden Bild sehen können. Es lässt mehrere Threads durch und ist praktisch „kostenlos für alle“.


Sobald wir in die Variable schreiben müssen, müssen wir eine Schreibsperre erhalten, wie wir im folgenden Bild sehen können. Wir versuchen, die Schreibsperre anzufordern, aber es gibt noch Threads, die aus der Variablen lesen, also müssen wir warten.


Sobald die Threads mit dem Lesen fertig sind, werden alle Lesevorgänge blockiert und der Schreibvorgang kann nur von einem einzelnen Thread aus erfolgen, wie in der folgenden Abbildung dargestellt. Sobald wir die Schreibsperre aufheben, kehren wir zur „Frei für alle“-Situation im ersten Bild zurück.


Dies ist ein leistungsstarkes Muster, das wir nutzen können, um Sammlungen viel schneller zu machen. Eine typische synchronisierte Liste ist bemerkenswert langsam. Es synchronisiert alle Vorgänge, ob Lesen oder Schreiben. Wir haben eine CopyOnWriteArrayList, die schnell liest, aber alle Schreibvorgänge sehr langsam ist.


Vorausgesetzt, Sie können die Rückgabe von Iteratoren aus Ihren Methoden vermeiden, können Sie Listenoperationen kapseln und diese API verwenden.


Im folgenden Code stellen wir beispielsweise die Namensliste als schreibgeschützt bereit. Wenn wir dann jedoch einen Namen hinzufügen müssen, verwenden wir die Schreibsperre. Dies kann synchronized Listen leicht übertreffen:

 private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }

StampedLock

Das erste, was wir über StampedLock verstehen müssen, ist, dass es nicht reentrant ist. Angenommen, wir haben diesen Block:

 synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }

Das wird funktionieren. Da synchronisiert wiedereintrittsfähig ist. Wir halten die Sperre bereits aufrecht, sodass das Aufrufen methodB() von methodA() nicht blockiert. Dies funktioniert auch mit ReentrantLock, vorausgesetzt, wir verwenden dieselbe Sperre oder dasselbe synchronisierte Objekt.


StampedLock gibt einen Stempel zurück, mit dem wir die Sperre aufheben. Aus diesem Grund gibt es einige Grenzen. Aber es ist immer noch sehr schnell und leistungsstark. Es enthält auch einen Lese- und Schreibstempel, mit dem wir eine gemeinsam genutzte Ressource schützen können.


Aber im Gegensatz zu ReadWriteReentrantLock, können wir damit die Sperre aktualisieren. Warum sollten wir das tun?

Schauen Sie sich die Methode addName() von vorhin an ... Was passiert, wenn ich sie zweimal mit „Shai“ aufrufe?


Ja, ich könnte ein Set verwenden ... Aber für den Zweck dieser Übung nehmen wir an, dass wir eine Liste benötigen ... Ich könnte diese Logik mit ReadWriteReentrantLock schreiben:

 public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }


Das ist scheiße. Ich habe für eine Schreibsperre nur „bezahlt“, um in manchen Fällen contains() zu überprüfen (vorausgesetzt, es gibt viele Duplikate). Wir können isInList(name) aufrufen, bevor wir die Schreibsperre erhalten. Dann würden wir:


  • Schnappen Sie sich die Lesesperre


  • Geben Sie die Lesesperre frei


  • Schnappen Sie sich die Schreibsperre


  • Geben Sie die Schreibsperre frei


In beiden Fällen des Greifens stehen wir möglicherweise in einer Warteschlange, und der zusätzliche Aufwand lohnt sich möglicherweise nicht.


Mit einem StampedLock können wir die Lesesperre in eine Schreibsperre aktualisieren und die Änderung bei Bedarf sofort wie folgt durchführen:

 public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }

Für diese Fälle handelt es sich um eine leistungsstarke Optimierung.

Endlich

Ich behandle viele ähnliche Themen in der Videoserie oben; Schauen Sie es sich an und teilen Sie mir Ihre Meinung mit.


Ich greife oft nach den synchronisierten Sammlungen, ohne darüber nachzudenken. Das kann manchmal sinnvoll sein, aber für die meisten ist es wahrscheinlich nicht optimal. Indem wir ein wenig Zeit mit Thread-bezogenen Grundelementen verbringen, können wir unsere Leistung erheblich verbessern.


Dies gilt insbesondere im Fall von Loom, wo der zugrunde liegende Streit weitaus heikler ist. Stellen Sie sich vor, Lesevorgänge auf 1 Mio. gleichzeitige Threads zu skalieren … In diesen Fällen ist die Reduzierung von Sperrkonflikten weitaus wichtiger.


Sie fragen sich vielleicht, warum synchronized Sammlungen nicht ReadWriteReentrantLock oder sogar StampedLock verwenden können?


Dies ist problematisch, da die Oberfläche der API so groß ist, dass es schwierig ist, sie für einen generischen Anwendungsfall zu optimieren. Hier kann die Kontrolle über die Low-Level-Primitive den Unterschied zwischen hohem Durchsatz und blockierendem Code ausmachen.