Idea volatile wydaje się być prosta – hej JVM! nie używaj optymalizacji i pamięci podręcznej (cache), raczej sięgnij bezpośrednio do pamięci.
Możliwa sytuacja bez volatile

Jak widać na powyższym diagramie bez użycia słowa kluczowego volatile może się zdarzyć, że jeden wątek otrzyma przy odczycie wartość 5 a drugi 0. Prawidłową wartością jest 5, ale wątek drugi jeszcze nie zdążył się zsynchronizować (a dokładnie wartość w pamięci podręcznej z wartością w pamięci głownej).
Z volatile

Z volatile sytuacja braku synchronizacji pomiędzy pamięcią podręczną a pamięcią główną nie jest możliwa – odczyt zawsze następuje z pamięci głównej.
Przykład
Większość przykładów (obecnych w sieci) jest oparta o odczyt zmiennej w pętli. Jest to najprostszy sposób na zmuszenie JVM do korzystania z pamięci podręcznej (cache). Przedstawiam tutaj taki wzorcowy przykład:
class VolatileExample {
//no volatile -> program will not end
private static boolean IS_RUNNING = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> { while (IS_RUNNING) { } }).start();
Thread.sleep(100);
IS_RUNNING = false;
}
}
Bez dodania słowa kluczowego volatile (do zmiennej IS_RUNNING) program się nigdy nie skończy, mimo tego, że po 100 milisekundach następuje zmiana wartości zmiennej IS_RUNNING na false. Jest to optymalizacja zastosowana przez JVM. Po dodaniu słowa kluczowego volatile, program kończy się normalnie.
* Niefortunny przykład w Java Language Specification
Gdy zaczynałem przyglądać się bliżej o co chodzi z tym volatile, pomyślałem o oficjalnych źródłach na ten temat – no i znalazłem – https://docs.oracle.com/javase/specs/jls/se19/html/jls-8.html#jls-8.3.1.4 (gdyby ten link nie działał tutaj jest lista wszystkich specyfikacji). Przykład tam zamieszczony to:
class Test {
static int i = 0, j = 0;
static void one() { i++; j++; }
static void two() {
System.out.println("i=" + i + " j=" + j);
}
}
I dołączony opis (przetłumaczony i skrócony): Pierwszy wątek uruchamia wielokrotnie metodę one a drugi od czasu do czasu metodę two. Może się tak wydarzyć, że podczas uruchomienia metody two wartość zmiennej i będzie się różniła od zmiennej j. No dobra, to testujemy:
public class DocumentationExample {
static volatile int i = 0, j = 0;
static void one() { i++; j++; }
static void two() {
if(i != j){
System.out.println("i=" + i + " j=" + j);
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {while(true) {one();} }).start();
while (true){
Thread.sleep(new Random().nextInt(1000) + 100);
new Thread(() -> two()).start();
}
}
}
Co się tutaj dzieje?
1. Dodałem słówko volatile do zmiennych i oraz j
2. Metoda two wypisuje wartości tylko i wyłącznie gdy są jakieś rozbieżności
3. W metodzie main jeden wątek cały czas podbija wartości zmiennych i oraz j (metoda one). A drugi co jakiś czas je sprawdza (metoda two).
I niestety ale po chwili na konsoli pojawiały się kolejne wyniki:
i=443932630 j=443932630 (temu się przyjrzymy później)
i=738692930 j=738692929 <- anomalia
i=1037268913 j=1037268913 (temu się przyjrzymy później)
Co jest nie tak? Brakuje tutaj synchronizacji – podczas gdy wykonywana jest metoda two, metoda one cały czas zmienia wartości zmiennych i oraz j. Przykładowo:
0. Na początku wartości zmiennych to i=0 oraz j=0
1. Odpalana jest metoda two():
a) Następuje porównanie zmiennych i oraz j
—–> Tutaj wtrąca się metoda one() i podbija wartość zmiennej i -> i=1
i) Odczyt zmiennej i=1
ii) Odczyt zmiennej j=0
b) Porównanie zmiennych i oraz j -> (1 != 0)
—–> Tutaj kontynuacja wykonania metody one() -> zmienna j jest podbijana -> j=1
Inny przykład:
0. Na początku wartości zmiennych to i=0 oraz j=0
1. Odpalana jest metoda two():
a) Następuje porównanie zmiennych i oraz j
i) Odczyt zmiennej i=0
—–> Tutaj wtrąca się metoda one() i podbija wartość zmiennej i -> i=1
—–> Tutaj kontynuacja wykonania metody one() -> zmienna j jest podbijana -> j=1
ii) Odczyt zmiennej j=1
b) Porównanie zmiennych i oraz j -> (0 != 1)
Wniosek jest prosty – należy nałożyć synchronizację na obydwie metody (nałożenie na jedną z nich nic nie zmienia, ta druga może wykonywać się w dowolnym momencie).
Niejednoznaczne wyniki
Jak łatwo zauważyć wyniki testu dają dziwne rezultaty – czasami zwracane są równe sobie wartości, dla przypomnienia:
i=443932630 j=443932630 <- tutaj
i=738692930 j=738692929
i=1037268913 j=1037268913 <- i tutaj
Jest to spowodowane tym, że operacja porównania i wypisania na konsolę nie jest atomowa – najpierw jest odczyt do celów porównania a następnie ponownie wartość jest odczytywana w celu wypisania jej na konsoli. I znów się kłania synchronizacja – pomiędzy pierwszym a drugim odczytem drugi wątek może podmienić wartości zmiennych. Jak rozwiązać tą sytuację – należy zacząć od zapisu zmiennych i oraz j w innych zmiennych. Brzmi enigmatycznie, ale już służę przykładem:
public class DocumentationExample {
static int storedI = -1;
static int storedJ = -1;
static volatile int i = 0, j = 0;
static void one() {
i++;
j++;
}
static void two() {
storedI = i;
storedJ = j;
if (storedI != storedJ) {
System.out.println("i:" + i + " j:" + j);
System.out.println("storedI:" + storedI + " storedJ:" + storedJ);
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (true) {
one();
}
}).start();
while (true) {
Thread.sleep(new Random().nextInt(1000) + 100);
new Thread(() -> two()).start();
}
}
}
W załączonym kodzie pojawiły się nowe zmienne storedI oraz storedJ. Służą one do tymczasowego zachowania wartości zmiennych i oraz j. A z racji, że nie biorą one udziału w podbijaniu wartości, to spokojnie mogą przechowywać wartości do porównania oraz do wypisania do konsoli. W ten prosty sposób zapewniliśmy atomowość odczytu, porównania oraz wypisania do konsoli. Wyniki teraz:
i:52786819 j:52786819
storedI:52785057 storedJ:52785450
i:142649456 j:142649455
storedI:142649418 storedJ:142649421
i:396173724 j:396173724
storedI:396173562 storedJ:396173575
i:578351503 j:578351503
storedI:578351424 storedJ:578351432
i:683742069 j:683742068
storedI:683741985 storedJ:683741989
i:896576809 j:896576808
storedI:896576663 storedJ:896576776
i:1104719113 j:1104719113
storedI:1104719052 storedJ:1104719058
i:1208438257 j:1208438256
storedI:1208438229 storedJ:1208438233
Jak widać zmienne i oraz j czasami zawierają te same wartości, aczkolwiek zmienne storedI oraz storedJ już nie.
Konkluzja
Niestety ale przykład z Java Language Specification wydaje się być bardziej problemem synchronizacji niż słowa kluczowego volatile. Z drugiej strony z teoretycznego punktu widzenia synchronizacja nie zapewnia ominięcia optymalizacji, stąd po dodaniu synchronizacji na obydwu metodach możliwe jest, że któraś wartość będzie odczytana z pamięci podręcznej (z starą wartością). Próbowałem wiele razy odpalać powyższy kod z dodaną synchronizacją ale bez słowa kluczowego volatile przy zmiennych i oraz j. Ani razu nie zdarzyła się anomalia.
Warto zajrzeć
- Wszystkie przykłady znajdują się tutaj
- https://docs.oracle.com/javase/specs/jls/se19/html/jls-8.html#jls-8.3.1.3 (lub dotrzeć z tego poziomu)