Comment un interprète interpréter le code?
pour la simplicité imaginez ce scénario, nous avons un ordinateur à 2 bits, qui a une paire de 2 registres à 2 bits appelés r1 et r2 et ne fonctionne avec l'adressage immédiat.
permet de dire la séquence de bits 00 signifie ajouter à notre cpu. De plus, 01 signifie données de déplacement à r1 et 10 signifie données de déplacement à r2.
il y a donc un langage D'assemblage pour cet ordinateur et un assembleur, où un exemple de code serait écrit comme
mov r1,1
mov r2,2
add r1,r2
simplement, quand j'assemble ce code à la langue maternelle et le fichier sera quelque chose comme:
0101 1010 0001
les 12 bits ci-dessus est le code natif pour:
Put decimal 1 to R1, Put decimal 2 to R2, Add the data and store in R1.
donc c'est essentiellement comme ça qu'un code compilé fonctionne, non?
disons que quelqu'un implémente une JVM pour cette architecture. En Java, j'écrirai du code comme:
int x = 1 + 2;
comment JVM interprétera-t-elle exactement ce code? Je veux dire que finalement le même schéma de bits doit être passé au cpu, n'est-ce pas? Tous les cpu ont un certain nombre d'instructions qu'il peut comprendre et exécuter, et ils sont après tout juste quelques bits. Disons que le code Java byte compilé ressemble à quelque chose comme ceci:
1111 1100 1001
ou autre.. Cela signifie-t-il que l'interprétation change ce code en 0101 1010 0001 lors de l'exécution? Si elle l'est, c'est déjà dans le code natif, alors pourquoi est-il dit que JIT ne se déclenche qu'après un certain nombre de fois? Si il ne convertit pas exactement à 0101 1010 0001, alors que fait-il? Comment fait-il le cpu faire l'addition?
il y a peut-être des erreurs dans mes suppositions.
je sais que l'interprétation est lente, le code compilé est plus rapide mais pas portable, et une machine virtuelle "interprète" un code, mais comment? Je cherche "comment exactement / techniquement interpréter" est faire. Tous les pointeurs (tels que les livres ou les pages web) sont les bienvenus au lieu des réponses.
4 réponses
l'architecture CPU que vous décrivez est malheureusement trop restreinte pour que cela soit vraiment clair avec toutes les étapes intermédiaires. Au lieu de cela, j'écrirai pseudo-C et pseudo-x86-assembleur, avec un peu de chance d'une manière qui est claire sans être terriblement familier avec C ou x86.
le bytecode JVM compilé pourrait ressembler à quelque chose comme ceci:
ldc 0 # push first first constant (== 1)
ldc 1 # push the second constant (== 2)
iadd # pop two integers and push their sum
istore_0 # pop result and store in local variable
L'interprète a (binaire) de ces instructions dans un tableau, et un index en référence à la présente instruction. Il dispose également d'un tableau de constantes, et une région de mémoire utilisé comme pile et une pour les variables locales. Alors la boucle de l'interpréteur ressemble à ceci:
while (true) {
switch(instructions[pc]) {
case LDC:
sp += 1; // make space for constant
stack[sp] = constants[instructions[pc+1]];
pc += 2; // two-byte instruction
case IADD:
stack[sp-1] += stack[sp]; // add to first operand
sp -= 1; // pop other operand
pc += 1; // one-byte instruction
case ISTORE_0:
locals[0] = stack[sp];
sp -= 1; // pop
pc += 1; // one-byte instruction
// ... other cases ...
}
}
ce code C est compilé en code machine et exécuté. Comme vous pouvez le voir, il est très dynamique: il inspecte chaque instruction de bytecode à chaque fois que cette instruction est exécutée, et toutes les valeurs passent par la pile (c.-à-d. la mémoire vive).
alors que l'addition elle-même se produit probablement dans un registre, le code entourant l'addition est assez différent de ce qu'un compilateur de code Java-to-machine émettrait. Voici un extrait de ce qu'un compilateur C pourrait transformer ce qui précède en (pseudo-x86):
.ldc:
incl %esi # increment the variable pc, first half of pc += 2;
movb %ecx, program(%esi) # load byte after instruction
movl %eax, constants(,%ebx,4) # load constant from pool
incl %edi # increment sp
movl %eax, stack(,%edi,4) # write constant onto stack
incl %esi # other half of pc += 2
jmp .EndOfSwitch
.addi
movl %eax, stack(,%edi,4) # load first operand
decl %edi # sp -= 1;
addl stack(,%edi,4), %eax # add
incl %esi # pc += 1;
jmp .EndOfSwitch
vous pouvez voir que les opérandes pour l'ajout viennent de la mémoire au lieu d'être codées en dur, même si pour les besoins du programme Java elles sont constantes. C'est parce que pour l'interprète , ils ne sont pas constants. L'interpréteur est compilé une fois et doit ensuite être capable d'exécuter toutes sortes de programmes, sans générer de code spécialisé.
le but du compilateur JIT est justement de générer du code spécialisé. Un JIT peut analyser les façons dont la pile est utilisée pour transférer des données, les valeurs réelles de diverses constantes dans le programme, et la séquence de calculs effectués, pour générer du code qui fait plus efficacement la même chose. Dans notre exemple de programme, il attribuerait la variable locale 0 à un registre, remplacerait l'accès à la table des constantes par des constantes mobiles dans les registres ( movl %eax,
), et redirigerait les accès stack vers les registres de la machine droite. En ignorant quelques optimisations supplémentaires (propagation de copie, pliage constant et élimination de code mort) qui seraient normalement effectuées, il pourrait finir avec le code comme ceci:
movl %ebx, # ldc 0
movl %ecx, # ldc 1
movl %eax, %ebx # (1/2) addi
addl %eax, %ecx # (2/2) addi
# no istore_0, local variable 0 == %eax, so we're done
tous les ordinateurs n'ont pas le même jeu d'instructions. Java bytecode est une sorte d'Espéranto - un langage artificiel pour améliorer la communication. La VM Java traduit le bytecode Java universel en jeu d'instructions de l'ordinateur sur lequel elle s'exécute.
alors comment est JIT ici? Le but principal du compilateur JIT est l'optimisation. Il y a souvent différentes façons de traduire un certain morceau de bytecode dans le code cible de la machine. La plupart des performances idéales la traduction est souvent pas évidente, car il peut dépendre des données. Il y a aussi des limites à la mesure dans laquelle un programme peut analyser un algorithme sans l'exécuter - le problème d'arrêt est une telle limitation bien connue, mais pas la seule. Donc, ce que le compilateur JIT fait est d'essayer différentes traductions possibles et de mesurer la vitesse à laquelle ils sont exécutés avec les données du monde réel que le programme traite. Donc il faut un certain nombre d'exécutions jusqu'à ce que le compilateur JIT trouve le parfait traduction.
une des étapes importantes en Java est que le compilateur traduit d'abord le code .java
en un fichier .class
, qui contient le bytecode Java. C'est utile, car vous pouvez prendre des fichiers .class
et les exécuter sur n'importe quelle machine qui comprend ce langue intermédiaire , en le traduisant ensuite sur place ligne par ligne, ou morceau par morceau. C'est l'une des fonctions les plus importantes du compilateur java + interprète. Vous peut compilent directement le code source Java en binaire natif, mais ceci nie l'idée d'écrire le code original une fois et d'être capable de l'exécuter n'importe où. Ceci est dû au fait que le code binaire natif compilé ne s'exécutera que sur la même architecture matérielle/système D'exploitation que celle pour laquelle il a été compilé. Si vous voulez l'exécuter sur une autre architecture, vous devez recompiler la source. Avec la compilation au bytecode de niveau intermédiaire, vous n'avez pas besoin de traîner autour du code source, mais le bytecode. C'est une question différente, car vous avez maintenant besoin d'une JVM qui peut interpréter et exécuter le bytecode. La compilation du bytecode de niveau intermédiaire, que l'interpréteur exécute ensuite, fait partie intégrante du processus.
pour ce qui est de l'exécution en temps réel du code: oui, la JVM interprétera/exécutera éventuellement un code binaire qui pourrait être identique ou non à du code compilé nativement. Et dans un exemple d'une ligne, ils peuvent sembler superficielle de même. Mais l'interpréter typiquement ne précompile pas tout, mais passe par le bytecode et se traduit en binaire ligne par ligne ou morceau par morceau. Il y a des avantages et des inconvénients à cela (par rapport au code compilé nativement, par exemple C et C Compilateurs) et beaucoup de ressources en ligne pour lire plus sur. Voir ma réponse ici , ou ce , ou ce .
simplification, interpréteur est une boucle infinie avec un commutateur géant à l'intérieur. Il lit le code octet Java (ou une représentation interne) et émule un CPU l'exécutant. De cette façon, le CPU réel exécute le code interprète, qui émule le CPU virtuel. C'est d'une lenteur pénible. Instruction virtuelle unique l'ajout de deux nombres nécessite trois appels de fonction et beaucoup d'autres opérations. Une seule instruction virtuelle prend un couple d'instructions réelles à exécuter. C'est aussi moins efficace en mémoire comme vous avez à la fois la pile réelle et émulée, les registres et les pointeurs d'instruction.
while(true) {
Operation op = methodByteCode.get(instructionPointer);
switch(op) {
case ADD:
stack.pushInt(stack.popInt() + stack.popInt())
instructionPointer++;
break;
case STORE:
memory.set(stack.popInt(), stack.popInt())
instructionPointer++;
break;
...
}
}
Lorsqu'une méthode est interprétée plusieurs fois, le compilateur JIT intervient. Il lira toutes les instructions virtuelles et générera une ou plusieurs instructions natives qui feront de même. Ici, je génère la chaîne avec l'assemblage de texte qui nécessiterait l'assemblage supplémentaire aux conversions binaires natives.
for(Operation op : methodByteCode) {
switch(op) {
case ADD:
compiledCode += "popi r1"
compiledCode += "popi r2"
compiledCode += "addi r1, r2, r3"
compiledCode += "pushi r3"
break;
case STORE:
compiledCode += "popi r1"
compiledCode += "storei r1"
break;
...
}
}
après la génération du code natif, JVM Copiez-le quelque part, marquez cette région comme exécutable et demandez à l'interpréteur de l'invoquer au lieu d'interpréter le code octet la prochaine fois que cette méthode est invoquée. Une seule instruction virtuelle peut toujours prendre plus d'une instruction native, mais ce sera presque aussi rapide qu'une compilation en code natif (comme en C ou C++). La Compilation est généralement beaucoup plus lente que l'interprétation, mais elle ne doit être effectuée qu'une seule fois et uniquement pour les méthodes choisies.