Parece que hay al menos dos posibles preguntas diferentes aquí. Uno se trata realmente de compiladores en general, con Java básicamente solo un ejemplo del género. El otro es más específico para Java, los códigos de bytes específicos que utiliza.
Compiladores en general
Consideremos primero la pregunta general: ¿por qué un compilador usaría una representación intermedia en el proceso de compilación del código fuente para ejecutarse en algún procesador en particular?
Reducción de Complejidad
Una respuesta a eso es bastante simple: convierte un problema O (N * M) en un problema O (N + M).
Si se nos dan N idiomas de origen y M objetivos, y cada compilador es completamente independiente, entonces necesitamos N * M compiladores para traducir todos esos idiomas de origen a todos esos objetivos (donde un "objetivo" es algo así como una combinación de un procesador y sistema operativo).
Sin embargo, si todos esos compiladores están de acuerdo en una representación intermedia común, entonces podemos tener N front-end del compilador que traducen los lenguajes de origen a la representación intermedia, y M back-end del compilador que traduce la representación intermedia a algo adecuado para un objetivo específico.
Segmentación de problemas
Mejor aún, separa el problema en dos dominios más o menos exclusivos. Las personas que conocen / se preocupan por el diseño del lenguaje, el análisis y cosas por el estilo pueden concentrarse en los componentes del compilador, mientras que las personas que conocen los conjuntos de instrucciones, el diseño del procesador y cosas por el estilo pueden concentrarse en el back-end.
Entonces, por ejemplo, dado algo como LLVM, tenemos muchos front-end para varios idiomas diferentes. También tenemos back-end para muchos procesadores diferentes. Un chico de idiomas puede escribir un nuevo front-end para su idioma y rápidamente admite muchos objetivos. Un chico de procesador puede escribir un nuevo back-end para su objetivo sin tener que lidiar con el diseño del lenguaje, el análisis, etc.
Separar los compiladores en un front-end y back-end, con una representación intermedia para comunicarse entre los dos no es original con Java. Ha sido una práctica bastante común durante mucho tiempo (desde mucho antes de que apareciera Java, de todos modos).
Modelos de distribución
En la medida en que Java agregó algo nuevo a este respecto, estaba en el modelo de distribución. En particular, a pesar de que los compiladores se han separado internamente en piezas de front-end y back-end durante mucho tiempo, generalmente se distribuyeron como un solo producto. Por ejemplo, si compró un compilador de Microsoft C, internamente tenía un "C1" y un "C2", que eran el front-end y el back-end respectivamente, pero lo que compró fue solo "Microsoft C" que incluía ambos piezas (con un "controlador compilador" que coordinaba las operaciones entre los dos). A pesar de que el compilador se construyó en dos partes, para un desarrollador normal que usa el compilador, fue solo una cosa que se tradujo del código fuente al código objeto, sin nada visible en el medio.
Java, en cambio, distribuyó el front-end en el Kit de desarrollo de Java y el back-end en la Máquina virtual de Java. Cada usuario de Java tenía un back-end compilador para apuntar al sistema que estaba usando. Los desarrolladores de Java distribuyeron código en el formato intermedio, por lo que cuando un usuario lo cargó, la JVM hizo lo que fue necesario para ejecutarlo en su máquina en particular.
Precedentes
Tenga en cuenta que este modelo de distribución tampoco era completamente nuevo. Solo por ejemplo, el sistema P de UCSD funcionó de manera similar: los componentes del compilador produjeron código P, y cada copia del sistema P incluía una máquina virtual que hacía lo necesario para ejecutar el código P en ese objetivo en particular 1 .
Código de bytes Java
El código de bytes de Java es bastante similar al código P. Se trata básicamente de instrucciones para una justa máquina simple. Esa máquina está destinada a ser una abstracción de las máquinas existentes, por lo que es bastante fácil traducir rápidamente a casi cualquier objetivo específico. La facilidad de traducción fue importante desde el principio porque la intención original era interpretar los códigos de bytes, como lo había hecho P-System (y sí, así es exactamente como funcionaban las primeras implementaciones).
Fortalezas
El código de bytes de Java es fácil de producir para un compilador front-end. Si (por ejemplo) tiene un árbol bastante típico que representa una expresión, generalmente es bastante fácil atravesar el árbol y generar código bastante directamente a partir de lo que encuentra en cada nodo.
Los códigos de bytes de Java son bastante compactos, en la mayoría de los casos, mucho más compactos que el código fuente o el código de máquina para la mayoría de los procesadores típicos (y, especialmente para la mayoría de los procesadores RISC, como el SPARC que Sun vendió cuando diseñaron Java). Esto fue particularmente importante en ese momento, porque una de las principales intenciones de Java era admitir applets (código incrustado en páginas web que se descargarían antes de la ejecución) en un momento en que la mayoría de las personas accedían a nosotros a través de módems a través de líneas telefónicas a aproximadamente 28.8 kilobits por segundo (aunque, por supuesto, todavía había bastantes personas que usaban módems más antiguos y más lentos).
Debilidades
La principal debilidad de los códigos de bytes de Java es que no son particularmente expresivos. Aunque pueden expresar los conceptos presentes en Java bastante bien, no funcionan tan bien para expresar conceptos que no son parte de Java. Del mismo modo, si bien es fácil ejecutar códigos de bytes en la mayoría de las máquinas, es mucho más difícil hacerlo de una manera que aproveche al máximo cualquier máquina en particular.
Por ejemplo, es bastante rutinario que si realmente desea optimizar los códigos de bytes de Java, básicamente realice una ingeniería inversa para traducirlos hacia atrás desde una representación similar a un código de máquina, y volverlos a convertir en instrucciones SSA (o algo similar) 2 . Luego manipulas las instrucciones de la SSA para hacer tu optimización, luego traduces desde allí a algo que se dirija a la arquitectura que realmente te importa. Sin embargo, incluso con este proceso bastante complejo, algunos conceptos que son ajenos a Java son lo suficientemente difíciles de expresar que es difícil traducir de algunos lenguajes de origen a código de máquina que se ejecuta (incluso cerca) de manera óptima en la mayoría de las máquinas típicas.
Resumen
Si está preguntando por qué usar representaciones intermedias en general, dos factores principales son:
- Reduzca un problema de O (N * M) a un problema de O (N + M), y
- Divide el problema en piezas más manejables.
Si está preguntando acerca de los detalles de los códigos de bytes de Java y por qué eligieron esta representación en particular en lugar de otra, entonces diría que la respuesta se debe en gran medida a su intención original y las limitaciones de la web en ese momento , lo que lleva a las siguientes prioridades:
- Representación compacta.
- Rápido y fácil de decodificar y ejecutar.
- Rápido y fácil de implementar en las máquinas más comunes.
Poder representar muchos idiomas o ejecutar de manera óptima en una amplia variedad de objetivos eran prioridades mucho más bajas (si se consideraban prioridades).
- Entonces, ¿por qué se olvida principalmente el sistema P? Principalmente una situación de precios. El sistema P se vendió bastante decente en Apple II, Commodore SuperPets, etc. Cuando salió la PC de IBM, el sistema P era un sistema operativo compatible, pero MS-DOS cuesta menos (desde el punto de vista de la mayoría de las personas, esencialmente se lanzó de forma gratuita) y rápidamente tenía más programas disponibles, ya que es para lo que escribieron Microsoft e IBM (entre otros).
- Por ejemplo, así es como funciona el hollín .