Dans cet article, nous allons nous pencher sur la façon d'aborder le code de la machine virtuelle Java HotSpot, et sa mise en œuvre dans le projet open-source OpenJDK - à la fois du point de vue de la machine virtuelle (VM), mais aussi en termes d'interaction avec les bibliothèques standards Java.
Introduction au code source de HotSpot
Regardons le code source du JDK et sa mise en œuvre des concepts Java. Il y a deux façons principales d'examiner le code source :
- utiliser l'archive src.zip (présente dans le répertoire
$JAVA_HOME
) dans un IDE, ou - utiliser le code source de l'OpenJDK et naviguer dans le système de fichiers.
Les deux approches sont utiles, mais il est important d'être à l'aise avec la seconde autant qu'avec la première. Le code source de l'OpenJDK est stocké dans Mercurial (un système de gestion de versions décentralisé similaire à Git). Si vous n'êtes pas familier avec Mercurial, il y a un livre gratuit couvrant les bases "La gestion de versions par l'exemple".
Pour récupérer les sources de l'OpenJDK 7, installez Mercurial, puis saisissez en ligne de commande :
hg clone http://hg.openjdk.java.net/jdk7/jdk7 jdk7_tl
Ceci créera une copie locale du dépôt de l'OpenJDK. Ce dépôt a la configuration de base du projet, mais ne contient pas encore tous les fichiers - étant donné que le projet OpenJDK est réparti dans plusieurs sous-dépôts.
Après le clone initial, le dépôt local ressemblera à ceci :
ariel-2:jdk7_tl boxcat$ ls -l
total 664
-rw-r--r-- 1 boxcat staff 1503 14 May 12:54 ASSEMBLY_EXCEPTION
-rw-r--r-- 1 boxcat staff 19263 14 May 12:54 LICENSE
-rw-r--r-- 1 boxcat staff 16341 14 May 12:54 Makefile
-rw-r--r-- 1 boxcat staff 1808 14 May 12:54 README
-rw-r--r-- 1 boxcat staff 110836 14 May 12:54 README-builds.html
-rw-r--r-- 1 boxcat staff 172135 14 May 12:54 THIRD_PARTY_README
drwxr-xr-x 12 boxcat staff 408 14 May 12:54 corba
-rwxr-xr-x 1 boxcat staff 1367 14 May 12:54 get_source.sh
drwxr-xr-x 14 boxcat staff 476 14 May 12:55 hotspot
drwxr-xr-x 19 boxcat staff 646 14 May 12:54 jaxp
drwxr-xr-x 19 boxcat staff 646 14 May 12:55 jaxws
drwxr-xr-x 13 boxcat staff 442 16 May 16:01 jdk
drwxr-xr-x 13 boxcat staff 442 14 May 12:55 langtools
drwxr-xr-x 18 boxcat staff 612 14 May 12:54 make
drwxr-xr-x 3 boxcat staff 102 14 May 12:54 test
Ensuite, vous devez exécuter le script get_source.sh
, qui a été récupéré lors du clone initial. Cela va permettre de récupérer le reste du projet et de cloner tous les fichiers nécessaires pour construire l'OpenJDK.
Avant de nous plonger dans une analyse complète du code source, il est important de mentionner qu'il ne faut pas avoir peur du code source de la plate-forme. Les développeurs pensent bien souvent, à tort, que le code du JDK est intimidant et inaccessible ; après tout, c'est le cœur de la plate-forme.
Le code du JDK est solide, passé en revue avec extrême attention et bien testé, mais accessible. Notons que le code n'a pas toujours été mis à jour pour être en phase avec les dernières fonctionnalités de Java. Par conséquent, il est assez fréquent de trouver des classes qui, par exemple, n'utilisent toujours pas les génériques.
Il y a plusieurs dépôts principaux du code source du JDK avec lesquels vous devriez être familier :
jdk
C'est là que se trouvent les bibliothèques standards. Il s'agit principalement de code Java (avec un peu de C pour les méthodes natives). C'est un excellent point de départ pour entrer dans le code source de l'OpenJDK. Les classes du JDK sont dans le répertoire jdk/src/share/classes.
hotspot
La machine virtuelle HotSpot - il s'agit de code C/C++ et assembleur (avec quelques outils de développement en Java). Le code est assez compliqué, et peut être un peu intimidant pour commencer si vous n'êtes pas un développeur C/C++ confirmé. Nous discuterons de quelques pistes plus en détail un peu plus loin.
langtools
Pour les personnes intéressées par les compilateurs et le développement d'outils, c'est là que les outils du langage et de la plate-forme peuvent être trouvés. Le code est principalement en Java et C - mais il n'est pas aussi facile a appréhender que le code du JDK. Néanmoins, il devrait être accessible à la plupart des développeurs.
Il y a aussi d'autres dépôts qui sont potentiellement moins importants ou intéressants pour la plupart des développeurs qui couvrent des choses comme corba, jaxp et jaxws.
Construire l'OpenJDK
Oracle a récemment lancé un projet visant à refondre complètement l'OpenJDK et à simplifier l'infrastructure de compilation. Ce projet, connu sous le nom "build-dev", est maintenant terminé et est la solution standard pour construire l'OpenJDK. Pour de nombreux utilisateurs utilisant un système de type Unix, une construction consiste désormais en l'installation d'un compilateur et d'un "bootstrap JDK" (ndt : une version précédente du JDK), puis en l'exécution des trois commandes suivantes :
./configure
make clean
make images
Pour plus de détails sur la construction de votre propre OpenJDK et comment commencer à le bidouiller, le programme AdoptOpenJDK (fondé par la Communauté Java de Londres) est un excellent point de départ. Il s'agit d'une communauté de près de 100 développeurs travaillant sur des projets tels que le nettoyage des messages d'avertissement, les petites corrections de bugs et les tests de compatibilité de l'OpenJDK 8 avec de grands projets open-source.
Comprendre l'environnement d'exécution de HotSpot
L'environnement d'exécution de Java (ndt : JRE), tel que fourni par l'OpenJDK, se compose de la JVM HotSpot combinée avec les bibliothèques standards (qui sont en grande partie packagées dans rt.jar).
Comme Java est un environnement mobile, tout ce qui nécessite un appel au système d'exploitation est traité en définitive par une méthode native. De plus, certaines méthodes nécessitent une aide particulière de la machine virtuelle Java (par exemple le chargement des classes). Ces dernières sont aussi transmises à la JVM via un appel natif.
Par exemple, regardons le code source en C des méthodes natives de la classe Object
. Le code source natif de la classe est disponible dans le fichier jdk/src/share/native/java/lang/Object.c. Il contient six méthodes.
L'interface native Java (JNI) nécessite que les méthodes natives implémentées en C soient nommées d'une manière très spécifique. Par exemple, la méthode native Object::getClass()
utilise la convention de nommage de sorte que son implémentation en C soit contenue dans une fonction ayant cette signature :
Java_java_lang_Object_getClass(JNIEnv *env, jobject this)
JNI a une autre façon de charger les méthodes natives, qui est utilisée par les cinq méthodes natives restantes de java.lang.Object
:
static JNINativeMethod methods[] = {
{"hashCode", "()I", (void *)&JVM_IHashCode},
{"wait", "(J)V", (void *)&JVM_MonitorWait},
{"notify", "()V", (void *)&JVM_MonitorNotify},
{"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
{"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
};
Ces cinq méthodes sont associées à des points d'entrée de la JVM (dont le nom des méthodes C est préfixé par "JVM_") - en utilisant le mécanisme de registerNatives()
(qui permet aux développeurs de modifier l'association entre les méthodes natives Java et les fonctions équivalentes en C).
D'une manière générale, et ceci autant que possible, l'environnement d'exécution Java est écrit en Java, avec seulement quelques endroits où la JVM a besoin d'intervenir. Le travail principal de la JVM, à l'exception de l'exécution du code, est de faire le ménage et d'entretenir l'environnement dans lequel les représentations d'exécution des objets Java vivent - le tas Java.
OOPs & KlassOOPs
Tout objet Java dans le tas est représenté par un pointeur d'objet ordinaire (POO) (ndt : à ne pas confondre avec programmation orientée objet). Un POO est un véritable pointeur dans le sens C/C++ - un mot machine qui pointe vers un emplacement mémoire dans le tas Java (ndt : un mot équivaut à 4 octets sur une JVM 32 bits et 8 sur une JVM 64 bits). Le tas Java est alloué sur une seule plage d'adresses contiguës en termes d'espace d'adresses virtuelles du processus de la JVM. La mémoire est ensuite gérée uniquement dans l'espace utilisateur (ndt : du système d'exploitation) par le processus de JVM lui-même, à moins que la JVM ait besoin de redimensionner le tas pour une raison quelconque.
Cela signifie que la création et le ramassage d'objets Java n'impliquent habituellement pas d'appels système pour allouer ou libérer de la mémoire.
Un POO se compose de deux mots machine en en-tête, qui sont appelés la Mark et la Klass, suivis par les champs de l'objet représenté. Un tableau possède un mot supplémentaire d'en-tête indiqué avant les champs - la taille du tableau.
Nous aurons beaucoup plus à dire - plus tard - au sujet des mots Mark et Klass, mais leurs noms sont délibérément suggestifs (ndt : en anglais) - le mot Mark est utilisé dans le ramassage des miettes (dans la partie mark de la technique mark-and-sweep) et le mot Klass est utilisé en tant que pointeur vers les métadonnées d'une classe.
Les champs de l'instance sont disposés dans un ordre très spécifique dans les octets suivants l'en-tête du POO. Pour plus de détails vous pouvez lire l'excellent article de Nitsan Wakart "Connais la disposition de tes objets Java en mémoire".
Les primitifs et les champs de référence sont définis après l'en-tête du POO (les références d'objets sont, bien évidemment, également des POOs). Regardons un exemple, la classe Entry
(comme utilisée dans java.util.HashMap
)
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
// méthodes...
}
A présent, calculons la taille de l'objet Entry
(sur une JVM 32 bits).
L'en-tête est constitué d'un mot Mark et d'un mot Klass. Par conséquent l'en-tête d'un POO est de 8 octets sur une JVM 32 bits (et 16 octets sur une JVM HotSpot 64 bits).
En utilisant la définition d'un POO, la taille globale est de 2 mots machine + la taille de tous les champs d'instance.
Les champs de type référence sont représentés comme des pointeurs, qui auront pour taille un mot machine sur n'importe quelle architecture de processeur normal.
Par conséquent, en ayant un champ int
, deux champs de référence (en référence aux objets de type K
et V
) et un champ Entry
, la taille totale est 2 mots (en-tête) + 1 mot (int) + 3 mots (pointeurs).
C'est-à-dire 24 octets (6 mots) au total pour stocker un seul objet HashMap.Entry
.
KlassOOPs
Le mot klass de l'en-tête est une des parties les plus importantes d'un POO. C'est un pointeur vers les métadonnées (qui est représentée par un type C++ appelé un klassOop) de la classe représentée. Parmi ces métadonnées, les méthodes de la classe sont d'une importance particulière, elles sont exprimées en C++ à l'aide d'une table de méthodes vituelles (une "vtable").
Nous ne souhaitons pas que chaque instance contienne tous les détails de leurs méthodes - cela serait extrêmement inefficient - par conséquent l'utilisation de la vtable dans le klassOop est un bon moyen de partager des informations entre les instances.
Il est aussi important de noter que les klassOops sont différents des objets Class
qui sont le résultat de l'opération de chargement des classes. La différence entre les deux peut être résumée de la manière suivante :
- Les objets
Class
(par exempleString.class
) sont de simple objets Java. Comme tout autre objet Java, ils sont représentés par des POOs (instanceOops) et ont le même comportement que les autres objets et ils peuvent être assignés à des variables Java. - Les klassOops sont la représentation pour la JVM des métadonnées d'une classe. Ils contiennent les méthodes de la classe dans une structure vtable. Il n'est pas possible d'obtenir une référence d'un klassOop depuis du code Java. Ces klassOops sont présents dans la PermGen (ndt : Génération permanente) du tas.
Le moyen facile de se rappeler cette distinction est de considérer un klassOop comme le «miroir» au niveau de la JVM de l'objet Class
d'une classe donnée.
Délégation virtuelle
La structure vtable des klassOops est directement liée à la délégation de méthodes Java et à l'héritage simple. Rappelez-vous que la délégation d'une méthode d'instance Java est virtuelle par défaut. Par conséquent les méthodes sont recherchées en utilisant l'identification du type à l'exécution (ndt : runtime type information) de l'instance appelée.
Ceci est mis en place dans les vtables de klassOop par l'utilisation d'offsets constants dans la vtable. Cela signifie qu'une méthode redéfinie est au même offset dans la vtable que la méthode qu'elle redéfinit dans la vtable du parent (ou grand-parent, etc.).
La délégation de méthodes est ensuite simplement mise en œuvre en remontant la hiérarchie d'héritage (de la classe, à la super classes, à la super super classe, etc.) et en recherchant l'implémentation d'une méthode, toujours au même offset dans la vtable.
Par exemple, ceci signifie que la méthode toString()
est toujours au même offset dans la vtable pour toutes les classes. Cette structure de vtable aide l'héritage simple, et aussi permet d'importantes optimisations lors de la compilation JIT.
(Cliquez sur l'image pour l'agrandir)
MarkOOPs
Le mot Mark de l'en-tête d'un POO est un pointeur vers une structure (une collection de champs de bits) qui détient des informations de nettoyage du POO.
Dans des circonstances normales sur une JVM 32 bits, les champs de bits de la structure Mark ressemblent à ceci (pour plus de détails, veuillez consulter le fichier hotspot/src/share/vm/oops/markOop.hpp) :
hash:25 —> | age:4 biased_lock:1 lock:2
Les 25 bits supérieurs constituent la valeur hashCode()
de l'objet. Ils sont suivis par 4 bits représentant l'âge de l'objet (en termes de nombre de ramassage de miettes auxquels l'objet a survécu). Les 3 derniers bits sont utilisés pour indiquer l'état de verrouillage de l'objet.
Java 5 a introduit une nouvelle approche à la synchronisation d'objets, appelée verrouillage biaisé (et elle est devenue la solution par défaut dans Java 6). L'idée est basée autour du comportement d'exécution observée des objets - dans de nombreux cas, les objets sont seulement verrouillés par un seul fil d'exécution.
Avec le verrouillage biaisé un objet est "biaisé" en direction du premier fil d'exécution qui le bloque - et ce fil d'exécution réalise alors de bien meilleures performances de blocage. Le fil qui a acquis le biais est indiqué dans l'en-tête Mark :
JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2
Si un autre fil d'exécution tente de verrouiller l'objet, alors le biais est révoqué (et il ne sera pas récupéré) - et à partir de là, tous les fils d'exécution devront explicitement verrouiller et déverrouiller l'objet.
Les différents états possibles d'un objet sont :
- Déverrouillé
- Biaisé
- Légèrement verrouillé
- Lourdement verrouillé
- Marqué (seulement possible durant le ramassage de miettes)
POOs dans le code de HotSpot
Il y a une hiérarchie complexe de types de POOs dans le code source de HotSpot. Ces types sont disponibles dans le répertoire hotspot/src/share/vm/oops contenant, entre autres :
- oop (base abstraite)
- instanceOop (instance)
- methodOop (représentations des méthodes)
- arrayOop (base abstraite des tableaux)
- symbolOop (symbole interne / classe string)
- klassOop
- markOop
Il y a quelques accidents historiques un peu étranges. Le contenu des tables de délégation virtuelle (vtables) sont séparées des klassOops, et le markOop ne ressemble en rien aux POOs, mais est encore présent dans la même hiérarchie.
Un endroit intéressant où l'on peut directement voir les POOs est en ligne de commande à l'aide de l'outil jmap. Cela donne un bref aperçu du contenu du tas y compris des POOs présents dans la PermGen (qui contient les sous-classes et structures utilitaires nécessaires aux klassOops).
$ jmap -histo 150 | head -18
num #instances #bytes class name
----------------------------------------------
1: 10555 21650048 [I
2: 272357 6536568 java.lang.Double
3: 25163 5670768 [Ljava.lang.Object;
4: 229099 5498376 com.jclarity.censum.dataset.CensumXYDataItem
5: 39021 5470944 <constMethodKlass>
6: 39021 5319320 <methodKlass>
7: 8269 4031248 [B
8: 3161 3855136 <constantPoolKlass>
9: 119759 2874216 org.jfree.data.xy.XYDataItem
10: 3161 2773120 <instanceKlassKlass>
11: 2894 2451648 <constantPoolCacheKlass>
12: 34012 2271576 [C
13: 87065 2089560 java.lang.Long
14: 20897 2006112 [Lcom.jclarity.censum.CollectionType;
15: 33798 1081536 java.util.HashMap$Entry
Les entrées indiquées à l'aide du symbole en diamant sont des POOs de différents types, alors que celles comme [I
et [B
représentent respectivement des tableaux de int
s et de byte
s.
L'interpréteur HotSpot
HotSpot est un interpréteur plus avancé qu'un simple "switch dans une boucle while", généralement plus familier des développeurs.
Au lieu de cela, HotSpot est un interpréteur modèle. Ceci signifie qu'une table de liaison dynamique contenant du code machine optimisé est construite (ndt : à chaque démarrage de la JVM). Le code machine étant spécifique au système d'exploitation et au processeur en cours d'utilisation. La majorité des instructions bytecode sont transformées en assembleur, avec seulement les instructions les plus complexes, telles que la recherche d'une entrée dans le pool de constantes d'un fichier .class, étant déléguées à la VM.
Cela améliore les performances de l'interpréteur de HotSpot, mais en rendant la machine virtuelle plus difficile à porter vers de nouvelles architectures et systèmes d'exploitation. Il rend aussi l'interpréteur plus difficile à comprendre pour les nouveaux développeurs.
Pour un premier contact, il est souvent préférable pour les développeurs d'acquérir une compréhension basique de l'environnement d'exécution fourni par l'OpenJDK :
- L'environnement est principalement écrit en Java
- La portabilité entre les systèmes d'exploitation est réalisée à l'aide des méthodes natives
- Les objets Java sont représentés dans le tas par des POOs
- Les métadonnées des classes sont représentées dans la JVM par des KlassOOPs
- Un interpréteur modèle avancé pour des performances élevées, même en mode interprété
De là, les développeurs peuvent commencer à explorer le code Java dans le dépôt du JDK, ou chercher à améliorer leur connaissance en C/C++ et assembleur pour étudier HotSpot de manière approfondie.
A propos de l'auteur
Ben Evans est CEO de jClarity, une start-up qui fournit des outils de performance pour aider les équipes de développement et opérationnelles. Il est un des organisateurs de la LJC (London JUG) et membre du Comité exécutif du JCP, aidant à définir des normes pour l'écosystème Java. Il est Java champion, JavaOne Rockstar, co-auteur de "The Well-Grounded Java Developer" et donne régulièrement des conférences sur la plate-forme Java, les performances, la concurrence, et des sujets connexes.