Tutoriel pour comparer les performances entre les lambdas et les classes anonymes

| Bruno Doolaeghe | Soat |

Depuis l'arrivée de Java 8 l'année dernière, et notamment des lambda expressions, nous avons découvert de nouveaux patterns de code : en effet, même si Java reste un langage orienté objet, il sera désormais teinté de programmation fonctionnelle ! Il s'agit là bien sûr d'une évolution majeure du langage (au même titre qu'avait pu l'être en son temps Java 5), qui va changer notre façon d'écrire un programme en Java ; mais c'est sous un autre angle que je vais étudier aujourd'hui cette nouveauté du langage : celui de la performance ! À vos benchmarks, prêts… partez !

Pour réagir à ce tutoriel, un espace de dialogue vous est proposé sur le forum :  Commentez

- Titre : Tutoriel pour comparer les performances entre les lambdas et les classes anonymes

- Auteurs : Bruno Doolaeghe, Soat

- Parution : 3 novembre 2016

- Licence : Copyright ® 2016 Bruno Doolaeghe. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Lambda, es-tu anonyme ?

- III. Don't guess, measure !

- III-A. On n'est pas que des code-bytes

- III-B. L'expérience témoin

- III-C. Le petit frère du témoin

- III-D. Le challenger

- III-E. La cousine du challenger

- III-F. Les mêmes, le parallélisme en plus

- III-G. Les mêmes, nourris au Stream

- III-H. Les mêmes, nourris au parallel Stream

- IV. Contexte du benchmark

- V. Résultats des courses

- V-A. Java 7 vs Java 8

- V-B. Tri parallèle

- V-C. API Stream

- V-D. Stream parallèle

- VI. Conclusion

- VII. Remerciements

I - Introduction

Depuis l'arrivée de Java 8 l'année dernière, et notamment des lambda expressions, nous avons découvert de nouveaux patterns de code : en effet, même si Java reste un langage orienté objet, il sera désormais teinté de programmation fonctionnelle ! Il s'agit là bien sûr d'une évolution majeure du langage (au même titre qu'avait pu l'être en son temps Java 5), qui va changer notre façon d'écrire un programme en Java ; mais c'est sous un autre angle que je vais étudier aujourd'hui cette nouveauté du langage : celui de la performance ! À vos benchmarks, prêts… partez !

II - Lambda, es-tu anonyme ?

Avant toute chose, un petit rappel, s'il en convient, sur les lambdas. Il existe déjà de nombreux articles sur le sujet ; je ferai donc la version courte :

lambda expression = fonction anonyme

Ah ! Rien de bien compliqué alors ! Qu'y a-t-il de révolutionnaire dans tout cela, me direz-vous ? Il y a déjà bien longtemps que Java dispose de classes anonymes, qui nous permettent d'écrire des fonctions anonymes (une classe anonyme avec une seule méthode). Les Runnable et Callable en sont de parfaits exemples !

1. callable = new Callable<String>() {

2. public String call() {

3. return "Hello world !";

4. }

5. };

… Qui devient une lambda :

Callable<String> f = () -> "hello world !";

Mais alors, pourquoi avoir introduit les lambdas ? S'agit-il simplement de « sucre syntaxique » pour économiser les lignes de code ?

Eh bien oui, les lambdas allègent le code… MAIS PAS QUE ! Et c'est ce que Brian Goetz nous avait promis lors de sa conférence Lambda : a peek under the hood : en effet, elles sont également plus performantes que les classes anonymes.

III - Don't guess, measure !

« Les lambdas, ça poutre ! » C'est en substance la conclusion que l'on retiendra de cette conférence, corroborée par ailleurs par les benchmarks réalisés par d'autres éminents spécialistes de chez Oracle tels que Sergey Kuksenko (voir Jdk 8 : Lambda performance study).

La confiance n'excluant par le contrôle (même envers des éminences dont la réputation n'est plus à faire), j'ai voulu mesurer et vérifier, avec mes petits yeux, sur un cas pratique, ce que les lambdas allaient pouvoir me faire gagner en performance.

III-A - On n'est pas que des code-bytes

J'ai donc sorti mon JMH de ma boîte à outils, pour benchmarker un cas pratique : le tri d'un tableau, en utilisant les Comparator, sur des beans simples, modélisant des personnes :

1. public class Personne {

2. String nom;

3. String prenom;

4. ...

5. }

Sur le banc d'essai, plusieurs implémentations.

III-B - L'expérience témoin

Tout d'abord, le code qui me servira de référence, utilisant une classe anonyme, compilée avec un JDK 7 et exécutée avec un JRE 7 :

