Eine Einführung in atomare Variablen in Java

1. Einleitung

Einfach ausgedrückt, ein gemeinsamer veränderlicher Zustand führt sehr leicht zu Problemen, wenn es um Parallelität geht. Wenn der Zugriff auf gemeinsam genutzte veränderbare Objekte nicht ordnungsgemäß verwaltet wird, können Anwendungen schnell zu schwer zu erkennenden Parallelitätsfehlern neigen.

In diesem Artikel werden wir die Verwendung von Sperren für den gleichzeitigen Zugriff erneut untersuchen, einige der mit Sperren verbundenen Nachteile untersuchen und schließlich als Alternative atomare Variablen einführen.

2. Schlösser

Werfen wir einen Blick auf die Klasse:

public class Counter { int counter; public void increment() { counter++; } }

In einer Single-Thread-Umgebung funktioniert dies einwandfrei. Sobald wir jedoch mehr als einen Thread schreiben lassen, erhalten wir inkonsistente Ergebnisse.

Dies liegt an der einfachen Inkrementierungsoperation ( Zähler ++ ), die wie eine atomare Operation aussehen kann, aber tatsächlich eine Kombination aus drei Operationen ist: Abrufen des Werts, Inkrementieren und Zurückschreiben des aktualisierten Werts.

Wenn zwei Threads gleichzeitig versuchen, den Wert abzurufen und zu aktualisieren, kann dies zu verlorenen Aktualisierungen führen.

Eine Möglichkeit, den Zugriff auf ein Objekt zu verwalten, besteht in der Verwendung von Sperren. Dies kann erreicht werden, indem das synchronisierte Schlüsselwort in der Signatur der Inkrementierungsmethode verwendet wird. Das synchronisierte Schlüsselwort stellt sicher, dass immer nur ein Thread gleichzeitig an der Methode teilnehmen kann (weitere Informationen zum Sperren und Synchronisieren finden Sie unter - Handbuch zum synchronisierten Schlüsselwort in Java):

public class SafeCounterWithLock { private volatile int counter; public synchronized void increment() { counter++; } }

Darüber hinaus müssen wir das flüchtige Schlüsselwort hinzufügen , um eine ordnungsgemäße Referenzsichtbarkeit zwischen Threads sicherzustellen.

Die Verwendung von Schlössern löst das Problem. Die Leistung nimmt jedoch einen Schlag.

Wenn mehrere Threads versuchen, eine Sperre zu erlangen, gewinnt einer von ihnen, während der Rest der Threads entweder blockiert oder angehalten wird.

Das Anhalten und anschließende Fortsetzen eines Threads ist sehr teuer und wirkt sich auf die Gesamteffizienz des Systems aus.

In einem kleinen Programm wie dem Zähler kann die Zeit, die für die Kontextumschaltung aufgewendet wird, viel länger sein als die tatsächliche Codeausführung, wodurch die Gesamteffizienz erheblich verringert wird.

3. Atomoperationen

Es gibt einen Forschungszweig, der sich auf die Erstellung nicht blockierender Algorithmen für gleichzeitige Umgebungen konzentriert. Diese Algorithmen nutzen atomare Maschinenanweisungen auf niedriger Ebene wie Compare-and-Swap (CAS), um die Datenintegrität sicherzustellen.

Eine typische CAS-Operation arbeitet mit drei Operanden:

  1. Der Speicherort, an dem gearbeitet werden soll (M)
  2. Der vorhandene Erwartungswert (A) der Variablen
  3. Der neue Wert (B), der eingestellt werden muss

Die CAS-Operation aktualisiert den Wert in M ​​atomar auf B, jedoch nur, wenn der vorhandene Wert in M ​​mit A übereinstimmt, andernfalls wird keine Aktion ausgeführt.

In beiden Fällen wird der vorhandene Wert in M ​​zurückgegeben. Dies kombiniert drei Schritte - Abrufen des Werts, Vergleichen des Werts und Aktualisieren des Werts - zu einer Operation auf Maschinenebene.

