Poza możliwością synchronizacji wątków, używając słowa sychronized, Java udostępnia wiele dedykowanych rozwiązań związanych z pracą w środowisku wielowątkowym. Większość z nich znajdziesz w pakiecie java.util.concurrent. W tym wpisie omówię dwa z nich: zmienne atomowe (ang. atomic variable) oraz zamki (ang. locks).
Zmienne atomowe
public class SynchronizedCounter { private static int counter = 0; private static final Object lock = new Object(); public void incrementCounter() { synchronized (lock) { counter++; System.out.println(Thread.currentThread().getName() + " : " + counter); } } public static int getCounter() { return counter; } }
Postaram się zoptymalizować powyższy kod. Jak wiesz, synchronized zadziała jedynie na obiektach, w takim razie musimy zastąpić jakoś int jako typ licznika. Najprostszą opcją byłoby zastosowanie zwykłego Integer, tak jak poniżej:
public class AtomicCounter { private static Integer counter = new Integer(0); public void incrementCounter() { synchronized (counter) { System.out.println(Thread.currentThread().getName() + " : " + counter); counter++; } } public static int getCounter() { return counter; } }
Inkrementacja a wielowątkowość
Teraz kod wygląda już lepiej, z racji, że nie występuje w nim zmienna pomocnicza lock. Można przeprowadzić synchronizację na samym liczniku, jednak zachodzi tu pewien problem. Ponieważ licznik typu całkowitego może wykonać jedną operację atomową w tym samym czasie, należy sobie odpowiedzieć na pytanie, czy inkrementacja jest taką operacją? Otóż to, co dzieje się pod spodem, to odczytanie wartości, zwiększenie jej o jeden, oraz przypisanie nowej wartości do zmiennej. Skoro tak, klasy opakowujące typy proste (takie jak Integer, Double) nie nadają się do pracy na wielu wątkach. Z pomocą za to przychodzą tzw. zmienne atomowe, czyli specjalnie przygotowane wrappery na typy proste, które świetnie sprawdzają się przy pracy na wielu wątkach. Przykładem takiej zmiennej jest np. AtomicInteger.
public class AtomicCounter { private static AtomicInteger counter = new AtomicInteger(0); public void incrementCounter() { synchronized (counter) { System.out.println(Thread.currentThread().getName() + " : " + counter); counter.incrementAndGet(); } } public static int getCounter() { return counter.get(); } }
IncrementAndGet vs getAndIncrement
Teraz kod korzysta nie tylko z synchronizacji, ale także ze zmiennej AtomicInteger, która umożliwia wykonanie inkrementacji w środowisku wielowątkowym. Dostępne są tu dwie metody: incrementAndGet (najpierw zwiększa wartość a później ją przypisuje) oraz getAndIncrement (pozostawia starą wartość w obecnej iteracji i zwiększy ją w następnej). Pamiętaj, że nie są one tożsame i możesz łatwo popełnić tu błąd.
Ostatnim krokiem będzie skorzystanie z napisanych już w poprzednim przykładzie klas ThreadStarter oraz CounterThread i sprawdzenie, czy nowy licznik działa poprawnie. Pamiętaj, że w klasie CounterThread musisz odnosić się do nowej implementacji.
number one : 0 number one : 1 number one : 2 number one : 3 number one : 4 number one : 5 number one : 6 number one : 7 number three : 8 number three : 9 number three : 10 number three : 11 number three : 12 number three : 13 number three : 14 number three : 15 number three : 16 number three : 17 number three : 18 number three : 19 number two : 20
Pamiętaj, że wyniki na Twoim komputerze mogą być trochę inne, z racji, że kompilator będzie optymalizował wykorzystanie wątków w zależności od dostępnych zasobów sprzętowych. Najważniejsze w tym przykładzie jest, aby licznik cały czas wzrastał o jeden.
Zamki
Ostatnim sposobem synchronizacji, który Ci pokażę, jest skorzystanie z zamka. W pakiecie concurrent istnieje kilka jego implementacji. Tu skupię się na najpopularniejszej z nich, czyli klasie ReentrantLock.
class CounterLock { private static int counter = 0; private static final ReentrantLock lock = new ReentrantLock(); public void incrementCounter() { lock.lock(); try { System.out.println(Thread.currentThread().getName() + " : " + counter); counter++; } finally { lock.unlock(); } } public static int getCounter() { return counter; } }
Ta implementacja znów bardzo przypomina kod z początku lekcji. Podstawową różnicą jest to, że tym razem zamiast korzystania z wartości atomowych lub synchronizowanego pola pomocniczego, używam klasy ReentrantLock. Pozwala mi ona na zabezpieczenie sekcji krytycznej, aż do momentu, kiedy zwolnię zmienną counter. Teoretycznie nie musisz korzystać z bloku try – finally. Jednak taki zapis zabezpieczy Cię przed przypadkiem, kiedy to zamek nigdy nie zostanie otworzony dla innych wątków.
Inne synchronizatory
Pomimo, że w Javie istnieją takie struktury jak zamki i zmienne atomowe, Java posiada jeszcze wiele innych sposób synchronizacji wątków. Nie będę jednak ich opisywał w tym kursie. Inne możliwości to np. semafory (ang. semaphores) i bariery (ang. barriers). Zachęcam Cię do poznania tych struktur już we własnym zakresie.
Link do kodu: https://github.com/developeronthego/java-advanced/tree/master/src/main/java/advanced/lesson11
Semafory: https://mkyong.com/java/java-thread-mutex-and-semaphore-example/
Bariery: https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/CyclicBarrier.html
Lekcja o tym, jak synchronizować dane poprzez słowo synchronized: Java #54: synchronizacja wątków (synchronized)