2015-01-25 13 views
23

Der Einfachheit halber stellen Sie sich dieses Szenario vor, wir haben einen 2-Bit-Computer, der ein Paar 2-Bit-Register namens r1 und r2 hat und nur mit unmittelbarer Adressierung funktioniert.Wie interpretiert ein Interpreter den Code?

Sagen wir die Bitfolge bedeutet hinzufügen zu unserer CPU. Auch bedeutet Verschieben von Daten zu r1 und bedeutet, Daten auf r2 verschieben.

So gibt es eine Assemblersprache für diesen Computer und ein Assembler, wo ein Codebeispiel

mov r1,1 
mov r2,2 
add r1,r2 

einfach wie

geschrieben werden würde, wenn ich diesen Code zu Muttersprache zusammenstellen und die Datei wird wie etwas sein:

0101 1010 0001 

die 12 Bits oben ist der native Code für:

Put decimal 1 to R1, Put decimal 2 to R2, Add the data and store in R1. 

So funktioniert ein kompilierter Code im Prinzip, oder?

Sagen wir, jemand implementiert eine JVM für diese Architektur. In Java werde ich Code schreiben wie:

int x = 1 + 2; 

Wie genau wird JVM diesen Code interpretieren? Ich meine, irgendwann muss das gleiche Bitmuster an die CPU weitergegeben werden, oder? Alle CPUs haben eine Anzahl von Anweisungen, die sie verstehen und ausführen können, und sie sind schließlich nur ein paar Bits. Sagen wir die kompilierten Java-Byte-Code etwa wie folgt aussieht:

1111 1100 1001 

oder was auch immer .. Bedeutet es, dass die Interpretation dieses Codes 0101 1010 0001 ändert sich bei der Ausführung? Wenn dies der Fall ist, ist es bereits im systemeigenen Code, also warum wird gesagt, dass JIT nur nach einer Anzahl von Malen einsetzt? Wenn es nicht genau in 0101 1010 0001 konvertiert wird, was macht es dann? Wie macht es die CPU den Zusatz zu tun?

Vielleicht gibt es einige Fehler in meinen Annahmen.

Ich weiß Interpretieren ist langsam, kompilierter Code ist schneller, aber nicht portabel, und eine virtuelle Maschine "interpretiert" einen Code, aber wie? Ich bin auf der Suche nach "wie genau/technisch interpretiert wird". Alle Zeiger (wie Bücher oder Webseiten) sind willkommen, anstatt Antworten.

+5

Ist Ihre Prämie eine bestehende Antwort zu belohnen? Wenn nicht welche zusätzlichen Informationen erwarten Sie? – assylias

Antwort

19

Die CPU-Architektur, die Sie beschreiben, ist leider zu beschränkt, um dies mit allen Zwischenschritten zu verdeutlichen. Stattdessen werde ich pseudo-C und pseudo-x86-Assembler schreiben, hoffentlich in einer Weise, die klar ist, ohne mit C oder x86 schrecklich vertraut zu sein.

Die kompilierte JVM Bytecode wie folgt aussehen könnte:

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 

Der Interpreter hat (eine binäre Codierung) diese Befehle in einem Array und einen Index auf die aktuelle Anweisung bezieht. Es hat auch eine Reihe von Konstanten und eine Speicherregion, die als Stapel und eine für lokale Variablen verwendet wird. Dann sucht der Interpreter-Schleife wie folgt aus:

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 ... 
    } 
} 

Dieser C-Code in Maschinencode und Lauf kompiliert wird. Wie Sie sehen können, ist es sehr dynamisch: Es prüft jeden Bytecode-Befehl jedes Mal, wenn dieser Befehl ausgeführt wird, und alle Werte durchlaufen den Stapel (d. H. RAM).

Während die tatsächliche Addition selbst wahrscheinlich in einem Register passiert, unterscheidet sich der Code, der die Addition umgibt, ziemlich von dem, was ein Java-to-Machine-Code-Compiler ausgeben würde. Hier ist ein Auszug aus dem, was ein C-Compiler die oben in (pseudo-x86) drehen könnten:

.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 

Sie können sehen, dass die Operanden für die Addition aus dem Speicher kommen, anstatt fest einprogrammiert zu werden, obwohl für die Zwecke der Java-Programm sind sie konstant. Das liegt daran, für den Interpreter, sie sind nicht konstant. Der Interpreter wird einmal kompiliert und muss dann in der Lage sein, alle Arten von Programmen auszuführen, ohne spezialisierten Code zu erzeugen.

