1. Słowo kluczowe synchronized: po co i jak
W Java słowo kluczowe synchronized – to jak tabliczka „Zajęte!” na drzwiach toalety: dopóki jeden wątek znajduje się wewnątrz „sekcji krytycznej”, pozostałe grzecznie czekają na swoją kolej. Dopiero gdy pierwszy wyjdzie, następny może wejść i wykonać swój kod.
Składnia: blok i metoda
Blok zsynchronizowany
synchronized (object) {
// sekcja krytyczna
}
- object – to dowolny obiekt, na którym chcesz „zawiesić zamek”. Dopóki jeden wątek wykonuje ten blok, inne wątki, które też chcą wejść do bloku z tym samym obiektem, będą czekać.
Metoda zsynchronizowana
public synchronized void increment() {
// sekcja krytyczna
}
- Tutaj „zamek” zakładany jest na sam obiekt (this). To znaczy, że tylko jeden wątek naraz może wykonywać dowolną zsynchronizowaną metodę tego obiektu.
Statyczna metoda zsynchronizowana
public static synchronized void foo() {
// sekcja krytyczna
}
- Tutaj blokada odbywa się na poziomie klasy (ClassName.class), a nie konkretnego obiektu.
Jak to działa pod maską
Kiedy wątek wchodzi do zsynchronizowanego bloku lub metody, przechwytuje „monitor” obiektu (lub klasy dla metod statycznych). Jeśli monitor jest już zajęty – wątek czeka. Gdy tylko monitor się zwolni, następny wątek może wejść.
2. Przykład: inkrementacja licznika z i bez synchronizacji
Bez synchronizacji
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Wartość końcowa: " + counter.getCount());
}
}
Oczekiwana wartość: 2000
Rzeczywista wartość: może być mniejsza (na przykład 1995, 1987...), i przy każdym uruchomieniu – własna „niespodzianka”.
Dlaczego? Ponieważ operacja count++ nie jest atomowa: rozbija się na trzy kroki – odczytanie wartości, zwiększenie, zapisanie z powrotem. Jeśli dwa wątki robią to jednocześnie, mogą „nadpisać” wyniki siebie nawzajem.
Rozwiązanie: synchronized
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
Teraz tylko jeden wątek naraz może wykonywać metodę increment(). Wartość końcowa zawsze będzie 2000.
Alternatywa: blok zsynchronizowany
public class Counter {
private int count = 0;
public void increment() {
synchronized (this) {
count++;
}
}
}
Wynik będzie taki sam. Można synchronizować nie całą metodę, a tylko potrzebny fragment.
3. Wprowadzenie do „monitora obiektu”
Monitor to „zamek” wbudowany w każdy obiekt w Javie. Gdy piszesz synchronized(object), wątek próbuje „zamknąć” ten obiekt. Jeśli zamek jest wolny – wątek go otrzymuje, jeśli nie – czeka na swoją kolej. Gdy tylko wątek wychodzi z bloku, zamek zostaje zwolniony.
Ważne! Jeśli synchronizujesz na różnych obiektach – wątki nie będą na siebie czekać. Dlatego bardzo ważne jest wybranie właściwego obiektu do synchronizacji.
Statyczne zsynchronizowane metody
Czasem wspólny zasób to nie obiekt, lecz coś wspólnego dla wszystkich egzemplarzy klasy (na przykład zmienna statyczna). W tym przypadku synchronizacja powinna być na poziomie klasy.
public class StaticCounter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static int getCount() {
return count;
}
}
To jest równoważne:
public static void increment() {
synchronized (StaticCounter.class) {
count++;
}
}
Monitor jest zakładany na obiekt klasy (Class), a nie na konkretną instancję.
4. Słowo kluczowe volatile: co to jest i po co
Problem widoczności między wątkami
W Java każdy wątek może buforować wartości zmiennych, aby przyspieszyć działanie. To oznacza, że jeśli jeden wątek zmieni zmienną, drugi wątek może tego „nie zauważyć”, wciąż czytając wartość ze swojej pamięci podręcznej. Jest to szczególnie krytyczne dla flag, którymi wątki sygnalizują sobie nawzajem.
Jak działa volatile
Jeśli zmienna jest zadeklarowana jako volatile, to znaczy:
- Wszystkie wątki zawsze czytają i zapisują ją wyłącznie do pamięci głównej, z pominięciem pamięci podręcznej.
- Każda zmiana zmiennej staje się natychmiast widoczna dla wszystkich wątków.
Ale! Operacje na volatile same w sobie nie są atomowe (z wyjątkiem prostego odczytu/zapisu prymitywów takich jak boolean, int itp.). Jeśli robisz coś bardziej złożonego niż przypisanie – potrzebna jest synchronizacja.
Przykład: flaga zakończenia pracy
public class Worker extends Thread {
private volatile boolean running = true;
public void run() {
while (running) {
// robimy coś pożytecznego
}
System.out.println("Wątek zakończył pracę");
}
public void shutdown() {
running = false;
}
}
Worker w = new Worker();
w.start();
// ... po pewnym czasie
w.shutdown();
Bez volatile wątek może „nie zauważyć” zmiany flagi i zapętlić się w nieskończoność (zwłaszcza na systemach wielordzeniowych). Z volatile – wszystko działa jak należy.
5. Ograniczenia volatile: nieatomowość
Wielu początkujących myśli: „Jeśli zrobić volatile int, to można pisać count++ i się nie przejmować”. Niestety, to nie tak:
private volatile int count = 0;
public void increment() {
count++;
}
Błąd! Operacja count++ nadal nie jest atomowa – to trzy kroki: (1) odczyt, (2) zwiększenie, (3) zapis z powrotem. Jeśli dwa wątki jednocześnie odczytają tę samą wartość, oba ją zwiększą i oba zapiszą identyczny wynik – jeden inkrement zostanie „zgubiony”.
Wniosek: volatile zapewnia tylko widoczność zmian, ale nie chroni przed wyścigami przy złożonych operacjach.
6. Kiedy używać synchronized, a kiedy – volatile
- volatile – gdy masz prostą flagę (na przykład boolean), którą jeden wątek zapisuje, a inny czyta. Przykłady: zakończenie pracy wątku, sygnalizacja zdarzenia.
- synchronized – gdy trzeba zapewnić atomowość złożonych operacji (na przykład inkrementacja, zmiana kilku zmiennych, praca ze strukturami danych).
Tabela do zapamiętania
| Scenariusz | volatile | synchronized |
|---|---|---|
| Przekazanie sygnału między wątkami | ✔ | ✔ |
| Operacja atomowa (inkrementacja) | ✖ | ✔ |
| Wiele kroków w sekcji krytycznej | ✖ | ✔ |
| Tylko widoczność zmian | ✔ | ✔ |
7. Typowe błędy przy użyciu synchronized i volatile
Błąd nr 1: Synchronizacja na niewłaściwym obiekcie. Jeśli synchronizujesz na zmiennej lokalnej albo na innym obiekcie w każdym wątku – nie uzyskasz żadnej ochrony.
Object lock = new Object();
synchronized (lock) {
// ...
}
Jeśli każdy wątek tworzy własny lock – efekt będzie zerowy. Potrzebny jest jeden punkt synchronizacji, wspólny obiekt dla wszystkich wątków.
Błąd nr 2: Oczekiwanie atomowości od volatile. volatile gwarantuje widoczność, a nie atomowość. Operacje w rodzaju count++ pozostają niebezpieczne bez synchronizacji.
Błąd nr 3: Synchronizacja zbyt dużego obszaru kodu. Jeśli synchronizujesz całą metodę, a potrzeba – tylko jednego wiersza, niepotrzebnie blokujesz inne wątki i tracisz na wydajności. Staraj się zmniejszać „sekcję krytyczną”.
Błąd nr 4: Brak „statycznej” synchronizacji dla danych statycznych. Jeśli masz zmienną statyczną, a synchronizujesz na this, to nie pomoże. Dla danych statycznych potrzebna jest synchronizacja na poziomie klasy: synchronized(ClassName.class).
Błąd nr 5: Synchronizacja na literałach łańcuchowych. Synchronizacja na łańcuchach jest niebezpieczna, ponieważ identyczne literały są internowane przez JVM. Można przypadkowo uzyskać wspólną blokadę dla różnych części programu.
GO TO FULL VERSION