1. Wprowadzenie do Java Memory Model (JMM)
Problem widoczności i porządkowania
W programie jednowątkowym wszystko jest proste: zapisujesz wartość do zmiennej — i od razu możesz ją odczytać. W świecie wielowątkowym działa to inaczej. Procesory buforują wartości, kompilator i JVM czasem zmieniają kolejność instrukcji i jeden wątek może widzieć „stare” wartości, nawet jeśli inny wątek właśnie je zmienił.
Java Memory Model (JMM) opisuje, jak wątki komunikują się przez pamięć: kiedy zmiany jednego wątku stają się widoczne dla innych i w jakim porządku zachodzą operacje. Jeśli tego nie uwzględnić, program może zachowywać się nieprzewidywalnie, choć na pierwszy rzut oka wszystko wygląda poprawnie.
Zrozumienie JMM pomaga pojąć, dlaczego czasem wątek nie widzi świeżych danych, jak prawidłowo używać volatile, synchronized i klas atomowych oraz dlaczego błędy w kodzie wielowątkowym mogą ujawniać się dopiero w środowisku produkcyjnym. Mówiąc prościej, JMM to zasady gry pamięci i jeśli je zignorujesz, nawet najstaranniejszy kod może obrócić się przeciwko tobie.
Analogia
Wyobraź sobie, że masz dwie osoby (wątki), które piszą i czytają notatki (zmienne) na tablicy (pamięć). Czasem jedna pisze, a druga jeszcze nie widzi nowego wpisu — bo patrzy na swoją kopię tablicy (cache). JMM określa, kiedy i jak te notatki stają się widoczne dla wszystkich.
2. happens-before: fundament JMM
Czym jest happens-before?
happens-before to relacja między dwoma działaniami w programie: jeśli działanie A happens-before działaniem B, to wszystkie zmiany wykonane w A są gwarantowanie widoczne w B.
Ważne: happens-before to nie tylko „zdarzyło się wcześniej”, lecz właśnie „jest gwarantowanie widoczne”.
Podstawowe reguły happens-before
1. W obrębie jednego wątku
Wszystko, co dzieje się w jednym wątku, jest uporządkowane: jeśli zapiszesz do zmiennej, a potem ją odczytasz — zobaczysz własną zmianę.
2. Bloki synchronized/monitory
Wszystko, co dzieje się przed wyjściem z bloku (synchronized), staje się widoczne dla wątku, który później wejdzie do tego bloku.
synchronized(lock) {
sharedVar = 42; // zapis
}
// ...
synchronized(lock) {
System.out.println(sharedVar); // na pewno zobaczymy 42
}
3. zapis/odczyt volatile
Zapis do pola volatile jest happens-before wobec każdego późniejszego odczytu tego pola przez inny wątek.
volatile boolean ready = false;
// Wątek 1
data = 123;
ready = true; // volatile write
// Wątek 2
if (ready) { // volatile read
System.out.println(data); // na pewno zobaczymy data = 123
}
4. Uruchamianie i kończenie wątków
- Wywołanie Thread.start() jest happens-before rozpoczęciem pracy wątku.
- Zakończenie wątku jest happens-before powrotem z Thread.join().
5. Zakończenie zadania w Executorze
Jeśli przekazałeś zadanie do Executor i poczekałeś na jego zakończenie (Future.get()), wszystkie zmiany wykonane w zadaniu są widoczne po get().
6. Pola final
Inicjalizacja pól z modyfikatorem final w konstruktorze jest happens-before publikacją referencji do obiektu. To ważne dla obiektów niemutowalnych.
3. Bezpieczna publikacja obiektów
Problem: „nieświeży” obiekt
Jeśli jeden wątek tworzy obiekt i przekazuje go innemu wątkowi bez synchronizacji, drugi wątek może zobaczyć „surowe” wartości pól (np. niezainicjalizowane lub stare wartości).
class Holder {
int value;
Holder() { value = 42; }
}
Holder holder = null;
// Wątek 1
holder = new Holder(); // tworzymy obiekt
// Wątek 2
if (holder != null) {
System.out.println(holder.value); // może zobaczyć 0, a nie 42!
}
Jak poprawnie publikować obiekty?
1. Przez pola final
Jeśli wszystkie pola obiektu są final i inicjalizowane w konstruktorze, obiekt można bezpiecznie publikować bez dodatkowej synchronizacji.
class SafeHolder {
final int value;
SafeHolder() { value = 42; }
}
2. Przez referencję oznaczoną volatile
Jeśli referencja do obiektu jest zadeklarowana jako volatile, to po przypisaniu obiekt jest gwarantowanie widoczny dla innych wątków.
volatile Holder holder;
// Wątek 1
holder = new Holder();
// Wątek 2
if (holder != null) {
System.out.println(holder.value); // na pewno zobaczymy 42
}
3. Przez jednowątkową inicjalizację przed publikacją
Jeśli obiekt jest tworzony i inicjalizowany zanim referencja do niego stanie się dostępna dla innych wątków, wszystko jest bezpieczne.
Holder holder = new Holder(); // tylko w jednym wątku
// ... potem holder staje się dostępny dla innych wątków
4. Przez blokadę
Jeśli obiekt jest tworzony wewnątrz zsynchronizowanego bloku, a referencja do niego jest odczytywana tylko wewnątrz takiego samego bloku, wszystko jest bezpieczne.
Holder holder;
synchronized(lock) {
if (holder == null) {
holder = new Holder();
}
}
// ... w innym wątku
synchronized(lock) {
if (holder != null) {
// bezpieczne
}
}
4. Double-checked locking i volatile
Czym jest double-checked locking?
To wzorzec do leniwej inicjalizacji obiektu singleton:
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 1. sprawdzenie
synchronized (Singleton.class) {
if (instance == null) { // 2. sprawdzenie
instance = new Singleton();
}
}
}
return instance;
}
}
Problem: Bez volatile-referencji do instance ten kod nie działa poprawnie! Wątek może zobaczyć nie w pełni zainicjalizowany obiekt.
Dlaczego bez volatile jest źle?
JVM może tak przestawić instrukcje, że referencja do obiektu zostanie przypisana przed zakończeniem konstruktora. Inny wątek zobaczy niezainicjalizowany obiekt.
Jak zrobić to poprawnie?
Zadeklaruj instance jako volatile:
private static volatile Singleton instance;
Teraz double-checked locking działa poprawnie: volatile gwarantuje relację happens-before między zapisem a odczytem referencji.
Alternatywa: inicjalizacja statyczna
Najprostszy i najbezpieczniejszy sposób zrobienia singletona — użyć inicjalizacji statycznej:
class Singleton {
private static final Singleton INSTANCE = new Singleton();
public static Singleton getInstance() { return INSTANCE; }
}
Tutaj JVM sama gwarantuje poprawną inicjalizację.
5. VarHandle: nowoczesny niskopoziomowy dostęp
Czym jest VarHandle?
VarHandle to nowoczesne API (Java 9+), które pozwala pracować ze zmiennymi na niskim poziomie: czytać, pisać, wykonywać operacje atomowe, kontrolować widoczność i porządek instrukcji.
Po co VarHandle, skoro są już klasy atomowe?
— VarHandle pozwala pracować z dowolnymi polami (nie tylko z int/long/Reference).
— Pozwala jawnie wybierać semantykę dostępu: volatile, acquire/release, opaque.
— Używany do implementacji wysoko wydajnych struktur danych.
Semantyki dostępu
- Volatile: pełna gwarancja happens-before (jak dla pola volatile).
- Acquire/Release: słabsza gwarancja, ale szybciej (używane w strukturach lock-free).
- Opaque: minimalna gwarancja widoczności, ale maksymalna wydajność.
Przykład użycia VarHandle
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
class Counter {
int value;
static final VarHandle VALUE_HANDLE;
static {
try {
VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
} catch (Exception e) {
throw new Error(e);
}
}
}
Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);
Kiedy używać VarHandle?
- Do implementacji własnych struktur danych lock-free.
- Gdy potrzebna jest maksymalna wydajność i kontrola nad porządkiem instrukcji.
- W zwykłych aplikacjach najczęściej wystarczają klasy atomowe i synchronized.
6. Fałszywe współdzielenie (false sharing) i wyrównanie cache'a
False sharing to sytuacja, gdy dwa wątki pracują na różnych zmiennych, ale te zmienne leżą w jednej linii cache procesora. W rezultacie wątki przeszkadzają sobie, ponieważ zmiana jednej zmiennej prowadzi do unieważnienia cache dla drugiej.
Analogia: Dwie osoby siedzą przy jednym stole (linia cache), ale każda pisze po swojej połowie. Jeśli jedna coś zmieni, druga musi „przeczytać” całą kartkę od nowa.
Dlaczego to jest złe?
- Wydajność gwałtownie spada: procesory tracą czas na synchronizację cache'y.
- Szczególnie krytyczne dla „gorących” zmiennych, które są często modyfikowane przez różne wątki.
Jak tego uniknąć?
Rozdzielaj „gorące” pola do różnych obiektów lub używaj specjalnych adnotacji/struktur do wyrównywania (na przykład @Contended). We współczesnych JVM można włączyć opcję -XX:-RestrictContended i używać @sun.misc.Contended (Java 8+) do wyrównywania pól.
Przykład:
@sun.misc.Contended
public volatile long value1;
@sun.misc.Contended
public volatile long value2;
NB: Adnotacja @Contended nie należy do standardowego API, ale jest używana w JDK do optymalizacji klas atomowych.
7. Praktyka: naprawiamy singleton i mikrobenchmarki JMH
Naprawiamy niedziałający singleton
Źle (bez volatile):
class BrokenSingleton {
private static BrokenSingleton instance;
public static BrokenSingleton getInstance() {
if (instance == null) {
synchronized (BrokenSingleton.class) {
if (instance == null) {
instance = new BrokenSingleton();
}
}
}
return instance;
}
}
Dobrze (z volatile):
class SafeSingleton {
private static volatile SafeSingleton instance;
public static SafeSingleton getInstance() {
if (instance == null) {
synchronized (SafeSingleton.class) {
if (instance == null) {
instance = new SafeSingleton();
}
}
}
return instance;
}
}
Najlepiej — inicjalizacja statyczna:
class StaticSingleton {
private static final StaticSingleton INSTANCE = new StaticSingleton();
public static StaticSingleton getInstance() { return INSTANCE; }
}
Mikrobenchmarki JMH: widoczność i atomiczność
Uwaga: JMH to specjalny framework do mikrobenchmarków w Javie. Nie wyciągaj wniosków o wydajności bez JMH — wyniki mogą być mylące!
Przykład: sprawdzanie widoczności volatile
public class VolatileVisibility {
volatile boolean flag = false;
public void writer() {
flag = true;
}
public void reader() {
while (!flag) {
// kręcimy się, dopóki nie zobaczymy true
}
// zobaczyliśmy zmianę
}
}
Przykład: nieatomowość volatile
public class VolatileNotAtomic {
volatile int counter = 0;
public void increment() {
counter++; // nie jest atomowe!
}
}
Mimo volatile, przy równoczesnym inkrementowaniu z wielu wątków wartość końcowa będzie mniejsza od oczekiwanej (operacja counter++ rozkłada się na odczyt, obliczenie i zapis).
8. Typowe błędy przy pracy z JMM, volatile i publikacją
Błąd nr 1: Oczekiwanie atomowości od volatile.
volatile gwarantuje tylko widoczność zmian, a nie atomowość operacji. Operacja counter++ nie staje się atomowa, gdy zmienna jest volatile.
Błąd nr 2: Publikacja obiektów bez synchronizacji.
Jeśli tworzysz obiekt w jednym wątku i przekazujesz go innemu bez volatile, synchronized lub pól final — inny wątek może zobaczyć „surowe” wartości.
Błąd nr 3: Double-checked locking bez volatile.
Bez volatile-referencji do singletona można otrzymać niezainicjalizowany obiekt w innym wątku.
Błąd nr 4: Używanie starych blokad z wirtualnymi wątkami.
Niektóre stare mechanizmy synchronizacji (native monitor) mogą przeszkadzać JVM w efektywnym zarządzaniu wirtualnymi wątkami.
Błąd nr 5: Ignorowanie false sharing.
Jeśli „gorące” zmienne leżą blisko siebie w pamięci, wątki będą sobie przeszkadzać z powodu linii cache.
Błąd nr 6: Wyciąganie wniosków o wydajności bez JMH.
Mikrobenchmarki bez JMH często dają mylące wyniki z powodu optymalizacji JVM i pamięci podręcznej procesora.
GO TO FULL VERSION