5 Atomic-Variablen
5.1 Atomarer Zugriff auf Variablen
5.1.1 Welche Zugriffe auf Variablen sind atomar, welche nicht?
- Lesen/Schreiben von Variablen
- bei primitiven Datentypen außer long und double atomar
- bei
volatile
immer atomar - Variablen mit Verweisen auf Objekte immer atomar
- Bei atomarem Zugriff wird der Wert nie inkonsistent.
- Lesen während anderer Thread ändert:
- Ergebnis ist Wert vor Änderung oder nach Änderung.
Der konkurrierende Zugriff auf eine einzelne Variable (abgesehen von long
und double
) ist also erstmal wenig problematisch. Sie ist vor der Änderung und nach der Änderung in einem konsistenten Zustand. Problematisch ist es aber, wenn eine Änderung aus mehreren inhaltlich zusammengehörenden Schritten besteht. Manchmal wird dafür der Begriff Transaktion verwendet: Mehrere Schritte machen die Transaktion aus. Sollten sich mehrere Transaktionen an einer (oder mehreren) Variablen überlappen, kommt es dann zu einem inhaltlich inkonsistenten Zustand. Man kann dabei folgende Fälle unterscheiden:
- Transaktionen an einer Variable vom Typ
long
oderdouble
- Transaktionen an einer Variablen eines primitiven Typs (nicht
long
oderdouble
), die aus mehreren atomaren Schritten bestehen (z.B. Inkrement, s. nächster Abschnitt) - Transaktionen an mehreren Variablen, bestehend aus mehreren Änderungen, die nur in ihrer Gesamtheit sinnvoll sind.
5.1.1.1 Code-Beispiel für Transaktion
Im folgenden Beispiel gibt es ein Objekt vom Typ Person
. Mit den beiden Instanzvariablen name
(vom Typ String
) und lebendig
(vom Typ boolean
). Das Setzen dieser beiden Properties darf inhaltlich gesehen nur gemeinsam, also als Transaktion, erfolgen.
Wenn dies nicht beachtet wird, wie im folgenden Beispiel, kann es zu einem inkonsistenten Zustand kommen.
class Person {
volatile String name;
volatile boolean lebendig;
public static void main(String... args) throws InterruptedException {
var person = new Person();
var t1 = new Thread(() -> {
person.name = "Martin Luther";
person.lebendig = false;
});
var t2 = new Thread(() -> {
person.name = "Barack Obama";
person.lebendig = true;
});
t2.start();
t1.start();
t1.join();
t2.join();
1 /* ... */
}
}
- 1
- Die Anwendung von zwei Transaktionen könnte an diesem Punkt zu den folgenden beiden konsistenten Folgezuständen führen:
person.name.equals("Barack Obama") && person.lebendig
person.name.equals("Martin Luther") && ! person.lebendig
Es könnte aber auch einer von zwei möglichen inkonsistenten Folgezuständen eintreten:
person.name.equals("Barack Obama") && ! person.lebendig
person.name.equals("Martin Luther") && person.lebendig
Um das Ändern eines Person
-Objekts so zu gestalten, dass es immer konsistent bleibt, ist gegenseitiger Ausschluss erforderlich:
class Person {
private String name;
private boolean lebendig;
public synchronized void set(String name, boolean lebendig) {
this.name = name;
this.lebendig = lebendig;
}
public static void main(String... args) throws InterruptedException {
var person = new Person();
var t1 = new Thread(() -> person.set("Martin Luther", false));
var t2 = new Thread(() -> person.set("Barack Obama", true));
t1.start();
t2.start();
t1.join();
t2.join();
}
}
5.1.2 Increment/Decrement sind nicht atomar!
Obwohl sie wie (atomare) Instruktionen aussehen mögen, sind Increment (++
-Operator) und Decrement (--
-Operator) keine atomaren Instruktionen.
- Es handelt sich um Transaktionen.
- Synchronisation ist erforderlich.
class SyncCounter {
private int c = 0;
public synchronized void inc() {
c++;
}
public synchronized void dec() {
c--;
}
public synchronized int get() {
return c;
}
}
5.1.2.1 Ergebnis Disassembler
Die obige Klasse SyncCounter
wird von Java-Compiler zur folgenden Liste von JVM-Instruktionen übersetzt. Um die JVM-Instruktionen im Klartext sichtbar zu machen, wurde der JDK Disassembler javap
eingesetzt.
Im Projekt pp.05.00-PresentationCode
(dort im Verzeichnis bin/
):
$ javap -c SyncCounter.class
Compiled from "SyncCounter.java"
class SyncCounter {
SyncCounter();
Code:
0: aload_0
1: invokespecial #10 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #12 // Field c:I
9: return
Disassemblierte Methode inc()
:
5.1.2.2 Erklärung für den JVM-Code von inc()
0: | this auf Stack |
1: | this auf Stack duplizieren |
2: | Variable Nr. 12 (c ) auf Stack pushen |
5: | int Konstante 1 auf Stack pushen |
6: | die obersten beiden Elemente des Stacks |
(müssen int sein) vom Stack poppen, |
|
addieren, Ergebnis auf Stack pushen | |
7: | Variable Nr. 12 (c ) auf obersten Wert |
des Stacks setzen und Wert vom Stack entfernen | |
10: | aus Unterprogramm zurückkehren |
Dies zeigt, dass ++
und --
Operationen sind, die aus mehreren Instruktionen zusammengesetzt sind. Daher sind sie nicht atomar, obwohl es “von außen” so aussehen mag.
- recht ineffizient (mglw. hoher Anteil an gegenseitigem Ausschluss)
5.2 Atomics generell und in Java
5.2.1 Compare-and-Swap (Vergleichen und Vertauschen) hier: Compare-and-Set (Vergleichen und Speichern)
Compare-and-Swap ist eine atomare Instruktion, die von den meisten Rechnerarchitekturen bereitgestellt wird.
Die Funktion soll durch den folgenden Pseudocode für Compare-and-Swap, bzw. in dieser Form auch Compare-and-Set genannt, (als Funktion cas
) verdeutlicht werden:
(Pseudocode-Notation, kein Java: Das Sternchen soll einen Pointer bzw. “call by reference” andeuten)
Die Funktion cas
hat drei Parameter:
p
ist ein Zeiger auf eineint
-Variableold
ist derint
-Wert, den*p
vor dem Aufruf hattenew
ist der Wert, der in der*p
-Variable gespeichert werden soll.
Wenn mehrere Threads überlappend mit cas
auf *p
zugreifen, dann kann es sein, dass zum Beginn dieser atomaren Operation in *p
nicht mehr wie erwartet der vorige Wert old
steht. In diesem Fall signalisiert dies die cas
-Fumktion, indem der Rückgabewert false
ist. Sonst hat kein anderer Thread überlappend auf die *p
-Variable zugegriffen. Dies wird durch true
als Rückgabe signalisiert.
Wenn der Inhalt der Speicherzelle p
mit dem erwarteten, alten Wert übereinstimmt, wird der neue an die Speicherstelle geschrieben (als atomare Operation). Stimmt der erwartete, alte Wert nicht mit dem aktuellen Wert überein, da ihn z.B. ein anderer Thread zwischenzeitlich geändert hat, findet keine Modifikation statt,
Wichtig ist: cas
(oder Vergleichbares) wird als atomare Instruktion von der CPU bzw. Rechnerarchitektur bereitgestellt. Dies ist auch die Basis für die interne Implementierung von Monitoren und anderen Techniken für gegenseitigen Ausschluss.
Threadsicheres Inkrement mit cas
ohne Erfordernis für gegenseitigen Ausschluss:
(wieder Pseudocode-Notation, kein Java)
Man versucht es also solange, bis zwischen der Ausführung der Instruktion in der ersten Zeile und dem atomaren Ausführen der cas
-Operation keine andere Änderung von counter
erfolgt ist.
5.2.2 Java-Schnittstelle für CAS-Funktionalität: AtomicBoolean
, AtomicInteger
, AtomicLong
Die CAS-Funktionalität ist nicht auf int
begrenzt. Die CAS-Funktionalität wird in Java durch Klassen im Package java.util.concurrent.atomic
bereitgestellt.
5.2.2.1 Konstruktoren
5.2.2.2 Compare-And-Set (atomar)
5.2.2.3 Lesen/Schreiben (alle atomar)
public final boolean/int/long get()
public final void set(boolean/int/long newValue)
public final boolean/int/long getAndSet(boolean/int/long newValue)
- liefert den alten Wert zurück
5.2.3 Atomare double
und float
gibt es nicht: “You can also hold floats using Float.floatToIntBits and Float.intBitstoFloat conversions, and doubles using Double.doubleToLongBits and Double.longBitsToDouble conversions.”1
public AtomicFloat(float initialValue) {
this.bits = new AtomicInteger(Float.floatToIntBits(initialValue));
}
public final boolean compareAndSet(float expect, float update) {
return this.bits.compareAndSet(Float.floatToIntBits(expect),
Float.floatToIntBits(update));
}
public final void set(float newValue) {
this.bits.set(Float.floatToIntBits(newValue));
}
public final float get() {
return Float.intBitsToFloat(this.bits.get());
}
5.2.4 Beliebige atomare Funktion seit Java 8 in Atomic*: updateAndGet
Seit Java 8 steht mit der Methode updateAndGet
eine mächtige Funktion für die Atomics-Klassen zur Verfügung. Im Folgenden wird die updateAndGet
-Methode stellvertretend anhand der beiden Typen long
und int
vorgestellt. Es gibt Sie aber bei allen Atomic-Klassen.
Die updateAndGet
-Methode hat einen Parameter. Je nachdem an welcher Atomic-Klasse die Methode aufgerufen wird muss ein passender Ausdruck als Parameter übergeben werden. Er ist z.B. vom Typ LongUnaryOperator
oder IntUnaryOperator
. Das Ergebnis des Methodenaufrufs ist dann long
bzw. int
.
public final long updateAndGet(LongUnaryOperator updateFunction)
public final int updateAndGet(IntUnaryOperator updateFunction)
Der Typ LongUnaryOperator
bzw. IntUnaryOperator
ist jeweils ein Functional Interface, also ein “Single Abstract Method”-Typ. Die eine abstrakte Methode, die in Implementierungen überschrieben werden muss, ist applyAsLong
bzw. applyAsInt
und liefert genau den gewünschten Typ long
bzw. int
zurück.
@FunctionalInterface
public interface LongUnaryOperator {
long applyAsLong(long operand);
}
@FunctionalInterface
public interface IntUnaryOperator {
int applyAsInt(int operand);
}
Da es sich bei diesem Interface um einen “Single Abstract Method”-Typ handelt, kann man durch Lambda-Ausdrücke Objekte dazu erzeugen.
var i = new AtomicInteger(4711);
i.updateAndGet((in) -> (in * 2));
var l = new AtomicLong(4711);
l.updateAndGet((in) -> (in * 2));
Der Effekt des Aufrufs von updateAndGet
ist, dass sich der Wert des Atomics, an dem die Methode ausgeführt wird, auf den Wert ändert, der als Ergebnis der Anwendung des Lambda-Ausdrucks auf den bisherigen Wert des Atomics berechnet wird. Der Rückgabewert von updateAndGet
ist der neue Wert des Atomics.
updateAndGet
ist threadsicher und funktioniert ohne gegenseitigen Ausschluss. Für die interne Implementierung von updateAndGet
wird compareAndSet
verwendet: