La Java de Broadway

Magazine
Marque
GNU/Linux Magazine
HS n°
Numéro
56
Mois de parution
septembre 2011
Domaines


Résumé
Depuis 15 ans, le langage Java a beaucoup évolué et se retrouve dans la plupart des développements professionnels, particulièrement en France où tous les grands comptes l'utilisent.

Body

1. Le langage Java

Java est un langage objet apportant comme principale innovation sa capacité à charger dynamiquement les classes dont le programme a besoin au fur et à mesure. Cette capacité facilite l'ajout de plugins et permet la mise à jour d'une partie du code sans devoir relancer tout le serveur. Cette technique est très utilisée dans les serveurs d'applications J2EE. C'est une sorte de bibliothèque partagée dont la granularité est limitée à la classe.

Le langage utilise une sémantique par valeur pour tous les types primitifs (entier, flottant) et une sémantique par référence pour tous les objets. Les chaînes de caractères sont considérées comme des objets. (Pour mémoire, C++ utilise une sémantique par valeur pour tous les types, Smalltalk utilise une sémantique par référence pour tous les types).

La mémoire est gérée automatiquement. Le développeur n'a pas à signaler qu'un objet n'est plus nécessaire et qu'il peut être supprimé. Un mécanisme de ramasse-miettes se charge de faire le ménage régulièrement. Cela réduit les risques de fuites mémoire, même si cela ne les élimine pas tous.

Pour gérer les erreurs, Java propose un mécanisme d'exception. Le flux de traitement est interrompu. L'erreur remonte la pile d'appel jusqu'à ce qu'une méthode décide de s'en charger. Si aucune méthode ne capture l'exception, elle est traitée par la JVM. Une trace indique l'état de la pile d'appel lors de l'émission de l'erreur et le programme est interrompu.

Les implémentations de Java utilisent généralement une machine virtuelle (JVM) en charge d'interpréter le code Java. Il existe d'autres approches comme des compilations natives du code Java.

2. La JVM

Le terme JVM fait référence à la machine virtuelle Java, responsable :

- de l'indépendance du JDK vis-à-vis du matériel et du système d'exploitation utilisé ;

- de la production de bytecodes (classes Java compilées), leur interprétation et leur exécution ;

- de la sécurité de la plate-forme.

Une Java Virtual Machine (JVM) est une simulation d'un microprocesseur. Les instructions en pseudo langage machine sont interprétées par un programme : la machine virtuelle. Elle effectue des manipulations mémoire, invoque des fonctions des systèmes d'exploitation, alloue et libère la mémoire, etc. Cette approche permet d'écrire et de compiler un programme une seule fois et de l'exécuter dans tous les environnements, d'une carte à puce à un Cloud.

Plusieurs compilateurs peuvent avoir une machine virtuelle Java comme cible. La JVM n'impose pas l'utilisation du langage Java, même si la structure interne y est très proche.

Les instructions de la machine virtuelle sont orientées « pile ». C'est-à-dire que les instructions manipulent des données dans une pile, les extraient, effectuent des calculs, push les résultats. Un peu comme les calculatrices en HP.

Pour optimiser l'interprétation des instructions par la machine virtuelle, les dernières générations vont transformer à la volée les instructions en langage machine natif. Cette compilation tardive ne peut utiliser toutes les techniques avancées des compilateurs traditionnels, car le temps de compilation ne doit pas pénaliser l'application. Les dernières générations de machines virtuelles savent identifier les 10 % de code les plus sollicités pour utiliser des techniques d'optimisation plus agressives. Le temps nécessaire à cette compilation est compensé par le fait que le code résultant est le plus sollicité.

Généralement, l'émission d'une exception invalide l'utilisation du code natif. La JVM se replie sur le pseudo code machine.

Depuis peu, une autre machine virtuelle Java utilisée pour Android (Dalvik) est capable d'exécuter du code Java après transformation du bytecode. Pour cela, le code compilé traditionnel doit être converti en un autre jeu d'instructions orienté « registre ». Les instructions sont plus longues en mémoire, mais manipulent moins de données car tout est mémorisé dans des registres du microprocesseur. Au final, le code s'exécute plus rapidement, en consommant moins de ressources, dans le contexte d'un téléphone portable.

3. Présentation de la plate-forme Java

La plate-forme Java Edition Standard (J2SE) permet de développer et déployer des applications clientes et serveurs, mais aussi dans des environnements temps réel ou embarqué (via la déclinaison J2ME). Java SE inclut des classes pour le développement de services web et constitue la base de la plate-forme Java Edition Entreprise (J2EE).

Java est un langage de développement qui fournit un très grand nombre d'API (interfaces de programmation) et de composants pour des thèmes très variés. Les technologies Java avec leur API sont la base de la plate-forme J2SE. Elles fournissent tout ce qui est nécessaire pour développer et exécuter des applications sur tous les systèmes d'exploitation.

