Konkurrierender Zugriff auf Daten

Parallele Programmierung (3IB)

Prof. Dr.-Ing. Sandro Leuchter
Hochschule Mannheim, Fakultät für Informatik
Sommersemester 2025

 

Dieses Werk ist lizenziert unter einer Creative Commons „Namensnennung – Nicht-kommerziell – Weitergabe unter gleichen Bedingungen 4.0 International“ Lizenz.

Überblick

=> Grundlage: Hettel und Tran (2016, Kap. 3 und 4)

  • Konkurrierender Zugriff auf Daten
    • Java Speichermodell
    • sequentielle Konsistenz und Memory Barrieren (u.a. volatile)
    • threadlokaler Speicher
  • Gegenseitiger Ausschluss
    • synchronized-Methoden und -Blöcke und “Lock-Objekte” (Monitor)
    • Threadsicherheit von Vector
    • Deadlock durch zyklischen gegenseitigen Ausschluss

Konkurrierender Zugriff auf Daten

Java Speichermodell

  • Stack (pro Thread):
    • Parameter, die einer Methode übergeben werden
    • lokale Variablen
      • Referenzen auf Objekte (im Heap)
      • Werte von primitiven Typen
    • etwaiger Rückgabewert
  • Heap (global: für alle Threads):
    • Objekte
    • Instanzvariablen
    • Klassenvariablen (static)

Hettel und Tran (2016, 35)

Java Speichermodell

  • Stack (pro Thread):
    • Parameter, die einer Methode übergeben werden
    • lokale Variablen
      • Referenzen auf Objekte (im Heap)
      • Variablen von primitiven Typen
    • etwaiger Rückgabewert
  • Heap (global: für alle Threads):
    • Objekte
    • Instanzvariablen
    • Klassenvariablen (static)

Hettel und Tran (2016, 37)

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public RamTest(int i) {
        this.i = i;
    }

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }

    public static void main(String... args) {
        new Thread(new RamTest(2)).start();
        new Thread(new RamTest(3)).start();
    }
}

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • Hauptprogramm startet

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • new RamTest(2) Aufruf

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • new RamTest(2) Objekterzeugung

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • new RamTest(2) fertig

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • new RamTest(2).start()

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • new RamTest(3)

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • new RamTest(3).start()

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • Hauptprogramm endet

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • run() \(\to\) print(this.i)

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • var a = i * i (parallel)

Java Speichermodell

public class RamTest extends Thread {
    private final int i;

    public void print(int i) {
        var a = i * i;
        var b = Integer.valueOf(a);
        System.out.println(b);
    }

    @Override
    public void run() {
        print(this.i);
    }
    /* ... */
}

Hauptprogramm:

new Thread(new RamTest(2)).start();
new Thread(new RamTest(3)).start();
  • var b = Integer.valueOf(a) 2x

Zugriff auf Daten von Threads

class Counter {
    int counter = 1;

    void set(int i) {
        this.counter = i;
    }
}
var c = new Counter();
var t1 = new Thread(() -> c.set(2));
var t2 = new Thread(() -> c.set(3));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(c.counter);
  • Die Ausgabe kann entweder 2 oder 3 sein.

Zugriff auf Daten von Threads

class Counter {
    int counter = 1;
    void add(int i) {
        var temp = this.counter;
        temp = temp + i;
        this.counter = temp;
    }
}
var c = new Counter();
var t3 = new Thread(() -> c.add(3));
var t4 = new Thread(() -> c.add(4));
t3.start();
t4.start();
t3.join();
t4.join();
System.out.println(c.counter);

Fall 1:

t3: temp = 1
t3: temp = 1+3
t3: c.counter = 4
t4: temp = 4
t4: temp = 4+4
t4: c.counter = 8

\(\to\) 8

Zugriff auf Daten von Threads

class Counter {
    int counter = 1;
    void add(int i) {
        var temp = this.counter;
        temp = temp + i;
        this.counter = temp;
    }
}
var c = new Counter();
var t3 = new Thread(() -> c.add(3));
var t4 = new Thread(() -> c.add(4));
t3.start();
t4.start();
t3.join();
t4.join();
System.out.println(c.counter);

Fall 1:

t3: temp = 1
t3: temp = 1+3
t3: c.counter = 4
t4: temp = 4
t4: temp = 4+4
t4: c.counter = 8

\(\to\) 8

Fall 2:

t3: temp = 1
t3: temp = 1+3
t4: temp = 1
t3: c.counter = 4
t4: temp = 1+4
t4: c.counter = 5

\(\to\) 5

Zugriff auf Daten von Threads

class Counter {
    int counter = 1;
    void add(int i) {
        var temp = this.counter;
        temp = temp + i;
        this.counter = temp;
    }
}
var c = new Counter();
var t3 = new Thread(() -> c.add(3));
var t4 = new Thread(() -> c.add(4));
t3.start();
t4.start();
t3.join();
t4.join();
System.out.println(c.counter);

Fall 1:

t3: temp = 1
t3: temp = 1+3
t3: c.counter = 4
t4: temp = 4
t4: temp = 4+4
t4: c.counter = 8