Der Zweck des JIT-Compilers ist genau das: Generieren Sie spezialisierten Code.Ein JIT kann die Art und Weise analysieren, wie der Stapel zum Übertragen von Daten verwendet wird, die tatsächlichen Werte verschiedener Konstanten in dem Programm und die Abfolge von durchgeführten Berechnungen, um Code zu erzeugen, der effizienter dasselbe tut. In unserem Beispielprogramm würde es die lokale Variable 0 einem Register zuordnen, den Zugriff auf die Konstantentabelle durch bewegliche Konstanten in Register (movl %eax, $1) setzen und die Stack-Zugriffe auf die richtigen Maschinenregister umleiten. Ignoriert ein paar mehr Optimierungen (Kopierausbreitungs, konstantes Falten und Beseitigung von totem Code), die normalerweise getan werden würde, es mit dem Code so enden könnte: inside

movl %ebx, $1 # ldc 0 
movl %ecx, $2 # 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 
+0

Können wir sagen, dass in Ihrem Beispiel JIT zum Hinzufügen der Werte eingelocht hat, aber das Speichern immer noch interpretiert wird? Tolle Antwort übrigens, danke. –

+0

@KorayTugay Ich würde nicht sagen "das Speichern wird noch interpretiert". Der Standort dieser Läden hat sich geändert, die Art und Weise, in der die Geschäfte stattfinden, hat sich geändert, und der JIT hat sehr genau verstanden, welcher Speicher welchen Speicherabschnitt beeinflusst hat. Die Registerumordnung ist leicht suboptimal (nach der weiteren Optimierung würde der erste Befehl "eax" anstelle von "ebx" verwenden und der dritte Befehl würde entfernt), aber er ist sehr klar kompiliert. – delnan

+0

@Koray Tugay: nein, eigentlich bedeutet speichern bedeutet, den Haufen zu ändern. Interpreter und kompilierter Code können daher eine völlig andere Art der Verarbeitung von lokalen Variablen und Stacks haben, solange sie sich über den Heap einig sind. In diesem einfachen Beispiel wird HotSpot feststellen, dass der Heap niemals geändert wird und das Ergebnis nicht zurückgegeben wird. Daher wird die gesamte Berechnung entfernt. Die Zeiten, als kompilierter Code den zugehörigen Bytecode 1: 1 widerspiegelte, waren vor 20 Jahren ... – Holger

2

Einer der wichtigen Schritte in Java ist, dass der Compiler zuerst den Code .java in eine .class-Datei übersetzt, die den Java-Bytecode enthält. Dies ist nützlich, da Sie .class Dateien nehmen und sie auf jedem Rechner ausführen können, der diese Zwischensprache versteht, indem Sie sie Zeile für Zeile oder Chunk-by-Chunk übertragen. Dies ist eine der wichtigsten Funktionen des Java Compilers + Interpreters. Sie können direkt kompilieren Java-Quellcode zu nativen Binärcode, aber dies negiert die Idee, den ursprünglichen Code einmal zu schreiben und in der Lage, es überall auszuführen. Dies liegt daran, dass der kompilierte native Binärcode nur auf der gleichen Hardware-/Betriebssystemarchitektur ausgeführt wird, für die er kompiliert wurde. Wenn Sie es in einer anderen Architektur ausführen möchten, müssen Sie die Quelle neu kompilieren. Bei der Kompilierung auf den Bytecode der mittleren Ebene müssen Sie nicht den Quellcode, sondern den Bytecode umherziehen. Es ist ein anderes Problem, da Sie jetzt eine JVM benötigen, die den Bytecode interpretieren und ausführen kann.Daher ist das Kompilieren zu dem Bytecode mittlerer Ebene, den der Interpreter dann ausführt, ein integraler Teil des Prozesses.

Wie für die tatsächliche Ausführung von Code in Echtzeit: Ja, die JVM wird schließlich Binärcode interpretieren/ausführen, der identisch mit dem nativ kompilierten Code sein kann oder nicht. Und in einem einzeiligen Beispiel mögen sie oberflächlich gleich aussehen. Der Interpreter kompiliert jedoch normalerweise nicht alles vor, sondern geht durch den Bytecode und wird binär Zeile für Zeile oder Stück für Stück umgerechnet. Es gibt Vor- und Nachteile (im Vergleich zu nativ kompiliertem Code, z. B. C- und C-Compiler) und viele Ressourcen online, um weiter zu lesen. Siehe meine Antwort here oder this oder this eins.

+0

"Sie können Java-Quellcode direkt zu nativer Binärdatei kompilieren, aber dies macht die Idee zunichte, den ursprünglichen Code einmal zu schreiben und ihn überall ausführen zu können." Wie? –

+1

@KorayTugay siehe http://www.excelsiorjet.com/ zum Beispiel. –

+0

