AXOPEN

JAVA 8 – Stream et ParallelStream – Performance sur des String

Etude de la performance de l’utilisation de la nouvelle API JAVA 8 Stream pour le traitement de string (chaîne de caractère)

But de l’article

Nous allons essayer d’étudier les différences de performances pour réaliser des traitements sur des ensembles ordonnées ou non (list, hahset, treeset) avec l’utilisation des Stream et lambda JAVA 8. Puis nous comparerons l’utilisons classique des streams avec les parallelStream pour voir si la parallélisation permet de gros gain de performance.

Stream – Méthodologie de test

Nous allons décrire dans cette partie la méthodologie de test que nous avons appliqué pour obtenir nos résultats. Bien sûr ce test n’a pas vocation à servir de référence pour des performances mais de voir un peu les impacts d’une utilisation ou d’une autre dans le monde réel des développements. Volontairement donc, il n’a pas été choisi d’optimiser particulièrement les routines de test afin de coller au plus près de ce que font les développeurs dans des programmes de tous les jours. Par exemple, le traitement de test retenu n’est pas particulièrement palpitant et ne met pas forcément l’utilisation des stream et des lambda à l’honneur mais ce traitement sur des chaînes de caractères peut se retrouver n’importe où dans une application, d’où l’intérêt de tester ce genre de code ainsi que l’impact sur les performances. Il est vrai que les lambdas et les streams ne sont pas là que pour les performances et que la facilité d’écriture, de lecture et de maintenance sont à prendre en compte en premier lieu surtout sur des applications ou le volume de données traitées est faible, ce qui avouons le est la majorité des applications que les développeurs réalisent.

Le traitement effectué pour le test de performance

Le traitement effectué correspond au filtrage dans un ensemble de String contenant la sous chaîne « test ». Pour toutes ces String qui contiennent « test » nous passons la première lettre en majuscule et concaténons « _test ».

Traitement en JAVA 7 avec les collections

Historiquement ce traitement en JAVA 7 avec des collections s’écrit de la manière suivante:

On retrouve l’utilisation habituelle des itérators avec la création d’une liste dans laquelle on vient accumuler les éléments nouvellement créés.