1. @Benchmark

2. public void anonymous_class(PersonnesContainer c, Blackhole blackHole) {

3. Arrays.sort(c.personneToSortArray,

4. new Comparator<Personne>() {

5. public int compare(Personne p1, Personne p2) {

6. int nomCompaison = p1.getNom().compareTo(p2.getNom());

7. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom()) ;

8. }

9. });

10. blackHole.consume(c.personneToSortArray);

11. }

III-C - Le petit frère du témoin

Il s'agit simplement du même code, mais compilé et tournant cette fois avec un JDK/JRE 8 :

1. @Benchmark

2. public void anonymous_class(PersonnesContainer c, Blackhole blackHole) {

3. Arrays.sort(c.personneToSortArray,

4. new Comparator<Personne>() {

5. public int compare(Personne p1, Personne p2) {

6. int nomCompaison = p1.getNom().compareTo(p2.getNom());

7. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom()) ;

8. }

9. });

10. blackHole.consume(c.personneToSortArray);

11. }

III-D - Le challenger

Sujet principal du benchmark : je remplace la classe anonyme par une expression lambda :

1. @Benchmark

2. public void lambda(PersonnesContainer c, Blackhole blackHole) {

3. Arrays.sort(c.personneToSortArray,

4. (p1, p2) -> {

5. int nomCompaison = p1.getNom().compareTo(p2.getNom());

6. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom());

7. });

8. blackHole.consume(c.personneToSortArray);

9. }

III-E - La cousine du challenger

Preuve que même sans classe, la « méthode référence » accompagnée de l'interface Comparator et de ses méthodes statiques comparing() et thenComparing(), est selon moi la manière la plus élégante d'exprimer simplement les critères d'un tri  :

1. @Benchmark

2. public void method_reference(PersonnesContainer c, Blackhole blackHole) {

3. Arrays.sort(c.personneToSortArray,

4. Comparator.comparing(Personne::getNom)

5. .thenComparing(Personne::getPrenom));

6. blackHole.consume(c.personneToSortArray);

7. }

III-F - Les mêmes, le parallélisme en plus

Le JDK 8 débarque avec une nouvelle méthode de tri parallèle utilisant l'ex fork/join pool de Java 7, qui tire profit du multicœur :

1. @Benchmark

2. public void parallel_anonymous_class(PersonnesContainer c, Blackhole blackHole) {

3. Arrays.parallelSort(c.personneToSortArray,

4. new Comparator<Personne>() {

5. public int compare(Personne p1, Personne p2) {

6. int nomCompaison = p1.getNom().compareTo(p2.getNom());

7. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom()) ;

8. }

9. });

10. blackHole.consume(c.personneToSortArray);

11. }

13. @Benchmark

14. public void parallel_lambda(PersonnesContainer c, Blackhole blackHole) {

15. Arrays.parallelSort(c.personneToSortArray,

16. (p1, p2) -> {

17. int nomCompaison = p1.getNom().compareTo(p2.getNom());

18. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom());

19. });

20. blackHole.consume(c.personneToSortArray);

21. }

23. @Benchmark

24. public void parallel_method_reference(PersonnesContainer c, Blackhole blackHole) {

25. Arrays.parallelSort(c.personneToSortArray,

26. Comparator.comparing(Personne::getNom)

27. .thenComparing(Personne::getPrenom));

28. blackHole.consume(c.personneToSortArray);

29. }

III-G - Les mêmes, nourris au Stream

Grosse nouveauté du JDK 8, l'API Stream (introduite pour faire du map/filter/reduce) permet également de faire du tri. Même s'il ne s'agit plus d'un tri « sur place », j'ai voulu voir, par curiosité, ce que cela donnait côté performance. On reprend donc les mêmes, mais nourris au Stream :

1. @Benchmark

2. public void stream_anonymous_class(PersonnesContainer c, Blackhole blackHole) {

3. Object[] sortedPersonnes = Arrays.stream(c.personneToSortArray)

4. .sorted(new Comparator<Personne>() {

5. public int compare(Personne p1, Personne p2) {

6. int nomCompaison = p1.getNom().compareTo(p2.getNom());

7. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom()) ;

8. }

9. })

10. .toArray();

11. blackHole.consume(sortedPersonnes);

12. }

14. @Benchmark

15. public void stream_lambda(PersonnesContainer c, Blackhole blackHole) {

16. Object[] sortedPersonnes = Arrays.stream(c.personneToSortArray)

17. .sorted((p1, p2) -> {

18. int nomCompaison = p1.getNom().compareTo(p2.getNom());

19. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom());

20. })

