Najkrótszy na świecie kurs współbieżności
-
Wątki
Java pozwala tworzyć nowe wątki, przez utworzenie obiektu Thread. Wątki w Javie są od pewnego czasu natywne, czyli JVM deleguje je na system. Dzięki temu system może przerzucać ich wykonanie między fizycznymi procesorami/rdzeniami nawet w trakcie ich działania. Dzięki temu system może równomiernie rozłożyć wiele wątków między wszystkie procesory/rdzenie. Utworzenie/zniszczenie wątku jest kosztowne czasowo. Wszystkie wątki na jednym cpu dzielą między sobą czas wykonania, a przełączanie między nimi też jest kosztowne czasowo. Przy dużej liczbie wątków na jeden cpu czas przełączania może być porównywalny z czasem wykonywania, dlatego każda aplikacja powinna ograniczać liczbę równolegle działających wątków do rzędu wielkości liczby procesorów.
-
Operacje
Jeżeli jakiś kod uruchomiony z co najmniej dwóch wątków robi jakieś operacje na polu, to może się zdarzyć, że odczyt lub zapis może się wykonać z obu wątków "jednocześnie". Jeżeli operacje te nie są atomowe, czyli niepodzielne na poziomie procesora, to jakiś odczyt może nastąpić w trakcie operacji zapisu, wtedy następuje awaria danych - najgorsze paskudztwo do debugowania bo operacja przestaje być deterministyczna (czytaj zaczyna być losowa).
Wszystkie zapisy i odczyty zmiennych maks. 32-bitowych (char, byte, short, int, float, referencja 32-bit) są operacjami atomowymi. Inkrementacja i dekrementacja nie są atomowe, reszta operacji też nie jest, ale mogą (zależnie od JVM).
Mimo, że operacje mogą być atomowe, to ze względu na budowę komputera, cache CPU, systemu i JVM ich nowe wartości mogą nie być od razu (lub w ogóle) widziane dla innych wątków. Odczyt w innym wątku może dać wcześniejszą wartość widzianą w tym wątku.
Żeby ten problem pominąć można za niewielką cenę szybkości kodu spowodować widoczność wartości zmiennej dla wszystkich wątków przez użycie modyfikatora volatile. Dla operacji atomowych to zwykle rozwiązuje sprawę. Dla operacji nieatomowych lub całego ciągu operacji stosuje się synchronizację.
Użycie konstrukcji synchronized(obiekt) {...} pozwala przywrócić z punktu widzenia wątku atomowość instrukcji znajdujących się w bloku za sporą cenę szybkości kodu. Pod warunkiem, że dostęp do pola będzie możliwy wyłącznie z kawałków kodu synchronizowanego. Dlatego tak istotna jest enkapsulacja prywatnych danych w obiekcie - dzięki temu można zsynchronizować samego settera i gettera i problem synchronizacji ma się z głowy. Napis obiekt w bloku synchronized oznacza dowolny obiekt, który będzie sobie trzymał identyfikację blokady. Dla każdego dostępu musi to być zawsze ten sam obiekt, blokady na różnych obiektach nie będą skuteczne i może się pojawić awaria danych. Jeżeli metoda ma modyfikator synchronized, to oznacza to, że cały jej blok jest synchronizowany i to obiektem this, czyli zazwyczaj tym, który posiada pola, które mają mieć dostęp synchronizowany. Użycie synchronizacji powoduje z automatu widoczność, więc w takim wypadku należy pozdejmować modyfikatory volatile na tych zmiennych (będą powodować tylko zbędny narzut czasowy). Operacje wewnątrz bloku synchronizowanego są krytyczne - im jest ich mniej i są krótsze tym lepiej. Nigdy nie należy wywoływać z sekcji krytycznych żadnych metod polimorficznych (public, protected), ani cudzego kodu (wywołań z kodu klienta) bo złamie to sekcję krytyczną i synchronizacja pójdzie się bujać lub pojawią się zakleszczenia. Dlatego synchronizowany getter i setter dla prostego pola prywatnego, to ideał synchronizacji.
Synchronizacja działa w ten sposób, że jeżeli wykonywane są instrukcje bloku synchronizowanego przez jeden wątek, to drugi musi czekać aż ten pierwszy skończy. Działa to jak semafor dla pociągów próbujących wjechać na wspólny tor. Na raz może tylko jeden dlatego nigdy nie pojawi się kolizja.
Synchronizacja modyfikowalnych pól obiektowych jest trudniejsza ponieważ dostęp do całego obiektu jest złożony z wielu operacji więc nie może być on nigdy atomowy - synchronizacja jest jedyną możliwością. Na dodatek jedynym sposobem zapewnienia synchronizacji przy przekazywaniu wartości takiego obiektu jest przekazanie jego głębokiej kopii (którą należy wykonać właśnie w sekcji krytycznej). Jedynym wyjątkiem od tej zasady są obiekty niezmienne (immutable), które można swobodnie przekazywać bez synchronizacji (wystarczy volatile na referencji).
Oprócz tego istnieją jeszcze obiekty synchronizujące takie jak Lock, które zastępują bloki synchronized i w dużej skali są wydajniejsze oraz Semaphore (->javadoc).
Alternatywą do synchronizacji jest użycie atomowych typów danych. Są one synchronizowane z automatu ze względu na specyficzną listę operacji i typ dostępu do danych i są w porównaniu do volatile bardzo szybkie, tym bardziej wobec synchronizacji. Jest kilka typów atomowych AtomicBoolean, AtomicInteger, AtomicLong*, AtomicReference* oraz tablice AtomicArray ( = nic lub różne/specyficzne typy -> javadoc). Trzy ostatnie mają kilka podtypów o różnych zachowaniach i typach operacji atomowych (innych nie mają). Nie trzeba ich synchronizować, ani (prawdopodobnie) używać volatile. Ceną ich użycia jest bardzo ograniczona liczba bardzo specyficznych operacji takich jak np. warunkowy zapis.
Najmniejszą cegiełką synchronizacji są operacje wait i notify/notifyAll. Działają one wyłącznie w sekcjach krytycznych, czyli w blokach synchronizowanych. Operują na nich wszystkie metody i operacje współbieżności z bibliotek Javy, więc ich ręczne używanie jest w normalnym kodzie właściwie zbędne (chyba, że ktoś chce wynajdować nową wersję koła). Operacje te są zastępowane przez wysokopoziomowe kolejki interfejsu BlockingQueue: LinkedBlockingQueue i ArrayBlockingQueue.
Operacje klasy Thread: suspend, resume, stop i destroy są przestarzałe ponieważ próbują zatrzymać i wznowić wykonanie kodu również w trakcie operacji nieatomowych, a na dodatek stan danych, na których operuje wątek staje się nieokreślony (jedne mogłyby być zaktualizowane, inne nie). Aby temu zaradzić zapętlony kod wątku powinien być ręcznie przerywany (np. warunkiem pętli lub break) po wykryciu stanu żądania jego zapauzowania/zatrzymania lub przez użycie metody Thread.isInterrupted(). Metoda Thread.yield() jest koncepcyjnie przestarzała i służy do wymuszania wielowątkowości na starych systemach bez wywłaszczania. Na systemach z wywłaszczaniem wątków może mieć lub nie mieć żadnego skutku.
Jeżeli dostęp do jednej danej synchronizowanej wymaga dostępu do danej synchronizowanej innego obiektu, a ta wymaga dostępu do pierwszej danej, to pojawia się cykl, który prowadzi do zakleszczenia programu. Cykl może składać się z większej ilości elementów niż 2.
- Przydatne klasy narzędziowe do współbieżności
CountDownLatch - wielowątkowy licznik wykonania zatrzymujący wątki do momentu wyzerowania licznika; pozwala zatrzymać i wznowić różne wątki w tym samym momencie.
CyclicBarrier - j.w., ale zatrzymuje wątki bez licznika i jest do wielokrotnego użycia
Reszta ->javadoc (Executors, Executor/ExecutorService, Runnable, Callable, Future, Executors: newSingleThreadExecutor, newFixedThreadPool, newScheduledThreadPool, newCachedThreadPool() i osobno new ForkJoinPool() z jego zadaniami RecursiveTask i RecursiveAction).