Java Language
Model pamięci Java
Szukaj…
Uwagi
Model pamięci Java to sekcja JLS, która określa warunki, w których jeden wątek ma zagwarantowane efekty zapisu do pamięci wykonanego przez inny wątek. Odpowiednią sekcją ostatnich wydań jest „Model pamięci JLS 17.4” (w Javie 8 , Java 7 , Java 6 )
W Java 5 dokonano gruntownej rewizji modelu pamięci Java, który (między innymi) zmienił sposób działania volatile
. Od tego czasu model pamięci pozostał zasadniczo niezmieniony.
Motywacja do modelu pamięci
Rozważ następujący przykład:
public class Example {
public int a, b, c, d;
public void doIt() {
a = b + 1;
c = d + 1;
}
}
Jeśli ta klasa zostanie użyta jako aplikacja jednowątkowa, wówczas obserwowalne zachowanie będzie dokładnie takie, jak można się spodziewać. Na przykład:
public class SingleThreaded {
public static void main(String[] args) {
Example eg = new Example();
System.out.println(eg.a + ", " + eg.c);
eg.doIt();
System.out.println(eg.a + ", " + eg.c);
}
}
wyświetli:
0, 0
1, 1
O ile „główny” wątek może powiedzieć , instrukcje w metodzie main()
i metodzie doIt()
będą wykonywane w kolejności, w jakiej zostały zapisane w kodzie źródłowym. Jest to wyraźny wymóg specyfikacji języka Java (JLS).
Rozważmy teraz tę samą klasę, co w aplikacji wielowątkowej.
public class MultiThreaded {
public static void main(String[] args) {
final Example eg = new Example();
new Thread(new Runnable() {
public void run() {
while (true) {
eg.doIt();
}
}
}).start();
while (true) {
System.out.println(eg.a + ", " + eg.c);
}
}
}
Co to wydrukuje?
W rzeczywistości, zgodnie z JLS, nie można przewidzieć, że zostanie to wydrukowane:
- Najpierw prawdopodobnie zobaczysz kilka linii
0, 0
. - Wtedy prawdopodobnie zobaczysz linie takie jak
N, N
lubN, N + 1
. - Możesz zobaczyć linie takie jak
N + 1, N
- Teoretycznie możesz nawet zauważyć, że linie
0, 0
trwają wiecznie 1 .
1 - W praktyce obecność instrukcji println
może spowodować nieoczekiwaną synchronizację i opróżnienie pamięci podręcznej. To prawdopodobnie ukryje niektóre efekty, które spowodowałyby powyższe zachowanie.
Jak więc możemy to wyjaśnić?
Zmiana kolejności zadań
Jednym z możliwych wyjaśnień nieoczekiwanych wyników jest to, że kompilator JIT zmienił kolejność przypisań w doIt()
. JLS wymaga, aby instrukcje wydawały się wykonywać w kolejności z perspektywy bieżącego wątku. W takim przypadku nic w kodzie metody doIt()
może zaobserwować efektu (hipotetycznego) zmiany kolejności tych dwóch instrukcji. Oznacza to, że kompilator JIT będzie mógł to zrobić.
Dlaczego miałby to zrobić?
Na typowym nowoczesnym sprzęcie instrukcje maszynowe są wykonywane przy użyciu potoku instrukcji, który umożliwia sekwencję instrukcji na różnych etapach. Niektóre fazy wykonywania instrukcji trwają dłużej niż inne, a operacje pamięci zwykle zajmują więcej czasu. Inteligentny kompilator może zoptymalizować przepływ instrukcji potoku, zamawiając instrukcje w celu zmaksymalizowania nakładania się. Może to prowadzić do wykonywania części instrukcji poza kolejnością. JLS pozwala na to, pod warunkiem, że nie wpływa to na wynik obliczeń z perspektywy bieżącego wątku .
Efekty pamięci podręcznej
Drugim możliwym wyjaśnieniem jest efekt buforowania pamięci. W klasycznej architekturze komputerowej każdy procesor ma mały zestaw rejestrów i większą ilość pamięci. Dostęp do rejestrów jest znacznie szybszy niż dostęp do pamięci głównej. We współczesnych architekturach istnieją pamięci podręczne wolniejsze niż rejestry, ale szybsze niż pamięć główna.
Kompilator wykorzysta to, próbując zachować kopie zmiennych w rejestrach lub w pamięciach podręcznych. Jeśli zmienna nie musi być opróżniana do pamięci głównej lub nie musi być odczytywana z pamięci, niesie to znaczące korzyści w zakresie wydajności. W przypadkach, gdy JLS nie wymaga, aby operacje pamięci były widoczne dla innego wątku, kompilator Java JIT prawdopodobnie nie doda instrukcji „bariery odczytu” i „bariery zapisu”, które wymuszą odczytywanie i zapisywanie w pamięci głównej. Po raz kolejny korzyści płynące z wydajności są znaczące.
Prawidłowa synchronizacja
Do tej pory widzieliśmy, że JLS pozwala kompilatorowi JIT na generowanie kodu, który przyspiesza kod jednowątkowy poprzez zmianę kolejności lub unikanie operacji pamięci. Ale co się stanie, gdy inne wątki będą w stanie obserwować stan (wspólnych) zmiennych w pamięci głównej?
Odpowiedź jest taka, że inne wątki mogą obserwować stany zmiennych, które wydawałyby się niemożliwe ... w oparciu o kolejność kodów instrukcji Java. Rozwiązaniem tego jest zastosowanie odpowiedniej synchronizacji. Trzy główne podejścia to:
- Używanie prymitywnych muteksów i
synchronized
konstrukcji. - Używanie zmiennych
volatile
. - Korzystanie z obsługi współbieżności wyższego poziomu; np. klasy w pakietach
java.util.concurrent
.
Ale nawet przy tym ważne jest, aby zrozumieć, gdzie potrzebna jest synchronizacja i na jakich efektach można polegać. Właśnie tutaj pojawia się model pamięci Java.
Model pamięci
Model pamięci Java to sekcja JLS, która określa warunki, w których jeden wątek ma zagwarantowane efekty zapisu do pamięci wykonanego przez inny wątek. Model pamięci jest określony z dość rygorystyczną formalną surowością i (w rezultacie) wymaga szczegółowej i uważnej lektury, aby zrozumieć. Ale podstawową zasadą jest to, że niektóre konstrukcje tworzą relację „dzieje się przed” między zapisem zmiennej przez jeden wątek, a późniejszym odczytem tej samej zmiennej przez inny wątek. Jeśli istnieje relacja „zdarzyło się przed”, kompilator JIT jest zobowiązany do wygenerowania kodu, który zapewni, że operacja odczytu zobaczy wartość zapisaną przez zapis.
Uzbrojeni w ten sposób można wnioskować o spójności pamięci w programie Java i zdecydować, czy będzie to przewidywalne i spójne dla wszystkich platform wykonawczych.
Zdarza się przed związkami
(Poniżej znajduje się uproszczona wersja tego, co mówi specyfikacja języka Java. Aby uzyskać głębsze zrozumienie, należy przeczytać samą specyfikację).
Relacje, które zdarzały się wcześniej, są częścią Modelu Pamięci, który pozwala nam zrozumieć i uzasadnić widoczność pamięci. Jak mówi JLS ( JLS 17.4.5 ):
„Dwie czynności mogą być uporządkowane według relacji przed zdarzeniem. Jeśli jedna akcja wydarzy się przed drugą, pierwsza będzie widoczna i uporządkowana przed drugą.”
Co to znaczy?
działania
Działania, do których odnosi się powyższy cytat, są określone w JLS 17.4.2 . Istnieje 5 rodzajów działań określonych przez specyfikację:
Przeczytaj: Odczyt zmiennej nielotnej.
Napisz: zapis zmiennej nieulotnej.
Działania synchronizacyjne:
Odczyt lotny: odczyt zmiennej lotnej.
Zapis niestabilny: pisanie zmiennej lotnej.
Zamek. Blokowanie monitora
Odblokować. Odblokowywanie monitora.
(Syntetyczne) pierwsze i ostatnie działania wątku.
Działania, które uruchamiają wątek lub wykrywają, że wątek został zakończony.
Działania zewnętrzne. Działanie, którego wynik zależy od środowiska, w którym program.
Działania dotyczące rozbieżności wątków. Modelują one zachowanie niektórych rodzajów nieskończonej pętli.
Kolejność programów i kolejność synchronizacji
Te dwa porządki ( JLS 17.4.3 i JLS 17.4.4 ) regulują wykonywanie instrukcji w Javie
Kolejność programów opisuje kolejność wykonywania instrukcji w jednym wątku.
Kolejność synchronizacji opisuje kolejność wykonywania instrukcji dla dwóch instrukcji połączonych przez synchronizację:
Operacja odblokowania na monitorze synchronizuje się ze wszystkimi kolejnymi operacjami blokady na tym monitorze.
Zapis do zmiennej lotnej synchronizuje się ze wszystkimi kolejnymi odczytami tej samej zmiennej przez dowolny wątek.
Akcja, która rozpoczyna wątek (tj. Wywołanie
Thread.start()
) synchronizuje się z pierwszą akcją w wątku, który uruchamia (tj. Wywołanie metodyrun()
wątku).Domyślna inicjalizacja pól synchronizuje się z pierwszą akcją w każdym wątku. (Zobacz JLS, aby uzyskać wyjaśnienie tego.)
Ostatnia akcja w wątku synchronizuje się z dowolną akcją w innym wątku, która wykrywa zakończenie; np. powrót wywołania
join()
lub wywołaniaisTerminated()
które zwracatrue
.Jeśli jeden wątek przerywa inny wątek, wywołanie przerwania w pierwszym wątku synchronizuje się z punktem, w którym inny wątek wykrywa, że wątek został przerwany.
Zdarza się przed zamówieniem
To uporządkowanie ( JLS 17.4.5 ) decyduje o tym, czy zapis w pamięci jest gwarantowany dla następnego odczytu pamięci.
Dokładniej mówiąc, odczyt zmiennej v
gwarantuje obserwację zapisu do v
wtedy i tylko wtedy, gdy nastąpi write(v)
- przed read(v)
ORAZ nie ma zapisu pośredniego do v
. Jeśli istnieją interwencyjne zapisy, to read(v)
może zobaczyć ich wyniki, a nie wcześniejszy.
Reguły definiujące zdarzenie przed złożeniem zamówienia są następujące:
Dzieje się przed regułą nr 1 - jeśli xiy są działaniami tego samego wątku, a x występuje przed y w porządku programu , to x dzieje się przed y.
Dzieje się przed regułą 2 - zdarza się, że krawędź przed końcem konstruktora obiektu do początku finalizatora tego obiektu.
Dzieje się przed regułą nr 3 - jeśli akcja x synchronizuje się z kolejną akcją y, to x dzieje się przed y.
Dzieje się - przed regułą 4 - jeśli x zdarzy się - zanim y i y zdarzy się - przed z, to x zdarzy się - przed z.
Ponadto różne klasy w bibliotekach standardowych Java są określone jako definiujące relacje przed zdarzeniem. Można to interpretować w ten sposób, że dzieje się jakoś, bez konieczności wiedzieć dokładnie, jak gwarancja zostanie spełnione.
Dzieje się tak, zanim do niektórych przykładów zastosowano rozumowanie
Zaprezentujemy kilka przykładów, aby pokazać, jak stosować, co się dzieje - przed uzasadnieniem, aby sprawdzić, czy zapisy są widoczne dla kolejnych odczytów.
Kod jednowątkowy
Jak można się spodziewać, zapisy są zawsze widoczne dla kolejnych odczytów w programie jednowątkowym.
public class SingleThreadExample {
public int a, b;
public int add() {
a = 1; // write(a)
b = 2; // write(b)
return a + b; // read(a) followed by read(b)
}
}
Zgodnie z regułą nr 1 Happens-Before:
- Akcja
write(a)
dzieje się przed działaniemwrite(b)
. - Akcja
write(b)
dzieje się przed operacjąread(a)
. - Akcja
read(a)
dzieje się przed operacjąread(a)
.
Zgodnie z regułą 4 przed Happens-Before:
-
write(a)
wydarzy się przedwrite(b)
ORAZwrite(b)
wydarzy się przedread(a)
IMPLIESwrite(a)
wydarzy się przedread(a)
. -
write(b)
dzieje się przedread(a)
ORAZread(a)
dzieje się przedread(b)
IMPLIESwrite(b)
dzieje się przedread(b)
.
Podsumowując:
- Relacja
write(a)
dzieje się przedread(a)
oznacza, że instrukcjaa + b
gwarantuje prawidłową wartośća
. - Relacja
write(b)
dzieje się przedread(b)
oznacza, że instrukcjaa + b
gwarantuje prawidłową wartośćb
.
Zachowanie „niestabilności” w przykładzie z 2 wątkami
Wykorzystamy następujący przykładowy kod, aby zbadać niektóre implikacje modelu pamięci dla `volatile.
public class VolatileExample {
private volatile int a;
private int b; // NOT volatile
public void update(int first, int second) {
b = first; // write(b)
a = second; // write-volatile(a)
}
public int observe() {
return a + b; // read-volatile(a) followed by read(b)
}
}
Najpierw rozważ następującą sekwencję instrukcji obejmującą 2 wątki:
- Utworzono jedno wystąpienie
VolatileExample
; nazwij tove
, -
ve.update(1, 2)
jest wywoływana w jednym wątku, i -
ve.observe()
jest wywoływany w innym wątku.
Zgodnie z regułą nr 1 Happens-Before:
- Akcja
write(a)
dzieje się przed działaniemvolatile-write(a)
. - Akcja
volatile-read(a)
ma miejsce przed akcjąread(b)
.
Według zasady Happens-Before nr 2:
- Akcja
volatile-write(a)
w pierwszym wątku ma miejsce przed akcjąvolatile-read(a)
w drugim wątku.
Zgodnie z regułą 4 przed Happens-Before:
- Akcja
write(b)
w pierwszym wątku ma miejsce przed operacjąread(b)
w drugim wątku.
Innymi słowy, dla tej konkretnej sekwencji mamy gwarancję, że 2. wątek zobaczy aktualizację nieulotnej zmiennej b
wykonanej przez pierwszy wątek. Jednak powinno być również jasne, że jeśli przypisania w metodzie update
byłyby odwrotne, lub metoda observe()
odczytała zmienną b
przed a
, wówczas łańcuch „ zdarza się przed” zostałby zerwany. Łańcuch zostałby również zerwany, gdyby volatile-read(a)
w drugim wątku nie następował po volatile-write(a)
w pierwszym wątku.
Gdy łańcuch zostanie zerwany, nie ma gwarancji, że observe()
zobaczy prawidłową wartość b
.
Lotny z trzema nitkami
Załóżmy, że dodamy trzeci wątek do poprzedniego przykładu:
- Utworzono jedno wystąpienie
VolatileExample
; nazwij tove
, -
update
połączenia dwóch wątków:-
ve.update(1, 2)
jest wywoływana w jednym wątku, -
ve.update(3, 4)
jest wywoływana w drugim wątku,
-
-
ve.observe()
jest następnie wywoływany w trzecim wątku.
Aby całkowicie to przeanalizować, musimy wziąć pod uwagę wszystkie możliwe przeplatania instrukcji w wątku pierwszym i drugim. Zamiast tego rozważymy tylko dwa z nich.
Scenariusz nr 1 - załóżmy, że update(1, 2)
poprzedza update(3,4)
otrzymujemy następującą sekwencję:
write(b, 1), write-volatile(a, 2) // first thread
write(b, 3), write-volatile(a, 4) // second thread
read-volatile(a), read(b) // third thread
W takim przypadku łatwo zauważyć, że dzieje się to nieprzerwanie przed łańcuchem od write(b, 3)
do read(b)
. Ponadto nie ma zapisu wtrącającego się do b
. Zatem w tym scenariuszu trzeci wątek gwarantuje, że b
ma wartość 3
.
Scenariusz nr 2 - załóżmy, że update(1, 2)
i update(3,4)
nakładają się na siebie, a acje są przeplatane w następujący sposób:
write(b, 3) // second thread
write(b, 1) // first thread
write-volatile(a, 2) // first thread
write-volatile(a, 4) // second thread
read-volatile(a), read(b) // third thread
Teraz, chociaż istnieje łańcuch przed zdarzeniem od write(b, 3)
do read(b)
, istnieje interwencja write(b, 1)
wykonywana przez drugi wątek. Oznacza to, że nie jesteśmy pewni, którą wartość read(b)
zobaczy.
(Poza tym: pokazuje to, że nie możemy polegać na volatile
celu zapewnienia widoczności zmiennych nieulotnych, z wyjątkiem bardzo ograniczonych sytuacji).
Jak uniknąć konieczności zrozumienia modelu pamięci
Model pamięci jest trudny do zrozumienia i trudny do zastosowania. Przydaje się, jeśli chcesz uzasadnić poprawność wielowątkowego kodu, ale nie chcesz tego robić w przypadku każdej pisanej aplikacji wielowątkowej.
Jeśli przyjmujesz następujące zasady podczas pisania współbieżnego kodu w Javie, możesz w dużej mierze uniknąć konieczności uciekania się do zdarzenia przed uzasadnieniem.
W miarę możliwości używaj niezmiennych struktur danych. Prawidłowo zaimplementowana niezmienna klasa będzie bezpieczna dla wątków i nie spowoduje problemów z bezpieczeństwem wątków, gdy będzie używana z innymi klasami.
Zrozum i unikaj „niebezpiecznych publikacji”.
Użyj prymitywnych muteksów lub
Lock
obiekty, aby zsynchronizować dostęp do stanu w obiektach zmiennych, które muszą być bezpieczne dla wątków 1 .Użyj programu
Executor
/ExecutorService
lub szkieletu łączenia widelców zamiast próbować bezpośrednio tworzyć wątki zarządzania.Skorzystaj z klas `java.util.concurrent, które zapewniają zaawansowane blokady, semafory, zatrzaski i bariery, zamiast bezpośredniego oczekiwania na powiadomienie / powiadomienie.
Używaj
java.util.concurrent
wersji map, zestawów, list, kolejek i deques zamiast zewnętrznej synchronizacji zbiorów niebieżnych.
Ogólną zasadą jest próba użycia wbudowanych bibliotek współbieżności Java zamiast „rozwijania własnej” współbieżności. Możesz na nich polegać, jeśli użyjesz ich prawidłowo.
1 - Nie wszystkie obiekty muszą być bezpieczne dla wątków. Na przykład, jeśli obiekt lub obiekty są ograniczone wątkiem (tj. Są dostępne tylko dla jednego wątku), to jego bezpieczeństwo wątków nie jest istotne.