Un cas pour garder les primitives en Java

Les primitives font partie du langage de programmation Java depuis sa sortie initiale en 1996, et pourtant elles restent l’une des fonctionnalités les plus controversées du langage. John Moore plaide fermement en faveur du maintien des primitives dans le langage Java en comparant de simples benchmarks Java, avec et sans primitives. Il compare ensuite les performances de Java à celles de Scala, C++ et JavaScript dans un type particulier d’application, où les primitives font une différence notable.

Question: Quels sont les trois facteurs les plus importants dans l’achat d’un bien immobilier?
Réponse: Emplacement, emplacement, emplacement.

Cet adage ancien et souvent utilisé signifie que l’emplacement domine complètement tous les autres facteurs en matière d’immobilier. Dans un argument similaire, les trois facteurs les plus importants à prendre en compte pour l’utilisation de types primitifs en Java sont les performances, les performances et les performances. Il existe deux différences entre l’argument pour l’immobilier et l’argument pour les primitives. Premièrement, avec l’immobilier, l’emplacement domine dans presque toutes les situations, mais les gains de performance liés à l’utilisation de types primitifs peuvent varier considérablement d’un type d’application à l’autre. Deuxièmement, avec l’immobilier, il y a d’autres facteurs à considérer même s’ils sont généralement mineurs par rapport à l’emplacement. Avec les types primitifs, il n’y a qu’une seule raison de les utiliser — la performance; et alors seulement si l’application est du type qui peut bénéficier de leur utilisation.

Les primitives offrent peu de valeur à la plupart des applications commerciales et Internet qui utilisent un modèle de programmation client-serveur avec une base de données sur le backend. Mais les performances des applications dominées par les calculs numériques peuvent grandement bénéficier de l’utilisation de primitives.

L’inclusion de primitives en Java a été l’une des décisions de conception de langage les plus controversées, comme en témoigne le nombre d’articles et de messages sur le forum liés à cette décision. Simon Ritter a noté dans son discours liminaire de JAX London en novembre 2011 que la suppression des primitives dans une future version de Java était sérieusement envisagée (voir diapositive 41). Dans cet article, je présenterai brièvement les primitives et le système à double type de Java. En utilisant des exemples de code et des benchmarks simples, je vais expliquer pourquoi les primitives Java sont nécessaires pour certains types d’applications. Je comparerai également les performances de Java à celles de Scala, C ++ et JavaScript.

Primitives par rapport aux objets

Comme vous le savez probablement déjà si vous lisez cet article, Java a un système à double type, généralement appelé types primitifs et types d’objets, souvent abrégé simplement en primitives et objets. Il existe huit types primitifs prédéfinis en Java, et leurs noms sont des mots-clés réservés. Les exemples couramment utilisés incluent int, double et boolean. Essentiellement, tous les autres types en Java, y compris tous les types définis par l’utilisateur, sont des types d’objets. (Je dis « essentiellement » parce que les types de tableaux sont un peu hybrides, mais ils ressemblent beaucoup plus à des types d’objets qu’à des types primitifs.) Pour chaque type primitif, il existe une classe wrapper correspondante qui est un type d’objet ; les exemples incluent Integer pour int, Double pour double et Boolean pour boolean.

Les types primitifs sont basés sur des valeurs, mais les types d’objets sont basés sur des références, et c’est là que réside à la fois le pouvoir et la source de controverse des types primitifs. Pour illustrer la différence, considérons les deux déclarations ci-dessous. La première déclaration utilise un type primitif et la seconde utilise une classe wrapper.

int n1 = 100;Integer n2 = new Integer(100);

En utilisant l’autoboxing, une fonctionnalité ajoutée à JDK 5, je pourrais raccourcir la deuxième déclaration à simplement

Integer n2 = 100;

mais la sémantique sous-jacente ne change pas. L’autoboxing simplifie l’utilisation des classes wrapper et réduit la quantité de code qu’un programmeur doit écrire, mais cela ne change rien à l’exécution.

La différence entre la primitive n1 et l’objet wrapper n2 est illustrée par le diagramme de la figure 1.

John I. Moore, Jr.

Figure 1. Disposition mémoire des primitives par rapport aux objets

La variable n1 contient une valeur entière, mais la variable n2 contient une référence à un objet, et c’est l’objet qui contient la valeur entière. De plus, l’objet référencé par n2 contient également une référence à l’objet de classe Double.

Le problème avec les primitives

