[JAVA] Fonctionnement du compilateur JVM JIT

Je vais résumer ce que j'ai appris sur le mécanisme du compilateur JIT de la JVM.

environnement

~/workspace/$ java -version
openjdk version "1.8.0_222"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_222-b10)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.222-b10, mixed mode)

Qu'est-ce que JIT Comparite?

Compilateur Just In Time, implémenté sur JVM. Just In Time, un compilateur qui compile "ce dont vous avez besoin, quand vous en avez besoin".

Flux jusqu'à l'exécution sur JVM

En gros, le flux suivant

Code source->compiler->Code intermédiaire->Exécuter (compiler)

image.png

Source

Le langage JVM ne compile pas tout le code source d'intérêt lorsque vous exécutez la compilation (javac pour java, sbt compile pour scala, etc.). Tout d'abord, créez un code intermédiaire. (code octet java). En prenant en sandwich le processus de génération de ce code intermédiaire, le même code peut être exécuté sur n'importe quel système d'exploitation tant qu'il existe un environnement JVM. (JVM doit être adapté à chaque système d'exploitation)

Après cela, le code intermédiaire créé n'est pas compilé (converti en code natif) à la fois. L'interpréteur compile le code source à chaque fois qu'il est exécuté. (Interpréteur Java dans la figure ci-dessus)

Il y a deux raisons à cela.

1. Compiler gaspille du temps de compilation si le code n'est utilisé qu'une seule fois

Comme la compilation prend du temps, pour le code qui n'est appelé qu'une seule fois, son exécution avec l'interpréteur réduira le temps total d'exécution. D'un autre côté, pour le code fréquemment appelé, le code compilé peut être exécuté plus rapidement et doit être compilé. La discussion du seuil sur la JVM pour savoir s'il faut exécuter avec l'interpréteur ou compiler et exécuter sera décrite plus loin.

2. Vous pouvez collecter les informations disponibles lors de la compilation.

Vous pouvez obtenir les informations dont vous avez besoin pour compiler lorsque vous l'exécutez dans l'interpréteur. Diverses optimisations peuvent être effectuées au moment de la compilation en utilisant les informations acquises. Cette optimisation fait une différence dans le temps d'exécution même dans ce code compilé.

Par exemple, considérons la méthode ʻequals ()`

J'ai le code suivant.

test.scala


val b = obj1.equals(obj2)

Lorsque l'interpréteur atteint la méthode ʻequals () , il devient nécessaire de rechercher si la méthode ʻequals () est la méthode définie dans obj1 ou la méthode de l'objet String. Avec seulement l'interpréteur, ce serait une perte de temps de chercher à chaque fois que la méthode ʻequals () `est atteinte.

