Java Language
Pułapki Java - wątki i współbieżność
Szukaj…
Pitfall: nieprawidłowe użycie funkcji wait () / notyfikuj ()
Metody object.wait()
, object.notify()
i object.notifyAll()
są przeznaczone do użycia w bardzo specyficzny sposób. (patrz http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 )
Problem „Zgubione powiadomienie”
Jednym z powszechnych błędów początkujących jest bezwarunkowe wywołanie object.wait()
private final Object lock = new Object();
public void myConsumer() {
synchronized (lock) {
lock.wait(); // DON'T DO THIS!!
}
doSomething();
}
Przyczyną tego błędu jest to, że zależy od jakiegoś innego wątku, aby wywołać lock.notify()
lub lock.notifyAll()
, ale nic nie gwarantuje, że inny wątek nie wykonał tego wywołania przed wątkiem konsumenta o nazwie lock.wait()
.
lock.notify()
i lock.notifyAll()
nie robią nic, jeśli jakiś inny wątek nie czeka już na powiadomienie. Wątek, który wywołuje myConsumer()
w tym przykładzie, zawiesi się na zawsze, jeśli będzie za późno na złapanie powiadomienia.
Błąd „Nielegalny stan monitorowania”
Jeśli wywołasz funkcję wait()
lub notify()
na obiekcie bez przytrzymywania blokady, wówczas JVM zgłosi IllegalMonitorStateException
.
public void myConsumer() {
lock.wait(); // throws exception
consume();
}
public void myProducer() {
produce();
lock.notify(); // throws exception
}
(Projekt funkcji wait()
/ notify()
wymaga, aby blokada była utrzymywana, ponieważ jest to konieczne, aby uniknąć systemowych warunków wyścigu. Gdyby można było wywołać funkcję wait()
lub notify()
bez blokady, niemożliwa byłaby implementacja podstawowy przypadek użycia tych prymitywów: oczekiwanie na wystąpienie warunku).
Oczekiwanie / powiadomienie jest zbyt niskie
Najlepszym sposobem uniknięcia problemów z wait()
i notify()
jest ich nieużywanie. Większość problemów z synchronizacją można rozwiązać za pomocą obiektów synchronizacji wyższego poziomu (kolejek, barier, semaforów itp.), java.utils.concurrent
są dostępne w pakiecie java.utils.concurrent
.
Pitfall - Rozszerzenie „java.lang.Thread”
Jawadok dla klasy Thread
pokazuje dwa sposoby definiowania i używania wątku:
Używanie niestandardowej klasy wątku:
class PrimeThread extends Thread {
long minPrime;
PrimeThread(long minPrime) {
this.minPrime = minPrime;
}
public void run() {
// compute primes larger than minPrime
. . .
}
}
PrimeThread p = new PrimeThread(143);
p.start();
Korzystanie z Runnable
:
class PrimeRun implements Runnable {
long minPrime;
PrimeRun(long minPrime) {
this.minPrime = minPrime;
}
public void run() {
// compute primes larger than minPrime
. . .
}
}
PrimeRun p = new PrimeRun(143);
new Thread(p).start();
(Źródło: java.lang.Thread
javadoc .)
Niestandardowe podejście do klasy wątków działa, ale ma kilka problemów:
Używanie
PrimeThread
w kontekście korzystającym z klasycznej puli wątków, modułu wykonującego lub frameworku ForkJoin jest niewygodne. (Nie jest to niemożliwe, ponieważPrimeThread
pośrednio implementujeRunnable
, ale użycie niestandardowej klasyThread
jakoRunnable
jest z pewnością niezdarne i może nie być wykonalne ... w zależności od innych aspektów tej klasy.)Jest więcej okazji do błędów w innych metodach. Na przykład, jeśli zadeklarujesz
PrimeThread.start()
bez delegowania doThread.start()
, skończysz na „wątku”, który działał w bieżącym wątku.
Podejście polegające na umieszczeniu logiki wątku w Runnable
pozwala uniknąć tych problemów. Rzeczywiście, jeśli użyjesz anonimowej klasy (Java 1.1 i nowsze) do wdrożenia Runnable
wynik będzie bardziej zwięzły i bardziej czytelny niż w powyższych przykładach.
final long minPrime = ...
new Thread(new Runnable() {
public void run() {
// compute primes larger than minPrime
. . .
}
}.start();
Przy wyrażeniu lambda (Java 8 i nowsze) powyższy przykład stałby się jeszcze bardziej elegancki:
final long minPrime = ...
new Thread(() -> {
// compute primes larger than minPrime
. . .
}).start();
Pitfall - zbyt wiele wątków powoduje spowolnienie aplikacji.
Wiele osób, które nie znają się na wielowątkowości, sądzi, że automatyczne używanie wątków przyspiesza działanie aplikacji. W rzeczywistości jest to o wiele bardziej skomplikowane. Ale jedną rzeczą, którą możemy z całą pewnością stwierdzić, jest to, że dla każdego komputera istnieje ograniczenie liczby wątków, które można uruchomić jednocześnie:
- Komputer ma stałą liczbę rdzeni (lub hipertekstów ).
- W celu uruchomienia wątek Java musi być zaplanowany na rdzeń lub hiperwątek.
- Jeśli jest więcej możliwych do uruchomienia wątków Java niż (dostępne) rdzenie / hiperwątki, niektóre z nich muszą poczekać.
To mówi nam, że po prostu tworzenie coraz większej liczby wątków Java nie może przyspieszyć działania aplikacji. Ale są też inne względy:
Każdy wątek wymaga stosu pamięci poza stosem. Typowy (domyślny) rozmiar stosu wątków wynosi 512 KB lub 1 MB. Jeśli masz znaczną liczbę wątków, użycie pamięci może być znaczące.
Każdy aktywny wątek będzie odnosił się do wielu obiektów na stercie. Zwiększa to działający zestaw osiągalnych obiektów, co wpływa na zbieranie pamięci i zużycie pamięci fizycznej.
Koszty związane z przełączaniem między wątkami nie są trywialne. Zwykle wymaga to przełączenia w przestrzeń jądra systemu operacyjnego w celu podjęcia decyzji dotyczącej planowania wątków.
Narzuty związane z synchronizacją wątków i sygnalizacją między wątkami (np. Wait (), notyfikuj () / notyfikuj wszystkie) mogą być znaczące.
W zależności od szczegółów aplikacji, czynniki te ogólnie oznaczają, że istnieje pewna „słaba strona” dla liczby wątków. Ponadto dodanie większej liczby wątków zapewnia minimalną poprawę wydajności i może pogorszyć wydajność.
Jeśli twoja aplikacja tworzy dla każdego nowego zadania, nieoczekiwany wzrost obciążenia pracą (np. Wysoki wskaźnik żądań) może prowadzić do katastrofalnego zachowania.
Lepszym sposobem na poradzenie sobie z tym jest użycie ograniczonej puli wątków, której wielkości można kontrolować (statycznie lub dynamicznie). Gdy jest zbyt dużo pracy, aplikacja musi ustawić kolejkę żądań. Jeśli użyjesz usługi ExecutorService
, zajmie się ona zarządzaniem pulą wątków i kolejkowaniem zadań.
Pitfall - Tworzenie wątków jest stosunkowo drogie
Rozważ te dwa mikro-testy:
Pierwszy test porównawczy po prostu tworzy, uruchamia i łączy wątki. Runnable
wątku nie działa.
public class ThreadTest {
public static void main(String[] args) throws Exception {
while (true) {
long start = System.nanoTime();
for (int i = 0; i < 100_000; i++) {
Thread t = new Thread(new Runnable() {
public void run() {
}});
t.start();
t.join();
}
long end = System.nanoTime();
System.out.println((end - start) / 100_000.0);
}
}
}
$ java ThreadTest
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C
Na typowym nowoczesnym komputerze PC z systemem Linux z 64-bitową Javą 8 u101 ten test porównawczy pokazuje średni czas potrzebny na utworzenie, uruchomienie i dołączenie wątku od 33,6 do 33,9 mikrosekund.
Drugi test porównawczy odpowiada pierwszemu, ale używa ExecutorService
do przesyłania zadań i Future
do spotkania z końcem zadania.
import java.util.concurrent.*;
public class ExecutorTest {
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
while (true) {
long start = System.nanoTime();
for (int i = 0; i < 100_000; i++) {
Future<?> future = exec.submit(new Runnable() {
public void run() {
}
});
future.get();
}
long end = System.nanoTime();
System.out.println((end - start) / 100_000.0);
}
}
}
$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C
Jak widać, średnie wynoszą od 5,3 do 5,6 mikrosekund.
Chociaż rzeczywiste czasy będą zależeć od różnych czynników, różnica między tymi dwoma wynikami jest znacząca. Wyraźnie szybsze jest używanie puli wątków do recyklingu wątków niż tworzenie nowych wątków.
Pitfall: zmienne współdzielone wymagają właściwej synchronizacji
Rozważ ten przykład:
public class ThreadTest implements Runnable {
private boolean stop = false;
public void run() {
long counter = 0;
while (!stop) {
counter = counter + 1;
}
System.out.println("Counted " + counter);
}
public static void main(String[] args) {
ThreadTest tt = new ThreadTest();
new Thread(tt).start(); // Create and start child thread
Thread.sleep(1000);
tt.stop = true; // Tell child thread to stop.
}
}
Celem tego programu jest uruchomienie wątku, uruchomienie go przez 1000 milisekund, a następnie spowodowanie zatrzymania przez ustawienie flagi stop
.
Czy będzie działać zgodnie z przeznaczeniem?
Może tak może nie.
Aplikacja niekoniecznie zatrzymuje się po powrocie main
metody. Jeśli utworzono inny wątek, który nie został oznaczony jako wątek demona, aplikacja będzie nadal działać po zakończeniu głównego wątku. W tym przykładzie oznacza to, że aplikacja będzie działać do momentu zakończenia wątku potomnego. Powinno tak się tt.stop
gdy parametr tt.stop
ma wartość true
.
Ale tak naprawdę nie jest to do końca prawdą. W rzeczywistości wątek potomny zatrzyma się po zaobserwowaniu stop
z wartością true
. Czy to się stanie? Może tak może nie.
Specyfikacja języka Java gwarantuje, że pamięć odczytuje i zapisuje wykonane w wątku są widoczne dla tego wątku, zgodnie z kolejnością instrukcji w kodzie źródłowym. Jednak na ogół NIE jest to gwarantowane, gdy jeden wątek pisze, a inny wątek (później) czyta. Aby uzyskać gwarantowaną widoczność, musi istnieć łańcuch zdarzeń przed relacjami między zapisem a kolejnym odczytem. W powyższym przykładzie nie ma takiego łańcucha aktualizacji flagi stop
, dlatego nie ma gwarancji, że wątek potomny zobaczy stop
zmiany na true
.
(Uwaga dla autorów: powinien istnieć osobny temat dotyczący modelu pamięci Java, aby uzyskać szczegółowe informacje techniczne).
Jak rozwiązać problem?
W takim przypadku istnieją dwa proste sposoby zapewnienia widoczności stop
aktualizacji:
Ogłaszaj, że
stop
jestvolatile
; to znaczyprivate volatile boolean stop = false;
W przypadku zmiennej
volatile
JLS określa, że istnieje relacja przed zdarzeniem między zapisem jednego wątku a późniejszym odczytem drugiego wątku.Użyj muteksu do synchronizacji w następujący sposób:
public class ThreadTest implements Runnable {
private boolean stop = false;
public void run() {
long counter = 0;
while (true) {
synchronize (this) {
if (stop) {
break;
}
}
counter = counter + 1;
}
System.out.println("Counted " + counter);
}
public static void main(String[] args) {
ThreadTest tt = new ThreadTest();
new Thread(tt).start(); // Create and start child thread
Thread.sleep(1000);
synchronize (tt) {
tt.stop = true; // Tell child thread to stop.
}
}
}
Oprócz zapewnienia wzajemnego wykluczenia, JLS określa, że istnieje związek przed wydaniem muteksu w jednym wątku i uzyskaniem tego samego muteksu w drugim wątku.
Ale czy przypisanie nie jest atomowe?
Tak to jest!
Jednak fakt ten nie oznacza, że efekty aktualizacji będą widoczne jednocześnie dla wszystkich wątków. Zagwarantuje to tylko odpowiedni łańcuch relacji poprzedzających .
Dlaczego oni to zrobili?
Programiści wykonujący programowanie wielowątkowe w Javie po raz pierwszy odkrywają, że model pamięci jest trudny. Programy zachowują się w sposób nieintuicyjny, ponieważ naturalnym oczekiwaniem jest, że zapisy są widoczne jednolicie. Dlaczego projektanci Java projektują model pamięci w ten sposób.
W rzeczywistości sprowadza się to do kompromisu między wydajnością a łatwością obsługi (dla programisty).
Nowoczesna architektura komputerowa składa się z wielu procesorów (rdzeni) z indywidualnymi zestawami rejestrów. Pamięć główna jest dostępna dla wszystkich procesorów lub dla grup procesorów. Inną właściwością współczesnego sprzętu komputerowego jest to, że dostęp do rejestrów jest zwykle o rząd wielkości szybszy niż dostęp do pamięci głównej. Wraz ze wzrostem liczby rdzeni łatwo zauważyć, że odczytywanie i zapisywanie w pamięci głównej może stać się głównym wąskim gardłem wydajności systemu.
To niedopasowanie rozwiązuje się poprzez wdrożenie jednego lub więcej poziomów buforowania pamięci między rdzeniami procesora a pamięcią główną. Każdy rdzeń uzyskuje dostęp do komórek pamięci za pośrednictwem swojej pamięci podręcznej. Zwykle odczyt pamięci głównej następuje tylko wtedy, gdy brakuje pamięci podręcznej, a zapis pamięci głównej następuje tylko wtedy, gdy trzeba wyczyścić linię pamięci podręcznej. W przypadku aplikacji, w której zestaw roboczy lokalizacji pamięci każdego rdzenia zmieści się w jego pamięci podręcznej, szybkość rdzenia nie jest już ograniczona szybkością / przepustowością pamięci głównej.
Ale to daje nam nowy problem, gdy wiele rdzeni odczytuje i zapisuje wspólne zmienne. Najnowsza wersja zmiennej może znajdować się w pamięci podręcznej jednego rdzenia. O ile ten rdzeń nie opróżni linii pamięci podręcznej do pamięci głównej ORAZ inne rdzenie unieważnią buforowaną kopię starszych wersji, niektóre z nich mogą zobaczyć nieaktualne wersje zmiennej. Ale jeśli pamięci podręczne zostałyby opróżnione do pamięci za każdym razem, gdy istnieje zapis do pamięci podręcznej („na wszelki wypadek” nastąpił odczyt innego rdzenia), który niepotrzebnie zajmowałby przepustowość pamięci głównej.
Standardowym rozwiązaniem stosowanym na poziomie zestawu instrukcji sprzętowych jest dostarczenie instrukcji dotyczących unieważnienia pamięci podręcznej i zapisu pamięci podręcznej oraz pozostawienie kompilatorowi decyzji, kiedy z nich skorzystać.
Wracając do Javy. Model pamięci został zaprojektowany w taki sposób, że kompilatory Java nie są zobowiązane do wydawania instrukcji unieważnienia pamięci podręcznej i zapisywania instrukcji, gdy nie są tak naprawdę potrzebne. Zakłada się, że programista użyje odpowiedniego mechanizmu synchronizacji (np. Prymitywne muteksy, volatile
klasy współbieżności wyższego poziomu itd.), Aby wskazać, że potrzebuje widoczności pamięci. W przypadku braku relacji „ zdarza się przed” kompilatory Java mogą założyć, że nie są wymagane żadne operacje pamięci podręcznej (lub podobne).
Ma to znaczące zalety pod względem wydajności dla aplikacji wielowątkowych, ale wadą jest to, że pisanie poprawnych aplikacji wielowątkowych nie jest prostą sprawą. Programista musi zrozumieć, co on robi.
Dlaczego nie mogę tego odtworzyć?
Istnieje wiele powodów, dla których takie problemy są trudne do odtworzenia:
Jak wyjaśniono powyżej, konsekwencją niepoprawnego rozwiązywania problemów z widocznością pamięci jest zazwyczaj to, że skompilowana aplikacja nie obsługuje poprawnie pamięci podręcznych pamięci. Jednak, jak wspomnieliśmy powyżej, pamięci podręczne i tak często są opróżniane.
Po zmianie platformy sprzętowej właściwości buforów pamięci mogą ulec zmianie. Może to prowadzić do różnych zachowań, jeśli aplikacja nie będzie poprawnie synchronizowana.
Być może obserwujesz skutki nieoczekiwanej synchronizacji. Na przykład, jeśli dodasz znaki śledzenia, zwykle jest to pewna synchronizacja zachodząca za scenami w strumieniach we / wy, która powoduje opróżnianie pamięci podręcznej. Dlatego dodanie śladów śledzenia często powoduje, że aplikacja zachowuje się inaczej.
Uruchamianie aplikacji w debugerze powoduje, że jest ona kompilowana inaczej przez kompilator JIT. Zaostrzają to punkty przerwania i pojedyncze kroki. Efekty te często zmieniają zachowanie aplikacji.
Te rzeczy sprawiają, że błędy wynikające z niewłaściwej synchronizacji są szczególnie trudne do rozwiązania.