Machine virtuelle Java - Le compilateur JIT
Dans ce chapitre, nous allons découvrir le compilateur JIT et la différence entre les langages compilés et interprétés.
Langues compilées ou interprétées
Les langages tels que C, C ++ et FORTRAN sont des langages compilés. Leur code est livré sous forme de code binaire ciblé sur la machine sous-jacente. Cela signifie que le code de haut niveau est compilé en code binaire à la fois par un compilateur statique écrit spécifiquement pour l'architecture sous-jacente. Le binaire produit ne fonctionnera sur aucune autre architecture.
D'un autre côté, les langages interprétés comme Python et Perl peuvent s'exécuter sur n'importe quelle machine, à condition qu'ils aient un interpréteur valide. Il parcourt ligne par ligne le code de haut niveau, le convertissant en code binaire.
Le code interprété est généralement plus lent que le code compilé. Par exemple, considérons une boucle. Un interprété convertira le code correspondant à chaque itération de la boucle. D'un autre côté, un code compilé fera de la traduction une seule. De plus, étant donné que les interprètes ne voient qu'une seule ligne à la fois, ils ne peuvent pas exécuter de code significatif tel que, changer l'ordre d'exécution des instructions comme les compilateurs.
Nous examinerons un exemple d'une telle optimisation ci-dessous -
Adding two numbers stored in memory. Étant donné que l'accès à la mémoire peut consommer plusieurs cycles de processeur, un bon compilateur émettra des instructions pour récupérer les données de la mémoire et n'exécuter l'ajout que lorsque les données sont disponibles. Il n'attendra pas et en attendant, exécutera d'autres instructions. En revanche, une telle optimisation ne serait pas possible lors de l'interprétation puisque l'interpréteur n'a pas connaissance de l'intégralité du code à un moment donné.
Mais alors, les langues interprétées peuvent fonctionner sur n'importe quelle machine disposant d'un interpréteur valide de cette langue.
Java est-il compilé ou interprété?
Java a essayé de trouver un terrain d'entente. Étant donné que la JVM se trouve entre le compilateur javac et le matériel sous-jacent, le compilateur javac (ou tout autre compilateur) compile le code Java dans le Bytecode, qui est compris par une JVM spécifique à la plate-forme. La JVM compile ensuite le Bytecode en binaire en utilisant la compilation JIT (Just-in-time), au fur et à mesure que le code s'exécute.
Points chauds
Dans un programme typique, il n'y a qu'une petite section de code qui est exécutée fréquemment, et souvent, c'est ce code qui affecte considérablement les performances de l'ensemble de l'application. Ces sections de code sont appeléesHotSpots.
Si une section de code n'est exécutée qu'une seule fois, la compilation serait un gaspillage d'efforts, et il serait plus rapide d'interpréter le Bytecode à la place. Mais si la section est une section active et est exécutée plusieurs fois, la JVM la compilera à la place. Par exemple, si une méthode est appelée plusieurs fois, les cycles supplémentaires nécessaires pour compiler le code seraient compensés par le binaire le plus rapide généré.
De plus, plus la JVM exécute une méthode particulière ou une boucle, plus elle rassemble d'informations pour effectuer diverses optimisations afin qu'un binaire plus rapide soit généré.
Considérons le code suivant -
for(int i = 0 ; I <= 100; i++) {
System.out.println(obj1.equals(obj2)); //two objects
}
Si ce code est interprété, l'interpréteur en déduire pour chaque itération que les classes de obj1. C'est parce que chaque classe en Java a une méthode .equals (), qui est étendue à partir de la classe Object et peut être remplacée. Ainsi, même si obj1 est une chaîne pour chaque itération, la déduction sera toujours effectuée.
D'un autre côté, ce qui se passerait réellement est que la JVM remarquerait que pour chaque itération, obj1 est de la classe String et par conséquent, elle générerait directement du code correspondant à la méthode .equals () de la classe String. Ainsi, aucune recherche ne sera requise et le code compilé s'exécuterait plus rapidement.
Ce type de comportement n'est possible que lorsque la JVM sait comment le code se comporte. Ainsi, il attend avant de compiler certaines sections du code.
Voici un autre exemple -
int sum = 7;
for(int i = 0 ; i <= 100; i++) {
sum += i;
}
Un interpréteur, pour chaque boucle, récupère la valeur de 'sum' dans la mémoire, y ajoute 'I' et la stocke en mémoire. L'accès à la mémoire est une opération coûteuse et prend généralement plusieurs cycles CPU. Étant donné que ce code s'exécute plusieurs fois, il s'agit d'un HotSpot. Le JIT compilera ce code et effectuera l'optimisation suivante.
Une copie locale de «somme» serait stockée dans un registre, spécifique à un thread particulier. Toutes les opérations seraient effectuées sur la valeur du registre et lorsque la boucle se terminerait, la valeur serait réécrite dans la mémoire.
Et si d'autres threads accèdent également à la variable? Comme les mises à jour sont effectuées sur une copie locale de la variable par un autre thread, ils verront une valeur périmée. La synchronisation des threads est nécessaire dans de tels cas. Une primitive de synchronisation très basique serait de déclarer «somme» comme volatile. Maintenant, avant d'accéder à une variable, un thread viderait ses registres locaux et extrairait la valeur de la mémoire. Après y avoir accédé, la valeur est immédiatement écrite dans la mémoire.
Voici quelques optimisations générales effectuées par les compilateurs JIT -
- Inlining de méthode
- Élimination du code mort
- Heuristique pour optimiser les sites d'appels
- Pliage constant