for (Iterator iterator = strings.iterator(); iterator.hasNext();) {
 string = (String) iterator.next();
 if (string.contains("test")) {
 lList.add(string.substring(0, 1).toUpperCase() + "_test");
 }

Traitement en JAVA 8 avec les stream

En Java 8 ce traitement peut s’écrire de manière plus lisible et plus simple de la manière suivante:

On retrouve ici l’utilisation classique du l’instruction filter pour ne garder que les éléments ne contenant « test » et l’instruction map, pour la création des nouvelles chaînes de caractères.

 lList = strings
 .stream()
.filter(x -> x.contains("test"))
 .map(x -> x.substring(0, 1).toUpperCase() + "_test")
 .collect(Collectors.toList()); 

Traitement en JAVA 8 avec les parallelStream

De même, on peut nativement paralléliser ce traitement avec les parallelStream de la manière suivante:

 lList = strings
 .parallelstream()
.filter(x -> x.contains("test"))
 .map(x -> x.substring(0, 1).toUpperCase() + "_test")
 .collect(Collectors.toList());

La volumétrie des tests

Afin de tester les performances, nous allons tester ces différents traitements avec les listes ou ensembles contenant dans 10 éléments puis 100 éléments, 1 000 éléments, 10 000 éléments, 100 000 éléments et 1 000 000 éléments. Ainsi nous aurons une idée de l’influence de la volumétrie sur les performances des streams.

Les conteneurs

En parallèle de ces tests, nous allons jouer sur les conteneurs pour voir les impacts sur les performances. Ainsi nous allons tester à chaque fois, avec les collections suivantes:

  • Une ArrayList (très utilisée dans les applications)
  • Un HashSet
  • Un TreeSet

Et enfin pour ces trois collections, nous allons faire le traitement en ouvrant un stream et un parellelStream pour voir les impacts sur les performances.

Les résultats

Pour chaque quantité dans l’ensemble de départ nous obtenons donc 7 résultats

Le traitement par liste classique

  • Le traitement par une collection classique
  • Le traitement par Stream sur un TreeSet
  • Le traitement par ParallelStream sur un TreeSet
  • Le traitement par Stream sur un HashSet
  • Le traitement par ParallelStream sur un HasSet
  • Le traitement par Stream sur une ArrayList
  • Le traitement par ParallelStream sur un ArrayList

Afin d’avoir des résultats moyennés, nous effectuons 50 fois les tests pour obtenir une moyenne des temps. Les temps absolus ne sont bien sur pas à prendre en compte, c’est surtout la rapport qui sont pertinents.

La machine de test

Pour information ces tests sont réalisés avec la machine suivante:

  • Processeur Intel Core i7-3770S 4 cœurs 8 cœurs logiques
  • 8 Go RAM
  • Windows 8.1
  • JVM HotSpot 64 Bit 1.8.0_05

Stream / ParallelStream : Résultats performances

Les résultats sont données dans ce tableau brut en millisecondes. Pour rappel chaque test a été effectué 50 fois d’affilée pour réaliser ces moyennes de temps. En rouge, les résultats les plus lent et en vert les plus rapide. La première ligne correspond au traitement classique en JAVA 7 sur une collection avec des itérateurs. Il est donné ici à titre de référence.

Type de test 10 100 1k 10k 100k 1 000k
List Collections 0.16 0.24 1.24 12.36 146.82 4067.68
TreeSet Stream 0.1 0.16 1.48 13.34 131.34 2300.90
TreeSet Parallel Stream 0.12 0.14 0.56 4.48 43.34 1420.12
HashSet Stream 0.08 0.24 1.5 13.92 135.84 3057.62
HashSet Parallel Stream 0.1 0.18 0.44 4.34 42.42 3148.04
ArrayList Stream 0.04 0.14 1.18 10.44 100.18 4020.40
ArrayList Parallel 0.12 0.12 0.52 3.68 39.84 2965.76

Stream performance – Interprétation des résultats:

Pour 10 et 100 éléments:

Ce qu’on constate immédiatement, c’est que pour des faible volumétrie, l’utilisation des différents techniques ne change pas grand chose. En effet pour moins de 1000 éléments, les temps de traitement sont similaire < 1ms (ce qui avouons le est déjà extrêmement performant!)

A partir de 1 000 éléments dans l’ensemble de départ, on commence à voir émerger une certain différence de temps de traitement.

Pour 1 000 éléments (1k): 

En regardant de plus prêt, on constate que l’utilisation des stream  par rapport à la liste classique prend environ le même temps. De l’ordre de 1,4 ms et ce quelque soit le conteneur (List, TreeSet, HashSet). Par contre le temps des traitements en parallèle est sensiblement plus petit avec presque un rapport de 3! Donc dès 1 000 éléments, le traitement par des parallelStream améliore sensiblement les performances!

=> Avantage ParallelStream

Pour 10 000 éléments (10 k):

On constat la même chose que pour 1000  éléments avec cette fois une différence qui se creuse avec l’utilisation de la liste comme conteneur 3,68 ms significativement plus rapide que le HashSet et le TreeSet.

=> Avantage ParallelStream ArrayList

Pour 100 000 éléments (100 k):

Pas de changement dans les résultats au détail près que les stream vont légèrement plus vite que le traitement par collection même non parallèle.

=> Avantage Stream!

Pour 1 000 000 d’éléments (1 000 k):

Là, les résultats sont toujours très en faveur des parallelStream avec pour le coup une démarcation assez surprenant du TreeSet.

=> Avantage TreeSet ParallelStream !

Conclusion

Nous pouvons ici très clairement voir que dans notre cas de test l’utilisation des stream peut significativement améliorer les performances dès lors que nous traitons plus de 1 000 éléments. A moins de 1 000 éléments, il est difficile de voir un véritable gain de performance. De plus l’utilisation des parallelStream sur plus de 1 000 éléments améliorent significativement les performance. Plus le volume d’information a traité est important plus de la gain de performance de l’utilisation des stream se fait sentir. Pour 1 000 000 d’éléments, le rapport de gain est de 3.14 en faveur des streams parallélisés. Dans ce test, il n’a pas été facile de mettre en évidence une grande différence de traitement entre les List, HashSet et TresSet. Le traitement effectué est surement trop simple pour montrer de réelle différence. Nous verrons dans un autre article si les conteneurs jouent un rôle plus important. De même dans cet article, nous ne nous sommes pas occupé de l’utilisation mémoire de ces tests, ce qui pourrait avoir de l’importance pour la mesure des performances.

Néanmoins, en conclusion, les tests montrent que l’utilisation des streams ne dégrade pas les performances (même si elle ne les améliore pas) sur des petits ensembles mais améliore significativement sur des gros volume de données. Rajouté aux gains d’écriture de codes et de maintenance, cet article pousse donc à l’utilisation des stream et des lambdas dans le développements des applications de tous les jours en remplacement des bonnes vielles collections!