Wenn Sie direkt für eine bestimmte Architektur kompilieren, können Sie nur den kompilierten Code für diese Architektur ausführen. Um den Code für eine andere Architektur auszuführen, müssen Sie diesen Code erneut kompilieren. Vielleicht sollte ich das in der Antwort klären. –

2

Nicht alle Computer haben den gleichen Befehlssatz. Java Bytecode ist eine Art Esperanto - eine künstliche Sprache zur Verbesserung der Kommunikation. Die Java-VM übersetzt den universellen Java-Bytecode in den Befehlssatz des Computers, auf dem er ausgeführt wird.

Also, wie sieht JIT hier aus? Der Hauptzweck des JIT-Compilers ist die Optimierung. Es gibt oft verschiedene Möglichkeiten, einen bestimmten Bytecode in den Zielmaschinencode zu übersetzen. Die leistungsstärkste Übersetzung ist oft nicht offensichtlich, weil sie von den Daten abhängen könnte. Es gibt auch Grenzen dafür, wie weit ein Programm einen Algorithmus analysieren kann, ohne es auszuführen - die halting problem ist eine bekannte Einschränkung, aber nicht die einzige. Was der JIT-Compiler tut, ist also, verschiedene mögliche Übersetzungen auszuprobieren und zu messen, wie schnell sie mit den realen Daten ausgeführt werden, die das Programm verarbeitet. Es dauert also eine Reihe von Ausführungen, bis der JIT-Compiler die perfekte Übersetzung gefunden hat.

+0

Also grundsätzlich jedes Mal, wenn der Code in nativen Code für die gegebene Architektur kompiliert wird. Aber sobald die schnellste Version gefunden wurde, kompiliert JIT sie ein letztes Mal? –

+0

@KorayTugay es hat bereits eine kompilierte Version, also warum würde es wieder kompilieren? – Philipp

+0

Nun, Sie sagen "So braucht es eine Reihe von Ausführungen, bis der JIT-Compiler die perfekte Übersetzung gefunden hat."Und die Ausführung bedeutet Bytecode zu nativem Code ist es nicht, was Compiling genannt wird? –

1

Vereinfachen, Dolmetscher ist eine Endlosschleife mit einem riesigen Schalter . Es liest Java-Byte-Code (oder einige interne Darstellung) und emuliert eine CPU, die es ausführt. Auf diese Weise führt die reale CPU den Interpretercode aus, der die virtuelle CPU emuliert. Das ist schmerzhaft langsam. Einzelne virtuelle Befehle, die zwei Zahlen hinzufügen, erfordern drei Funktionsaufrufe und viele andere Operationen. Einzelner virtueller Befehl benötigt einige echte Anweisungen zur Ausführung. Dies ist auch weniger speichereffizient, da Sie sowohl echte als auch emulierte Stacks, Register und Befehlszeiger haben.

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; 
     ... 

    } 
} 

Wenn einige Verfahren in mehrfach, JIT-Compiler Tritte interpretiert wird. Es werden alle virtuellen Anweisungen lesen und eine oder mehrere native Befehle erzeugen, die das gleiche tut. Hier erzeuge ich String mit Text Assembly, die zusätzliche Assemblierung zu nativen Binärkonvertierungen erfordern würde.

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; 
     ... 

    } 
} 

Nach nativen Code erzeugt wird, wird JVM es irgendwo kopieren, markieren Sie diese Region als ausführbare Datei und weisen Sie den Interpreter es aufzurufen, statt diese Methode Bytecode beim nächsten Mal der Interpretation aufgerufen wird. Einzelner virtueller Befehl kann immer noch mehr als einen nativen Befehl benötigen, aber dies wird fast genauso schnell sein wie vor dem Zeitpunkt der Kompilierung zu nativem Code (wie in C oder C++). Die Kompilierung ist normalerweise viel langsamer als das Interpretieren, muss aber nur einmal und nur für ausgewählte Methoden durchgeführt werden.

+0

Aber wie emuliert es? –

+1

Sehen Sie sich das erste Code-Snippet an. Anstatt die CPU in Silikon mit Logikgattern und Flip-Flops zu erstellen, wird dies in einer höheren Programmiersprache unter Verwendung von Steuerstrukturen und Variablen durchgeführt. –

+3

Basierend auf Ihrem ersten Satz wollte ich upvote, aber dann stieß ich auf "erfordert drei Funktionsaufrufe", die einfach unwahr ist. Sie nehmen nur an, dass die Stapeloperationen Funktionsaufrufe sind. In einem echten Dolmetscher wären sie nicht. Der interpretierende Overhead muss nur aus dem Abruf- und dem Versandzyklus bestehen. Alles andere ist oder sollte dasselbe sein. – EJP

Verwandte Themen