1. Zapomniany unlock/release: pułapka dla nieuważnych
Jednym z najbardziej podstępnych błędów przy użyciu nowoczesnych narzędzi synchronizacji, takich jak ReentrantLock lub Semaphore, jest zapomnienie wywołania unlock() albo release(). Jeśli nie zwolnisz blokady, inne wątki będą czekały na jej zwolnienie... w nieskończoność. Program się zawiesi, a ty będziesz długo patrzeć w ekran, próbując zrozumieć, dlaczego nic się nie dzieje.
Rozważmy przykład z ReentrantLock:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
// Ups! Zapomnieliśmy o unlock() — teraz wszyscy się zawieszą!
count++;
}
}
Wszystko wygląda niewinnie, ale jeśli wywołać increment() kilka razy z różnych wątków, po pierwszym wywołaniu pozostałe wątki będą czekać na zwolnienie blokady bez końca.
Aby uniknąć tej sytuacji, użyj konstrukcji try-finally:
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
Teraz, nawet jeśli w środku metody wystąpi wyjątek, blokada zostanie na pewno zwolniona.
To tak, jakby ktoś zajął toaletę (zamknął się od środka), a potem zapomniał otworzyć drzwi i wyszedł przez okno. Reszta będzie czekać, aż ta osoba wyjdzie... Nie róbcie tak!
2. Synchronizacja na niewłaściwym obiekcie: „Och, zawiesiłem zamek nie tam, gdzie trzeba!”
W Javie słowo kluczowe synchronized może blokować dostęp do określonego obiektu. Jeśli jednak wybierzesz niewłaściwy obiekt do blokowania, synchronizacja nie zadziała tak, jak oczekujesz.
Błąd nr 1: synchronizacja na zmiennej lokalnej
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// Za każdym razem nowy obiekt — żadnej synchronizacji!
// Wątki nie czekają na siebie.
// Sekcja krytyczna nie jest chroniona!
}
}
Tutaj każdy wątek tworzy własny obiekt lock. W rezultacie żadna realna blokada nie zachodzi — wątki wchodzą do sekcji krytycznej jednocześnie.
Poprawnie:
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// Teraz wszystkie wątki używają tego samego obiektu lock
// i rzeczywiście czekają na siebie.
}
}
Błąd nr 2: synchronizacja na literałach łańcuchowych
public void doSomething() {
synchronized ("lock") {
// Literały łańcuchowe są internowane: różne części programu mogą
// przypadkowo synchronizować się na tym samym łańcuchu!
}
}
Wniosek:
Synchronizuj się tylko na prywatnych obiektach utworzonych specjalnie do tego celu, które nie są używane nigdzie indziej.
3. Wzajemna blokada (deadlock): „Ty mi — ja tobie, i obaj stoimy”
Deadlock (wzajemna blokada) to klasyka gatunku. Dwa (lub więcej) wątków na przemian przechwytują różne blokady i czekają na siebie nawzajem, aż program stanie dęba.
Przykład:
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
// Poczekajmy chwilę dla czystości eksperymentu
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockB) {
// ...
}
}
}
public void method2() {
synchronized (lockB) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockA) {
// ...
}
}
}
}
Jeśli jeden wątek wywoła method1(), a inny — method2(), to pierwszy przechwyci lockA i będzie czekał na lockB, a drugi — na odwrót. W rezultacie oba będą czekać na siebie w nieskończoność.
Jak uniknąć?
- Zawsze przechwytuj blokady w tej samej kolejności we wszystkich wątkach.
- Minimalizuj liczbę blokad utrzymywanych jednocześnie.
- Używaj narzędzi diagnostycznych (np. jstack), jeśli program się zawiesił.
Analogia:
To tak, jakby dwie osoby spotkały się w wąskim korytarzu i każda postanowiła ustąpić, ale tylko jeśli druga ustąpi najpierw. W efekcie obie stoją i czekają, aż ktoś pierwszy odpuści.
4. Nadmierna synchronizacja: „Lepiej dmuchać na zimne?” — nie zawsze!
Czasem programiści, obawiając się błędów, synchronizują wszystko jak leci. W efekcie wydajność spada, a korzyści — zero.
Przykład:
public synchronized void add(int value) {
// Jest tu tylko jedna linijka, która nie wymaga synchronizacji!
System.out.println("Dodano: " + value);
}
W tym przypadku synchronizacja nie jest potrzebna: wypisywanie na ekran przez System.out.println jest już bezpieczne dla wątków, a sama metoda nie korzysta ze wspólnych zasobów.
Gdzie to jest krytyczne?
Jeśli synchronizujesz metody, które są wywoływane często i nie wymagają ochrony, gwałtownie obniżasz wydajność programu. Wątki ustawiają się w kolejce, chociaż mogłyby pracować równolegle.
Dobra praktyka:
Synchronizuj tylko to, co naprawdę konieczne. Sekcja krytyczna powinna być możliwie jak najmniejsza.
5. Nieprawidłowe użycie volatile: „Jest widoczność, nie ma atomowości!”
Modyfikator volatile w Javie gwarantuje, że zmiany zmiennej będą widoczne we wszystkich wątkach. Ale on nie gwarantuje atomowości operacji.
Błąd:
private volatile int counter = 0;
public void increment() {
counter++; // Nie jest atomowe!
}
Operacja counter++ składa się z odczytu wartości, zwiększenia i zapisania z powrotem. Jeśli dwa wątki jednocześnie wykonują ten kod, wynikowa wartość może być mniejsza od oczekiwanej.
Poprawnie:
Dla operacji atomowych używaj synchronized, AtomicInteger lub innych klas bezpiecznych dla wątków.
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
Kiedy używać volatile?
Do prostych flag (np. „zakończyć pracę”), gdy nie jest wymagana atomowość.
GO TO FULL VERSION