Si l'on a besoin de JVM différentes pour différentes architectures, je ne peux pas comprendre quelle est la logique derrière l'introduction de ce concept. Dans d'autres langages, nous avons besoin de différents compilateurs pour différentes machines, mais en Java nous avons besoin de JVM différentes, alors quelle est la logique derrière l'introduction du concept d'une JVM ou de cette étape supplémentaire ??
La logique est que le bytecode JVM est beaucoup plus simple que Java code source.
Les compilateurs peuvent être considérés, à un niveau très abstrait, comme ayant trois parties fondamentales: l'analyse, l'analyse sémantique et la génération de code.
L'analyse consiste à lire le code et à le transformer en une représentation arborescente dans la mémoire du compilateur. L'analyse sémantique est la partie où elle analyse cet arbre, comprend ce que cela signifie et simplifie toutes les constructions de haut niveau jusqu'aux constructions de niveau inférieur. Et la génération de code prend l'arborescence simplifiée et l'écrit dans une sortie plate.
Avec un fichier de bytecode, la phase d'analyse est grandement simplifiée, car elle est écrite dans le même format de flux d'octets plats que le JIT utilise, plutôt que dans un langage source récursif (structuré en arbre). De plus, une grande partie de la lourde tâche de l'analyse sémantique a déjà été effectuée par le compilateur Java (ou autre langage). Tout ce qu'il a à faire est donc de lire le code en continu, de faire un minimum l'analyse et l'analyse sémantique minimale, puis effectuer la génération de code.
Cela rend la tâche que le JIT doit effectuer beaucoup plus simple, et donc beaucoup plus rapide à exécuter, tout en préservant les métadonnées de haut niveau et les informations sémantiques qui permettent théoriquement d'écrire du code multiplateforme à source unique.
Les représentations intermédiaires de divers types sont de plus en plus courantes dans la conception de compilateur/runtime, pour plusieurs raisons.
Dans le cas de Java, la raison numéro un au départ était probablement portabilité: Java a été initialement commercialisé comme "Write Once, Run Anywhere". Bien que vous puissiez y parvenir en distribuant le code source et l'utilisation de différents compilateurs pour cibler différentes plates-formes, cela a quelques inconvénients:
Les autres avantages d'une représentation intermédiaire incluent:
On dirait que vous vous demandez pourquoi nous ne distribuons pas seulement du code source. Permettez-moi de retourner cette question: pourquoi ne distribuons-nous pas simplement du code machine?
De toute évidence, la réponse ici est que Java, par conception, ne suppose pas qu'il sait quelle est la machine où votre code s'exécutera; il peut s'agir d'un ordinateur de bureau, d'un super-ordinateur, d'un téléphone ou de tout ce qui se situe entre et au-delà. Java laisse la place au compilateur JVM local pour faire son travail. En plus d'augmenter la portabilité de votre code, cela a l'avantage de permettre au compilateur de faire des choses comme profiter de la machine- des optimisations spécifiques, si elles existent, ou produisent toujours au moins du code de travail dans le cas contraire. Des choses comme SSE les instructions ou l'accélération matérielle ne peuvent être utilisées que sur les machines qui les soutiennent.
Vu sous cet angle, le raisonnement pour utiliser le code octet sur le code source brut est plus clair. Se rapprocher le plus possible du langage machine brut nous permet de réaliser ou de réaliser partiellement certains des avantages du code machine, tels que:
Notez que je ne mentionne pas une exécution plus rapide. Le code source et le code d'octet sont ou peuvent (en théorie) être entièrement compilés dans le même code machine pour une exécution réelle.
De plus, le code octet permet certaines améliorations par rapport au code machine. Bien sûr, il y a l'indépendance de la plate-forme et les optimisations spécifiques au matériel que j'ai mentionnées plus tôt, mais il y a aussi des choses comme l'entretien du compilateur JVM pour produire de nouveaux chemins d'exécution à partir de l'ancien code. Cela peut être pour corriger les problèmes de sécurité, ou si de nouvelles optimisations sont découvertes, ou pour tirer parti des nouvelles instructions matérielles. Dans la pratique, il est rare de voir de grands changements de cette façon, car cela peut exposer des bogues, mais c'est possible, et c'est quelque chose qui se produit de petites manières tout le temps.
Il semble y avoir au moins deux questions différentes possibles ici. L'un concerne vraiment les compilateurs en général, avec Java essentiellement un exemple du genre. L'autre est plus spécifique à Java les codes d'octets spécifiques qu'il utilise.
Examinons d'abord la question générale: pourquoi un compilateur utiliserait-il une représentation intermédiaire dans le processus de compilation du code source pour s'exécuter sur un processeur particulier?
Une réponse à cela est assez simple: il convertit un problème O (N * M) en un problème O (N + M).
Si on nous donne N langues sources et M cibles, et que chaque compilateur est complètement indépendant, alors nous avons besoin de N * M compilateurs pour traduire toutes ces langues sources vers toutes ces cibles (où une "cible" est quelque chose comme une combinaison d'un processeur et OS).
Si, cependant, tous ces compilateurs s'accordent sur une représentation intermédiaire commune, alors nous pouvons avoir N frontaux de compilateur qui traduisent les langues source en représentation intermédiaire, et M backends de compilateur qui traduisent la représentation intermédiaire en quelque chose qui convient à une cible spécifique.
Mieux encore, il sépare le problème en deux domaines plus ou moins exclusifs. Les personnes qui connaissent/se soucient de la conception du langage, de l'analyse et des choses de ce genre peuvent se concentrer sur les frontaux du compilateur, tandis que les personnes qui connaissent les jeux d'instructions, la conception du processeur et des choses de ce genre peuvent se concentrer sur le backend.
Ainsi, par exemple, étant donné quelque chose comme LLVM, nous avons beaucoup de frontaux pour différentes langues différentes. Nous avons également des back-ends pour de nombreux processeurs différents. Un spécialiste de la langue peut écrire un nouveau front-end pour sa langue et prendre en charge rapidement de nombreuses cibles. Un processeur peut écrire un nouveau back-end pour sa cible sans avoir à gérer la conception du langage, l'analyse, etc.
La séparation des compilateurs en un front-end et un back-end, avec une représentation intermédiaire pour communiquer entre les deux n'est pas originale avec Java. C'est une pratique assez courante depuis longtemps (depuis bien avant Java est arrivé, de toute façon).
Dans la mesure où Java a ajouté quelque chose de nouveau à cet égard, c'était dans le modèle de distribution. En particulier, même si les compilateurs ont été séparés en composants front-end et back-end en interne pendant une longue période temps, ils étaient généralement distribués en tant que produit unique. Par exemple, si vous avez acheté un compilateur Microsoft C, en interne, il avait un "C1" et un "C2", qui étaient respectivement le front-end et le back-end - mais quoi que vous avez acheté était juste "Microsoft C" qui comprenait les deux pièces (avec un "pilote de compilateur" qui coordonnait les opérations entre les deux). Même si le compilateur était construit en deux pièces, pour un développeur normal utilisant le compilateur, ce n'était qu'une seule chose qui traduit du code source en code objet, sans rien visible entre les deux.
Java, à la place, a distribué le frontal dans le Java, et le back-end dans la machine virtuelle Java. Chaque Java avait un back-end de compilateur pour cibler le système qu'il utilisait. Java distribuaient du code au format intermédiaire, donc quand un utilisateur le chargeait, la JVM faisait tout ce qui était nécessaire pour l'exécuter sur leur machine particulière.
Notez que ce modèle de distribution n'était pas entièrement nouveau non plus. Par exemple, le système P UCSD fonctionnait de la même manière: les frontaux du compilateur produisaient du code P, et chaque copie du système P incluait une machine virtuelle qui faisait le nécessaire pour exécuter le code P sur cette cible particulière1.
Le code d'octet Java est assez similaire au code P. Il s'agit essentiellement d'instructions pour une machine assez simple. Cette machine est destinée à être une abstraction des machines existantes, il est donc assez facile de la traduire rapidement vers presque n'importe quelle cible spécifique. La facilité de traduction a été importante au début car l'intention initiale était d'interpréter les codes d'octets, un peu comme P-System l'avait fait (et, oui, c'est exactement comme cela que les premières implémentations ont fonctionné).
Le code d'octet Java est facile à produire pour un frontal de compilateur. Si (par exemple) vous avez un arbre assez typique représentant une expression, il est généralement assez facile de parcourir l'arbre et de générer du code assez directement à partir de ce que vous trouvez à chaque nœud.
Les codes d'octets Java sont assez compacts - dans la plupart des cas, beaucoup plus compacts que le code source ou le code machine pour la plupart des processeurs typiques (et, en particulier pour la plupart des processeurs RISC, tels que le SPARC that Sun a vendu lors de la conception de Java), ce qui était particulièrement important à l'époque, car l'une des principales intentions de Java était de prendre en charge les applets - du code intégré dans des pages Web qui seraient téléchargées avant l'exécution - à une époque où la plupart des gens accédaient au we via des modems via des lignes téléphoniques à environ 28,8 kilobits par seconde (bien que, bien sûr, il y avait encore pas mal de personnes utilisant des modems plus anciens et plus lents).
La principale faiblesse de Java codes d'octets est qu'ils ne sont pas particulièrement expressifs. Bien qu'ils puissent exprimer les concepts présents dans Java assez bien, ils ne le font pas fonctionnent presque aussi bien pour exprimer des concepts qui ne font pas partie de Java. De même, bien qu'il soit facile d'exécuter des codes d'octets sur la plupart des machines, c'est beaucoup plus difficile à faire d'une manière qui tire pleinement parti d'une machine particulière.
Par exemple, il est assez routinier que si vous voulez vraiment optimiser les codes d'octets Java, vous faites essentiellement de la rétro-ingénierie pour les traduire en arrière à partir d'une représentation semblable à un code machine et les retourner en SSA instructions (ou quelque chose de similaire)2. Vous manipulez ensuite les instructions SSA pour effectuer votre optimisation, puis traduisez à partir de là quelque chose qui cible l'architecture qui vous tient vraiment à cœur. Cependant, même avec ce processus plutôt complexe, certains concepts qui sont étrangers à Java sont suffisamment difficiles à exprimer qu'il est difficile de traduire à partir de certains langages source en code machine qui s'exécute (même de près) de manière optimale sur la plupart des machines typiques.
Si vous demandez pourquoi utiliser des représentations intermédiaires en général, deux facteurs principaux sont:
Si vous posez des questions sur les détails des codes d'octets Java, et pourquoi ils ont choisi cette représentation particulière au lieu d'une autre, alors je dirais que la réponse revient en grande partie à leur intention initiale et les limites du Web à l'époque, conduisant aux priorités suivantes:
Être capable de représenter de nombreuses langues ou de s'exécuter de manière optimale sur une grande variété d'objectifs était des priorités beaucoup plus faibles (si elles étaient considérées comme des priorités).
À l'origine, la JVM était un pur interprète. Et vous obtenez l'interprète le plus performant si la langue que vous interprétez est aussi simple que possible. C'était le but du code d'octet: fournir une entrée interprétable de manière efficace à l'environnement d'exécution. Cette seule décision a placé Java plus près d'un langage compilé que d'un langage interprété, à en juger par ses performances.
Ce n'est que plus tard, lorsqu'il est devenu évident que les performances des JVM d'interprétation étaient toujours nulles, que les gens ont investi l'effort pour créer des compilateurs juste à temps performants. Cela a quelque peu réduit l'écart avec les langages plus rapides comme le C et le C++. (Certains Java problèmes de vitesse inhérents restent cependant, donc vous n'obtiendrez probablement jamais un Java qui fonctionne aussi bien qu'un code C bien écrit.)
Bien sûr, avec les techniques de compilation juste à temps à portée de main, nous pourrions revenir à la distribution effective du code source et à la compilation juste à temps en code machine. Cependant, cela diminuerait considérablement les performances de démarrage jusqu'à ce que toutes les parties pertinentes du code soient compilées. Le code d'octets est toujours une aide importante ici car il est tellement plus simple à analyser que le code équivalent Java.
Le sens est que la compilation du code octet en code machine est plus rapide que l'interprétation de votre code d'origine en code machine juste à temps. Mais nous avons besoin d'interprétations pour rendre notre application multiplateforme, car nous voulons utiliser notre code d'origine sur chaque plate-forme sans modifications et sans aucune préparation (compilations). Ainsi, javac compile d'abord notre source en code octet, puis nous pouvons exécuter ce code octet n'importe où et il sera interprété par Java Machine virtuelle pour coder machine plus rapidement. La réponse: cela fait gagner du temps.
En plus des avantages que d'autres personnes ont soulignés, le bytecode est beaucoup plus petit, il est donc plus facile à distribuer et à mettre à jour et prend moins de place dans l'environnement cible. Ceci est particulièrement important dans les environnements fortement limités en espace.
Cela facilite également la protection du code source protégé par des droits d'auteur.