Máquina virtual Java: el compilador JIT

En este capítulo, aprenderemos sobre el compilador JIT y la diferencia entre lenguajes compilados e interpretados.

Idiomas compilados frente a interpretados

Los lenguajes como C, C ++ y FORTRAN son lenguajes compilados. Su código se entrega como código binario dirigido a la máquina subyacente. Esto significa que el código de alto nivel se compila en código binario a la vez mediante un compilador estático escrito específicamente para la arquitectura subyacente. El binario que se produce no se ejecutará en ninguna otra arquitectura.

Por otro lado, los lenguajes interpretados como Python y Perl pueden ejecutarse en cualquier máquina, siempre que tengan un intérprete válido. Repasa línea por línea el código de alto nivel, convirtiéndolo en código binario.

El código interpretado suele ser más lento que el código compilado. Por ejemplo, considere un bucle. Un interpretado convertirá el código correspondiente para cada iteración del ciclo. Por otro lado, un código compilado hará que la traducción sea solo una. Además, dado que los intérpretes ven solo una línea a la vez, no pueden realizar ningún código significativo, como cambiar el orden de ejecución de declaraciones como compiladores.

Veremos un ejemplo de dicha optimización a continuación:

Adding two numbers stored in memory. Dado que acceder a la memoria puede consumir varios ciclos de CPU, un buen compilador emitirá instrucciones para obtener los datos de la memoria y ejecutar la adición solo cuando los datos estén disponibles. No esperará y, mientras tanto, ejecutará otras instrucciones. Por otro lado, tal optimización no sería posible durante la interpretación, ya que el intérprete no es consciente del código completo en un momento dado.

Pero luego, los lenguajes interpretados pueden ejecutarse en cualquier máquina que tenga un intérprete válido de ese idioma.

¿Java está compilado o interpretado?

Java intentó encontrar un término medio. Dado que la JVM se encuentra entre el compilador javac y el hardware subyacente, el compilador javac (o cualquier otro compilador) compila el código Java en Bytecode, que se entiende por una JVM específica de la plataforma. La JVM luego compila el Bytecode en binario usando la compilación JIT (Just-in-time), a medida que se ejecuta el código.

Puntos calientes

En un programa típico, solo hay una pequeña sección de código que se ejecuta con frecuencia y, a menudo, es este código el que afecta significativamente el rendimiento de toda la aplicación. Estas secciones de código se denominanHotSpots.

Si alguna sección de código se ejecuta solo una vez, compilarla sería una pérdida de esfuerzo y sería más rápido interpretar el Bytecode en su lugar. Pero si la sección es una sección activa y se ejecuta varias veces, la JVM la compilaría en su lugar. Por ejemplo, si un método se llama varias veces, los ciclos adicionales que se necesitarían para compilar el código serían compensados ​​por el binario más rápido que se genera.

Además, cuanto más ejecute la JVM un método en particular o un bucle, más información recopilará para realizar diversas optimizaciones de modo que se genere un binario más rápido.

Consideremos el siguiente código:

for(int i = 0 ; I <= 100; i++) {
   System.out.println(obj1.equals(obj2)); //two objects
}

Si se interpreta este código, el intérprete deduciría para cada iteración que las clases de obj1. Esto se debe a que cada clase en Java tiene un método .equals (), que se extiende desde la clase Object y se puede anular. Entonces, incluso si obj1 es una cadena para cada iteración, la deducción aún se realizará.

Por otro lado, lo que realmente sucedería es que la JVM notaría que para cada iteración, obj1 es de la clase String y, por lo tanto, generaría código correspondiente al método .equals () de la clase String directamente. Por lo tanto, no se requerirán búsquedas y el código compilado se ejecutará más rápido.

Este tipo de comportamiento solo es posible cuando la JVM sabe cómo se comporta el código. Por tanto, espera antes de compilar determinadas secciones del código.

A continuación se muestra otro ejemplo:

int sum = 7;
for(int i = 0 ; i <= 100; i++) {
   sum += i;
}

Un intérprete, para cada ciclo, obtiene el valor de 'suma' de la memoria, le agrega 'I' y lo almacena de nuevo en la memoria. El acceso a la memoria es una operación cara y normalmente requiere varios ciclos de CPU. Dado que este código se ejecuta varias veces, es un HotSpot. El JIT compilará este código y realizará la siguiente optimización.

Una copia local de 'suma' se almacenaría en un registro, específico para un hilo en particular. Todas las operaciones se harían al valor en el registro y cuando el ciclo se complete, el valor se volvería a escribir en la memoria.

¿Qué sucede si otros subprocesos también acceden a la variable? Dado que algún otro hilo está realizando actualizaciones en una copia local de la variable, verían un valor obsoleto. La sincronización de subprocesos es necesaria en tales casos. Una primitiva de sincronización muy básica sería declarar 'suma' como volátil. Ahora, antes de acceder a una variable, un hilo vaciaría sus registros locales y obtendría el valor de la memoria. Después de acceder a él, el valor se escribe inmediatamente en la memoria.

A continuación se muestran algunas optimizaciones generales que realizan los compiladores JIT:

  • Método de inserción
  • Eliminación de código muerto
  • Heurística para optimizar los sitios de llamadas
  • Plegado constante