Uchwycić volatile. Czyli o zmiennych ulotnych w Javie słów kilka

Radosław Kondziołka
Ikona kalendarza
16 listopada 2020

Zmienna "volatile" jest jednym ze słów kluczowych w języku Java. Znaczenie tego słowa często jest błędnie rozumiane choćby ze względu na fakt, że występuje ono w języku C/C++ i jego przeznaczenie jest zgoła inne niż w Javie. Być może dotychczas go nie używałeś bądź nawet nie spotkałeś się z nim w swojej codziennej pracy z kodem.

Przyczyny takiego stanu rzeczy można dopatrywać się w tym, że da się z powodzeniem pisać poprawne programy, nie wiedząc nawet o istnieniu tej konstrukcji. W związku z tym volatile może być uznane w pewnym sensie za nadmiarowe. Inaczej mówiąc, dowolny program (również współbieżny - w zasadzie tylko w takich programach sensowne jest rozważanie volatile) może zostać skonstruowany poprawnie, nie używając w ogóle tego słowa. Bez najmniejszego kłopotu możemy zastąpić zmienne ulotne (czyli zmienne volatile) przy pomocy bloku synchronized i na tym oprzeć poprawność programu. Relacja w drugą stronę jednak nie zachodzi, tj. nie da się zastąpić dowolnego programu, opartego o synchronized zmienną volatile. Z tego właśnie powodu warto spojrzeć na tę konstrukcję nie tylko pod kątem jej znaczenia, ale też jej zastosowań.

Operacje na zmiennych volatile

Zmienna volatile w gruncie rzeczy nie różni się od swojej "normalnej" wersji. Różnica pojawia się przy operacjach zapisu i odczytu na takich zmiennych. Wyobraźmy sobie na moment, że mamy zadeklarowaną zmienną v:

1volatile [Type] v;

Specyfikacja języka Java gwarantuje, że wątek odczytujący wartość zmiennej v widzi zawsze ostatni zapis do tej zmiennej, być może wykonany w innym wątku. Co więcej, wątek odczytujący wartość zmiennej v obserwuje wynik wszystkich zapisów, które zostały wykonane w innym wątku przed wykonaniem zapisu do zmiennej v.

obraz1.webp

Powyższy rysunek w sposób graficzny przedstawia to, co zostało wyżej opisane.

W dużym uproszczeniu można powiedzieć, że volatile sygnalizuje kompilatorowi i maszynie wirtualnej, że tak oznaczona zmienna może być współdzielona przez wątki. W związku z tym kompilator oraz runtime (JVM) powinny powstrzymywać się od wykonywania:

  • zmiany kolejności wykonywanych operacji na pamięci,
  • różnych optymalizacji, np. polegających na cache'owaniu wartości zmiennej.

W celu lepszego zrozumienia problematyki spójrzmy na następujący program:

1boolean x = false;
2
3T1:         		T2:
4while(!x) {} 		x = true

Dla jasności przekazu programy są uproszczone i mają postać pseudokodu. Zawartość kolumny T1 reprezentuje kod wykonywany przez wątek T1 natomiast kod w drugiej kolumnie przedstawia kod wykonywany w innym wątku - T2. Na pierwszy rzut oka może wydawać się, że ten program musi się zakończyć. Nic bardziej mylnego. Może zdarzyć się tak, że kompilator bądź JVM uzna, że nie wykona zapisu do globalnej zmiennej x. Zarówno JVM, jak i kompilator są uprawnieni do wykonania tego typu zabiegu, ponieważ zezwala na to specyfikacja języka. Tak napisany program jest po prostu niepoprawny. Wzbogacenie zmiennej x o atrybut volatile sprawia, że taki kod jest już poprawny - mamy bowiem gwarancję, że odczyt w wątku T1 zaobserwuje zapis wykonany w wątku T2.

Spójrzmy teraz jeszcze na inny program:

1int a = 0, b = 0;
2
3T1:         		T2:
4int r1 = b; 		a = 1;
5int r2 = a; 		b = 1;

Załóżmy, że wykonujemy ten program wiele razy i zapisujemy wyniki odczytów poczynionych w wątku T1. Po wykonaniu takiego testu możemy uzyskać następujące wyniki odczytów r1,r2:

1[0,0; 1,1; 0,1, 1,0]

O ile trzy pierwsze rezultaty nie zaskakują i są w prosty sposób wytłumaczalne, o tyle wynik 1,0 wydaje się być niemożliwy do uzyskania. Przecież, skoro wątek T1 zaobserwował zapis do zmiennej b, a ten zapis odbył się po zapisie do zmiennej a w wątku T2, to w takim razie odczyt zmiennej a, który odbywa się po odczycie b powinien zwrócić wartość 1. Wynik 1,0 przeczy jednak takiemu przebiegowi. Jedyne wyjaśnienie takiego wyniku prowadzi do konkluzji, że została zmieniona kolejność operacji. Takie wykonanie jest zgodne z JLS (Java Language Specification). Oznaczenie zmiennych a,b jako ulotnych gwarantuje, że kolejność tych operacji nie zostanie zmieniona. W konsekwencji jedyne dopuszczalne wyniki to:

1[0,0; 1,1; 0,1] 

Wynika to wprost z semantyki odczytów i zapisów volatile - jeżeli wątek T1 odczytał wartość 1 ze zmiennej b to niemożliwe jest, aby późniejszy odczyt zmiennej a w sensie porządku programu odczytał wartość 0, gdyż standard języka gwarantuje, że jeżeli wątek T1 odczytał wartość zmiennej volatile to zaobserwuje też wcześniejsze zapisy, a więc w szczególności zapis do zmiennej a.