Il existe deux principaux produits dans la famille de produits Java SE (Standard Edition) : le JDK (Kit de Développement Java) et le JRE (Environnement d'exécution Java).

Le JDK comprend le JRE avec ses outils, plus les compilateurs et débogueurs du langage Java. Il est utilisé par les développeurs parce qu'il contient en premier lieu le compilateur du langage.

Le JRE peut être utilisé pour les environnements d'exécution tels que les serveurs d'applications. Parfois, les applications génèrent dynamiquement du code source et souhaitent alors invoquer le compilateur. Elles ont alors besoin du JDK.

Tomcat (v5.5 et suivantes) utilise son propre moteur de compilation (Eclipse JDT Java Compiler) pour les pages JSP en lieu et place du compilateur javac présent dans le JDK.

4. Historique des versions

La première version de Java a été publiée pour permettre la création d'applets, des petites applications dans les pages HTML. À l'époque, les pages étaient statiques et ne permettaient pas d'ergonomie très riche. Le langage avait initialement été développé pour des systèmes embarqués ou des cartes à puces. Lors de l’émergence du Web, les concepteurs ont exploité la capacité du langage à charger dynamiquement des classes, pour les récupérer directement depuis un serveur web. C'est cette idée géniale, apparue au bon moment, qui a permis la diffusion du langage. L’emballement a été si rapide qu'un langage de script indépendant en cours de réalisation a été baptisé Javascript pour bénéficier de l'aura de Java. Puis, rapidement, Java a été utilisé côté serveur et a perdu de son attrait côté client.

Le langage Java a connu plusieurs évolutions depuis le JDK 1.0 sorti en janvier 1996, avec l'ajout de nombreuses classes et paquetages à la bibliothèque standard. Depuis le J2SE 1.4, l'évolution de Java est dirigée par le JCP (Java Community Process) qui utilise les JSR (Java Specifications Requests) pour proposer des ajouts et des changements sur la plate-forme Java.

Voici les dates de sortie des quatre dernières versions majeures du JDK :

- la version 1.3 en mai 2000 ;

- la version 1.4 en février 2002 ;

- la version 5.0 en septembre 2004 ;

- la version 6.0 en décembre 2006 ;

- la version 7 ne va pas tarder à sortir, le JRE 7.0 est déjà disponible.

Autant les versions 1.3 et 1.4 n'avaient pas apporté beaucoup de nouveautés majeures au JDK depuis la version 1.2, autant la version 5.0 et suivantes proposent beaucoup d'éléments nouveaux (généricité, annotations, outils, options, etc.).

5. Modèle mémoire de Java

Dans tous les langages de développement, les problèmes dans la gestion mémoire au niveau applicatif concernent le mécanisme de désallocation mémoire. Ces problèmes peuvent être classifiés en deux catégories : désallocation prématurée (pointeurs corrompus) et désallocation incomplète (fuite mémoire).

Dans le cas d'une désallocation incomplète, il existe deux sous-catégories : les bogues de codage et les bogues de conception. Les bogues de codage sont dépendants du langage et ils peuvent être illustrés par exemple en C par le fait d'appeler la méthode de libération mémoire (free) moins souvent que celle d'allocation (malloc). Les bogues de conception, au contraire, sont indépendants du langage et sont plutôt dus à des conceptions inappropriées pour l'usage de l'application.

Avec un langage tel que le C/C++, toute la gestion mémoire était prise en charge par le développeur. Les problèmes cités plus haut pouvaient survenir même si le développeur avait passé du temps à s'assurer que le code était correct. En effet, en C/C++, plus on passe de temps pour corriger les fuites mémoires, plus il y a de chances d'avoir des problèmes de désallocation prématurée, et vice-versa. Et par nature, le risque d'avoir ces problèmes augmente avec la taille et la complexité du code. Ainsi, il est très difficile de protéger les applicatifs volumineux de ces types de problèmes.

Avec le langage Java et sa partie runtime, il n'y a plus de problème de désallocation prématurée et de fuite mémoire dus à des bogues de codage car :

- La mémoire est seulement allouée aux objets. Il n'y a pas d'allocation explicite de mémoire à réaliser car cela se fait automatiquement lors de la création de nouveaux objets (avec l'appel du constructeur via le mot-clé new).

- Le runtime Java utilise un « garbage collector » (GC) qui récupère la mémoire occupée par un objet une fois qu'il a déterminé qu'il n'était plus utilisable. Ce processus automatique rend plus sûre l'opération de suppression des références non utilisées car le GC ne collectera pas un objet s'il est encore référencé par un autre. Par conséquent, en Java, ce principe assure de ne jamais supprimer des références encore utilisées et donc supprime le risque de désallocation prématurée.

- En Java, il est facile de déréférencer un « arbre » entier d'objets en passant la référence de la racine à null. Le GC ramassera alors tous ces objets (à moins que quelques uns soient utilisés par ailleurs). Cela est donc beaucoup plus facile à écrire que de coder chaque destructeur d'objet avec ses dépendances (ce qui est un problème de codage en C++).

Ainsi, il reste pour le langage Java à gérer les problèmes de désallocation incomplète (fuite mémoire) dus à une programmation incorrecte. Avec une programmation de ce type, les références vers des objets non utilisés empêchent le GC de les collecter en vue de les supprimer. Cela relève de la responsabilité du développeur, car c'est lui qui tisse les liens entre tous les objets de la pile Java et donc qui sait comment déréférencer des objets qui ne sont plus utilisés.

Mais attention, le fait d'avoir un mécanisme de ramasse-miettes ne permet pas de gérer les autres types de ressources comme les fichiers ou les sockets ouverts. Il n'est pas pertinent de s'appuyer sur les mécanismes de nettoyage pour ces ressources en exploitant la méthode finalize qui est invoquée juste avant la perte d'un objet en mémoire, car il n'est pas possible de prévoir quand cela arrivera. Il n'est pas rare d'avoir des ressources physiques arrivées aux limites car le ramasse-miettes n'est pas encore intervenu pour faire le ménage.

5.1 Présentation du Garbage Collector

Comment fonctionne le nettoyage de la mémoire, appelé également « ramasse-miettes » ou « garbage collector » ? Lorsqu'une allocation ne peut aboutir, la JVM va partir d'un premier pointeur et parcourir tous les chemins possibles. Un drapeau est déposé sur chaque objet traversé. Ensuite, il suffit de reprendre la liste de tous les objets en mémoire et de supprimer les objets n'ayant pas de drapeau (Figure 1). Ceci est la version simpliste de l'algorithme.

100002010000013E000000AEF57584A9

Ce mécanisme ne peut s'exécuter en même temps que le programme fonctionne. En effet, des objets pourraient être créés ou perdus entre l'étape de traversée des objets et l'étape de nettoyage. Il est donc nécessaire d'interrompre tous les traitements de la JVM le temps que s'effectue le ménage. Les applications Java sont alors figées.

Pour réduire au maximum les interruptions de la JVM dues à l'exécution du ramasse-miettes, elle utilise différentes stratégies. Il a été constaté que les objets ont une durée de vie soit très courte, soit très longue. Les objets sont créés juste le temps de faire un traitement d'une méthode, ou au contraire, ils servent à mémoriser un état important, pour une période plus longue, un cache par exemple.

Fort de ce constat, la mémoire de la JVM est découpée en plusieurs parties. Une zone est réservée aux objets à durée de vie courte. Une autre zone est réservée aux objets à durée de vie longue. Très régulièrement, un algorithme de nettoyage des objets à durée de vie courte est exécuté. Cet algorithme est très rapide, mais est incapable d'identifier tous les objets à nettoyer. Par exemple, il a du mal à identifier des groupes d'objets se référençant les uns les autres, mais dont il n'existe aucun chemin pour l'atteindre. On appelle ces situations des îlots. Pour tous les autres objets, l'algorithme identifie très rapidement les objets obsolètes. Si un objet survit à cet algorithme, un compteur de durée de vie est incrémenté. C'est le cas des îlots. Si un seuil est dépassé, l'objet est déplacé dans la zone mémoire des objets à durée de vie longue. Il sera traité lorsqu'une allocation mémoire est impossible (déclenchement d'un « Full GC »). Soit il résiste et reste en mémoire, soit il n'est plus accessible et est finalement supprimé de la mémoire.

De cette description, nous comprenons qu'il y a un fort impact à adapter la taille réservée à la zone mémoire pour les objets à courte durée de vie et la zone mémoire maximum réservée à la JVM. Cela a deux effets contradictoires. Avec beaucoup de mémoire, le nettoyage complet (Full GC) arrive plus rarement, mais il dure plus longtemps. Avec moins de mémoire, le nettoyage complet arrive plus souvent, mais dure moins longtemps.

Il faut adapter la taille mémoire maximum pour obtenir un temps moyen de quelques secondes pour le nettoyage complet. Contrairement à une idée reçue, ce n'est pas en augmentant la mémoire réservée à la JVM qu'on améliore les performances. Si le nettoyage complet prend trop de temps, cela interrompt tous les traitements de tous les utilisateurs, au risque de déclencher des timeouts.

Le ramasse-miettes est une fonctionnalité majeure de la machine virtuelle Java, permettant au développeur de ne plus avoir à allouer et libérer la mémoire nécessaire aux objets créés. Il est d'ailleurs aujourd'hui considéré que les implémentations modernes du ramasse-miettes sont plus sûres et plus rapides que les meilleures allocations « manuelles » (par exemple, à base de malloc en C).

5.2 Les générations d'objets

Les objets Java ont une durée de vie variable, fonction de l'utilité de chacun. Un objet n'est pas libéré par le développeur, mais est considéré comme éligible au ramasse-miettes quand il ne peut plus être atteint par aucun pointeur du programme en exécution.

Afin de ne pas avoir à parcourir l'ensemble de la pile d'objets à chaque passage du ramasse-miettes, les objets sont regroupés par génération. Une génération peut être considérée comme une zone mémoire retenant des objets d'une même classe d'âge. Au sein des JVM récentes, les différentes générations sont :

- young : également appelée new, car les nouvelles allocations de mémoire sont réalisées au sein de cet espace, et plus spécifiquement au sein de l'espace eden (voir figure ci-dessous).

Pour la majorité des applicatifs, la mortalité infantile est élevée, et la plupart des objets (de 80 à 98 %) naissent et meurent dans le young space.

- tenured : également appelé old (ou titulaire en français), car héberge des objets d'âge plus avancé. Cet espace est par défaut plus grand que le young space.

- perm : zone mémoire séparée du reste de la heap (voir schéma) JVM, dans laquelle sont archivées les définitions de classes. Des erreurs de type OutOfMemory peuvent survenir lorsque cette zone est saturée, cela peut être le cas pour un applicatif avec beaucoup de JSP ou utilisant les objets proxy du langage Java.

Chacune des trois générations contient un espace validé (commited), où sont effectivement alloués les objets, et un espace virtuel, contenant de l'espace libre ou des données dans un état temporaire.

10000201000001B0000000CC1CE5B920

5.2.1 Les types de collectes

Lorsqu'une génération d'objets atteint un certain seuil de remplissage, le ramasse-miettes enclenche une collecte. Celle-ci peut être de deux types :

- mineure : quand l'espace eden atteint un taux d'occupation élevé, le GC réalise une collecte mineure ; les objets encore référencés de l'espace eden et du survivor occupé sont alors copiés dans l'espace survivor resté vide. Au fil des collectes mineures, les objets restés en vie passent d'un espace survivor à l'autre, laissant toujours vide un des deux espaces survivor. Quand ces objets atteignent un certain âge (tenure threshold, recalculé à chaque collecte, et correspondant à un nombre maximum de collectes), ou quand l'espace survivor d'accueil n'est plus assez grand, ils sont copiés vers l'espace tenured.

À noter que l'algorithme de collecte mineure est très rapide, puisqu'il réalise principalement des copies.

- majeure (major, ou full) : l'espace tenured est plus grand et se remplit moins rapidement que le young space. Sa collecte, nommée majeure ou full, intervient donc moins fréquemment et dure plus longtemps qu'une collecte mineure. Elle réalise deux parcours de la mémoire : le premier marque les objets à détruire, alors que le second défragmente la mémoire (les objets encore vivants sont regroupés afin de libérer un espace mémoire contigu). Cela permet d'éviter la fragmentation de la mémoire qui peut aboutir à refuser une allocation, alors que l'espace libre est suffisant. L'algorithme de la collecte majeure est donc long et gourmand (il suspend tous les threads), pour cela celle-ci est surnommée « stop the world », ce qui veut aussi dire que l'applicatif est stoppé pendant le passage du full GC.

La JVM génère une OutOfMemoryError quand un full GC n'a pas réussi à récupérer d'espace mémoire.

Les dernières générations de GC savent faire le ménage dans la zone majeure en réduisant au maximum le temps d'indisponibilité de la JVM, par des traitements en tâche de fond, ou en interrompant le processus s'il dure trop longtemps.




Articles qui pourraient vous intéresser...

CrossDev sous Eclipse

Magazine
Marque
GNU/Linux Magazine
HS n°
Numéro
112
Mois de parution
janvier 2021
Domaines
Résumé

Le développement logiciel nécessite l’utilisation d’outils pour l’écriture, la compilation et le débogage de code. La prise en main de ces outils n’est pas toujours évidente, alors lorsqu’on en maîtrise un, autant l’utiliser dans le maximum de cas. Eclipse permet cela et nous allons le voir dans le cas du développement embarqué.

L’édition des liens démystifiée

Magazine
Marque
GNU/Linux Magazine
Numéro
244
Mois de parution
janvier 2021
Domaines
Résumé

Parmi les étapes concourant à la fabrication d’un exécutable, l’édition des liens est certainement la plus méconnue. Elle est pourtant cruciale à plus d’un titre. Le choix entre édition statique et dynamique des liens a notamment des implications sur la facilité de développement, la sécurité et la performance.