21. .toArray();

22. blackHole.consume(sortedPersonnes);

23. }

25. @Benchmark

26. public void stream_method_ref(PersonnesContainer c, Blackhole blackHole) {

27. Object[] sortedPersonnes = Arrays.stream(c.personneToSortArray)

28. .sorted(Comparator.comparing(Personne::getNom)

29. .thenComparing(Personne::getPrenom))

30. .toArray();

31. blackHole.consume(sortedPersonnes);

32. }

III-H - Les mêmes, nourris au parallel Stream

Quitte à faire du Stream, faisons du parallel Stream !

1. @Benchmark

2. public void parallel_stream_anonymous_class(PersonnesContainer c, Blackhole blackHole) {

3. Object[] sortedPersonnes = Arrays.stream(c.personneToSortArray)

4. .parallel()

5. .sorted(new Comparator<Personne>() {

6. public int compare(Personne p1, Personne p2) {

7. int nomCompaison = p1.getNom().compareTo(p2.getNom());

8. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom()) ;

9. }

10. })

11. .toArray();

12. blackHole.consume(sortedPersonnes);

13. }

15. @Benchmark

16. public void parallel_stream_lambda(PersonnesContainer c, Blackhole blackHole) {

17. Object[] sortedPersonnes = Arrays.stream(c.personneToSortArray)

18. .parallel()

19. .sorted((p1, p2) -> {

20. int nomCompaison = p1.getNom().compareTo(p2.getNom());

21. return (nomCompaison != 0) ? nomCompaison : p1.getPrenom().compareTo(p2.getPrenom());

22. })

23. .toArray();

24. blackHole.consume(sortedPersonnes);

25. }

27. @Benchmark

28. public void parallel_stream_method_ref(PersonnesContainer c, Blackhole blackHole) {

29. Object[] sortedPersonnes = Arrays.stream(c.personneToSortArray)

30. .parallel()

31. .sorted(Comparator.comparing(Personne::getNom)

32. .thenComparing(Personne::getPrenom))

33. .toArray();

34. blackHole.consume(sortedPersonnes);

35. }

IV - Contexte du benchmark

J'ai réalisé ce benchmark JMH en mode AverageTime, sur 100 itérations de 100 ms chacune, et une phase de « warmup » équivalente. Afin de ne pas retrier un tableau déjà trié par l'invocation précédente (puisqu'il s'agit d'un tri sur place), j'ai utilisé une fixture pour réinitialiser l'ordre des éléments du tableau entre chaque invocation. Comme nous le verrons dans les résultats, la durée d'une invocation étant d'une part suffisamment longue, et d'autre part le temps de réinitialisation du tableau suffisamment court, les valeurs mesurées ne devraient pas être significativement biaisées par le benchmark lui-même. De plus, la marge d'erreur obtenue sur les mesures est satisfaisante (de l'ordre de quelques pourcents).

Par ailleurs, afin de prendre en compte l'impact de la taille du tableau sur les différentes méthodes de tri, j'ai paramétré mon benchmark pour faire varier la taille du tableau de 10 à 500 000 items à trier.

Précisons enfin que le benchmark a été exécuté sur la machine/JVM suivante :

JDK 7 (JDK 1.7.0_75) et JRE 7 oracle 64 bits

Java version « 1.7.0_75 »

Java(TM) SE Runtime Environment (build 1.7.0_75-b13)

Java HotSpot(TM) 64 Bit Server VM (build 24.75-b04, mixed mode)

JDK 8 (JDK 1.8.0_51) et JRE 8 Oracle 64 bits

Java version « 1.8.0_51 »

Java(TM) SE Runtime Environment (build 1.8.0_51-b16) Java HotSpot(TM) 64-Bit Server VM (build 25.51-b03, mixed mode)

CPU Intel(R) Core(TM) i5-2500K CPU @ 3.30GHz

Système Ubuntu 15.04 64 bits (kernel 3.19.0-31-generic)

Il ne reste plus qu'à donner le top départ !

N.B. : les sources Java du benchmark sont disponibles sur github.

V - Résultats des courses

Comme vous allez le voir, les résultats de ce benchmark sont assez surprenants. Les logs complets sont disponibles ici pour Java 7 et là pour Java 8. En voici une synthèse…

V-A - Java 7 vs Java 8

Commençons par comparer les résultats de la classe anonyme de Java 7 à ses challengers de Java 8, à savoir :

- la classe anonyme (code identique, mais JRE 8) ;

