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.

Zdarza się wcześniej

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.