Dlaczego strumień równoległy z lambdą w inicjatorze statycznym powoduje zakleszczenie?

86

Natknąłem się na dziwną sytuację, w której użycie równoległego strumienia z lambdą w statycznym inicjatorze zajmuje pozornie wieczność bez użycia procesora. Oto kod:

class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(i -> i).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Wydaje się, że jest to minimalne odtworzenie przypadku testowego dla tego zachowania. Jeśli ja:

  • umieść blok w głównej metodzie zamiast statycznego inicjatora,
  • usuń równoległość lub
  • usunąć lambdę,

kod natychmiast się kończy. Czy ktoś może wyjaśnić to zachowanie? Czy to błąd, czy jest to zamierzone?

Używam OpenJDK w wersji 1.8.0_66-internal.

Przywróć Monikę
źródło
4
Przy zakresie (0, 1) program kończy się normalnie. Z zawieszeniem (0, 2) lub wyższym.
Laszlo Hirdi
5
podobne pytanie: stackoverflow.com/questions/34222669/…
Alex - GlassEditor.com
2
Właściwie jest to dokładnie to samo pytanie / problem, tylko z innym API.
Didier L
3
Próbujesz użyć klasy w wątku w tle, ale nie zakończyłeś inicjalizacji klasy, więc nie można jej użyć w wątku w tle.
Peter Lawrey
4
@ Solomonoff'sSecret as i -> inie jest odwołaniem do metody, jest static methodzaimplementowany w klasie zakleszczenia. Jeśli zastąpić i -> iz Function.identity()tym kodem powinno być dobrze.
Peter Lawrey,

Odpowiedzi:

71

Znalazłem raport o błędzie dotyczący bardzo podobnej sprawy ( JDK-8143380 ), która została zamknięta jako „Not an Issue” przez Stuarta Marksa:

To jest zakleszczenie inicjalizacji klas. Główny wątek programu testowego wykonuje statyczny inicjator klasy, który ustawia flagę inicjalizacji w toku dla klasy; ta flaga pozostaje ustawiona do zakończenia inicjalizacji statycznej. Inicjator statyczny wykonuje strumień równoległy, co powoduje, że wyrażenia lambda są oceniane w innych wątkach. Te wątki blokują oczekiwanie na zakończenie inicjalizacji klasy. Jednak główny wątek jest blokowany w oczekiwaniu na zakończenie równoległych zadań, co powoduje zakleszczenie.

Program testowy powinien zostać zmieniony, aby przenieść logikę strumienia równoległego poza inicjalizator statyczny klasy. Zamknięcie nie jest problemem.


Udało mi się znaleźć inny raport o błędzie ( JDK-8136753 ), również zamknięty jako „Not an Issue” autorstwa Stuarta Marksa:

Jest to zakleszczenie, które występuje, ponieważ statyczny inicjator wyliczenia Fruit źle współdziała z inicjalizacją klasy.

Szczegółowe informacje na temat inicjowania klas można znaleźć w specyfikacji języka Java, sekcja 12.4.2.

http://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2

Krótko mówiąc, to, co się dzieje, jest następujące.

  1. Główny wątek odwołuje się do klasy Fruit i rozpoczyna proces inicjalizacji. Spowoduje to ustawienie flagi w toku inicjalizacji i uruchomienie statycznego inicjatora w głównym wątku.
  2. Inicjator statyczny uruchamia kod w innym wątku i czeka na jego zakończenie. W tym przykładzie zastosowano strumienie równoległe, ale nie ma to nic wspólnego ze strumieniami jako takimi. Wykonywanie kodu w innym wątku w dowolny sposób i czekanie na zakończenie tego kodu będzie miało ten sam efekt.
  3. Kod w drugim wątku odwołuje się do klasy Fruit, która sprawdza flagę inicjalizacji w toku. Powoduje to blokowanie drugiego wątku do momentu wyczyszczenia flagi. (Patrz krok 2 JLS 12.4.2.)
  4. Główny wątek jest blokowany w oczekiwaniu na zakończenie drugiego wątku, więc inicjator statyczny nigdy się nie kończy. Ponieważ flaga w toku inicjalizacji nie jest czyszczona do czasu zakończenia inicjowania statycznego, wątki są zakleszczone.

Aby uniknąć tego problemu, upewnij się, że statyczna inicjalizacja klasy kończy się szybko, bez powodowania wykonywania przez inne wątki kodu wymagającego ukończenia inicjalizacji tej klasy.

Zamknięcie nie jest problemem.


Zauważ, że FindBugs ma otwarty problem z dodaniem ostrzeżenia o tej sytuacji.

Tunaki
źródło
20
„Uznano, kiedy zaprojektowane z funkcji” i „Wiemy, co powoduje ten błąd, ale nie jak to naprawić” zrobić nie średnia „to nie jest to błąd” . To jest absolutnie błąd.
BlueRaja - Danny Pflughoeft
13
@ bayou.io Głównym problemem jest używanie wątków w statycznych inicjalizatorach, a nie lambd.
Stuart Marks
5
BTW Tunaki dzięki za odkopanie moich raportów o błędach. :-)
Stuart Marks
13
@ bayou.io: na poziomie klasy jest to to samo, co w konstruktorze, pozwalając na thisucieczkę podczas budowy obiektu. Podstawowa zasada brzmi: nie używaj operacji wielowątkowych w inicjatorach. Nie sądzę, żeby było to trudne do zrozumienia. Twój przykład rejestracji funkcji zaimplementowanej przez lambdę w rejestrze to inna sprawa, nie tworzy zakleszczeń, chyba że masz zamiar czekać na jeden z tych zablokowanych wątków w tle. Niemniej jednak zdecydowanie odradzam wykonywanie takich operacji w inicjatorze klas. Nie do tego są przeznaczone.
Holger
9
Wydaje mi się, że lekcja stylu programowania brzmi: utrzymuj statyczne inicjatory w prostocie.
Raedwald
16

