1. Kennenlernen der Race Condition (race condition)
Erinnern wir uns an den Begriff Race Condition – eine Situation, in der das Ergebnis eines Programms davon abhängt, in welcher Reihenfolge Threads Zugriff auf gemeinsame Daten oder Ressourcen erhalten. Wenn sich die Ausführungsreihenfolge ändert, wird das Ergebnis unvorhersehbar. Das ist so, als ob Sie und ein Freund gleichzeitig dasselbe Dokument bearbeiten: Wer schneller tippt, „gewinnt“, und der endgültige Text kann sehr merkwürdig ausfallen.
In Java (und in jeder anderen Sprache mit Multithreading-Unterstützung) entsteht eine Race Condition, wenn mehrere Threads gleichzeitig dieselbe Variable ohne ausreichende Synchronisation lesen und/oder ändern.
Warum entsteht eine Race Condition?
Java-Threads arbeiten parallel. Greifen zwei Threads gleichzeitig auf dieselbe Variable zu (z. B. erhöhen einen gemeinsamen Zähler), können sie sich gegenseitig „überholen“. Selbst wenn eine Operation atomar wirkt (etwa counter++), ist sie das in Wirklichkeit nicht!
Wie funktioniert counter++?
Die Inkrement-Operation umfasst mehrere Schritte:
- Das aktuelle Variablenwert aus dem Speicher lesen.
- Diesen Wert um eins erhöhen.
- Den neuen Wert zurück in den Speicher schreiben.
Wenn in diesem Moment ein anderer Thread ebenfalls counter++ ausführt, können beide denselben alten Wert lesen, beide erhöhen und beide denselben neuen Wert schreiben – dadurch geht ein Inkrement „verloren“.
2. Beispiel für eine Race Condition: Zähler-Inkrement
Schreiben wir ein einfaches Programm, das mehrere Threads startet, von denen jeder den gemeinsamen Zähler um 1 erhöht. Eigentlich sollte bei 1000 Threads der Endwert des Zählers 1000 sein. Prüfen wir das!
public class RaceConditionDemo {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
int threads = 1000;
Thread[] threadArray = new Thread[threads];
for (int i = 0; i < threads; i++) {
threadArray[i] = new Thread(() -> {
counter++; // GEFÄHRLICHE Operation!
});
threadArray[i].start();
}
// Warten auf den Abschluss aller Threads
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("Erwartet: " + threads);
System.out.println("Erhalten: " + counter);
}
}
Erwartete Ausgabe:
Erwartet: 1000
Erhalten: 843
Der Wert kann bei jedem Lauf unterschiedlich sein: manchmal 900, manchmal 700 und manchmal auch 1000 – aber sehr selten.
Warum passiert das?
Threads lesen gleichzeitig den Wert von counter, erhöhen ihn und schreiben ihn zurück. Wenn zwei Threads denselben Wert lesen, ihn beide erhöhen und beide schreiben, geht ein Inkrement verloren. Das Endergebnis ist daher immer kleiner als erwartet.
3. Noch ein Beispiel: Bank ohne Synchronisation
Stellen wir uns vor, wir haben ein Bankkonto, und zwei Threads heben gleichzeitig Geld ab.
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// Simulation einer langen Operation
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
public class BankDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(100));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Erwartet: 0 oder 100");
System.out.println("Tatsächlicher Kontostand: " + account.getBalance());
}
}
Manchmal sehen beide Threads, dass 100 auf dem Konto sind, und heben beide ab. Das Ergebnis ist ein Kontostand von -100! (Im echten Leben kommt das nicht vor, im Code jedoch sehr wohl.)
4. Hilfreiche Details
Folgen einer Race Condition
Eine Race Condition sind nicht nur „merkwürdige“ Resultate. Sie ist echter Kopfschmerz für Entwickler, denn:
- Fehler treten nicht immer auf. Manchmal funktioniert das Programm korrekt, manchmal nicht – je nachdem, wie die Threads ihre Aktionen „timingmäßig“ ausführen.
- Tests garantieren keinen Erfolg. Sie können das Programm viele Male starten – alles wirkt gut, und dann bricht plötzlich alles zusammen.
- Fehler sind schwer einzufangen. Das Verhalten hängt von CPU-Geschwindigkeit, Systemlast und anderen laufenden Programmen ab.
- Kritische Ausfälle sind möglich: Datenverlust, falsche Berechnungen, Abstürze der Anwendung.
Reale Beispiele
- Finanzanwendungen: falsche Saldenberechnung, doppelte Abbuchungen.
- Server: Nachrichtenverlust, fehlerhafte Verarbeitung von Anfragen.
- Spiele: „Teleportation“ von Figuren, falsche Punktevergabe.
Warum schützt Testen nicht vor Race Conditions?
Eine Race Condition ist ein typischer „Heisenbug“ (ein Bug, der verschwindet, wenn man versucht, ihn zu fassen). Selbst wenn Sie tausendmal Tests ausführen und keinen Fehler sehen, heißt das nicht, dass es ihn nicht gibt! Es hängt davon ab, wie das OS die Arbeit der Threads plant. Manchmal läuft alles glatt, und manchmal „kollidieren“ Threads, und das Problem tritt auf.
Wie vermeidet man Race Conditions?
- Synchronisation: Verwenden Sie das Schlüsselwort synchronized für Methoden oder Codeblöcke, damit jeweils nur ein Thread die gemeinsamen Daten ändern kann.
- Atomare Operationen: Verwenden Sie Klassen aus dem Paket java.util.concurrent.atomic (zum Beispiel AtomicInteger), die sichere Operationen ohne explizite Synchronisation bieten.
- Immutabilität: Wenn ein Objekt nicht veränderbar ist, sind Race Conditions unmöglich.
Beispiel mit Synchronisation
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
Jetzt gilt: Wenn mehrere Threads increment() aufrufen, kann immer nur ein Thread diese Methode zur gleichen Zeit ausführen.
5. Typische Fehler beim Umgang mit gemeinsamen Variablen in Threads
Fehler Nr. 1: Naives Vertrauen in die Sicherheit einfacher Operationen.
Viele denken, dass counter++ eine einzige Operation ist und nichts Schlimmes passieren kann. In Wirklichkeit sind es drei Operationen, und dazwischen kann sich ein anderer Thread „dazwischenschieben“.
Fehler Nr. 2: Verwendung gewöhnlicher Variablen für den Austausch zwischen Threads.
Wenn mehrere Threads ohne Synchronisation in dieselbe Variable schreiben und daraus lesen – hallo, Race Condition!
Fehler Nr. 3: Die Erwartung, dass der Fehler immer auftritt.
Eine Race Condition zeigt sich nur manchmal, und das macht sie besonders heimtückisch. Verlassen Sie sich nicht darauf, dass alles gut ist, nur weil es in den Tests funktioniert hat.
Fehler Nr. 4: Ignorieren der Synchronisation bei der Arbeit mit Collections.
Gewöhnliche Collections wie ArrayList sind nicht thread-sicher. Wenn mehrere Threads Elemente hinzufügen oder entfernen, sind Ausfälle und sogar Abstürze möglich.
Fehler Nr. 5: Der Versuch, eine Race Condition mit Verzögerungen zu „reparieren“.
Zum Beispiel mit Thread.sleep(10) oder anderen „magischen“ Pausen. Dieser Ansatz löst das Problem nicht, sondern kaschiert es nur. Die echte Lösung sind Synchronisation oder atomare Operationen.
GO TO FULL VERSION