Wenn mehrere Threads versuchen, denselben Wert über CAS zu aktualisieren, gewinnt einer von ihnen und aktualisiert den Wert. Im Gegensatz zu Sperren wird jedoch kein anderer Thread ausgesetzt . Stattdessen werden sie lediglich darüber informiert, dass es ihnen nicht gelungen ist, den Wert zu aktualisieren. Die Threads können dann weitere Arbeiten ausführen und Kontextwechsel werden vollständig vermieden.

Eine weitere Konsequenz ist, dass die Kernprogrammlogik komplexer wird. Dies liegt daran, dass wir das Szenario behandeln müssen, in dem die CAS-Operation nicht erfolgreich war. Wir können es immer wieder versuchen, bis es erfolgreich ist, oder wir können nichts tun und je nach Anwendungsfall weitermachen.

4. Atomvariablen in Java

Die in Java am häufigsten verwendeten atomaren Variablenklassen sind AtomicInteger, AtomicLong, AtomicBoolean und AtomicReference. Diese Klassen stellen eine int- , long- , boolesche bzw. Objektreferenz dar, die atomar aktualisiert werden kann. Die wichtigsten Methoden dieser Klassen sind:

  • get () - ruft den Wert aus dem Speicher ab, sodass Änderungen, die von anderen Threads vorgenommen wurden, sichtbar sind. entspricht dem Lesen einer flüchtigen Variablen
  • set () - schreibt den Wert in den Speicher, sodass die Änderung für andere Threads sichtbar ist; entspricht dem Schreiben einer flüchtigen Variablen
  • lazySet () - schreibt den Wert schließlich in den Speicher, möglicherweise mit nachfolgenden relevanten Speicheroperationen neu angeordnet. Ein Anwendungsfall ist das Aufheben von Referenzen zum Zwecke der Speicherbereinigung, auf die nie wieder zugegriffen wird. In diesem Fall wird eine bessere Leistung erzielt, indem das flüchtige Schreiben von Null verzögert wird
  • compareAndSet () - wie in Abschnitt 3 beschrieben, gibt true zurück, wenn dies erfolgreich ist, andernfalls false
  • schwachesCompareAndSet () - wie in Abschnitt 3 beschrieben, jedoch schwächer in dem Sinne, dass keine Vorbestellungen erstellt werden . Dies bedeutet, dass möglicherweise nicht unbedingt Aktualisierungen anderer Variablen angezeigt werden. Ab Java 9 ist diese Methode in allen atomaren Implementierungen zugunsten von schwachCompareAndSetPlain () veraltet . Die Memory-Effekte von schwachCompareAndSet () waren klar, aber seine Namen implizierten flüchtige Memory-Effekte. Um diese Verwirrung zu vermeiden, haben sie diese Methode verworfen und vier Methoden mit unterschiedlichen Speichereffekten hinzugefügt, z. B. schwachesCompareAndSetPlain () oder schwachesCompareAndSetVolatile ()

Ein mit AtomicInteger implementierter thread-sicherer Zähler ist im folgenden Beispiel dargestellt:

public class SafeCounterWithoutLock { private final AtomicInteger counter = new AtomicInteger(0); public int getValue() { return counter.get(); } public void increment() { while(true) { int existingValue = getValue(); int newValue = existingValue + 1; if(counter.compareAndSet(existingValue, newValue)) { return; } } } }

Wie Sie sehen können, wiederholen wir die Operation compareAndSet und erneut bei einem Fehler, da wir sicherstellen möchten, dass der Aufruf der Inkrementierungsmethode den Wert immer um 1 erhöht.

5. Schlussfolgerung

In diesem kurzen Tutorial haben wir eine alternative Methode zum Umgang mit Parallelität beschrieben, bei der die mit dem Sperren verbundenen Nachteile vermieden werden können. Wir haben uns auch die wichtigsten Methoden angesehen, die von den atomaren Variablenklassen in Java verfügbar gemacht werden.

Wie immer sind die Beispiele alle auf GitHub verfügbar.

Weitere Informationen zu Klassen, die intern nicht blockierende Algorithmen verwenden, finden Sie in einem Handbuch zu ConcurrentMap.