Portrait de Philippe PradosPrados.FRProgrammation, architecture & sécurité — depuis 1998

La Java de Broadway

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.

Par Philippe PRADOS - 2012
www.prados.fr

Le langage Java

Java est un langage objet, apportant comme principale innovation sa capacité à charger dynamiquement les classes dont le programme à besoin, au fur et à mesure. Cette capacité facilite l'ajout de plugin 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 librairie 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-miette 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 l'émission de l'erreur et le programme est interrompu.

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

La JVM

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

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émoires, 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 est orientés « pile ». C'est-à-dire que les instructions manipulent des données dans une pile, les extraits, effectue 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.

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éels ou embarqués (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'APIs (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 souhaite 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.

Historique des versions

La première version de Java a été publiée pour permettre la création d'Applet, des petites applications dans les pages HTML. A l'époque, les pages étaient statiques et ne permettaient pas d'ergonomie très riches. 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é coté serveur et à 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 ci les dates de sorties 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.).

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èmes de désallocation prématurée et de fuite mémoire dues à des bogues de codage car :

Ainsi, il reste pour le langage Java à gérer les problèmes de désallocation incomplète (fuite mémoire) due à 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-miette 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-miette n'est pas encore intervenu pour faire le ménage.

Présentation du Garbage Collector

Comment fonctionne le nettoyage de la mémoire, appelé également « ramasse-miette » 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.

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ées 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).

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 :

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.

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

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, où en interrompant le processus s'il dure trop longtemps.