Warum verursacht ein paralleler Stream mit Lambda im statischen Initialisierer einen Deadlock?

85

Ich bin auf eine seltsame Situation gestoßen, in der die Verwendung eines parallelen Streams mit einem Lambda in einem statischen Initialisierer scheinbar ohne CPU-Auslastung ewig dauert. Hier ist der Code:

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

Dies scheint ein minimaler Reproduktionstestfall für dieses Verhalten zu sein. Wenn ich:

  • Setzen Sie den Block in die Hauptmethode anstelle eines statischen Initialisierers.
  • Parallelisierung entfernen oder
  • Entfernen Sie das Lambda,

Der Code wird sofort vervollständigt. Kann jemand dieses Verhalten erklären? Ist es ein Fehler oder ist das beabsichtigt?

Ich verwende OpenJDK Version 1.8.0_66-intern.

Stellen Sie Monica wieder her
quelle
4
Mit Bereich (0, 1) wird das Programm normal beendet. Mit (0, 2) oder höher hängt.
Laszlo Hirdi
5
ähnliche Frage: stackoverflow.com/questions/34222669/…
Alex - GlassEditor.com
2
Eigentlich ist es genau die gleiche Frage / das gleiche Problem, nur mit einer anderen API.
Didier L
3
Sie versuchen, eine Klasse in einem Hintergrundthread zu verwenden, wenn Sie die Klasse noch nicht initialisiert haben, sodass sie nicht in einem Hintergrundthread verwendet werden kann.
Peter Lawrey
4
@ Solomonoff'sSecret i -> iist keine Methodenreferenz, sondern static methodin der Deadlock-Klasse implementiert. Wenn ersetzt i -> imit Function.identity()diesem Code sollte in Ordnung sein.
Peter Lawrey

Antworten:

71

Ich habe einen Fehlerbericht eines sehr ähnlichen Falls ( JDK-8143380 ) gefunden, der von Stuart Marks als "Not a Issue" geschlossen wurde:

Dies ist ein Deadlock für die Klasseninitialisierung. Der Hauptthread des Testprogramms führt den statischen Klasseninitialisierer aus, der das Flag für die laufende Initialisierung für die Klasse setzt. Dieses Flag bleibt gesetzt, bis der statische Initialisierer abgeschlossen ist. Der statische Initialisierer führt einen parallelen Stream aus, wodurch Lambda-Ausdrücke in anderen Threads ausgewertet werden. Diese Threads blockieren das Warten, bis die Klasse die Initialisierung abgeschlossen hat. Der Hauptthread wird jedoch blockiert, bis die parallelen Aufgaben abgeschlossen sind, was zu einem Deadlock führt.

Das Testprogramm sollte geändert werden, um die Parallelstromlogik außerhalb des statischen Klasseninitialisierers zu verschieben. Schließen als kein Problem.


Ich konnte einen weiteren Fehlerbericht darüber finden ( JDK-8136753 ), der von Stuart Marks ebenfalls als "Not an Issue" geschlossen wurde:

Dies ist ein Deadlock, der auftritt, weil der statische Initialisierer der Fruit-Enumeration schlecht mit der Klasseninitialisierung interagiert.

Weitere Informationen zur Klasseninitialisierung finden Sie in der Java-Sprachspezifikation, Abschnitt 12.4.2.

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

Kurz gesagt, was passiert, ist wie folgt.

  1. Der Hauptthread verweist auf die Fruit-Klasse und startet den Initialisierungsprozess. Dadurch wird das Flag für die laufende Initialisierung gesetzt und der statische Initialisierer auf dem Hauptthread ausgeführt.
  2. Der statische Initialisierer führt Code in einem anderen Thread aus und wartet, bis er abgeschlossen ist. In diesem Beispiel werden parallele Streams verwendet, dies hat jedoch nichts mit Streams an sich zu tun. Das Ausführen von Code in einem anderen Thread auf irgendeine Weise und das Warten auf das Beenden dieses Codes hat den gleichen Effekt.
  3. Der Code im anderen Thread verweist auf die Fruit-Klasse, die das Flag für die laufende Initialisierung überprüft. Dadurch wird der andere Thread blockiert, bis das Flag gelöscht wird. (Siehe Schritt 2 von JLS 12.4.2.)
  4. Der Hauptthread wird blockiert und wartet darauf, dass der andere Thread beendet wird, sodass der statische Initialisierer nie abgeschlossen wird. Da das Flag für die laufende Initialisierung erst nach Abschluss des statischen Initialisierers gelöscht wird, sind die Threads blockiert.

Um dieses Problem zu vermeiden, stellen Sie sicher, dass die statische Initialisierung einer Klasse schnell abgeschlossen wird, ohne dass andere Threads Code ausführen, für den diese Klasse die Initialisierung abgeschlossen haben muss.

Schließen als kein Problem.


Beachten Sie, dass FindBugs ein offenes Problem beim Hinzufügen einer Warnung für diese Situation hat.