Dla tych, którzy zastanawiają się, gdzie są inne wątki odwołujące się do Deadlocksamej klasy, lambdy Java zachowują się tak, jak napisałeś:

public class Deadlock {
    public static int lambda1(int i) {
        return i;
    }
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return lambda1(operand);
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Przy zwykłych klasach anonimowych nie ma impasu:

public class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return operand;
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}
Tamas Hegedus
źródło
5
@ Solomonoff'sSecret To wybór implementacji. Kod w lambdzie musi gdzieś iść. Javac kompiluje go do metody statycznej w klasie zawierającej (analogicznie do lambda1i w tym przykładzie). Umieszczenie każdej lambdy w jej własnej klasie byłoby znacznie droższe.
Stuart Marks
1
@StuartMarks Biorąc pod uwagę, że lambda tworzy klasę implementującą interfejs funkcjonalny, czy nie byłoby równie wydajne umieszczenie implementacji lambdy w implementacji lambdy interfejsu funkcjonalnego, jak w drugim przykładzie tego postu? Z pewnością jest to oczywisty sposób robienia rzeczy, ale jestem pewien, że istnieje powód, dla którego robi się to tak, jak jest.
Przywróć Monikę
6
@ Solomonoff'sSecret Lambda może utworzyć klasę w czasie wykonywania (poprzez java.lang.invoke.LambdaMetafactory ), ale treść lambda musi zostać umieszczona gdzieś w czasie kompilacji. Klasy lambda mogą więc korzystać z pewnej magii maszyn wirtualnych, aby być tańszymi niż zwykłe klasy ładowane z plików .class.
Jeffrey Bosboom,
1
@ Solomonoff'sSecret Tak, odpowiedź Jeffreya Bosbooma jest prawidłowa. Jeśli w przyszłej JVM stanie się możliwe dodanie metody do istniejącej klasy, metafabryka może to zrobić zamiast przędzenia nowej klasy. (Czysta spekulacja.)
Stuart Marks
3
@ Sekret Solomonoffa: nie oceniaj, patrząc na takie trywialne wyrażenia lambda, jak twoje i -> i; nie będą normą. Wyrażenia lambda mogą używać wszystkich członków swojej otaczającej ich klasy, w tym klas, privateco sprawia, że ​​sama klasa definiująca jest ich naturalnym miejscem. Pozwolenie, aby wszystkie te przypadki użycia cierpiały z powodu implementacji zoptymalizowanej dla specjalnego przypadku inicjatorów klas z wielowątkowym użyciem trywialnych wyrażeń lambda, bez używania elementów składowych ich definiującej klasy, nie jest realną opcją.
Holger,
14

Jest doskonałe wyjaśnienie tego problemu przez Andrieja Pangina , datowane na 7 kwietnia 2015 r. Jest dostępne tutaj , ale jest napisane w języku rosyjskim (i tak proponuję przejrzeć próbki kodu - są one międzynarodowe). Ogólnym problemem jest blokada podczas inicjalizacji klasy.

Oto kilka cytatów z artykułu:


Według JLS każda klasa ma unikalną blokadę inicjalizacji, która jest przechwytywana podczas inicjalizacji. Gdy inny wątek spróbuje uzyskać dostęp do tej klasy podczas inicjalizacji, zostanie zablokowany na zamku do czasu zakończenia inicjalizacji. Gdy klasy są inicjowane jednocześnie, można uzyskać zakleszczenie.

Napisałem prosty program, który oblicza sumę liczb całkowitych, co powinien wydrukować?

public class StreamSum {
    static final int SUM = IntStream.range(0, 100).parallel().reduce((n, m) -> n + m).getAsInt();

    public static void main(String[] args) {
        System.out.println(SUM);
    }
} 

Teraz usuń parallel()lub zastąp lambdę Integer::sumwywołaniem - co się zmieni?

Tutaj znów widzimy zakleszczenie [w artykule było kilka przykładów zakleszczeń w inicjatorach klas]. Ponieważ parallel()operacje na strumieniu są uruchamiane w oddzielnej puli wątków. Te wątki próbują wykonać treść lambda, która jest zapisywana w kodzie bajtowym jako private staticmetoda wewnątrz StreamSumklasy. Ale ta metoda nie może zostać wykonana przed zakończeniem statycznego inicjatora klasy, który oczekuje na wyniki zakończenia strumienia.

Co więcej, ten kod działa inaczej w różnych środowiskach. Będzie działać poprawnie na komputerze z jednym procesorem i najprawdopodobniej zawiesi się na komputerze z wieloma procesorami. Ta różnica wynika z implementacji puli Fork-Join. Możesz to zweryfikować samodzielnie zmieniając parametr-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

AdamSkywalker
źródło