Pourquoi le flux parallèle avec lambda dans l'initialiseur statique provoque-t-il un blocage?

86

Je suis tombé sur une situation étrange où l'utilisation d'un flux parallèle avec un lambda dans un initialiseur statique prend apparemment une éternité sans utilisation du processeur. Voici le code:

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

Cela semble être un scénario de test de reproduction minimum pour ce comportement. Si je:

  • mettre le bloc dans la méthode main au lieu d'un initialiseur statique,
  • supprimer la parallélisation, ou
  • retirer le lambda,

le code se termine instantanément. Quelqu'un peut-il expliquer ce comportement? Est-ce un bug ou est-ce voulu?

J'utilise OpenJDK version 1.8.0_66-internal.

Réintégrer Monica
la source
4
Avec la plage (0, 1), le programme se termine normalement. Avec (0, 2) ou supérieur se bloque.
Laszlo Hirdi
5
question similaire: stackoverflow.com/questions/34222669/…
Alex - GlassEditor.com
2
En fait, c'est exactement la même question / problème, juste avec une API différente.
Didier L
3
Vous essayez d'utiliser une classe, dans un thread d'arrière-plan, lorsque vous n'avez pas fini d'initialiser la classe afin qu'elle ne puisse pas être utilisée dans un thread d'arrière-plan.
Peter Lawrey
4
@ Solomonoff'sSecret car i -> in'est pas une référence de méthode, il est static methodimplémenté dans la classe Deadlock. Si remplacer i -> ipar Function.identity()ce code devrait être bien.
Peter Lawrey

Réponses:

71

J'ai trouvé un rapport de bogue d'un cas très similaire ( JDK-8143380 ) qui a été fermé comme «Pas un problème» par Stuart Marks:

Il s'agit d'un blocage d'initialisation de classe. Le thread principal du programme de test exécute l'initialiseur statique de classe, qui définit l'indicateur d'initialisation en cours pour la classe; cet indicateur reste défini jusqu'à ce que l'initialiseur statique se termine. L'initialiseur statique exécute un flux parallèle, ce qui entraîne l'évaluation des expressions lambda dans d'autres threads. Ces threads se bloquent en attendant que la classe termine l'initialisation. Cependant, le thread principal est bloqué en attendant que les tâches parallèles soient terminées, ce qui entraîne un blocage.

Le programme de test doit être modifié pour déplacer la logique de flux parallèle en dehors de l'initialiseur statique de classe. La fermeture n'est pas un problème.


J'ai pu trouver un autre rapport de bogue à ce sujet ( JDK-8136753 ), également fermé comme "Pas un problème" par Stuart Marks:

Il s'agit d'un blocage qui se produit car l'initialiseur statique de l'énumération Fruit interagit mal avec l'initialisation de classe.

Voir la spécification du langage Java, section 12.4.2 pour plus de détails sur l'initialisation de classe.

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

