Si uno necesita diferentes JVM para diferentes arquitecturas, no puedo entender cuál es la lógica detrás de la introducción de este concepto. En otros idiomas, necesitamos diferentes compiladores para diferentes máquinas, pero en Java requerimos diferentes JVM, ¿cuál es la lógica detrás de la introducción del concepto de JVM o este paso adicional?
37
Respuestas:
La lógica es que el código de bytes JVM es mucho más simple que el código fuente de Java.
Se puede pensar que los compiladores, en un nivel muy abstracto, tienen tres partes básicas: análisis, análisis semántico y generación de código.
El análisis consiste en leer el código y convertirlo en una representación de árbol dentro de la memoria del compilador. El análisis semántico es la parte en la que analiza este árbol, descubre lo que significa y simplifica todas las construcciones de alto nivel a las de nivel inferior. Y la generación de código toma el árbol simplificado y lo escribe en una salida plana.
Con un archivo de código de bytes, la fase de análisis se simplifica enormemente, ya que está escrita en el mismo formato de flujo de bytes plano que utiliza el JIT, en lugar de un lenguaje fuente recursivo (estructurado en árbol). Además, gran parte del trabajo pesado del análisis semántico ya lo ha realizado el compilador de Java (u otro lenguaje). Por lo tanto, todo lo que tiene que hacer es leer el código en secuencia, realizar un análisis mínimo y un análisis semántico mínimo, y luego realizar la generación del código.
Esto hace que la tarea que el JIT debe realizar sea mucho más simple y, por lo tanto, mucho más rápida de ejecutar, al tiempo que conserva los metadatos de alto nivel y la información semántica que hace posible escribir teóricamente código multiplataforma de fuente única.
fuente
Las representaciones intermedias de varios tipos son cada vez más comunes en el diseño del compilador / tiempo de ejecución, por algunas razones.
En el caso de Java, la razón número uno inicialmente era probablemente la portabilidad : Java se comercializó inicialmente como "Escribir una vez, ejecutar en cualquier lugar". Si bien puede lograr esto distribuyendo el código fuente y utilizando diferentes compiladores para apuntar a diferentes plataformas, esto tiene algunas desventajas:
Otras ventajas de una representación intermedia incluyen:
fuente
Parece que te estás preguntando por qué no solo distribuimos el código fuente. Permítanme cambiar esa pregunta: ¿por qué no solo distribuimos código de máquina?
Claramente, la respuesta aquí es que Java, por diseño, no asume que sabe cuál es la máquina donde se ejecutará su código; podría ser una computadora de escritorio, una supercomputadora, un teléfono o cualquier cosa que esté en el medio y más allá. Java deja espacio para que el compilador JVM local haga lo suyo. Además de aumentar la portabilidad de su código, tiene el beneficio de permitir que el compilador haga cosas como aprovechar las optimizaciones específicas de la máquina, si existen, o aún producir al menos código de trabajo si no lo hacen. Cosas como las instrucciones SSE o la aceleración de hardware se pueden usar solo en las máquinas que las admiten.
Visto desde esta perspectiva, el razonamiento para usar el código de bytes sobre el código fuente sin procesar es más claro. Acercarse lo más posible al lenguaje de máquina sin procesar nos permite darnos cuenta o darnos cuenta parcialmente de algunos de los beneficios del código de máquina, como:
Tenga en cuenta que no menciono una ejecución más rápida. Tanto el código fuente como el código de bytes son o pueden (en teoría) compilarse completamente en el mismo código de máquina para la ejecución real.
Además, el código de bytes permite algunas mejoras sobre el código de máquina. Por supuesto, existe la independencia de la plataforma y las optimizaciones específicas del hardware que mencioné anteriormente, pero también hay cosas como dar servicio al compilador JVM para producir nuevas rutas de ejecución a partir del código antiguo. Esto puede ser para parchear problemas de seguridad, o si se descubren nuevas optimizaciones, o para aprovechar las nuevas instrucciones de hardware. En la práctica, es raro ver grandes cambios de esta manera, porque puede exponer errores, pero es posible, y es algo que sucede de manera pequeña todo el tiempo.
fuente
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:
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:
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).
fuente
Además de las ventajas que otras personas han señalado, el código de bytes es mucho más pequeño, por lo que es más fácil de distribuir y actualizar, y ocupa menos espacio en el entorno de destino. Esto es especialmente importante en entornos con mucho espacio limitado.
También facilita la protección del código fuente protegido por derechos de autor.
fuente
El sentido es que compilar el código de bytes en código máquina es más rápido que interpretar el código original en código máquina justo a tiempo. Pero necesitamos interpretaciones para hacer que nuestra aplicación sea multiplataforma, porque queremos usar nuestro código original en cada plataforma sin cambios y sin ninguna preparación (compilaciones). Entonces, primero Java compila nuestra fuente en código de bytes, luego podemos ejecutar este código de bytes en cualquier lugar y será interpretado por Java Virtual Machine para codificar el código más rápidamente. La respuesta: ahorra tiempo.
fuente
Originalmente, la JVM era un intérprete puro . Y obtendrá el mejor intérprete si el idioma que está interpretando es lo más simple posible. Ese era el objetivo del código de bytes: proporcionar una entrada eficientemente interpretable al entorno de tiempo de ejecución. Esta única decisión colocó a Java más cerca de un lenguaje compilado que de un lenguaje interpretado, a juzgar por su rendimiento.
Solo más tarde, cuando se hizo evidente que el rendimiento de las JVM de interpretación todavía apestaba, las personas invirtieron el esfuerzo para crear compiladores just-in-time que funcionen bien. Esto cerró un poco la brecha a lenguajes más rápidos como C y C ++. (Sin embargo, persisten algunos problemas de velocidad inherentes a Java, por lo que probablemente nunca obtendrá un entorno Java que funcione tan bien como el código C escrito).
Por supuesto, con las técnicas de compilación justo a tiempo, podríamos volver a distribuir el código fuente y compilarlo justo a tiempo en el código de máquina. Sin embargo, esto disminuiría considerablemente el rendimiento de inicio hasta que se compilen todas las partes relevantes del código. El código de bytes sigue siendo de gran ayuda porque es mucho más sencillo de analizar que el código Java equivalente.
fuente
El código fuente del texto es una estructura que pretende ser fácil de leer y modificar por un humano.
El código de bytes es una estructura que pretende ser fácil de leer y ejecutar por una máquina.
Dado que todo lo que JVM hace con el código es leerlo y ejecutarlo, el código de bytes es mejor para el consumo de JVM.
Noto que todavía no ha habido ningún ejemplo. Pseudo ejemplos tontos:
Por supuesto, el código de bytes no se trata solo de optimizaciones. Una gran parte de esto se trata de poder ejecutar código sin tener que preocuparse por reglas complicadas, como verificar si la clase contiene un miembro llamado "foo" en algún lugar más abajo en el archivo cuando un método se refiere a "foo".
fuente