Wenn man verschiedene JVMs für verschiedene Architekturen benötigt, kann ich nicht herausfinden, welche Logik hinter der Einführung dieses Konzepts steckt. In anderen Sprachen benötigen wir unterschiedliche Compiler für unterschiedliche Maschinen, aber in Java benötigen wir unterschiedliche JVMs. Welche Logik steckt also hinter der Einführung des Konzepts einer JVM oder dieses zusätzlichen Schritts?
37
Antworten:
Die Logik ist, dass JVM-Bytecode viel einfacher ist als Java-Quellcode.
Man kann sich Compiler auf einer sehr abstrakten Ebene mit drei grundlegenden Teilen vorstellen: Parsen, semantische Analyse und Codegenerierung.
Beim Parsen wird der Code gelesen und in eine Baumdarstellung im Speicher des Compilers umgewandelt. Semantische Analyse ist der Teil, in dem dieser Baum analysiert wird, herausgefunden wird, was er bedeutet, und alle Konstrukte auf hoher Ebene bis zu Konstrukten auf niedrigerer Ebene vereinfacht werden. Bei der Codegenerierung wird der vereinfachte Baum in eine flache Ausgabe geschrieben.
Mit einer Bytecode-Datei wird die Parsing-Phase erheblich vereinfacht, da sie nicht in einer rekursiven (baumstrukturierten) Quellsprache, sondern im gleichen Flat-Byte-Stream-Format wie die JIT geschrieben wird. Außerdem hat der Java-Compiler (oder ein Compiler in einer anderen Sprache) bereits einen Großteil der umfangreichen semantischen Analyse durchgeführt. Sie müssen also nur den Code per Stream lesen, minimales Parsing und minimale semantische Analyse durchführen und dann den Code generieren.
Dies macht die Aufgabe, die das JIT zu erfüllen hat, viel einfacher und daher viel schneller, während die Metadaten und semantischen Informationen auf hoher Ebene erhalten bleiben, die das theoretische Schreiben von plattformübergreifendem Code aus einer Quelle ermöglichen.
quelle
Intermediate-Darstellungen verschiedener Art sind im Compiler / Runtime-Design aus mehreren Gründen immer häufiger anzutreffen.
In Javas Fall war der Hauptgrund anfangs wahrscheinlich die Portabilität : Java wurde anfangs stark als "Write Once, Run Anywhere" vermarktet. Sie können dies zwar erreichen, indem Sie den Quellcode verteilen und verschiedene Compiler für verschiedene Plattformen verwenden, dies hat jedoch einige Nachteile:
Weitere Vorteile einer Zwischendarstellung sind:
quelle
Es hört sich so an, als würden Sie sich fragen, warum wir nicht nur Quellcode vertreiben. Lassen Sie mich diese Frage umkehren: Warum verteilen wir nicht einfach Maschinencode?
Die Antwort lautet hier eindeutig: Java geht von vornherein nicht davon aus, dass es weiß, auf welcher Maschine Ihr Code ausgeführt wird. Es kann sich um einen Desktop, einen Supercomputer, ein Telefon oder irgendetwas dazwischen und darüber hinaus handeln. Java lässt dem lokalen JVM-Compiler Raum, um seine Sache zu erledigen. Dies erhöht nicht nur die Portabilität Ihres Codes, sondern bietet auch den Vorteil, dass der Compiler beispielsweise die Möglichkeit hat, maschinenspezifische Optimierungen zu nutzen, falls vorhanden, oder zumindest funktionierenden Code zu erstellen, wenn dies nicht der Fall ist. Dinge wie SSE- Anweisungen oder Hardwarebeschleunigung können nur auf den Maschinen verwendet werden, die sie unterstützen.
In diesem Licht ist die Begründung für die Verwendung von Byte-Code gegenüber Roh-Quellcode klarer. Die Annäherung an die Maschinensprache ermöglicht es uns, einige der Vorteile von Maschinencode zu realisieren oder teilweise zu realisieren, wie zum Beispiel:
Beachten Sie, dass ich eine schnellere Ausführung nicht erwähne. Sowohl Quellcode als auch Bytecode sind oder können (theoretisch) zur tatsächlichen Ausführung vollständig mit demselben Maschinencode kompiliert werden.
Darüber hinaus ermöglicht der Bytecode einige Verbesserungen gegenüber dem Maschinencode. Natürlich gibt es die Plattformunabhängigkeit und hardwarespezifische Optimierungen, die ich bereits erwähnt habe, aber es gibt auch Dinge wie die Wartung des JVM-Compilers, um aus altem Code neue Ausführungspfade zu erstellen. Dies kann sein, um Sicherheitsprobleme zu beheben, oder wenn neue Optimierungen entdeckt werden, oder um neue Hardwareanweisungen zu nutzen. In der Praxis kommt es selten vor, dass große Änderungen auf diese Weise vorgenommen werden, da dies Fehler aufdecken kann. Dies ist jedoch möglich und geschieht die ganze Zeit über auf kleine Weise.
quelle
Hier scheint es mindestens zwei verschiedene mögliche Fragen zu geben. Man geht wirklich um Compiler im Allgemeinen, wobei Java im Grunde nur ein Beispiel für das Genre ist. Die andere ist spezifischer für Java als die spezifischen Byte-Codes, die es verwendet.
Compiler im Allgemeinen
Betrachten wir zunächst die allgemeine Frage: Warum sollte ein Compiler beim Kompilieren von Quellcode eine Zwischendarstellung verwenden, um auf einem bestimmten Prozessor ausgeführt zu werden?
Komplexitätsreduzierung
Eine Antwort darauf ist ziemlich einfach: Es wandelt ein O (N * M) -Problem in ein O (N + M) -Problem um.
Wenn wir N Ausgangssprachen und M Ziele haben und jeder Compiler völlig unabhängig ist, brauchen wir N * M Compiler, um alle diese Ausgangssprachen in alle diese Ziele zu übersetzen (wobei ein "Ziel" so etwas wie eine Kombination von a ist Prozessor und Betriebssystem).
Wenn sich jedoch alle diese Compiler auf eine gemeinsame Zwischendarstellung einigen, können wir N Compiler-Frontends haben, die die Ausgangssprachen in die Zwischendarstellung übersetzen, und M Compiler-Backends, die die Zwischendarstellung in etwas übersetzen, das für ein bestimmtes Ziel geeignet ist.
Problemsegmentierung
Besser noch, es unterteilt das Problem in zwei mehr oder weniger exklusive Domänen. Leute, die sich mit Sprachdesign, Parsing und ähnlichen Dingen auskennen / auskennen, können sich auf Compiler-Frontends konzentrieren, während Leute, die sich mit Befehlssätzen, Prozessordesign und ähnlichen Dingen auskennen, sich auf das Backend konzentrieren können.
So haben wir zum Beispiel für LLVM viele Frontends für verschiedene Sprachen. Wir haben auch Backends für viele verschiedene Prozessoren. Ein Sprachtyp kann ein neues Frontend für seine Sprache schreiben und schnell viele Ziele unterstützen. Ein Prozessor-Typ kann ein neues Back-End für sein Ziel schreiben, ohne sich mit Sprachdesign, Parsen usw. zu befassen.
Die Trennung von Compilern in ein Front-End und ein Back-End mit einer Zwischendarstellung für die Kommunikation zwischen beiden ist mit Java nicht original. Es ist schon lange üblich (jedenfalls lange bevor Java hinzukam).
Verteilungsmodelle
Soweit Java diesbezüglich etwas Neues hinzufügte, befand es sich im Verteilungsmodell. Insbesondere wurden Compiler, obwohl sie intern lange Zeit in Front-End- und Back-End-Teile unterteilt waren, in der Regel als einzelnes Produkt vertrieben. Wenn Sie beispielsweise einen Microsoft C-Compiler gekauft haben, hatte dieser intern ein "C1" und ein "C2", die jeweils das Front-End und das Back-End waren. Sie haben jedoch nur "Microsoft C" gekauft, das beide enthielt Stücke (mit einem "Compiler-Treiber", der Operationen zwischen den beiden koordiniert). Obwohl der Compiler zweiteilig aufgebaut war, war es für einen normalen Entwickler, der den Compiler verwendete, nur eine einzige Sache, die vom Quellcode in den Objektcode übersetzt wurde, wobei dazwischen nichts sichtbar war.
Java verteilte stattdessen das Front-End im Java Development Kit und das Back-End in der Java Virtual Machine. Jeder Java-Benutzer hatte ein Compiler-Back-End, um auf das von ihm verwendete System zuzugreifen. Java-Entwickler verteilten Code im Zwischenformat, sodass die JVM beim Laden alles Notwendige tat, um ihn auf ihrem jeweiligen Computer auszuführen.
Präzedenzfälle
Beachten Sie, dass dieses Verteilungsmodell auch nicht ganz neu war. Nur zum Beispiel funktionierte das UCSD-P-System ähnlich: Compiler-Frontends erzeugten P-Code, und jede Kopie des P-Systems enthielt eine virtuelle Maschine, die das tat, was notwendig war, um den P-Code auf diesem bestimmten Ziel 1 auszuführen .
Java-Bytecode
Java byte code ist hinreichend ähnlich zu P-code. Grundsätzlich handelt es sich um Anweisungen für eine relativ einfache Maschine. Diese Maschine soll eine Abstraktion bestehender Maschinen sein, so dass es ziemlich einfach ist, sie schnell auf fast jedes spezifische Ziel zu übersetzen. Die einfache Übersetzung war von Anfang an wichtig, da die ursprüngliche Absicht darin bestand, Bytecodes zu interpretieren, ähnlich wie es P-System getan hatte (und ja, genau so funktionierten die frühen Implementierungen).
Stärken
Java-Bytecode ist für ein Compiler-Front-End einfach zu erstellen. Wenn Sie (zum Beispiel) einen ziemlich typischen Baum haben, der einen Ausdruck darstellt, ist es normalerweise ziemlich einfach, den Baum zu durchlaufen und Code ziemlich direkt von dem zu generieren, was Sie an jedem Knoten finden.
Java-Bytecodes sind recht kompakt - in den meisten Fällen viel kompakter als der Quellcode oder der Maschinencode für die meisten typischen Prozessoren (und insbesondere für die meisten RISC-Prozessoren, wie den SPARC, den Sun bei der Entwicklung von Java verkauft hat). Dies war zu dieser Zeit besonders wichtig, da Java vor allem Applets unterstützen wollte - Code, der in Webseiten eingebettet war, die vor der Ausführung heruntergeladen wurden - zu einer Zeit, als die meisten Leute um ca. 28.8 Uhr über Modems über Telefonleitungen auf das we zugegriffen haben Kilobit pro Sekunde (obwohl es natürlich immer noch einige Leute gab, die ältere, langsamere Modems verwendeten).
Schwächen
Die größte Schwäche von Java-Bytecodes besteht darin, dass sie nicht besonders aussagekräftig sind. Obwohl sie die in Java vorhandenen Konzepte ziemlich gut ausdrücken können, funktionieren sie nicht annähernd so gut, um Konzepte auszudrücken, die nicht Teil von Java sind. Während es auf den meisten Computern einfach ist, Byte-Codes auszuführen, ist dies auf eine Weise, die die Vorteile eines bestimmten Computers voll ausnutzt, viel schwieriger.
Wenn Sie beispielsweise Java-Bytecodes wirklich optimieren möchten, müssen Sie im Grunde ein Reverse Engineering durchführen, um sie von einer maschinencodeähnlichen Darstellung rückwärts zu übersetzen und sie wieder in SSA-Anweisungen (oder etwas Ähnliches) umzuwandeln . Sie manipulieren dann die SSA-Anweisungen, um Ihre Optimierung durchzuführen, und übersetzen von dort in etwas, das auf die Architektur abzielt, die Ihnen wirklich am Herzen liegt. Selbst bei diesem ziemlich komplexen Prozess sind einige Java-fremde Konzepte so schwierig auszudrücken, dass es schwierig ist, aus einigen Quellensprachen in Maschinencode zu übersetzen, der auf den meisten typischen Maschinen (sogar nahezu) optimal ausgeführt wird.
Zusammenfassung
Wenn Sie nach dem Grund für die Verwendung von Zwischendarstellungen im Allgemeinen fragen, sind zwei Hauptfaktoren:
Wenn Sie nach den Besonderheiten der Java-Bytecodes fragen und wissen, warum diese bestimmte Darstellung anstelle einer anderen gewählt wurde, würde ich sagen, dass die Antwort weitgehend auf ihre ursprüngliche Absicht und die damaligen Einschränkungen des Webs zurückgeht , was zu folgenden Prioritäten führt:
In der Lage zu sein, viele Sprachen zu repräsentieren oder eine Vielzahl von Zielen optimal zu erfüllen, waren viel niedrigere Prioritäten (wenn sie überhaupt als Prioritäten angesehen wurden).
quelle
Zusätzlich zu den Vorteilen, auf die andere hingewiesen haben, ist Bytecode viel kleiner, sodass die Verteilung und Aktualisierung einfacher ist und weniger Platz in der Zielumgebung beansprucht. Dies ist besonders wichtig in Umgebungen mit beengten Platzverhältnissen.
Es erleichtert auch den Schutz von urheberrechtlich geschütztem Quellcode.
quelle
Der Sinn ist, dass das Kompilieren von Bytecode zu Maschinencode schneller ist, als das just-in-time-Interpretieren Ihres ursprünglichen Codes zu Maschinencode. Wir benötigen jedoch Interpretationen, um unsere Anwendung plattformübergreifend zu gestalten, da wir unseren Originalcode auf jeder Plattform ohne Änderungen und ohne Vorbereitungen (Kompilierungen) verwenden möchten. Also kompiliert Javac zuerst unseren Quellcode in Byte-Code, dann können wir diesen Byte-Code überall ausführen und er wird von Java Virtual Machine so interpretiert, dass er Code schneller verarbeitet. Die Antwort: Es spart Zeit.
quelle
Ursprünglich war die JVM ein reiner Dolmetscher . Und Sie erhalten den Dolmetscher mit der besten Leistung, wenn die von Ihnen gedolmetschte Sprache so einfach wie möglich ist. Das war das Ziel des Bytecodes: Eine effizient interpretierbare Eingabe für die Laufzeitumgebung. Diese einzige Entscheidung brachte Java näher an eine kompilierte Sprache als an eine interpretierte Sprache, gemessen an ihrer Leistung.
Erst später, als sich herausstellte, dass die Leistung der interpretierenden JVMs immer noch nicht zufriedenstellend war, wurde der Aufwand betrieben, um leistungsfähige Just-in-Time-Compiler zu erstellen. Dies schloss die Lücke zu schnelleren Sprachen wie C und C ++. (Einige Java-inhärente Geschwindigkeitsprobleme bleiben jedoch bestehen, sodass Sie wahrscheinlich niemals eine Java-Umgebung erhalten, die so gut funktioniert wie gut geschriebener C-Code.)
Natürlich können wir mit den verfügbaren Kompilierungstechniken für Just-in-Time-Code wieder auf die eigentliche Verteilung des Quellcodes und die Just-in-Time-Kompilierung in Maschinencode zurückgreifen. Dies würde jedoch die Startleistung stark verringern, bis alle relevanten Teile des Codes kompiliert sind. Der Bytecode ist hier immer noch eine wichtige Hilfe, da er viel einfacher zu analysieren ist als der entsprechende Java-Code.
quelle
Text Source Code ist eine Struktur, die für den Menschen leicht lesbar und veränderbar sein soll .
Bytecode ist eine Struktur, die leicht von einer Maschine gelesen und ausgeführt werden soll.
Da alles, was die JVM mit Code macht, gelesen und ausgeführt wird, ist Byte-Code für die JVM besser geeignet.
Mir ist aufgefallen, dass es noch keine Beispiele gegeben hat. Dumme Pseudobeispiele:
Natürlich geht es bei Bytecode nicht nur um Optimierungen. Ein großer Teil davon besteht darin, Code ausführen zu können, ohne sich um komplizierte Regeln kümmern zu müssen, z. B. zu prüfen, ob die Klasse irgendwo weiter unten in der Datei einen Member namens "foo" enthält, wenn eine Methode auf "foo" verweist.
quelle