1. Was ist eine gemeinsame Ressource
Mit gemeinsamen Ressourcen sind Sie bereits in Berührung gekommen. Das Haus, in dem eine Familie lebt, ist eine gemeinsame Ressource dieser Familie. Der Büro-Kühlschrank ist eine gemeinsame Ressource für alle Kolleginnen und Kollegen. Die Idee ist klar.
In der Programmierung ist eine gemeinsame Ressource eine Variable, ein Objekt oder eine Datenstruktur, auf die mehrere Threads gleichzeitig zugreifen können. Das kann sein:
- Eine Zählvariable für die Anzahl verarbeiteter Bestellungen.
- Eine Anfragenliste, die von einigen Threads befüllt und von anderen verarbeitet wird.
- Eine geöffnete Datei, in die mehrere Threads schreiben.
- Eine Datenbankverbindung, mit der verschiedene Teile des Programms arbeiten.
In Java wird jedes Objekt oder jede Variable, auf die mehrere Threads zugreifen können, potenziell zu einer „gemeinsamen Ressource“.
Beispiel für eine gemeinsame Ressource: globaler Zähler
public class Counter {
public int value = 0;
}
Wenn mehrere Threads diesen Zähler erhöhen, greifen sie auf dieselbe Variable value zu – das ist genau eine gemeinsame Ressource.
2. Probleme bei gleichzeitigen Zugriffen
In einem Single-Thread-Programm ist alles einfach: ein Thread – ein Ausführender – er läuft durch den Code wie ein Zug auf Schienen. Sobald jedoch mehrere Threads ins Spiel kommen, beginnt ein echter „Säbeltanz“: Threads können sich an den unerwartetsten Stellen gegenseitig in die Quere kommen.
Race Condition (Wettlaufsituation)
Race Condition ist eine Situation, in der das Ergebnis des Programms davon abhängt, wie genau die Aktionen der Threads „vermischt“ wurden. Das heißt, wenn Sie dasselbe Programm mehrmals ausführen, kann das Ergebnis unterschiedlich sein – und das ist kein Bug, sondern ein „Feature“ der Nebenläufigkeit.
Klassisches Beispiel: Zwei Threads erhöhen den Zähler
Lassen Sie uns eine einfache Situation simulieren: Wir haben einen gemeinsamen Zähler, und zwei Threads erhöhen seinen Wert je tausend Mal.
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // GEFÄHRLICHE STELLE!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Erwarteter Wert: 2000");
System.out.println("Tatsächlicher Wert: " + counter.value);
}
}
Was sehen wir auf dem Bildschirm?
Manchmal 2000, manchmal 1985, manchmal 1937 ... Warum? Weil der Vorgang counter.value++ nicht atomar ist! Er besteht aus drei Schritten:
- Den aktuellen Wert von counter.value lesen.
- Um 1 erhöhen.
- Zurückschreiben.
Wenn zwei Threads denselben Wert gleichzeitig lesen, erhöhen beide und schreiben anschließend das Ergebnis – am Ende „geht eine Erhöhung verloren“. Das ist ein Lost Update – ein verlorenes Update.
Inkonsistenter Objektzustand
Wenn Sie ein komplexes Objekt mit mehreren Feldern haben und Threads gleichzeitig verschiedene Felder ändern, kann sich das Objekt in einem „merkwürdigen“ oder inkonsistenten Zustand wiederfinden. Zum Beispiel wurde der Kontostand verringert, aber der Transaktionsverlauf nicht aktualisiert – der Kunde ist beunruhigt, die Buchhaltung schockiert.
3. Warum Synchronisation nötig ist
Synchronisation ist die Möglichkeit, dem Programm zu sagen: „Stopp, dieser Codeabschnitt darf jeweils nur von einem Thread gleichzeitig ausgeführt werden! Alle anderen warten!“ Das ist wie ein Schild „Reinigung! Bitte nicht eintreten!“ an der Tür: Solange jemand drinnen ist, warten die anderen draußen (und verfluchen insgeheim denjenigen, der nicht herauskommt).
Gewährleistung der Datenintegrität
Wenn wir möchten, dass unser Zähler immer korrekt erhöht wird, müssen wir die gleichzeitige Änderung seines Wertes durch mehrere Threads verbieten.
Beispiel: Synchronisiertes Erhöhen des Zählers
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
Wenn zwei Threads jetzt increment() aufrufen, kann nur einer von ihnen diese Methode zu einem bestimmten Zeitpunkt ausführen. Der zweite wartet, bis der erste fertig ist.
Schema: Was passiert bei der Synchronisation
+-------------------+
| Thread 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Thread 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Thread 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Thread N |/
+-------------------+
Alle Threads stellen sich in eine Warteschlange an, um den geschützten Codeabschnitt auszuführen. Zu einem Zeitpunkt kann nur ein Thread innerhalb der „kritischen Sektion“ (des synchronized-Blocks) sein.
4. Kurzer Überblick über Synchronisationsmethoden
Synchronisation in Java ist nicht nur eine einzige Methode, sondern ein ganzer „Werkzeugkasten“, um eine gemeinsame Ressource vor gleichzeitigen Zugriffen zu schützen.
Schlüsselwort synchronized
Dies ist das grundlegende Synchronisationswerkzeug in Java. Es lässt sich auf zwei Arten verwenden:
Synchronisierte Methode
public synchronized void increment() {
value++;
}
Synchronisierter Block
public void increment() {
synchronized (this) {
value++;
}
}
Hier ist this das Objekt, auf dem die Sperre liegt. Solange ein Thread diesen Block ausführt, warten andere Threads, die in einen gleichen Block mit demselben Objekt eintreten möchten.
Spezialisierte Klassen aus java.util.concurrent
- Lock, ReentrantLock – eine flexiblere Alternative zu synchronized.
- ReadWriteLock – um Lese- und Schreibsperren zu trennen.
- Semaphore – begrenzt die Anzahl der Threads, die den Code gleichzeitig ausführen.
- CountDownLatch, CyclicBarrier und andere – zur Koordination der Threads.
Wichtig: Heute machen wir nur die ersten Schritte – über diese Klassen sprechen wir etwas später.
5. Praxisbeispiel: Anwendung mit einem Zähler in mehreren Threads
Nehmen wir an, wir implementieren eine Zugriffsstatistik für einen Dienst. Jeder Thread stellt eine Nutzerin/einen Nutzer dar, die/der den gemeinsamen Zähler erhöht.
Ohne Synchronisation
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Erwarteter Wert: 30000");
System.out.println("Tatsächlicher Wert: " + counter.value);
}
}
Ergebnis: Fast immer weniger als 30000. Manchmal – deutlich weniger! Warum? Weil sich die Threads gegenseitig „überfahren“.
Synchronisation: den Fehler beheben
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Erwarteter Wert: 30000");
System.out.println("Tatsächlicher Wert: " + counter.value);
}
}
Ergebnis: Immer 30000. Hurra, die Synchronisation funktioniert!
6. Nützliche Feinheiten
Visualisierung: Wie eine Race Condition aussieht
Zeichnen wir eine kleine Tabelle, um zu zeigen, wie zwei Threads ein Inkrement „verlieren“ können:
| Schritt | Thread 1 | Thread 2 | Wert von value |
|---|---|---|---|
| 1 | Liest value=0 | 0 | |
| 2 | Liest value=0 | 0 | |
| 3 | Erhöht auf 1 | 0 | |
| 4 | Erhöht auf 1 | 0 | |
| 5 | Schreibt 1 | 1 | |
| 6 | Schreibt 1 | 1 |
Wann Synchronisation nötig ist
Synchronisation ist nicht immer erforderlich. Wenn eine Variable in ihrer kleinen Welt lebt und nur von einem Thread verwendet wird – kein Problem. Sobald Sie sie aber mit anderen Threads teilen, geht es nicht mehr ohne Synchronisation. Selbst wenn es so aussieht, als würde alles gutgehen – verlassen Sie sich nicht darauf. Der Fehler des Wettlaufs ist tückisch: Er kann lange verborgen bleiben und dann im unpassendsten Moment zuschlagen.
Ausblick: Welche weiteren Synchronisationsmittel gibt es
Heute haben wir nur das Grundwerkzeug – synchronized – kennengelernt. In den nächsten Vorlesungen betrachten wir:
- Wie ein Objektmonitor funktioniert und welche Sperrtypen es gibt.
- Was statische synchronisierte Methoden sind (static + synchronized).
- Wie das Schlüsselwort volatile funktioniert und wozu es dient.
- Welche modernen Synchronisationsklassen es gibt (Lock, Semaphore u. a.).
7. Typische Fehler beim Umgang mit gemeinsamen Ressourcen
Fehler Nr. 1: Nebenläufigkeit ignorieren.
Einer der häufigsten Fehler ist, nicht zu bedenken, dass auf eine Variable mehrere Threads zugreifen können. Selbst wenn das Programm jetzt single-threaded ist, wird später jemand Threads hinzufügen – und Bugs tauchen „aus dem Nichts“ auf.
Fehler Nr. 2: Unzureichende oder übermäßige Synchronisation.
Wenn der Zugriff auf eine gemeinsame Ressource nicht synchronisiert wird – erhalten Sie Race Conditions und inkonsistente Daten. Wenn hingegen „alles“ synchronisiert wird, „erstickt“ das Programm an Sperren und wird langsam. Synchronisieren Sie immer nur das, was wirklich nötig ist.
Fehler Nr. 3: Auf dem falschen Objekt synchronisieren.
Wenn auf unterschiedlichen Objekten synchronisiert wird (z. B. auf lokalen Variablen oder String-Literalen), schützt das die gemeinsame Ressource nicht. Alle Threads müssen auf demselben Objekt synchronisieren.
Fehler Nr. 4: Von nicht-atomaren Operationen Atomarität erwarten.
Die Operation i++ ist nicht atomar! Selbst wenn die Variable als volatile deklariert ist, macht das den Inkrement nicht atomar. Für solche Operationen ist Synchronisation erforderlich.
Fehler Nr. 5: „Ich habe Glück, bei mir funktioniert alles“.
Eine Race Condition zeigt sich möglicherweise nicht auf Ihrem Rechner, aber ganz sicher auf dem Server oder bei Nutzenden. Verlassen Sie sich niemals auf „wird schon gutgehen“ in nebenläufigen Programmen!
GO TO FULL VERSION