- la lambda ;

- la méthode référence.

Globalement, on constate que :

- la classe anonyme de Java 8 est meilleure que celle de Java 7 de 4,4 % en moyenne ;

- de même, pour la lambda avec un gain moyen de 4 %.

Le JRE 8 apporte donc un gain immédiat : la classe anonyme de Java 8 et la lambda donnent en revanche des temps similaires (parfois même légèrement moins bons pour la lambda !). Par ailleurs, la méthode référence est toujours plus lente de 8,7 % en moyenne.

V-B - Tri parallèle

Ce benchmark nous donne également une comparaison du tri parallèle de la nouvelle méthode Arrays.parallelSort() de Java 8, avec son équivalent historique non parallélisé toujours présent dans le JDK 8 (Arrays.sort()) :

Comme on peut le voir :

- sur les tris de moins de 5 000 items, les tris parallèles se sont montrés un peu plus lents que leurs homologues monothread (de 5 à 6 % en moyenne), sauf pour la méthode référence qui donne des résultats similaires, parallélisée ou non, mais encore une fois moins bons qu'avec une classe anonyme ou une lambda ;

- avec 10 000 items et plus en revanche, la parallélisation apporte un gain de performance de 60 % en moyenne !

V-C - API Stream

Dans le monde de Java 8, l'utilisation de l'API Stream pour trier un tableau, au lieu de la méthode Arrays.sort(), nous donne les performances suivantes :

Pas de contestation possible : l'utilisation d'un Stream uniquement pour réaliser un tri est contre-performante. Plus le tableau est petit, plus les performances se dégradent. Néanmoins, plus le tableau est grand, plus l'écart se réduit.

V-D - Stream parallèle

Pour être exhaustif, j'ai également comparé le tri parallèle du JDK 8 (Arrays.parallelSort()) avec le tri parallèle via l'API Stream :

Alors que l'implémentation de Arrays.parallSort() a l'intelligence de ne pas paralléliser le tri sur des tableaux trop petits (limitant ainsi la casse), le Stream parallèle quant à lui est clairement contre-performant sur les petits tableaux (jusqu'à 8 fois plus lent sur ces tris !). Plus le tableau est grand, plus l'écart de performance se réduit.

VI - Conclusion

Nous venons de réaliser un benchmark comparatif entre Java 7 et Java 8, sur une opération simple en Java : le tri d'un tableau. Alors, Java 8 plus performant ou pas ?

Tout d'abord, la 1re bonne nouvelle, c'est que migrer son application Java en version 8, même sans adaptation de code, améliore la performance (gain de l'ordre de 4 % en moyenne mesuré sur l'exemple du tri).

L'autre bonne nouvelle est l'apparition dans le JDK 8 du tri parallèle sans fork/join pool, par la méthode Arrays.parallelSort() qui diminue significativement le temps d'un gros tri.

Enfin, le dernier constat que j'ai pu faire à travers ce benchmark est que, contrairement à ce que j'espérais, l'utilisation d'une lambda à la place d'une classe anonyme n'a pas amélioré la performance d'un tri (temps significativement identiques). L'explication semblant la plus probable selon moi est que les gains de performances annoncés sur les lambdas ne touchent jusqu'à présent que le « linkage » et la « capture » de la lambda (correspondant au « class loading » et à l'instanciation de la classe), mais pas son « Invocation ». Or, l'utilisation d'un Comparator dans une méthode de tri ne nécessite qu'une seule opération de « linkage » et de « capture », puis de nombreuses (n.log(n)) « invocations ». Donc pas d'amélioration majeure côté performance sur ce cas d'utilisation !

Néanmoins, les expressions lambda étant encore bien jeunes dans le monde Java, on est en droit d'espérer des optimisations notables sur leur invocation, dans les prochaines versions de JVM (optimisations qui seront prises en compte dynamiquement, sur un bytecode Java déjà « lambda ready », grâce à l'utilisation d'invokeDynamic !).

Pour terminer enfin sur le sujet des lambdas (et l'argument est d'autant plus valable pour les « méthodes références »), je conclurai de la manière suivante : même si le gain en performance n'est pas, à ce jour, à la hauteur de mes espérances, cela n'a pas d'importance, leur principale qualité restant, à mes yeux, qu'utilisées à bon escient elles améliorent la lisibilité et la maintenabilité du code !

VII - Remerciements

Cet article a été publié avec l'aimable autorisation de la société Soat.

Nous tenons à remercier Malick SECK pour la mise au gabarit et sa relecture orthographique.