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 oder double
  • Transaktionen an einer Variablen eines primitiven Typs (nicht long oder double), 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():

  public synchronized void inc();
    Code:
       0: aload_0
       1: dup
       2: getfield #12 // Field c:I
       5: iconst_1
       6: iadd
       7: putfield #12 // Field c:I
      10: return
  public synchronized void dec();
    Code:
       0: aload_0
       1: dup
       2: getfield #12 // Field c:I
       5: iconst_1
       6: isub
       7: putfieldi #12 // Field c:I
      10: return

  public synchronized int get();
    Code:
       0: aload_0
       1: getfield #12 // Field c:I
       4: ireturn
}

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:

function cas(p: *int, old: int, new: int):
    if *p <> old
        return false
    *p := new
    return true

(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 eine int-Variable
  • old ist der int-Wert, den *p vor dem Aufruf hatte
  • new 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:

int tmp = counter
while (! cas(counter, tmp, tmp+1))
    tmp = counter;

(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

AtomicBoolean() AtomicInteger() AtomicLong()
AtomicBoolean/Integer/Long(boolean/int/long initialValue)

5.2.2.2 Compare-And-Set (atomar)

public final boolean compareAndSet(boolean/int/long expect, boolean/int/long update)

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:

public final int updateAndGet(IntUnaryOperator updateFunction) {
    int prev, next;
    do {
        prev = get();
        next = updateFunction.applyAsInt(prev);
    } while (!compareAndSet(prev, next));
    return next;
}