En bref, ce qui se passe est le suivant.

  1. Le thread principal fait référence à la classe Fruit et démarre le processus d'initialisation. Cela définit l'indicateur d'initialisation en cours et exécute l'initialiseur statique sur le thread principal.
  2. L'initialiseur statique exécute du code dans un autre thread et attend qu'il se termine. Cet exemple utilise des flux parallèles, mais cela n'a rien à voir avec les flux en soi. Exécuter du code dans un autre thread par n'importe quel moyen et attendre que ce code se termine aura le même effet.
  3. Le code de l'autre thread fait référence à la classe Fruit, qui vérifie l'indicateur d'initialisation en cours. Cela provoque le blocage de l'autre thread jusqu'à ce que l'indicateur soit effacé. (Voir l'étape 2 de JLS 12.4.2.)
  4. Le thread principal est bloqué en attendant que l'autre thread se termine, de sorte que l'initialiseur statique ne se termine jamais. Étant donné que l'indicateur d'initialisation en cours n'est pas effacé tant que l'initialisation statique n'est pas terminée, les threads sont bloqués.

Pour éviter ce problème, assurez-vous que l'initialisation statique d'une classe se termine rapidement, sans que d'autres threads exécutent du code qui nécessite que cette classe ait terminé l'initialisation.

La fermeture n'est pas un problème.


Notez que FindBugs a un problème ouvert pour l'ajout d'un avertissement pour cette situation.

Tunaki
la source
20
«Cela a été pris en compte lorsque nous avons conçu la fonctionnalité» et «Nous savons ce qui cause ce bogue mais pas comment le corriger» ne veut pas dire «ce n'est pas un bogue» . C'est absolument un bug.
BlueRaja - Danny Pflughoeft
13
@ bayou.io Le principal problème est l'utilisation de threads dans des initialiseurs statiques, pas de lambdas.
Stuart marque le
5
BTW Tunaki merci d'avoir déterré mes rapports de bogues. :-)
Stuart marque le
13
@ bayou.io: c'est la même chose au niveau de la classe que dans un constructeur, laissant thiséchapper lors de la construction de l'objet. La règle de base est de ne pas utiliser d'opérations multithreads dans les initialiseurs. Je ne pense pas que ce soit difficile à comprendre. Votre exemple d'enregistrement d'une fonction implémentée lambda dans un registre est une chose différente, il ne crée pas de blocages à moins que vous n'attendiez l'un de ces threads d'arrière-plan bloqués. Néanmoins, je déconseille fortement d'effectuer de telles opérations dans un initialiseur de classe. Ce n'est pas ce à quoi ils sont destinés.
Holger
9
Je suppose que la leçon de style de programmation est la suivante: gardez les initaliseurs statiques simples.
Raedwald
16

Pour ceux qui se demandent où sont les autres threads référençant la Deadlockclasse elle-même, les lambdas Java se comportent comme vous l'avez écrit:

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

Avec les classes anonymes régulières, il n'y a pas de blocage:

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
la source
5
@ Solomonoff'sSecret C'est un choix d'implémentation. Le code dans le lambda doit aller quelque part. Javac le compile en une méthode statique dans la classe contenant (analogue à lambda1cet exemple). Mettre chaque lambda dans sa propre classe aurait été beaucoup plus cher.
Stuart Marks le
1
@StuartMarks Étant donné que le lambda crée une classe implémentant l'interface fonctionnelle, ne serait-il pas aussi efficace de mettre l'implémentation du lambda dans l'implémentation du lambda de l'interface fonctionnelle que dans le deuxième exemple de cet article? C'est certainement la façon la plus évidente de faire les choses, mais je suis sûr qu'il y a une raison pour laquelle elles sont faites telles qu'elles sont.
Réintégrer Monica le
6
@ Solomonoff'sSecret Le lambda peut créer une classe à l'exécution (via java.lang.invoke.LambdaMetafactory ), mais le corps lambda doit être placé quelque part au moment de la compilation. Les classes lambda peuvent ainsi profiter de la magie des VM pour être moins chères que les classes normales chargées à partir de fichiers .class.
Jeffrey Bosboom
1
@ Solomonoff'sSecret Oui, la réponse de Jeffrey Bosboom est correcte. Si dans une future JVM, il devient possible d'ajouter une méthode à une classe existante, la métafactory pourrait le faire au lieu de lancer une nouvelle classe. (Pure spéculation.)
Stuart marque le
3
@ Solomonoff's Secret: ne jugez pas en regardant ces expressions lambda triviales comme la vôtre i -> i; ce ne sera pas la norme. Les expressions Lambda peuvent utiliser tous les membres de leur classe environnante, y compris privatecertains, ce qui fait de la classe de définition elle-même leur place naturelle. Laisser tous ces cas d'utilisation souffrir d'une implémentation optimisée pour le cas particulier des initialiseurs de classe avec une utilisation multithread d'expressions lambda triviales, n'utilisant pas les membres de leur classe de définition, n'est pas une option viable.
Holger
14

Il y a une excellente explication de ce problème par Andrei Pangin , datée du 07 avril 2015. Elle est disponible ici , mais elle est écrite en russe (je suggère quand même de revoir les échantillons de code - ils sont internationaux). Le problème général est un verrou lors de l'initialisation de la classe.

Voici quelques citations de l'article:


Selon JLS , chaque classe possède un verrou d'initialisation unique qui est capturé lors de l'initialisation. Lorsqu'un autre thread tente d'accéder à cette classe pendant l'initialisation, il sera bloqué sur le verrou jusqu'à ce que l'initialisation soit terminée. Lorsque les classes sont initialisées simultanément, il est possible d'obtenir un blocage.

J'ai écrit un programme simple qui calcule la somme des nombres entiers, que doit-il imprimer?

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

Supprimer maintenant parallel() ou remplacez lambda par Integer::sumcall - qu'est-ce qui va changer?

Ici, nous voyons à nouveau des blocages [il y avait quelques exemples de blocages dans les initialiseurs de classe précédemment dans l'article]. En raison des parallel()opérations de flux s'exécutent dans un pool de threads distinct. Ces threads essaient d'exécuter le corps lambda, qui est écrit en bytecode en tant que private staticméthode à l'intérieur de la StreamSumclasse. Mais cette méthode ne peut pas être exécutée avant la fin de l'initialiseur statique de classe, qui attend les résultats de la fin du flux.

Quoi de plus époustouflant: ce code fonctionne différemment dans différents environnements. Il fonctionnera correctement sur une seule machine à processeur et sera probablement suspendu à une machine à plusieurs processeurs. Cette différence vient de l'implémentation du pool Fork-Join. Vous pouvez le vérifier vous-même en modifiant le paramètre-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

AdamSkywalker
la source