Avant d’essayer de vous convaincre de la nécessité de types primitifs, je dois reconnaître que beaucoup de gens ne seront pas d’accord avec moi. Sherman Alpert dans « Primitive types considered harmful » soutient que les primitives sont nuisibles car elles mélangent « la sémantique procédurale dans un modèle orienté objet par ailleurs uniforme. Les primitives ne sont pas des objets de première classe, mais elles existent dans un langage qui implique principalement des objets de première classe. »Les primitives et les objets (sous la forme de classes wrapper) fournissent deux façons de gérer des types logiquement similaires, mais ils ont une sémantique sous-jacente très différente. Par exemple, comment comparer deux instances pour l’égalité ? Pour les types primitifs, on utilise l’opérateur ==, mais pour les objets, le choix préféré est d’appeler la méthode equals(), ce qui n’est pas une option pour les primitives. De même, différentes sémantiques existent lors de l’attribution de valeurs ou du passage de paramètres. Même les valeurs par défaut sont différentes ; par exemple, 0 pour int contre null pour Integer.

Pour plus d’informations sur cette question, voir le billet de blog d’Eric Bruno, « Une discussion primitive moderne », qui résume certains des avantages et des inconvénients des primitives. Un certain nombre de discussions sur le débordement de pile se concentrent également sur les primitives, notamment « Pourquoi les gens utilisent-ils encore des types primitifs en Java? »et « Y a-t-il une raison de toujours utiliser des objets au lieu de primitives?. »Programmers Stack Exchange héberge une discussion similaire intitulée « Quand utiliser la classe primitive vs en Java? ».

Utilisation de la mémoire

A double en Java occupe toujours 64 bits en mémoire, mais la taille d’une référence dépend de la machine virtuelle Java (JVM). Mon ordinateur exécute la version 64 bits de Windows 7 et une machine virtuelle java 64 bits, et donc une référence sur mon ordinateur occupe 64 bits. Sur la base du diagramme de la figure 1, je m’attendrais à ce qu’un seul double tel que n1 occupe 8 octets (64 bits), et je m’attendrais à ce qu’un seul Double tel que n2 occupe 24 octets — 8 pour la référence à l’objet, 8 pour la valeur double stockée dans l’objet et 8 pour la référence à l’objet de classe pour Double. De plus, Java utilise de la mémoire supplémentaire pour prendre en charge la collecte des ordures pour les types d’objets, mais pas pour les types primitifs. Voyons ça.

En utilisant une approche similaire à celle de Glen McCluskey dans « Java primitive types vs. wrappers », la méthode indiquée dans la liste 1 mesure le nombre d’octets occupés par une matrice n par n (tableau à deux dimensions) de double.

Liste 1. Calcul de l’utilisation de la mémoire de type double

En modifiant le code de la liste 1 avec les changements de type évidents (non représentés), on peut également mesurer le nombre d’octets occupés par une matrice n par n Double. Lorsque je teste ces deux méthodes sur mon ordinateur à l’aide de matrices 1000 par 1000, j’obtiens les résultats indiqués dans le tableau 1 ci-dessous. Comme illustré, la version pour le type primitif double équivaut à un peu plus de 8 octets par entrée dans la matrice, à peu près ce à quoi je m’attendais. Cependant, la version pour le type d’objet Double nécessitait un peu plus de 28 octets par entrée dans la matrice. Ainsi, dans ce cas, l’utilisation de la mémoire de Double est plus de trois fois l’utilisation de la mémoire de double, ce qui ne devrait pas surprendre quiconque comprend la disposition de la mémoire illustrée à la figure 1 ci-dessus.

Performances d’exécution

Pour comparer les performances d’exécution des primitives et des objets, nous avons besoin d’un algorithme dominé par des calculs numériques. Pour cet article, j’ai choisi la multiplication matricielle, et je calcule le temps nécessaire pour multiplier deux matrices 1000 par 1000. J’ai codé la multiplication matricielle pour double de manière simple, comme indiqué dans la liste 2 ci-dessous. Bien qu’il puisse y avoir des moyens plus rapides d’implémenter la multiplication matricielle (peut-être en utilisant la concurrence), ce point n’est pas vraiment pertinent pour cet article. Tout ce dont j’ai besoin est du code commun dans deux méthodes similaires, l’une utilisant la primitive double et l’autre utilisant la classe wrapper Double. Le code pour multiplier deux matrices de type Double est exactement comme celui de la liste 2 avec les changements de type évidents.

Liste 2. Multipliant deux matrices de type double