Warto wspomnieć, że volatile ma jeszcze jedno, dodatkowe znaczenie w przypadku zmiennych typu long i double. Generalnie, zapisy i odczyty do zmiennych reprezentujących typy proste i referencyjne są atomowe. Wyjątek stanowią tutaj wcześniej wspomniane long i double. Odczyty i zapisy do takich zmiennych nie mają gwarancji atomowości. Z pomocą przychodzi tutaj oznaczenie ich jako volatile - operacje odczytu i zapisu na takich zmiennych są atomowe.

Kiedy warto użyć volatile?

Pierwszym powodem, dla którego warto byłoby sięgnąć po ten mechanizm, mogą być kwestie wydajnościowe. Nie w każdym przypadku, w którym wątki współdzielą zasób konieczne jest korzystanie z sekcji synchronized, a więc de facto blokowania (odblokowywania) monitora. Czasami wystarczą gwarancje, o których niżej, dostarczane przez zmienną volatile. Spójrzmy na poniższy prosty przykład:

1public class CompareSynchronizedAndVolatileRead {
2	static volatile int v;
3	static int i;
4
5	@Benchmark
6	@BenchmarkMode(Mode.AverageTime)
7	@OutputTimeUnit(TimeUnit.NANOSECONDS)
8	public static void takeLock(Blackhole blackhole) {
9    		synchronized(CompareSynchronizedAndVolatileRead.class) {
10        		blackhole.consume(i);
11    	}
12}
13
14
15	@Benchmark
16	@BenchmarkMode(Mode.AverageTime)
17	@OutputTimeUnit(TimeUnit.NANOSECONDS)
18	public static void volatileRead(Blackhole blackhole) {
19    		blackhole.consume(v);
20	}
21
22	public static void main(String[] args) {
23    	org.openjdk.jmh.Main.main(args);
24	}
25}

Przedstawiony program wyznacza przy pomocy benchmarku Java Microbenchmark Harness (JMH) ile czasu zajmuje wykonanie tych dwóch funkcji. Obydwie metody realizują tą samą funkcjonalność - odczyt zmiennej całkowitoliczbowej w sposób poprawny pod kątem współbieżności, tzw. threadsafe. W pierwszym przypadku, żeby to zrobić używamy bloku synchronized, w drugim zaś odczytu zmiennej typu volatile. Na potrzeby naszych rozważań możemy pominąć wyrażenie Blackhole.consume - nie jest ono istotne. Wyniki prezentują się następująco:

Wynik jest oczywiście taki, jakiego należałoby się spodziewać. Dość powiedzieć, że operacje na zmiennych volatile nie są blokujące, w przeciwieństwie do zajmowania monitora. Wynika to po prostu z wcześniej wspomnianego już faktu, że operacje na zmiennej “ulotnej” same w sobie są lżejszym mechanizmem synchronizacji.

Drugim argumentem, który przemawiałby za volatile jest fakt, że w pewnych przypadkach użycie go ułatwia implementację, a co za tym idzie ta jest prostsza w odbiorze. Generalnie, poza jakimiś szczególnymi przypadkami, to ten drugi argument powinien przeważać nad tym pierwszym. W tym miejscu należy jednak uważać gdyż nadużycie volatile może się w tym kontekście okazać bronią obosieczną.

obraz2.webp

Wynik jest oczywiście taki, jakiego należałoby się spodziewać. Dość powiedzieć, że operacje na zmiennych volatile nie są blokujące, w przeciwieństwie do zajmowania monitora. Wynika to po prostu z wcześniej wspomnianego już faktu, że operacje na zmiennej “ulotnej” same w sobie są lżejszym mechanizmem synchronizacji.

Drugim argumentem, który przemawiałby za volatile jest fakt, że w pewnych przypadkach użycie go ułatwia implementację, a co za tym idzie ta jest prostsza w odbiorze. Generalnie, poza jakimiś szczególnymi przypadkami, to ten drugi argument powinien przeważać nad tym pierwszym. W tym miejscu należy jednak uważać gdyż nadużycie volatile może się w tym kontekście okazać bronią obosieczną.

Podsumowanie

W tym krótkim artykule został przedstawiony uproszczony i intuicyjny obraz zmiennych volatile. Jeżeli tematyka Cię zainteresowała, bardziej formalny opis można znaleźć w specyfikacji języka, którą możesz odnaleźć np. pod tym linkiem. W przypadku chęci skorzystania ze zmiennej volatile należy zawsze zastanowić się, dlaczego chcę to zrobić i czy rzeczywiście gwarancje dostarczane przez Javę dla tych zmiennych są w tym przypadku wystarczające. Zagadnienie volatile jest szczegółowo omawiane podczas szkolenia Wielowątkowość w języku Java.

Przeczytaj także

Ikona kalendarza

29 marzec

To be or not to be a multi cloud company? Przewodnik dla kadry kierowniczej, doradców ds. chmury i architektów IT. (część 2)

Po przeczytaniu pierwszej części poradnika zauważymy, że strategie organizacji są różne. Część firm oparło swój biznes wyłącznie na j...

Ikona kalendarza

27 wrzesień

Sages wdraża system Omega-PSIR oraz System Oceny Pracowniczej w SGH

Wdrożenie Omega-PSIR i Systemu Oceny Pracowniczej w SGH. Sprawdź, jak nasze rozwiązania wspierają zarządzanie uczelnią i potencjałem ...

Ikona kalendarza

12 wrzesień

Playwright vs Cypress vs Selenium - czy warto postawić na nowe?

Playwright, Selenium czy Cypress? Odkryj kluczowe różnice i zalety każdego z tych narzędzi do automatyzacji testów aplikacji internet...