In vielen Projekten gibt es die Notwendigkeit, zwei Threads miteinander zu synchronisieren. Zwei Threads sollen dabei gleichzeitig laufen, und ein Thread soll eine Schleife im anderen abbrechen können.
| |
Auf den ersten Blick sieht der Code problemlos aus - Worker prüft, ob der runFlag gesetzt ist und läuft solange, Main setzt diesen nach einiger Zeit auf false. Die erwartete Ausgabe entspricht:
$ java Main
Main stopped
Worker stopped
$Führt man das Programm aber aus, entspricht die wirkliche Ausgabe aber meist diesem:
$ java Main
Main stoppedDie main-Methode ist durchgelaufen, aber der Worker-Thread läuft endlos weiter.
Gründe und generierter Assemblercode
Der Grund für das vermeintlich fehlerhafte Verhalten sind Optimierungen, die die JVM durchführt.
Die Schleife ist ein Hotspot, und wird dementsprechende soweit möglich optimiert - die einzige Optimierungsmöglichkeit ist in diesem Fall der Zugriff auf runFlag.
Worker ist ein Objekt, und liegt somit auf dem Heap, ein Zugriff auf runFlag erfordert deshalb jedes Mal (langsamen) Zugriff auf den Arbeitsspeicher.
Um diesen langsamen Zugriff zu optimieren, kann die JVM den Wert in einem Register ablegen und muss ihn so nicht mehr aus dem RAM laden.
Da die Variable danach innerhalb der Schleife nicht geändert wird und kein anderer Thread diese ändern kann (da sie nur in einem Register liegt), kann ein Zugriff auf die Variable generell entfernt werden.
Sehen kann man das, wenn man sich den von der JVM generierten Assembler-Code für diese Methode anguckt:
| |
- In Zeile 7 wird der Wert des Feldes in das Register
ebpgeladen. - In Zeile 11 wird das Register auf
truegetestet (test führt ein bitweises AND durch, dabei wird das zero flag gesetzt. ZF ist dann 1, wenn epb 0 ist, wasrunFlag == falseentspricht. ) - je in Zeile 12 prüft, ob ZF 1 ist, und führt in diesem Fall einen Sprung zu Zeile 20 durch.
- Wenn ZF 1 ist (was
runFlag == trueentspricht), wird Zeile 15 ausgeführt, allerdings ohne relevante Nebeneffekte - Relevant ist Zeile 19, diese führt einen Sprung zurück zu Zeile 15 durch, und führt damit zu einer nicht-unterbrechbaren Endlosschleife
volatile
Beheben lässt sich das Problem auf einfache Weise - das Feld muss als volatile makiert werden: volatile boolean runFlag = true;
Das führt, in diesem Fall, dazu, dass der Wert bei jedem Zugriff neu aus dem Arbeitsspeicher geladen werden muss. Es wird damit nicht mehr auf eine lokal gecachte Kopie zugegriffen, und eine Änderung des Wertes ist direkt für alle anderen Threads sichtbar.
Die Änderung lässt sich im generierten Assemblercode wiederfinden:
| |
- Zeile 6-10 sind identisch zu 7-11 aus dem vorherigen Code - es wird geprüft, ob das Feld
falseist, und die Schleife in dem Fall übersprungen. - Zeile 13 ist ein NOP
- Zeile 17 entspricht dem Schleifenbeginn in Zeile 15 im vorherigen Code
- In Zeile 21 wird dann jeweils der aktuelle Wert des Feldes geladen
- und in Zeile 24 und 25 auf
0getestet - wenn der Werttrueist, wird wieder zum Schleifenanfang gesprungen.
Mit volatile wird die Variable also in jedem Schleifendurchlauf korrekt abgefragt, und das Programm wird wie erwartet ausgeführt:
$ java Main
Main stopped
Worker stopped
$