J’ai exécuté les deux méthodes pour multiplier plusieurs fois deux matrices 1000 par 1000 sur mon ordinateur et mesuré les résultats. Les temps moyens sont indiqués dans le tableau 2. Ainsi, dans ce cas, la performance d’exécution de double est plus de quatre fois plus rapide que celle de Double. C’est tout simplement une trop grande différence pour être ignorée.

Le benchmark SciMark 2.0

Jusqu’à présent, j’ai utilisé le benchmark simple et simple de la multiplication matricielle pour démontrer que les primitives peuvent produire des performances de calcul nettement supérieures à celles des objets. Pour renforcer mes affirmations, j’utiliserai une référence plus scientifique. SciMark 2.0 est une référence Java pour le calcul scientifique et numérique disponible auprès du National Institute of Standards and Technology (NIST). J’ai téléchargé le code source de ce benchmark et créé deux versions, la version originale utilisant des primitives et une deuxième version utilisant des classes wrapper. Pour la deuxième version, j’ai remplacé int par Integer et double par Double pour obtenir le plein effet de l’utilisation des classes wrapper. Les deux versions sont disponibles dans le code source de cet article.

télécharger

John I. Moore, Jr.

Le benchmark SciMark mesure les performances de plusieurs routines de calcul et rapporte un score composite en Mflops approximatifs (millions d’opérations en virgule flottante par seconde). Ainsi, des nombres plus importants sont meilleurs pour cet indice de référence. Le tableau 3 donne les scores composites moyens de plusieurs exécutions de chaque version de ce benchmark sur mon ordinateur. Comme indiqué, les performances d’exécution des deux versions du benchmark SciMark 2.0 étaient cohérentes avec les résultats de multiplication matricielle ci-dessus en ce sens que la version avec primitives était presque cinq fois plus rapide que la version utilisant des classes wrapper.

Vous avez vu quelques variantes de programmes Java effectuer des calculs numériques, en utilisant à la fois un benchmark local et un benchmark plus scientifique. Mais comment Java se compare-t-il à d’autres langages? Je conclurai par un rapide aperçu de la comparaison des performances de Java avec celles de trois autres langages de programmation: Scala, C ++ et JavaScript.

Analyse comparative Scala

Scala est un langage de programmation qui fonctionne sur la machine virtuelle java et semble gagner en popularité. Scala a un système de type unifié, ce qui signifie qu’il ne fait pas de distinction entre les primitives et les objets. Selon Erik Osheim dans la classe de type numérique de Scala (Pt. 1), Scala utilise des types primitifs lorsque cela est possible mais utilisera des objets si nécessaire. De même, la description de Martin Odersky des tableaux de Scala dit que « … un tableau Scala Array est représenté par un Java int, un Array est représenté par un Java double… »

Cela signifie-t-il que le système de type unifié de Scala aura des performances d’exécution comparables aux types primitifs de Java? Voyons voir.

Je ne maîtrise pas aussi bien Scala que Java, mais j’ai essayé de convertir le code du benchmark de multiplication matricielle directement de Java en Scala. Le résultat est indiqué dans la liste 3 ci-dessous. Lorsque j’ai exécuté la version Scala du benchmark sur mon ordinateur, elle était en moyenne de 12,30 secondes, ce qui place les performances de Scala très proches de celles de Java avec les primitives. Ce résultat est bien meilleur que prévu et soutient les affirmations sur la façon dont Scala gère les types numériques.

télécharger

John I. Moore, Jr.

Liste 3. Multiplier deux matrices dans Scala

Benchmarking C++

Étant donné que C++ s’exécute directement sur du « métal nu » plutôt que dans une machine virtuelle, on s’attendrait naturellement à ce que C++ s’exécute plus rapidement que Java. De plus, les performances de Java sont légèrement réduites par le fait que Java vérifie les accès aux tableaux pour s’assurer que chaque index est dans les limites déclarées pour le tableau, alors que C++ ne le fait pas (une fonctionnalité C ++ qui peut entraîner des débordements de tampon, qui peuvent être exploités par des pirates). J’ai trouvé que C ++ était un peu plus gênant que Java pour traiter les tableaux bidimensionnels de base, mais heureusement, une grande partie de cette maladresse peut être cachée dans les parties privées d’une classe. Pour C++, j’ai créé une version simple d’une classe Matrix et j’ai surchargé l’opérateur * pour multiplier deux matrices, mais l’algorithme de multiplication matricielle de base a été converti directement à partir de la version Java. Le code source C++ est affiché dans la liste 4.

télécharger

John I. Moore, Jr.