\(\to\) 8

Fall 2:

t3: temp = 1
t3: temp = 1+3
t4: temp = 1
t3: c.counter = 4
t4: temp = 1+4
t4: c.counter = 5

\(\to\) 5

Fall 3:

t3: temp = 1
t4: temp = 1
t3: temp = 1+3
t4: temp = 1+4
t4: c.counter = 5
t3: c.counter = 4

\(\to\) 4

Zugriff auf Daten von Threads

  • Möglichkeit unterschiedlicher Traces: Race Condition (auch: Data Race)

  • kritische Abschnitte (engl. “critical regions”)

  • Im vorigen Beispiel sind die Methoden set und add kritische Abschnitte.

  • Sie schließen sich gegenseitig und untereinander aus.

  • gegenseitiger Ausschluss = “mutual exclusion”, verkürzt “mutex”

Memory Barriers

Hardware-Speichermodell und “sequentielle Konsistenz”

  • JVM arbeitet intern auf einem hardwarenäheren Speichermodell.
  • Zur Optimierung der Verschiebeoperationen von/nach Caches darf die Reihenfolge von Operationen geändert werden, wenn das keine inhaltliche Änderung bedeutet (außer eine Variable ist mit volatile gekennzeichnet).
  • Threads werden dabei aber nicht berücksichtigt.

Hettel und Tran (2016) (abgewandelt)

Besonderheit des Java Speichermodells bei Nebenläufigkeit

Threads greifen immer auf eine lokale Kopie des Heaps zu!

  • enthält die transitive Hülle aller vom dem Stack referenzierten Objekte im Heap
  • lokale Heap-Kopien können untereinander abweichen
  • keine Kommunikation zwischen Threads über Variablen
  • Ausnahme volatile
    • solche Variablen verhalten sich so, als wären sie im “echten Heap” (und nicht in der gecachten Kopie)
  • Ausnahme synchronized
    • lokaler Cache wird aktualisiert:
    • “pull” beim Eintritt in synchronized-Block
    • “push” beim Ende eines synchronized-Blocks

Hettel und Tran (2016, 35) (abgewandelt)

Sequentielle Konsistenz durch Memory Barrier

Folgende Fälle bilden Memory Barriers, an denen die Cache-lokalen Änderungen zwischen Threads ausgetauscht werden:

  • Zugriff auf volatile-Variablen
    Das Schreiben einer volatile-Variablen bewirkt die Synchronisierung mit allen Threads, die zu einem späteren Zeitpunkt die Variable lesen.
  • Lock-Objekte bzw. synchronized
    Eine Lock-Freigabe synchronisiert alle durchgeführten Änderungen mit allen Threads, die danach den Lock erwerben.
  • Starten eines Threads
    Alle Aktionen vor dem Starten finden vor der ersten Operation des neu gestarteten Threads statt.

Sequentielle Konsistenz durch Memory Barrier

Folgende Fälle bilden Memory Barriers, an denen die Cache-lokalen Änderungen zwischen Threads ausgetauscht werden:

  • Initialisierung
    mit Defaultwerten (0, false oder null) aller Variablen sorgt für die Synchronisierung mit dem ersten Zugriff.
  • Ende eines Threads
    bewirkt die Synchronisierung mit jeder Aktion eines auf dessen Ende wartenden Threads. Wenn z.B. ein join()-Aufruf zurückkehrt, sieht der Aufrufer alle von dem Thread gemachten Änderungen.
  • Unterbrechung
    Wenn Thread T1 Thread T2 unterbricht, wird garantiert, dass alle Threads die Unterbrechung sehen. Ein Aufruf von isInterrupted() liefert immer den aktuellen Unterbrechungsstatus.

Laboraufgabe “Anwendung von Memory Barrieren”

  • Projekt: pp.02.01-MemoryBarrier
  • Bearbeitungszeit: 15 Minuten
  • Musterlösung: 15 Minuten
  • Kompatibilität: mindestens Java SE 10

Threadlokaler Objektspeicher

Threadlokale Daten

Besonderheit: threadlokaler Speicher (TLS)

  • TLS mit Heap für Objekte, die nur ein bestimmter Thread “sehen” kann.
  • nur für Objekte vom Typ T, wenn über ThreadLocal<T> erzeugt
    \(\to\) hier ist T Integer
  • sehr viel schneller als Heap, braucht nicht synchronisiert werden
public class ThreadLocalDemo {
    public static class Runner implements Runnable {
        public static ThreadLocal<Integer> mem = 
            new ThreadLocal<>() {
                @Override
                protected Integer initialValue() {
                    return Integer.valueOf(1);
                }
            };
        @Override
        public void run() {
            while (true) {
                mem.set(mem.get() + 1);
            }
        }
    }
    public static void main(String... args) {
        var runnable = new Runner();
        new Thread(runnable, "Runner-1").start();
        new Thread(runnable, "Runner-2").start();
    }
}

Threadlokaler Zufallszahlengenerator

Man kann einen threadlokalen Zufallszahlengenerator abrufen. Dieser ist nicht threadsicher implementiert und damit effizienter als der “normale” Zufallszahlengenerator java.util.Random, der threadsicher implementiert ist. ThreadLocalRandom aus dem Package java.util.concurrent hat aber dieselbe Signatur wie Random:

import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;

Random r = ThreadLocalRandom.current();

Laboraufgabe “threadlokaler Speicher”

  • Projekt: pp.02.02-ThreadLocal
  • Bearbeitungszeit: 15 Minuten
  • Musterlösung: 15 Minuten
  • Kompatibilität: mindestens Java SE 10

Gegenseitiger Ausschluss (“Mutex”)

“synchronized”-Schlüsselwort für Methoden

  • Schlüsselwort synchronized in Methoden-Signatur
public synchronized void doubler() {
    this.counter = this.counter * 2;
}
  • bewirkt gegenseitigen Ausschluss
    • nur maximal ein Thread
    • für alle synchronized-Methoden eines Objekts gemeinsam
  • keine Synchronisation von Methodenaufrufen an unterschiedlichen Objekten
  • Eintritt/Austritt in/aus synchronized-Block fungiert gleichzeitig als Memory Barrier (wichtig für Getter und Setter)

“synchronized”-Block

  • Schlüsselwort für Methoden-Signatur ist nur “syntaktischer Zucker” für synchronized-Block
    • Monitor von this
    • über den ganzen Methodenrumpf
public void doubler() {
    synchronized (this) {
        this.counter = this.counter * 2;
    }
}
  • ermöglicht Unterscheidung
    • unterschiedliche Monitore
    • gegenseitiger Ausschluss nur für die wirklich kritischen Code-Teile und nicht den ganzen Methodenrumpf

“synchronized”-Block mit anderem Objekt als Monitor

private final Object lock = new Object();

public void doubler() {
    synchronized (this.lock) {
        this.counter = this.counter * 2;
    }
}
  • Monitor (“Schlossvariable”) kann ein Objekt einer beliebigen Klasse sein, z.B. Object.
  • Muss von allen Threads “gesehen” werden können.

“synchronized”-Schlüsselwort für Klassenmethoden

  • falls die Methode static ist (Klassenmethode), wird die Klasse selber als Monitor verwendet
class C {
    static synchronized void m() {
        // work
    }
}
class C {
    static void m() {
        synchronized (C.class) {
            // work
        }
    }
}

Laboraufgabe “gegenseitiger Ausschluss”

  • Projekt: pp.02.03-Lock
  • Bearbeitungszeit: 15 Minuten
  • Musterlösung: 15 Minuten
  • Kompatibilität: mindestens Java SE 10

Threadsicherheit von “Vector” und “HashTable”

  • StringBuffer, Vector und HashTable:
    • alle Methoden synchronized
  • StringBuilder, ArrayList, LinkedList, HashMap, IdentityHashMap, TreeMap
  • keine Methode synchronized
  • effizienter
  • Transaktionen (z.B. Iterieren)
    • in der Regel nicht threadsicher
private static Vector<Integer> vec = new Vector<>();
public static void main(String... args) 
    throws InterruptedException {
    var remover = new Thread(() -> {
        for (var i = 0; i < vec.size(); i++) {
            if ((vec.get(i) % 2) == 1) {
                vec.remove(i);
            }
        }
    }, "Odd-Remover");
    var adder = new Thread(() -> {
        for (var i = 0; i < MAX; i++) {
            vec.add(i);
        }
    }, "Adder");
    remover.start(); adder.start();
    remover.join(); adder.join();

“synchronized” im Sequenzdiagramm (Notation für diese LV)

public class Sync {
    private static Object l = new Object();

    public static void m1() {
        synchronized (l) {
            /* ... */
        }
    }

    public static void main(String... args) {
        var t1 = new Thread(Sync::m1); t1.start();
        var t2 = new Thread(Sync::m1); t2.start();
    }
}

Deadlock durch gegenseitiges Warten

public class Deadlock {
    private static Object l1 = new Object();
    private static Object l2 = new Object();
    public static void m1() {
        synchronized (l1) {
            synchronized (l2) {
                /* ... */
            }
        }
    }
    public static void m2() {
        synchronized (l2) {
            synchronized (l1) {
                /* ... */
            }
        }
    }
    public static void main(String... args) {
        var t1 = new Thread(Deadlock::m1); t1.start();
        var t2 = new Thread(Deadlock::m2); t2.start();
    }
}

Deadlock durch gegenseitiges Warten

public class Deadlock {
    private static Object l1 = new Object();
    private static Object l2 = new Object();
    public static void m1() {
        synchronized (l1) {
            synchronized (l2) {
                /* ... */
            }
        }
    }
    public static void m2() {
        synchronized (l2) {
            synchronized (l1) {
                /* ... */
            }
        }
    }
    public static void main(String... args) {
        var t1 = new Thread(Deadlock::m1); t1.start();
        var t2 = new Thread(Deadlock::m2); t2.start();
    }
}

Referenzen

Hettel, Jörg und Manh Tien Tran. 2016. Nebenläufige Programmierung mit Java. Konzepte und Programmiermodelle für Multicore-Systeme. Heildelberg: dpunkt.verlag.