1. Czym jest zasób współdzielony
Ze wspólnymi zasobami już się spotkałeś. Dom, w którym mieszka rodzina — to wspólny zasób tej rodziny. Biurkowa lodówka w biurze — wspólny zasób dla wszystkich kolegów. Myślę, że idea jest jasna.
W programowaniu zasób współdzielony to zmienna, obiekt lub struktura danych, do których jednocześnie może odwoływać się wiele wątków. Może to być:
- Zmienna-licznik liczby przetworzonych zamówień.
- Lista zgłoszeń, którą jedne wątki wypełniają, a inne przetwarzają.
- Otwarty plik, do którego zapisuje kilka wątków.
- Połączenie z bazą danych, z którym pracują różne części programu.
W Javie każdy obiekt lub zmienna, do których może uzyskać dostęp kilka wątków, staje się potencjalnie „zasobem współdzielonym”.
Przykład zasobu współdzielonego: globalny licznik
public class Counter {
public int value = 0;
}
Jeśli kilka wątków będzie zwiększać ten licznik, będą odwoływać się do tej samej zmiennej value — i oto masz zasób współdzielony.
2. Problemy współbieżnego dostępu
W programie jednowątkowym wszystko jest proste: jeden wątek — jeden wykonawca — idzie po kodzie jak pociąg po torach. Ale gdy tylko do gry wchodzi kilka wątków, zaczyna się prawdziwy „taniec z szablami”: wątki mogą wtrącać się w pracę innych w najbardziej nieoczekiwanych miejscach.
Race condition (stan wyścigu)
Race condition to sytuacja, w której wynik działania programu zależy od tego, jak dokładnie „przemieszały się” działania wątków. To znaczy, jeśli uruchomisz ten sam program kilka razy, wynik może być różny — i to nie bug, to „ficzer” wielowątkowości.
Klasyczny przykład: dwa wątki zwiększają licznik
Spróbujmy zasymulować prostą sytuację: mamy wspólny licznik i dwa wątki po tysiąc razy zwiększają jego wartość.
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // NIEBEZPIECZNE MIEJSCE!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Oczekiwana wartość: 2000");
System.out.println("Rzeczywista wartość: " + counter.value);
}
}
Co zobaczymy na ekranie?
Czasem — 2000, czasem — 1985, czasem — 1937... Dlaczego? Bo operacja counter.value++ nie jest atomowa! Składa się z trzech kroków:
- Odczytać bieżącą wartość counter.value.
- Zwiększyć ją o 1.
- Zapisać z powrotem.
Jeśli dwa wątki jednocześnie odczytają tę samą wartość, oba ją zwiększą, a następnie oba zapiszą wynik — w efekcie jedno zwiększenie „zniknie”. To właśnie lost update — utracona aktualizacja.
Niespójny stan obiektu
Jeśli masz złożony obiekt składający się z kilku pól i wątki jednocześnie zmieniają różne pola, obiekt może znaleźć się w „dziwnym” lub niespójnym stanie. Na przykład saldo na koncie się zmniejszyło, ale historia operacji się nie zaktualizowała — klient w panice, księgowy w szoku.
3. Po co jest potrzebna synchronizacja
Synchronizacja to sposób, by powiedzieć programowi: „Stop, ten fragment kodu ma być wykonywany tylko przez jeden wątek naraz! Pozostali — czekają!” To jak tabliczka „Sprzątanie! Nie wchodzić!” na drzwiach toalety: dopóki jedna osoba jest w środku, reszta czeka na zewnątrz (i w myślach przeklina tego, kto długo nie wychodzi).
Gwarancja integralności danych
Jeśli chcemy, aby nasz licznik zawsze zwiększał się poprawnie, musimy zabronić równoczesnej zmiany jego wartości przez wiele wątków.
Przykład: synchronizacja inkrementacji licznika
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
Teraz, jeśli dwa wątki wywołają increment(), tylko jeden z nich będzie mógł wykonać tę metodę w danej chwili. Drugi poczeka, aż pierwszy skończy.
Schemat: co się dzieje podczas synchronizacji
+-------------------+
| Wątek 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Wątek 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Wątek 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Wątek N |/
+-------------------+
Wszystkie wątki ustawiają się w kolejce do wykonania chronionego fragmentu kodu. Tylko jeden wątek może jednocześnie znajdować się w „sekcji krytycznej” ( synchronized-bloku).
4. Krótkie wprowadzenie do sposobów synchronizacji
Synchronizacja w Javie to nie jeden sposób, lecz cały „arsenał” narzędzi pozwalających chronić zasób współdzielony przed równoczesnym dostępem.
Słowo kluczowe synchronized
To podstawowe narzędzie synchronizacji w Javie. Można go używać na dwa sposoby:
Zsynchronizowana metoda
public synchronized void increment() {
value++;
}
Zsynchronizowany blok
public void increment() {
synchronized (this) {
value++;
}
}
Tutaj this to obiekt, na którym zakładana jest blokada. Dopóki jeden wątek wykonuje ten blok, inne wątki, które chcą wejść do takiego samego bloku na tym samym obiekcie, będą czekać.
Wyspecjalizowane klasy z java.util.concurrent
- Lock, ReentrantLock — bardziej elastyczna alternatywa dla synchronized.
- ReadWriteLock — do rozdzielenia blokad na odczyt i zapis.
- Semaphore — ograniczenie liczby wątków jednocześnie wykonujących kod.
- CountDownLatch, CyclicBarrier i inne — do koordynacji pracy wątków.
Ważne: Dziś tylko poznajemy podstawy — o tych klasach porozmawiamy nieco później.
5. Przykład praktyczny: aplikacja z wielowątkowym licznikiem
Załóżmy, że piszemy statystyki wywołań użytkowników do pewnej usługi. Każdy wątek to osobny użytkownik, który zwiększa wspólny licznik.
Bez synchronizacji
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Oczekiwana wartość: 30000");
System.out.println("Rzeczywista wartość: " + counter.value);
}
}
Wynik: Prawie zawsze mniej niż 30000. Czasem — znacznie mniej! Dlaczego? Bo wątki „wbijają się” sobie nawzajem w pracę.
Synchronizacja: naprawiamy błąd
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Oczekiwana wartość: 30000");
System.out.println("Rzeczywista wartość: " + counter.value);
}
}
Wynik: Zawsze 30000. Hurra, synchronizacja działa!
6. Przydatne szczegóły
Wizualizacja: jak wygląda race condition
Narysujmy małą tabelę, aby pokazać, jak dwa wątki mogą „zgubić” inkrementację:
| Krok | Wątek 1 | Wątek 2 | Wartość value |
|---|---|---|---|
| 1 | Odczytuje value=0 | 0 | |
| 2 | Odczytuje value=0 | 0 | |
| 3 | Zwiększa do 1 | 0 | |
| 4 | Zwiększa do 1 | 0 | |
| 5 | Zapisuje 1 | 1 | |
| 6 | Zapisuje 1 | 1 |
Kiedy potrzebna jest synchronizacja
Synchronizacja nie zawsze jest potrzebna. Gdy zmienna żyje w swoim małym świecie i pracuje z nią tylko jeden wątek — można się rozluźnić. Ale wystarczy tylko podzielić się nią z innymi wątkami i bez synchronizacji ani rusz. Nawet jeśli wydaje się, że „jakoś to będzie” — nie wierz. Błąd wyścigu jest podstępny: może długo się nie ujawniać, a potem nagle wystrzelić w najmniej odpowiednim momencie.
Zajawka na przyszłość: jakie są jeszcze środki synchronizacji
Dziś poznaliśmy tylko podstawowe narzędzie — synchronized. W kolejnych wykładach omówimy:
- Jak działa monitor obiektu i jakie są typy blokad.
- Czym są statyczne metody zsynchronizowane (static + synchronized).
- Jak działa słowo kluczowe volatile i po co jest potrzebne.
- Jakie są nowoczesne klasy do synchronizacji (Lock, Semaphore i in.).
7. Typowe błędy przy pracy ze współdzielonymi zasobami
Błąd nr 1: Ignorowanie wielowątkowości.
Jeden z najczęstszych błędów — nie zastanawiać się nad tym, że zmienna może być dostępna z wielu wątków. Nawet jeśli teraz program jest jednowątkowy, później ktoś doda wątki — i błędy pojawią się „znikąd”.
Błąd nr 2: Niewystarczająca lub nadmierna synchronizacja.
Jeśli nie synchronizować dostępu do zasobu współdzielonego — dostaniesz race condition i niespójne dane. Jeśli zaś synchronizować wszystko jak leci, program „udusi się” od blokad i stanie się wolny. Zawsze staraj się synchronizować tylko to, co naprawdę konieczne.
Błąd nr 3: Synchronizacja na niewłaściwym obiekcie.
Jeśli synchronizować dostęp na różnych obiektach (np. na zmiennych lokalnych lub literałach String), nie ochroni to zasobu współdzielonego. Wszystkie wątki muszą synchronizować się na tym samym obiekcie.
Błąd nr 4: Oczekiwanie atomowości od nieatomowych operacji.
Operacja i++ nie jest atomowa! Nawet jeśli zmienna jest zadeklarowana jako volatile, nie czyni to inkrementacji atomową. Do takich operacji potrzebna jest synchronizacja.
Błąd nr 5: „Mam szczęście, u mnie wszystko działa”.
Race condition może nie ujawniać się na twoim komputerze, ale na pewno ujawni się na serwerze albo u użytkownika. Nigdy nie polegaj na „jakoś to będzie” w programach wielowątkowych!
GO TO FULL VERSION