Volatile Variablen und Auswirkungen auf den generierten Assembler-Code

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
  
    public static void main(String[] args) throws InterruptedException {
        Worker worker = new Worker();
        worker.start();
        Thread.sleep(10_000);
        worker.runFlag = false;
        System.out.println("Main stopped");
    }

    private static class Worker extends Thread {
        boolean runFlag = true;
        @Override
        public void run() {
            while (runFlag) {

            }
            System.out.println("Worker stopped");
        }
    }
}

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 stopped

Die 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:

 0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
;irrelevanter Code abgeschnitten
0x000000011c46ff00: mov    %eax,-0x14000(%rsp)
0x000000011c46ff07: push   %rbp
0x000000011c46ff08: sub    $0x20,%rsp           ;*synchronization entry
                                                ; - Main$Worker::run@-1 (line 15)

0x000000011c46ff0c: mov    %rsi,%r10
0x000000011c46ff0f: movzbl 0x178(%rsi),%ebp     ;*getfield runFlag
                                                ; - Main$Worker::run@1 (line 15)

0x000000011c46ff16: test   %ebp,%ebp
0x000000011c46ff18: je     0x000000011c46ff22   ; OopMap{r10=Oop off=58}
                                                ;*goto
                                                ; - Main$Worker::run@7 (line 15)

0x000000011c46ff1a: test   %eax,-0xf2fcf20(%rip)        # 0x000000010d173000
                                                        ;*goto
                                                        ; - Main$Worker::run@7 (line 15)
                                                        ;   {poll}
0x000000011c46ff20: jmp    0x000000011c46ff1a
0x000000011c46ff22: mov    $0xffffff65,%esi
0x000000011c46ff27: mov    %r10,(%rsp)
0x000000011c46ff2b: callq  0x000000011c3a26a0   ; OopMap{[0]=Oop off=80}
                                                ;*ifeq
                                                ; - Main$Worker::run@4 (line 15)
                                                ;   {runtime_call}
0x000000011c46ff30: callq  0x000000010de8997c   ;*goto
                                                ; - Main$Worker::run@7 (line 15)
                                                ;   {runtime_call}
0x000000011c46ff35: callq  0x000000010de8997c   ;   {runtime_call}
  • In Zeile 7 wird der Wert des Feldes in das Register ebp geladen.
  • In Zeile 11 wird das Register auf true getestet (test führt ein bitweises AND durch, dabei wird das zero flag gesetzt. ZF ist dann 1, wenn epb 0 ist, was runFlag == false entspricht. )
  • 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 == true entspricht), 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:

 0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
;irrelevanter Code abgeschnitten
0x000000010e284e80: mov    %eax,-0x14000(%rsp)
0x000000010e284e87: push   %rbp
0x000000010e284e88: sub    $0x20,%rsp         ;*synchronization entry
                                            ; - Main$Worker::run@-1 (line 15)

0x000000010e284e8c: movzbl 0x178(%rsi),%ebp   ;*getfield runFlag
                                            ; - Main$Worker::run@1 (line 15)

0x000000010e284e93: test   %ebp,%ebp
0x000000010e284e95: je     0x000000010e284ec9  ;*ifeq
                                            ; - Main$Worker::run@4 (line 15)

0x000000010e284e97: nopw   0x0(%rax,%rax,1)   ; OopMap{rsi=Oop off=64}
                                            ;*goto
                                            ; - Main$Worker::run@7 (line 15)

0x000000010e284ea0: test   %eax,-0xa919ea6(%rip)        # 0x000000010396b000
                                            ;*goto
                                            ; - Main$Worker::run@7 (line 15)
                                            ;   {poll}
0x000000010e284ea6: movzbl 0x178(%rsi),%r11d  ;*getfield runFlag
                                            ; - Main$Worker::run@1 (line 15)

0x000000010e284eae: test   %r11d,%r11d
0x000000010e284eb1: jne    0x000000010e284ea0  ;*ifeq
                                            ; - Main$Worker::run@4 (line 15)

0x000000010e284eb3: mov    %rsi,%rbp
0x000000010e284eb6: mov    %r11d,(%rsp)
0x000000010e284eba: mov    $0xffffff65,%esi
0x000000010e284ebf: callq  0x000000010e1b76a0  ; OopMap{rbp=Oop off=100}
                                            ;*ifeq
                                            ; - Main$Worker::run@4 (line 15)
                                            ;   {runtime_call}
0x000000010e284ec4: callq  0x000000010468997c  ;*ifeq
                                            ; - Main$Worker::run@4 (line 15)
                                            ;   {runtime_call}
0x000000010e284ec9: mov    %rsi,(%rsp)
0x000000010e284ecd: mov    $0xffffff65,%esi
0x000000010e284ed2: nop
0x000000010e284ed3: callq  0x000000010e1b76a0  ; OopMap{[0]=Oop off=120}
                                            ;*ifeq
                                            ; - Main$Worker::run@4 (line 15)
                                            ;   {runtime_call}
0x000000010e284ed8: callq  0x000000010468997c  ;*ifeq
                                            ; - Main$Worker::run@4 (line 15)
                                            ;   {runtime_call}
  • Zeile 6-10 sind identisch zu 7-11 aus dem vorherigen Code - es wird geprüft, ob das Feld false ist, 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 0 getestet - wenn der Wert trueist, 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
$