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 lub N, 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 metody run() 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łania isTerminated() które zwraca true .

  • 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:

  1. Akcja write(a) dzieje się przed działaniem write(b) .
  2. Akcja write(b) dzieje się przed operacją read(a) .
  3. Akcja read(a) dzieje się przed operacją read(a) .

Zgodnie z regułą 4 przed Happens-Before:

  1. write(a) wydarzy się przed write(b) ORAZ write(b) wydarzy się przed read(a) IMPLIES write(a) wydarzy się przed read(a) .
  2. write(b) dzieje się przed read(a) ORAZ read(a) dzieje się przed read(b) IMPLIES write(b) dzieje się przed read(b) .

Podsumowując:

  1. Relacja write(a) dzieje się przed read(a) oznacza, że instrukcja a + b gwarantuje prawidłową wartość a .
  2. Relacja write(b) dzieje się przed read(b) oznacza, że instrukcja a + 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:

  1. Utworzono jedno wystąpienie VolatileExample ; nazwij to ve ,
  2. ve.update(1, 2) jest wywoływana w jednym wątku, i
  3. ve.observe() jest wywoływany w innym wątku.

Zgodnie z regułą nr 1 Happens-Before:

  1. Akcja write(a) dzieje się przed działaniem volatile-write(a) .
  2. Akcja volatile-read(a) ma miejsce przed akcją read(b) .

Według zasady Happens-Before nr 2:

  1. 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:

  1. 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:

  1. Utworzono jedno wystąpienie VolatileExample ; nazwij to ve ,
  2. 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,
  3. 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.



Modified text is an extract of the original Stack Overflow Documentation
Licencjonowany na podstawie CC BY-SA 3.0
Nie związany z Stack Overflow