Przewodnik po Volatile Keyword w Javie

1. Przegląd

W przypadku braku niezbędnych synchronizacji kompilator, środowisko uruchomieniowe lub procesory mogą zastosować wszelkiego rodzaju optymalizacje. Mimo że te optymalizacje są korzystne w większości przypadków, czasami mogą powodować subtelne problemy.

Buforowanie i zmiana kolejności należą do tych optymalizacji, które mogą nas zaskoczyć w równoległych kontekstach. Java i JVM zapewniają wiele sposobów kontrolowania kolejności pamięci, a słowo kluczowe volatile jest jednym z nich.

W tym artykule skupimy się na tej podstawowej, ale często źle rozumianej koncepcji języka Java - słowu kluczowym volatile . Najpierw zaczniemy od trochę informacji o tym, jak działa podstawowa architektura komputera, a następnie zaznajomimy się z kolejnością pamięci w Javie.

2. Współdzielona architektura wieloprocesorowa

Procesory są odpowiedzialne za wykonywanie instrukcji programu. Dlatego muszą pobrać zarówno instrukcje programu, jak i wymagane dane z pamięci RAM.

Ponieważ procesory są w stanie wykonać znaczną liczbę instrukcji na sekundę, pobieranie z pamięci RAM nie jest dla nich idealne. Aby poprawić tę sytuację, procesory używają sztuczek, takich jak wykonanie poza kolejnością, przewidywanie gałęzi, wykonanie spekulacyjne i oczywiście buforowanie.

W tym miejscu do gry wkracza następująca hierarchia pamięci:

Ponieważ różne rdzenie wykonują więcej instrukcji i przetwarzają więcej danych, zapełniają swoje pamięci podręczne bardziej odpowiednimi danymi i instrukcjami. Poprawi to ogólną wydajność kosztem wprowadzenia wyzwań związanych ze spójnością pamięci podręcznej .

Mówiąc prościej, powinniśmy dwa razy pomyśleć o tym, co się stanie, gdy jeden wątek zaktualizuje buforowaną wartość.

3. Kiedy używać ulotnych

Aby bardziej rozwinąć spójność pamięci podręcznej, zapożyczmy jeden przykład z książki Java Concurrency in Practice:

public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }

TaskRunner klasa utrzymuje dwa proste zmienne. W swojej głównej metodzie tworzy kolejny wątek, który obraca się na zmiennej gotowej, o ile jest fałszywa. Gdy zmienna staje się prawdą, nitka po prostu wydrukować numer zmiennej.

Wielu może oczekiwać, że ten program po prostu wypisze 42 po krótkim opóźnieniu. Jednak w rzeczywistości opóźnienie może być znacznie dłuższe. Może nawet zawiesić się na zawsze, a nawet wydrukować zero!

Przyczyną tych anomalii jest brak odpowiedniej widoczności i uporządkowania pamięci . Przeanalizujmy je bardziej szczegółowo.

3.1. Widoczność pamięci

W tym prostym przykładzie mamy dwa wątki aplikacji: wątek główny i wątek czytnika. Wyobraźmy sobie scenariusz, w którym system operacyjny planuje te wątki na dwóch różnych rdzeniach procesora, gdzie:

  • Główny wątek ma swoją kopię gotowych i numerycznych zmiennych w swojej podstawowej pamięci podręcznej
  • Wątek czytelnika również kończy się jego kopiami
  • Główny wątek aktualizuje wartości zapisane w pamięci podręcznej

W przypadku większości nowoczesnych procesorów żądania zapisu nie będą stosowane od razu po ich wydaniu. W rzeczywistości procesory mają tendencję do kolejkowania tych zapisów w specjalnym buforze zapisu . Po chwili wszystkie te zapisy zostaną zapisane w pamięci głównej.

Biorąc to wszystko pod uwagę, gdy główny wątek aktualizuje liczbę i gotowe zmienne, nie ma gwarancji, co może zobaczyć wątek czytający. Innymi słowy, wątek czytający może zobaczyć zaktualizowaną wartość od razu, z pewnym opóźnieniem lub wcale!

Ta widoczność w pamięci może powodować problemy z aktywnością w programach zależnych od widoczności.

3.2. Zmiana kolejności

Co gorsza, wątek czytelnika może zobaczyć te zapisy w innej kolejności niż rzeczywista kolejność programu . Na przykład, ponieważ najpierw zaktualizować numer zmiennej:

public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }

Możemy spodziewać się, że wątek czytelnika wypisze 42. Jednak w rzeczywistości można zobaczyć zero jako wydrukowaną wartość!

Zmiana kolejności jest techniką optymalizacji poprawiającą wydajność. Co ciekawe, różne komponenty mogą stosować tę optymalizację:

  • Procesor może opróżniać swój bufor zapisu w dowolnej kolejności innej niż kolejność programu
  • Procesor może zastosować technikę wykonania poza kolejnością
  • Kompilator JIT może zoptymalizować poprzez zmianę kolejności

3.3. ulotna kolejność pamięci

Aby mieć pewność, że aktualizacje zmiennych są propagowane w przewidywalny sposób do innych wątków, powinniśmy zastosować modyfikator volatile do tych zmiennych:

public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }

W ten sposób komunikujemy się ze środowiskiem wykonawczym i procesorem, aby nie zmieniać kolejności żadnych instrukcji dotyczących zmiennej lotnej . Ponadto procesory rozumieją, że powinny natychmiast opróżnić wszystkie aktualizacje tych zmiennych.

4. nietrwałe i synchronizacja wątków

W przypadku aplikacji wielowątkowych musimy zapewnić kilka reguł zapewniających spójne zachowanie:

  • Wzajemne wykluczenie - tylko jeden wątek wykonuje krytyczną sekcję naraz
  • Widoczność - zmiany wprowadzone przez jeden wątek w udostępnianych danych są widoczne dla innych wątków w celu zachowania spójności danych

zsynchronizowane metody i bloki zapewniają obie powyższe właściwości kosztem wydajności aplikacji.

zmienny jest dość przydatnym słowem kluczowym, ponieważ może pomóc zapewnić widoczność zmiany danych, oczywiście bez wzajemnego wykluczania . Dlatego jest przydatny w miejscach, w których nie przeszkadza nam wiele wątków wykonujących równolegle blok kodu, ale musimy zapewnić właściwość visibility.

5. Dzieje się przed złożeniem zamówienia

The memory visibility effects of volatile variables extend beyond the volatile variables themselves.

To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).

5.1. Piggybacking

Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:

public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }

Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.

Korzystając z tej semantyki, możemy zdefiniować tylko kilka zmiennych w naszej klasie jako zmienne i zoptymalizować gwarancję widoczności.

6. Wniosek

W tym samouczku omówiliśmy więcej słów kluczowych volatile i jego możliwości, a także ulepszeń wprowadzonych do niego począwszy od języka Java 5.

Jak zawsze, przykłady kodu można znaleźć na GitHub.