Wprowadzenie do modelu pamięci Java
Model pamięci Java (JMM) opisuje zachowanie wątków w środowisku wykonawczym Java. Model pamięci jest częścią semantyki języka Java i opisuje, czego programista może, a czego nie powinien oczekiwać, tworząc oprogramowanie nie dla określonej maszyny Java, ale dla Javy jako całości.
Opracowany w 1995 roku oryginalny model pamięci Javy (który w szczególności odnosi się do „pamięci perkolokalnej”) jest uważany za porażkę: wielu optymalizacji nie da się przeprowadzić bez utraty gwarancji bezpieczeństwa kodu. W szczególności istnieje kilka opcji pisania wielowątkowego „pojedynczego”:
- albo każdy akt dostępu do singletona (nawet gdy obiekt został utworzony dawno temu i nic nie może się zmienić) spowoduje blokadę między wątkami;
- lub w pewnych okolicznościach system wyda niedokończonego samotnika;
- lub w pewnych okolicznościach system stworzy dwóch samotników;
- lub projekt będzie zależał od zachowania konkretnej maszyny.
Dlatego mechanizm pamięci został przeprojektowany. W 2005 roku, wraz z wydaniem Java 5, zaprezentowano nowe podejście, które zostało dodatkowo udoskonalone wraz z wydaniem Java 14.
Nowy model opiera się na trzech zasadach:
Zasada nr 1 : Programy jednowątkowe działają pseudosekwencyjnie. Oznacza to: w rzeczywistości procesor może wykonać kilka operacji na zegar, jednocześnie zmieniając ich kolejność, jednak wszystkie zależności danych pozostają, więc zachowanie nie różni się od sekwencyjnego.
Zasada nr 2 : nie ma wartości znikąd. Odczyt dowolnej zmiennej (z wyjątkiem nieulotnych long i double, dla których ta reguła może nie obowiązywać) zwróci albo wartość domyślną (zero), albo coś zapisanego tam przez inne polecenie.
I reguła numer 3 : pozostałe zdarzenia są wykonywane w kolejności, jeśli są połączone ścisłą relacją porządku częściowego „wykonuje się przed” ( dzieje się przed ).
Zdarza się wcześniej
Leslie Lamport wpadł na pomysł Happens już wcześniej . Jest to ścisła relacja częściowego porządku wprowadzona między poleceniami atomowymi (++ i -- nie są atomowe) i nie oznacza „fizycznie przed”.
Mówi się, że drugi zespół będzie „świadomy” zmian wprowadzonych przez pierwszy.

Na przykład jeden jest wykonywany przed drugim dla takich operacji:
Synchronizacja i monitory:
- Przechwytywanie monitora ( metoda blokady , zsynchronizowany start) i cokolwiek dzieje się w tym samym wątku po nim.
- Powrót monitora (metoda unlock , koniec synchronizacji) i co się dzieje na tym samym wątku przed nim.
- Zwrócenie monitora, a następnie przechwycenie go przez inny wątek.
Pisanie i czytanie:
- Zapisywanie do dowolnej zmiennej, a następnie odczytywanie jej w tym samym strumieniu.
- Wszystko w tym samym wątku przed zapisem do zmiennej lotnej i samym zapisem. volatile read i wszystko w tym samym wątku po nim.
- Zapisywanie do zmiennej lotnej, a następnie odczytywanie jej ponownie. Zapis ulotny oddziałuje z pamięcią w taki sam sposób, jak powrót monitora, podczas gdy odczyt jest jak przechwytywanie. Okazuje się, że jeśli jeden wątek zapisał zmienną ulotną, a drugi ją znalazł, to wszystko, co poprzedza zapis, jest wykonywane przed wszystkim, co następuje po odczycie; widzieć zdjęcie.
Konserwacja obiektu:
- Inicjalizacja statyczna i dowolne akcje z dowolnymi instancjami obiektów.
- Zapisywanie do końcowych pól w konstruktorze i wszystkiego po konstruktorze. Jako wyjątek, relacja dzieje się przed nie łączy się przechodnie z innymi regułami i dlatego może powodować wyścig między wątkami.
- Dowolna praca z obiektem i finalize() .
Usługa przesyłania strumieniowego:
- Uruchamianie wątku i dowolnego kodu w wątku.
- Zerowanie zmiennych związanych z wątkiem i dowolnego kodu w wątku.
- Kod w wątku i join() ; kod w wątku i isAlive() == false .
- przerwać () wątek i wykryć, że został zatrzymany.
Dzieje się przed niuansami pracy
Zwolnienie monitora następuje przed uzyskaniem tego samego monitora. Warto zauważyć, że jest to wydanie, a nie wyjście, czyli nie musisz się martwić o bezpieczeństwo podczas korzystania z wait.
Zobaczmy, jak ta wiedza pomoże nam poprawić nasz przykład. W tym przypadku wszystko jest bardzo proste: wystarczy usunąć kontrolę zewnętrzną i pozostawić synchronizację taką, jaka jest. Teraz drugi wątek ma gwarancję, że zobaczy wszystkie zmiany, ponieważ otrzyma monitor dopiero po zwolnieniu go przez drugi wątek. A ponieważ nie wyda go, dopóki wszystko nie zostanie zainicjowane, zobaczymy wszystkie zmiany na raz, a nie osobno:
public class Keeper {
private Data data = null;
public Data getData() {
synchronized(this) {
if(data == null) {
data = new Data();
}
}
return data;
}
}
Zapis do zmiennej lotnej ma miejsce przed odczytem z tej samej zmiennej. Zmiana, którą wprowadziliśmy, oczywiście naprawia błąd, ale powoduje, że osoba, która napisała oryginalny kod, wraca tam, skąd pochodzi – za każdym razem blokując. Niestabilne słowo kluczowe może uratować. W rzeczywistości stwierdzenie, o którym mowa, oznacza, że czytając wszystko, co jest zadeklarowane jako ulotne, zawsze otrzymamy rzeczywistą wartość.
Ponadto, jak powiedziałem wcześniej, dla pól lotnych pisanie jest zawsze (w tym long i double) operacją atomową. Kolejna ważna kwestia: jeśli masz zmienną jednostkę, która ma odniesienia do innych jednostek (na przykład tablicę, Listę lub inną klasę), to tylko odwołanie do samej jednostki będzie zawsze „świeże”, ale nie do wszystkiego w to przychodzące.
Wróćmy więc do naszych siłowników z podwójną blokadą. Używając volatile, możesz naprawić sytuację w następujący sposób:
public class Keeper {
private volatile Data data = null;
public Data getData() {
if(data == null) {
synchronized(this) {
if(data == null) {
data = new Data();
}
}
}
return data;
}
}
Tutaj nadal mamy blokadę, ale tylko wtedy, gdy data == null. Odfiltrowujemy pozostałe przypadki za pomocą odczytu ulotnego. Poprawność zapewnia fakt, że volatile store dzieje się przed volatile read, a wszystkie operacje jakie zachodzą w konstruktorze są widoczne dla każdego, kto odczyta wartość pola.
GO TO FULL VERSION