Si l'interpréteur détermine que obj1 est une méthode d'un objet String, il compile la méthode ʻequals () `comme une méthode d'un objet String. Il compile et élimine le temps qu'il passe à explorer lors de l'interprétation, ce qui accélère le code.

Comme vous pouvez le voir, le compilateur JIT ne compile pas le code tout de suite car il ne peut pas être optimisé sans exécuter et examiner le code.

Trois types de compilateur JIT

Il existe trois types de compilateurs JIT. A partir de java8, le troisième compilateur hiérarchique est défini par défaut.

Compilateur client (C1)

Compiler à un stade précoce

Compilateur de serveur (C2)

Rassemblez des informations sur le comportement de votre code avant de le compiler. Comme mentionné ci-dessus, il est optimisé et compilé, il est donc plus rapide que le compilateur client.

Compilateur hiérarchique

Une collection de compilateur client et de compilateur serveur. Au début, il est compilé en C1 et lorsque les informations d'optimisation sont collectées (lorsque le code chauffe), il est compilé en C2.

Vérification

Vous pouvez vérifier le compilateur configuré avec java -version Dans mon cas

~/workspace/$ java -version
openjdk version "1.8.0_222"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_222-b10)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.222-b10, mixed mode)

Chaque compilateur doit être utilisé correctement en fonction de l'application à créer. Par exemple, si vous exécutez une application GUI sur une machine virtuelle Java, vous devez utiliser un compilateur client car il est préférable pour UX d'avoir un temps d'accès initial plus rapide que d'augmenter la vitesse de traitement à mesure que vous l'utilisez.

Sélectionnez le compilateur en fonction de l'application et de ce que vous souhaitez réaliser.

Seuil compilé

Comme mentionné ci-dessus, le code d'octet est d'abord exécuté par l'interpréteur. Ensuite, à quel moment pouvez-vous passer de l'interpréteur au compilateur JIT? Il y a deux seuils

Compteur d'appels (compilation standard)

Nombre d'appels de la méthode cible

Lorsque ce nombre dépasse le seuil, la méthode cible est mise en file d'attente pour la compilation et compilée.

Compteur de bord de sac

Le nombre de fois que le traitement retourne du code dans la boucle au sein de la méthode

Lorsque ce nombre dépasse le seuil, la boucle elle-même devient la cible de la compilation et est compilée. La compilation effectuée à ce moment-là est appelée compilation OSR. Lorsque la compilation est terminée, il est échangé contre le code compilé sur la pile, et le code compilé est exécuté à partir du processus suivant.

réglage

Les seuils de compteur ci-dessus sont différents entre le compilateur client et le compilateur serveur. Vous devez régler ce seuil correctement.

Exemple

Si vous abaissez le seuil de compilation standard avec le compilateur serveur, les informations requises pour la compilation seront réduites, ce qui rendra son optimisation difficile et entraînera un code de compilation lent.

Pourtant, il y a un mérite d'abaisser le seuil

1. Le temps de préchauffage peut être un peu raccourci

C'est vrai.

2. Le code qui ne se compile pas à des seuils élevés compile également

À cet égard, il est probable que le seuil du compteur d'appels / du compteur de bord de sac sera finalement atteint au fur et à mesure que le code continue à s'exécuter. Cependant, la valeur du compteur est soustraite au fil du temps.

Comme mentionné ci-dessus, vous devez régler correctement.

En fait, régler

Observez et ajustez le comportement du compilateur JIT avec le code suivant

Vous pouvez spécifier l'option jvm dans .jvmopts comme indiqué ci-dessous.

.jvmopts


-XX:+PrintCompilation
-XX:CompileThreshold=1

Il crache le journal de compilation comme indiqué ci-dessous

Le format est

Désoptimisation de la taille du nom de la méthode d'attribut ID de compilation d'horodatage


$ sbt run
41    1       3       java.lang.Object::<init> (1 bytes)
42    2       3       java.lang.String::hashCode (55 bytes)
44    3       3       java.lang.String::charAt (29 bytes)
45    4       3       java.lang.String::equals (81 bytes)
45    5     n 0       java.lang.System::arraycopy (native)   (static)
45    6       3       java.lang.Math::min (11 bytes)
45    7       3       java.lang.String::length (6 bytes)
52    8       1       java.lang.Object::<init> (1 bytes)
52    1       3       java.lang.Object::<init> (1 bytes)   made not entrant
53    9       3       java.util.jar.Attributes$Name::isValid (32 bytes)
53   10       3       java.util.jar.Attributes$Name::isAlpha (30 bytes)
・ ・ ・ ・

Vous pouvez spécifier le nombre d'exécutions de la boucle de méthode avant sa compilation.

Essayez-le avec le code suivant

Test.scala


object Test extends App{
  def compileTest() = {
    for (i <- 0 to 1000) {
      sampleLoop(i)
    }
  }

  def sampleLoop(num: Int) = {
    println(s"loopppp${num}")
  }

  println(compileTest())
}

.jvmopts


-XX:+PrintCompilation
-XX:CompileThreshold=1

résultat

Depuis que j'ai défini -XX: CompileThreshold = 1, je peux confirmer que la méthode compileTest a été compilée en exécutant ce code une fois. De plus, la méthode sampleLoop est également une boucle, elle est donc compilée.

9983 9336       3       Test$$$Lambda$3666/873055587::apply$mcVI$sp (5 bytes)
9983 9338       3       Test$::sampleLoop (1 bytes)
9983 9337       3       Test$::$anonfun$compileTest$1 (8 bytes)
9984 9334       4       java.lang.invoke.MethodType::makeImpl (66 bytes)
9986 9339   !   3       scala.Enumeration$Val::toString (55 bytes)
・ ・ ・

La méthode compileTest est compilée 9 secondes après le démarrage de la JVM.

Par exemple, qu'en est-il des paramètres suivants?


object Test extends App{
  def compileTests() = {
    for (i <- 0 to 10) { //Passer à 10 boucles
      sampleLoop(i)
    }
  }

  def sampleLoop(num: Int) = {
    println(s"loopppp${num}")
  }

  println(compileTests())
}

.jvmopts


-XX:+PrintCompilation
-XX:CompileThreshold=100 #Changer le seuil à 100 fois

Si vous définissez -XX: CompileThreshold = 100 etc., la méthode compileTest ne sera pas compilée simplement en exécutant le code ci-dessus une fois. De plus, la méthode sampleLoop n'est pas exécutée car elle n'est pas exécutée 100 fois.

Résumé

Il est facile de comprendre si vous regardez réellement le processus de compilation JIT.

référence

Recommended Posts

Fonctionnement du compilateur JVM JIT
Java Performance Chapter 4 Fonctionnement du compilateur JIT
Comment fonctionne jul-to-slf4j
[Note] À propos du problème Fizz_Buzz (Fonctionnement de Ruby on Rails)
Demandez à la JVM de résoudre le numéro
Scala s'exécute sur une JVM
[Java] Fonctionnement de Spring DI