Liste 4. En multipliant deux matrices en C++

En utilisant Eclipse CDT (Eclipse pour les développeurs C++) avec le compilateur MinGW C++, il est possible de créer à la fois des versions de débogage et de publication d’une application. Pour tester C ++, j’ai exécuté la version de sortie plusieurs fois et fait la moyenne des résultats. Comme prévu, C++ a fonctionné nettement plus rapidement sur ce simple benchmark, avec une moyenne de 7,58 secondes sur mon ordinateur. Si les performances brutes sont le principal facteur de sélection d’un langage de programmation, alors C++ est le langage de choix pour les applications à forte intensité numérique.

Benchmarking JavaScript

D’accord, celui-ci m’a surpris. Étant donné que JavaScript est un langage très dynamique, je m’attendais à ce que ses performances soient les pires de toutes, encore pires que Java avec des classes wrapper. Mais en fait, les performances de JavaScript étaient beaucoup plus proches de celles de Java avec les primitives. Pour tester JavaScript, j’ai installé Node.js, un moteur JavaScript réputé très efficace. Les résultats ont été obtenus en moyenne en 15,91 secondes. La liste 5 montre la version JavaScript du benchmark de multiplication matricielle que j’ai exécuté sur Node.js

télécharger

John I. Moore, Jr.

Liste 5. Multiplier deux matrices en JavaScript

En conclusion

Lorsque Java est arrivé sur la scène il y a environ 18 ans, ce n’était pas le meilleur langage du point de vue des performances pour des applications dominées par les calculs numériques. Mais au fil du temps, avec les progrès technologiques dans des domaines tels que la compilation juste à temps (JIT) (aussi appelée compilation adaptative ou dynamique), les performances de Java pour ce type d’applications sont désormais comparables à celles des langages compilés en code natif lorsque des types primitifs sont utilisés.

De plus, les primitives éliminent le besoin de collecte de déchets, offrant ainsi un autre avantage de performance des primitives par rapport aux types d’objets. Le tableau 4 résume les performances d’exécution du benchmark de multiplication matricielle sur mon ordinateur. D’autres facteurs tels que la maintenabilité, la portabilité et l’expertise des développeurs font de Java un meilleur choix pour de nombreuses applications de ce type.

Comme indiqué précédemment, Oracle semble envisager sérieusement la suppression des primitives dans une future version de Java. À moins que le compilateur Java puisse générer du code avec des performances comparables à celles des primitives, je pense que leur suppression de Java empêcherait l’utilisation de Java pour certaines classes d’applications; à savoir les applications dominées par les calculs numériques. Dans cet article, j’ai utilisé un benchmark simple basé sur la multiplication matricielle et un benchmark plus scientifique, SciMark 2.0, pour argumenter ce point.

À propos de l’auteur

John I. Moore, Jr., Professeur de mathématiques et d’informatique à La Citadelle, possède une large expérience dans l’industrie et le milieu universitaire, avec une expertise spécifique dans les domaines de la technologie orientée objet, du génie logiciel et des mathématiques appliquées. Pendant plus de trois décennies, il a conçu et développé des logiciels utilisant des bases de données relationnelles et plusieurs langages de premier ordre, et il a beaucoup travaillé en Java depuis la version 1.1. En outre, il a développé et enseigné de nombreux cours académiques et séminaires industriels sur des sujets avancés en informatique.

Pour en savoir plus

  1. Paul Krill a écrit sur les plans à long terme d’Oracle pour Java dans « Oracle expose les intentions Java à long terme » (JavaWorld, mars 2012). Cet article, ainsi que le fil de commentaires associé, m’a motivé à écrire cette défense des primitives.
  2. Szymon Guz écrit à propos de ses résultats dans benchmarking des types primitifs et des classes wrapper dans « Primitives and objects benchmark in Java » (SimonOnSoftware, janvier 2011).
  3. Sur le site Web de support pour la programmation — Principes et Pratiques en utilisant C++ (Addison-Wesley, 2009), le créateur de C++ Bjarne Stroustrup fournit une implémentation pour une classe de matrice beaucoup plus complète que celle accompagnant cet article.
  4. John Rose, Brian Goetz et Guy Steele discutent d’un concept appelé types de valeurs dans « L’état des valeurs » (OpenJDK.net , avril 2014). Les types de valeurs peuvent être considérés comme des types d’agrégats définis par l’utilisateur immuables sans identité, en combinant les propriétés des objets et des primitives. Le mantra des types de valeurs est « code comme une classe, fonctionne comme un int. »

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.