Tunaki
quelle
20
„Dies galt als wir die Funktion entworfen“ und „Wir wissen , was diesen Fehler verursacht , aber nicht , wie es zu beheben“ tun nicht Mittel „dies ist kein Fehler“ . Dies ist absolut ein Fehler.
BlueRaja - Danny Pflughoeft
13
@ bayou.io Das Hauptproblem ist die Verwendung von Threads in statischen Initialisierern, nicht von Lambdas.
Stuart Marks
5
Übrigens, Tunaki, danke, dass du meine Fehlerberichte ausgegraben hast. :-)
Stuart Marks
13
@ bayou.io: Es ist das gleiche auf Klassenebene wie in einem Konstruktor, wenn man es lässt this der während der Objektkonstruktion entkommen kann. Die Grundregel lautet: Verwenden Sie in Initialisierern keine Multithread-Operationen. Ich denke nicht, dass das schwer zu verstehen ist. Ihr Beispiel für die Registrierung einer von Lambda implementierten Funktion in einer Registrierung ist etwas anderes. Es werden keine Deadlocks erstellt, es sei denn, Sie warten auf einen dieser blockierten Hintergrund-Threads. Trotzdem rate ich dringend davon ab, solche Operationen in einem Klasseninitialisierer durchzuführen. Es ist nicht das, wofür sie gedacht sind.
Holger
9
Ich denke, die Lektion im Programmierstil lautet: Halten Sie statische Initialisierer einfach.
Raedwald
16

Für diejenigen, die sich fragen, wo sich die anderen Threads befinden, die auf die DeadlockKlasse selbst verweisen , verhalten sich Java-Lambdas so, wie Sie dies geschrieben haben:

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) {}
}

Bei regulären anonymen Klassen gibt es keinen Deadlock:

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
quelle
5
@ Solomonoff'sSecret Es ist eine Wahl der Implementierung. Der Code im Lambda muss irgendwohin. Javac kompiliert es zu einer statischen Methode in der enthaltenden Klasse (analog zu lambda1diesem Beispiel). Jedes Lambda in eine eigene Klasse zu bringen, wäre erheblich teurer gewesen.
Stuart Marks
1
@StuartMarks Angesichts der Tatsache, dass das Lambda eine Klasse erstellt, die die Funktionsschnittstelle implementiert, wäre es nicht genauso effizient, die Implementierung des Lambda in die Implementierung des Lambda der Funktionsschnittstelle einzubeziehen, wie im zweiten Beispiel dieses Beitrags? Das ist sicherlich der offensichtliche Weg, Dinge zu tun, aber ich bin sicher, es gibt einen Grund, warum sie so gemacht werden, wie sie sind.
Stellen Sie Monica
6
@ Solomonoff'sSecret Das Lambda erstellt möglicherweise zur Laufzeit eine Klasse (über java.lang.invoke.LambdaMetafactory ), aber der Lambda-Body muss zur Kompilierungszeit irgendwo platziert werden. Die Lambda-Klassen können daher die Vorteile von VM-Magie nutzen, um kostengünstiger zu sein als normale Klassen, die aus .class-Dateien geladen werden.
Jeffrey Bosboom
1
@ Solomonoff'sSecret Ja, die Antwort von Jeffrey Bosboom ist korrekt. Wenn es in einer zukünftigen JVM möglich wird, einer vorhandenen Klasse eine Methode hinzuzufügen, kann die Metafabrik dies tun, anstatt eine neue Klasse zu drehen. (Reine Spekulation.)
Stuart Marks
3
@ Solomonoffs Geheimnis: Beurteilen Sie nicht, indem Sie sich solche trivialen Lambda-Ausdrücke wie Ihren i -> iansehen. Sie werden nicht die Norm sein. Lambda-Ausdrücke können alle Mitglieder ihrer umgebenden Klasse verwenden, einschließlich derer private, und das macht die definierende Klasse selbst zu ihrem natürlichen Platz. Es ist keine praktikable Option, all diese Anwendungsfälle unter einer Implementierung leiden zu lassen, die für den Spezialfall von Klasseninitialisierern optimiert ist, bei denen triviale Lambda-Ausdrücke mit mehreren Threads verwendet werden und keine Mitglieder ihrer definierenden Klasse verwendet werden.
Holger
14

Es gibt eine ausgezeichnete Erklärung für dieses Problem von Andrei Pangin vom 07. April 2015. Es ist hier verfügbar , aber in russischer Sprache verfasst (ich empfehle, Codebeispiele trotzdem zu überprüfen - sie sind international). Das allgemeine Problem ist eine Sperre während der Klasseninitialisierung.

Hier sind einige Zitate aus dem Artikel:


Laut JLS verfügt jede Klasse über eine eindeutige Initialisierungssperre , die während der Initialisierung erfasst wird. Wenn ein anderer Thread während der Initialisierung versucht, auf diese Klasse zuzugreifen, wird sie für die Sperre blockiert, bis die Initialisierung abgeschlossen ist. Wenn Klassen gleichzeitig initialisiert werden, kann es zu einem Deadlock kommen.

Ich habe ein einfaches Programm geschrieben, das die Summe der ganzen Zahlen berechnet. Was soll es drucken?

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);
    }
} 

Entfernen parallel()oder ersetzen Sie nun Lambda durchInteger::sum Call - was wird sich ändern?

Hier sehen wir wieder einen Deadlock [es gab einige Beispiele für Deadlocks in Klasseninitialisierern zuvor im Artikel]. Aufgrund der parallel()Stream-Operationen werden in einem separaten Thread-Pool ausgeführt. Diese Threads versuchen, den Lambda-Body auszuführen, der als private staticMethode innerhalb der StreamSumKlasse in Bytecode geschrieben ist . Diese Methode kann jedoch nicht vor Abschluss des statischen Klasseninitialisierers ausgeführt werden, der auf die Ergebnisse der Stream-Fertigstellung wartet.

Was noch umwerfender ist: Dieser Code funktioniert in verschiedenen Umgebungen unterschiedlich. Es funktioniert ordnungsgemäß auf einem einzelnen CPU-Computer und hängt höchstwahrscheinlich auf einem Multi-CPU-Computer. Dieser Unterschied ergibt sich aus der Implementierung des Fork-Join-Pools. Sie können dies selbst überprüfen, indem Sie den Parameter ändern-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

AdamSkywalker
quelle