1. Dlaczego i++ nie działa w środowisku wielowątkowym?
Zacznijmy od klasycznego zadania: mamy zmienną‑licznik, na przykład liczbę przetworzonych zgłoszeń albo pobranych plików. Chcemy, aby kilka wątków zwiększało ten licznik. Co może pójść nie tak, jeśli po prostu napiszemy i++?
Przykład: warunek wyścigu przy inkrementacji
public class Counter {
public int count = 0;
public void increment() {
count++; // Nie jest atomowe!
}
}
Wyobraźmy sobie, że dwa wątki jednocześnie wywołują increment(). Oba wątki czytają starą wartość, oba zwiększają ją o 1 i oba zapisują… tę samą nową wartość! W rezultacie jedna inkrementacja „gubi się”. Jeśli powtarzać to wiele razy, wynikowa wartość będzie mniejsza od oczekiwanej.
Dlaczego tak się dzieje?
Operacja i++ w rzeczywistości składa się z trzech kroków:
- Odczytać wartość zmiennej (np. 5).
- Zwiększyć tę wartość o 1.
- Zapisać nową wartość z powrotem w pamięci.
A w środowisku wielowątkowym inny wątek może zdążyć zmienić zmienną pomiędzy tymi krokami. Efekt — „warunek wyścigu” (race condition).
Czym są operacje atomowe?
Operacja atomowa — to działanie, które albo wykonuje się w całości, albo wcale, i żaden inny wątek nie może „wtrącić się” w środek tej operacji.
W Javie istnieje zestaw klas zapewniających takie operacje dla prymitywów i referencji. Znajdują się w pakiecie java.util.concurrent.atomic. Najpopularniejsze to:
- AtomicInteger — atomowy typ całkowity.
- AtomicLong — atomowy long.
- AtomicBoolean — atomowy boolean.
- AtomicReference<T> — atomowa referencja do obiektu dowolnego typu.
2. AtomicInteger: licznik bezpieczny wątkowo
Deklaracja i podstawowe użycie
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Atomowe zwiększenie
}
public int get() {
return count.get();
}
}
Tutaj incrementAndGet() wykonuje „zwiększ i zwróć nową wartość” jako jedną niepodzielną operację. Nawet jeśli 100 wątków jednocześnie wywoła tę metodę, żadna inkrementacja się nie zgubi.
Przydatne metody:
| Metoda | Opis |
|---|---|
|
Pobierz bieżącą wartość |
|
Ustaw wartość |
|
Zwiększ o 1 i zwróć nową wartość |
|
Zwróć bieżącą wartość i zwiększ o 1 |
|
Zwiększ o delta i zwróć nową wartość |
|
Jeśli bieżąca wartość równa jest expect, ustaw update (CAS) |
Przykład: wielowątkowy licznik
Załóżmy, że mamy klasę, która zlicza liczbę przetworzonych wiadomości na czacie.
public class MessageStatistics {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void onMessageReceived() {
int newCount = messageCount.incrementAndGet();
System.out.println("Łącznie wiadomości: " + newCount);
}
public int getMessageCount() {
return messageCount.get();
}
}
Wnętrze: jak działa AtomicInteger?
Wewnątrz AtomicInteger wykorzystuje specjalną instrukcję procesora — CAS (Compare-And-Swap, „porównaj i zamień”). Jest to operacja atomowa, która porównuje bieżącą wartość zmiennej z oczekiwaną i jeśli są równe — zapisuje nową wartość. Jeśli inny wątek zdążył zmienić zmienną — operacja nie jest wykonana i następuje ponowna próba.
Schemat działania:
1. Czytamy bieżącą wartość (np. 5)
2. Porównujemy z oczekiwaną (5)
3. Jeśli się zgadza — zapisujemy nową wartość (6)
4. Jeśli się nie zgadza — ponawiamy próbę
Wszystko to dzieje się bardzo szybko i bez blokad (lock‑free). Dlatego klasy atomowe są często szybsze niż synchronized, zwłaszcza przy dużej liczbie wątków.
3. AtomicReference: atomowa referencja na obiekt
AtomicReference<T> to uniwersalny atomowy kontener dla dowolnego obiektu. Pozwala bezpiecznie zmieniać referencję do obiektu z wielu wątków.
Przykład: wątkowo‑bezpieczna aktualizacja referencji
import java.util.concurrent.atomic.AtomicReference;
public class AtomicReferenceExample {
private final AtomicReference<String> latestMessage = new AtomicReference<>("");
public void updateMessage(String message) {
latestMessage.set(message);
}
public String getLatestMessage() {
return latestMessage.get();
}
}
Zastosowanie compareAndSet
Najciekawsza operacja to compareAndSet(expected, newValue). Pozwala zaktualizować wartość tylko wtedy, gdy nie zmieniła się od czasu ostatniego odczytu.
public void safeUpdate(String oldValue, String newValue) {
boolean success = latestMessage.compareAndSet(oldValue, newValue);
if (success) {
System.out.println("Aktualizacja powiodła się!");
} else {
System.out.println("Ktoś już zmienił wartość, spróbuj ponownie.");
}
}
To podstawa algorytmów nieblokujących: od kolejek i stosów po cache, gdzie ważne jest unikanie zbędnych blokad.
4. Przykłady użycia w aplikacji
Przykład 1: wielowątkowy licznik wiadomości
public class ChatRoom {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void receiveMessage(String message) {
// ... przetwarzanie wiadomości ...
int count = messageCount.incrementAndGet();
System.out.println("Nowa wiadomość: " + message + ". Łącznie wiadomości: " + count);
}
}
Przykład 2: bezpieczna aktualizacja referencji do ostatniej wiadomości
public class ChatRoom {
private final AtomicReference<String> lastMessage = new AtomicReference<>("");
public void receiveMessage(String message) {
lastMessage.set(message);
// ... przetwarzanie ...
}
public String getLastMessage() {
return lastMessage.get();
}
}
Jeśli trzeba aktualizować referencję tylko wtedy, gdy ostatnia wiadomość nie uległa zmianie (aby uniknąć „utraty” przy jednoczesnych aktualizacjach), użyj compareAndSet.
5. Ograniczenia i pułapki
Kiedy klasy atomowe nie są panaceum?
Zmienne atomowe świetnie nadają się do prostych operacji: inkrementacji, ustawiania wartości, sprawdzania i zamiany. Jednak gdy trzeba zaktualizować kilka zmiennych jednocześnie, atomowość nie jest już gwarantowana. Na przykład, jeśli masz dwa liczniki i chcesz zwiększyć oba jako jedną operację — potrzebny jest tutaj synchronized lub inny mechanizm synchronizacji.
Przykład nieprawidłowego użycia
// NIE jest atomowe!
if (ref.get() == null) {
ref.set("Hello");
}
Pomiędzy get() a set(...) inny wątek może zmienić wartość i warunek będzie już nieprawdziwy. W takich przypadkach używaj compareAndSet.
Klasy atomowe ≠ obiekty bezpieczne wątkowo
Jeśli obiekt, na który wskazuje AtomicReference, sam nie jest bezpieczny wątkowo, to podmiana referencji będzie atomowa, ale zmiana pól obiektu — już nie. Na przykład, jeśli w AtomicReference<List<String>> przechowujesz zwykły ArrayList, to samą listę nie uczyni to thread‑safe.
6. Zaawansowane klasy atomowe
W pakiecie java.util.concurrent.atomic są też inne przydatne klasy:
- AtomicLong, AtomicBoolean — dla long i boolean.
- AtomicIntegerArray, AtomicReferenceArray — operacje atomowe na tablicach.
- LongAdder, LongAccumulator — dla wysoko obciążonych liczników.
LongAdder i LongAccumulator
Jeśli masz bardzo dużo wątków i zwykły AtomicInteger staje się „wąskim gardłem” (wszystkie wątki konkurują o jedną zmienną), użyj LongAdder. Dzieli on licznik na kilka wewnętrznych komórek i sumuje je przy odczycie wartości, co daje zysk przy wysokiej konkurencji.
import java.util.concurrent.atomic.LongAdder;
public class FastCounter {
private final LongAdder adder = new LongAdder();
public void increment() {
adder.increment();
}
public long getCount() {
return adder.sum();
}
}
7. Typowe błędy podczas pracy ze zmiennymi atomowymi
Błąd №1: Oczekiwanie atomowości złożonych operacji.
Jeśli trzeba wykonać kilka działań na wartości, klasy atomowe nie pomogą — między krokami inny wątek może zmienić dane. Dla operacji złożonych używaj compareAndSet lub synchronizacji.
Błąd №2: Ignorowanie thread‑safety obiektów zagnieżdżonych.
Jeśli w AtomicReference leży zwykły obiekt, jego metody i pola nie stają się bezpieczne wątkowo. Atomowa jest tylko podmiana referencji.
Błąd №3: Używanie klas atomowych bez potrzeby.
W kodzie jednowątkowym typy atomowe są zbędne i nieco wolniejsze od zwykłych zmiennych z powodu dodatkowych sprawdzeń.
Błąd №4: Przedwczesna optymalizacja.
Czasami prościej i pewniej jest użyć synchronized, zwłaszcza gdy logika jest złożona i dotyka kilku zmiennych naraz. Budowanie rozwiązań lock‑free nie zawsze jest uzasadnione.
Błąd №5: Zapomniana kwestia problemu ABA.
Rzadki, ale istotny przypadek: wartość zmienia się z A na B i z powrotem na A — compareAndSet „myśli”, że nic się nie zmieniło. W takich scenariuszach używaj specjalnych klas, takich jak AtomicStampedReference (lub AtomicMarkableReference).
GO TO FULL VERSION