Accueil / Articles PiApplications. / La plate-forme Java / Le langage

Les streams Java et le parallélisme.

Il existe 2 méthodes pour obtenir une "source" tout en précisant son mode d'exécution :

  1. stream : qui déclenche l'exécution séquentielle du flux ;
  2. parallelStream : qui déclenche l'opération des opérations du stream en parallèle.

Il est toutefois possible de modifier le mode d'exécution grâce aux opérations sequential ou parallel.

Exécution parallèle.

Contrairement à ce que pourrait suggérer la séquence (le "tuyau") d'opérations, une même séquence n'exécute qu'un seul de ces modes pour l'ensemble des opérations. Autrement dit, si la séquence comporte plusieurs opérations sequential ou parallel, seule la dernière rencontrée dans la séquence fixe le mode pour l'ensemble des opérations de cette séquence. Il n'est pas possible d'exécuter une opération de la séquence dans un mode et une autre dans l'autre. Une conséquence de cette règle est que lorsque la séquence ne comporte qu'une seule de ces opérations, peut importe l'endroit où elle est placée dans la séquence.

L'opération concat permet d'ajouter deux flux en créant un flux résultat où chaque élément du 1er flux et suivi par l'élément correspondant du second. S'i l'un des flux s'exécute en parallèle et l'autre de façon séquentielle, ils seront alors exécutés tous deux de manière parallèle.

Il faut bien comprendre que la présentation des opérations en une séquences est une "vue logique". A l'exécution, la "vue physique" de toute la séquence est une seule et même entité logicielle compilée et exécutée par le compilateur dans son ensemble. Le parallélisme est déclenché lors du passage de chaque élément du flux. selon le modèle fork-join. Dans ce modèle, le compilateur initialise un pool de threads et transmet à chacun d'eux l'exécution de la totalité du code compilé de la séquence. il ajoute ensuite les éléments de synchronisation nécessaire à la distribution des éléments du flux vers chaque thread ainsi que ceux éventuellement nécessaire à l'exécution de l'opération terminale.

Par défaut, le compilateur affecte autant de thread que le système d'exploitation retourne de processeurs via l'interrogation par la JVM de certaines des primitives système. Il arrive fréquemment que l'on ne souhaite pas "saturer" la machine de façon à laisser des processeurs disponibles pour exécuter d'autres opérations que celles de la séquence. Pour fixer le nombre maximum de threads aux séquences, on doit jouer sur une propriété système nommée "java.util.concurrent.ForkJoinPool.common.parallelism" en en affectant à cette dernière un nombre entre 0 et 32767 représentant le nombre maximum de threads ouverts simultanément par la JVM en cours d'exécution :

System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "4");

Cette instruction limitera le nombre de threads simultanés de la JVM à 4.

Considérations sur le parallélisme.

Le parallélisme est dangereux par nature car il autorise l'écrasement involontaire d'une donnée par une autre et pire encore l'affectation de valeurs aléatoires à une variable en raison de la possible prise en compte d'un de ses états transitoires lors de sa mise à jour. Pour éviter cela, il est nécessaire d'utiliser un certain nombre de techniques pour protéger les données d'un accès simultané. Ces techniques ajoutent de la complexité et amoindrissent les performances. Ce n'est pas parce qu'un thread demande un délai d'exécution de t s que n threads en demanderont t x n. En général on observe une plutôt une loi du type T = t x (n +f(n))f(n) est une fonction qui croit avec la valeur de n. Il peut même arrivé qu'une exécution en parallèle conduise à de plus mauvaises performances qu'une exécution séquentielle. Si n est généralement facile à déterminer, t est beaucoup plus délicate à évaluer. On fait généralement appel à un outil de profilage (profiler) pour y parvenir.

Certaines opérations terminales par exemple se prêtent peu à l'exécution parallèle. C'est le cas de findFirst qui doit produire un résultat déterministe (i.e. identique quelque soit le mode d'exécution). C'est pourquoi il existe l'opération findAny qui dans le cadre d'une exécution parallèle ne donnera pas systématiquement le même résultat mais le produira en optimisant l'exécution parallèle.

Une observation identique peut être fait entre forEach qui n'impose aucun ordre dans la lecture des éléments du flux alors que forEachOrdered l'imposera et sera donc peu adapté à une exécution parallèle.

La question qui se pose alors est quand doit-on utiliser l'exécution parallèle d'une séquence d'opérations ? La réponse à cette question est complexe et dépend de l'analyse de la séquence. Toutefois on peu dégager les règles qui suivent :

Comme on le pressent ici, chaque fois que l'élément d'un flux est lié de près ou de loin avec un ou plusieurs autres éléments du flux, la performance de l'exécution parallèle s'en trouvera compromise.

(c) PiApplications 2015