Este artículo será el segundo artículo de la serie de optimización del rendimiento de JVM (el primer artículo: Portal), y el compilador de Java será el contenido principal que se analiza en este artículo.
En este artículo, la autora (Eva Andreasson) presenta por primera vez diferentes tipos de compiladores y compara el rendimiento de ejecución de la compilación del lado del cliente, el compilador del lado del servidor y la compilación multicapa. Luego, al final del artículo, se presentan varios métodos comunes de optimización de JVM, como la eliminación de código muerto, la incrustación de código y la optimización del cuerpo del bucle.
La característica más orgullosa de Java, la "independencia de plataforma", se origina en el compilador de Java. Los desarrolladores de software hacen todo lo posible para escribir las mejores aplicaciones Java posibles y un compilador se ejecuta detrás de escena para producir código ejecutable eficiente basado en la plataforma de destino. Diferentes compiladores son adecuados para diferentes requisitos de aplicación, lo que produce diferentes resultados de optimización. Por lo tanto, si puede comprender mejor cómo funcionan los compiladores y conocer más tipos de compiladores, podrá optimizar mejor su programa Java.
Este artículo destaca y explica las diferencias entre los distintos compiladores de máquinas virtuales Java. Al mismo tiempo, también analizaré algunas soluciones de optimización comúnmente utilizadas por los compiladores justo a tiempo (JIT).
¿Qué es un compilador?
En pocas palabras, un compilador toma un programa en lenguaje de programación como entrada y otro programa en lenguaje ejecutable como salida. Javac es el compilador más común. Existe en todos los JDK. Javac toma el código Java como salida y lo convierte en código ejecutable JVM: código de bytes. Estos códigos de bytes se almacenan en archivos que terminan en .class y se cargan en el entorno de ejecución de Java cuando se inicia el programa Java.
La CPU no puede leer el código de bytes directamente. También debe traducirse a un lenguaje de instrucciones de máquina que la plataforma actual pueda entender. Hay otro compilador en la JVM que se encarga de traducir el código de bytes en instrucciones ejecutables por la plataforma de destino. Algunos compiladores JVM requieren varios niveles de etapas de código de bytes. Por ejemplo, es posible que un compilador deba pasar por varias formas diferentes de etapas intermedias antes de traducir el código de bytes en instrucciones de máquina.
Desde una perspectiva independiente de la plataforma, queremos que nuestro código sea lo más independiente posible de la plataforma.
Para lograr esto, trabajamos en el último nivel de traducción, desde la representación de código de bytes más bajo hasta el código de máquina real, que realmente vincula el código ejecutable a la arquitectura de una plataforma específica. Desde el nivel más alto, podemos dividir los compiladores en compiladores estáticos y compiladores dinámicos. Podemos elegir el compilador apropiado según nuestro entorno de ejecución objetivo, los resultados de optimización que deseamos y las limitaciones de recursos que debemos cumplir. En el artículo anterior analizamos brevemente los compiladores estáticos y los compiladores dinámicos, y en las siguientes secciones los explicaremos con más profundidad.
Compilación estática VS compilación dinámica
El javac que mencionamos anteriormente es un ejemplo de compilación estática. Con un compilador estático, el código de entrada se interpreta una vez y la salida es la forma en que se ejecutará el programa en el futuro. A menos que actualice el código fuente y lo vuelva a compilar (a través del compilador), el resultado de la ejecución del programa nunca cambiará: esto se debe a que la entrada es una entrada estática y el compilador es un compilador estático.
Con compilación estática, el siguiente programa:
Copie el código de código de la siguiente manera:
staticint add7(int x ){ return x+7;}
se convertirá en código de bytes similar al siguiente:
Copie el código de código de la siguiente manera:
iload0 bipush 7 iadd ireturn
Un compilador dinámico compila dinámicamente un idioma en otro idioma. La llamada dinámica se refiere a compilar mientras el programa se está ejecutando, ¡compilando mientras se ejecuta! La ventaja de la compilación y optimización dinámicas es que puede manejar algunos cambios cuando se carga la aplicación. El tiempo de ejecución de Java a menudo se ejecuta en entornos impredecibles o incluso cambiantes, por lo que la compilación dinámica es muy adecuada para el tiempo de ejecución de Java. La mayoría de las JVM utilizan compiladores dinámicos, como los compiladores JIT. Vale la pena señalar que la compilación dinámica y la optimización del código requieren el uso de algunas estructuras de datos, subprocesos y recursos de CPU adicionales. Cuanto más avanzado sea el optimizador o el analizador de contexto de código de bytes, más recursos consumirá. Pero estos costos son insignificantes en comparación con las importantes mejoras de rendimiento.
Tipos de JVM e independencia de plataforma de Java
Una característica común de todas las implementaciones de JVM es compilar código de bytes en instrucciones de máquina. Algunas JVM interpretan el código cuando se carga la aplicación y utilizan contadores de rendimiento para encontrar código "activo"; otras lo hacen mediante compilación; El principal problema de la compilación es que la centralización requiere muchos recursos, pero también conduce a mejores optimizaciones del rendimiento.
Si eres nuevo en Java, las complejidades de la JVM definitivamente te confundirán. ¡Pero la buena noticia es que no es necesario que lo descubras! La JVM gestionará la compilación y optimización del código, y usted no necesita preocuparse por las instrucciones de la máquina ni por cómo escribir el código para que coincida mejor con la arquitectura de la plataforma en la que se ejecuta el programa.
Del código de bytes de Java al ejecutable
Una vez que su código Java se compila en código de bytes, el siguiente paso es traducir las instrucciones del código de bytes a código de máquina. Este paso se puede implementar a través de un intérprete o mediante un compilador.
explicar
La interpretación es la forma más sencilla de compilar código de bytes. El intérprete encuentra la instrucción de hardware correspondiente a cada instrucción de código de bytes en forma de tabla de búsqueda y luego la envía a la CPU para su ejecución.
Puede pensar en el intérprete como un diccionario: para cada palabra específica (instrucción de código de bytes), hay una traducción específica (instrucción de código de máquina) correspondiente. Debido a que el intérprete ejecuta inmediatamente una instrucción cada vez que la lee, este método no puede optimizar un conjunto de instrucciones. Al mismo tiempo, cada vez que se llama a un código de bytes, debe interpretarse inmediatamente, por lo que el intérprete se ejecuta muy lentamente. El intérprete ejecuta código de manera muy precisa, pero debido a que el conjunto de instrucciones de salida no está optimizado, es posible que no produzca resultados óptimos para el procesador de la plataforma de destino.
compilar
El compilador carga todo el código que se ejecutará en el tiempo de ejecución. De esta manera puede hacer referencia a todo o parte del contexto de tiempo de ejecución cuando traduce el código de bytes. Las decisiones que toma se basan en los resultados del análisis del gráfico de código. Como comparar diferentes ramas de ejecución y hacer referencia a datos de contexto de tiempo de ejecución.
Una vez que la secuencia de código de bytes se traduce a un conjunto de instrucciones de código de máquina, se puede realizar la optimización en función de este conjunto de instrucciones de código de máquina. El conjunto de instrucciones optimizado se almacena en una estructura llamada búfer de código. Cuando estos códigos de bytes se ejecutan nuevamente, el código optimizado se puede obtener directamente de este búfer de código y ejecutar. En algunos casos, el compilador no utiliza el optimizador para optimizar el código, sino que utiliza una nueva secuencia de optimización: "recuento de rendimiento".
La ventaja de utilizar un caché de código es que las instrucciones del conjunto de resultados se pueden ejecutar inmediatamente sin necesidad de reinterpretación o compilación.
Esto puede reducir en gran medida el tiempo de ejecución, especialmente para aplicaciones Java donde un método se llama varias veces.
mejoramiento
Con la introducción de la compilación dinámica, tenemos la oportunidad de insertar contadores de rendimiento. Por ejemplo, el compilador inserta un contador de rendimiento que se incrementa cada vez que se llama a un bloque de código de bytes (correspondiente a un método específico). El compilador utiliza estos contadores para encontrar "bloques activos" para poder determinar qué bloques de código se pueden optimizar para brindar la mayor mejora de rendimiento a la aplicación. Los datos del análisis del rendimiento en tiempo de ejecución pueden ayudar al compilador a tomar más decisiones de optimización en el estado en línea, mejorando así aún más la eficiencia de ejecución del código. Debido a que obtenemos datos de análisis de rendimiento del código cada vez más precisos, podemos encontrar más puntos de optimización y tomar mejores decisiones de optimización, como: cómo secuenciar mejor las instrucciones y si utilizar un conjunto de instrucciones más eficiente. Reemplace el conjunto de instrucciones original. si eliminar operaciones redundantes, etc.
Por ejemplo
Considere el siguiente código Java Copiar código El código es el siguiente:
staticint add7(int x ){ return x+7;}
Javac lo traducirá estáticamente al siguiente código de bytes:
Copie el código de código de la siguiente manera:
carga0
bipush 7
iadd
irevolver
Cuando se llama a este método, el código de bytes se compilará dinámicamente en instrucciones de máquina. El método puede optimizarse cuando el contador de rendimiento (si existe) alcanza un umbral específico. Los resultados optimizados pueden parecerse al siguiente conjunto de instrucciones de máquina:
Copie el código de código de la siguiente manera:
lea rax,[rdx+7] retirada
Diferentes compiladores son adecuados para diferentes aplicaciones.
Diferentes aplicaciones tienen diferentes necesidades. Las aplicaciones empresariales del lado del servidor generalmente necesitan ejecutarse durante mucho tiempo, por lo que generalmente desean una mayor optimización del rendimiento, mientras que los subprogramas del lado del cliente pueden querer tiempos de respuesta más rápidos y menos consumo de recursos; Analicemos tres compiladores diferentes y sus ventajas y desventajas.
Compiladores del lado del cliente
C1 es un compilador de optimización muy conocido. Al iniciar la JVM, agregue el parámetro -client para iniciar el compilador. Por su nombre podemos encontrar que C1 es un compilador cliente. Es ideal para aplicaciones cliente que tienen pocos recursos del sistema disponibles o requieren un inicio rápido. C1 realiza la optimización del código mediante el uso de contadores de rendimiento. Este es un método de optimización simple con menos intervención en el código fuente.
Compiladores del lado del servidor
Para aplicaciones de larga ejecución (como aplicaciones empresariales del lado del servidor), es posible que utilizar un compilador del lado del cliente no sea suficiente. En este momento deberíamos elegir un compilador del lado del servidor como C2. El optimizador se puede iniciar agregando el servidor a la línea de inicio de JVM. Debido a que la mayoría de las aplicaciones del lado del servidor suelen ser de larga ejecución, podrá recopilar más datos de optimización del rendimiento utilizando el compilador C2 que las aplicaciones del lado del cliente livianas y de corta duración. Por lo tanto también podrás aplicar técnicas y algoritmos de optimización más avanzados.
Consejo: caliente su compilador del lado del servidor
Para implementaciones del lado del servidor, el compilador puede tardar algún tiempo en optimizar esos códigos "calientes". Por lo tanto, la implementación del lado del servidor a menudo requiere una fase de "calentamiento". Por lo tanto, cuando realice mediciones de rendimiento en implementaciones del lado del servidor, asegúrese siempre de que su aplicación haya alcanzado un estado estable. Darle al compilador suficiente tiempo para compilar traerá muchos beneficios a su aplicación.
El compilador del lado del servidor puede obtener más datos de ajuste del rendimiento que el compilador del lado del cliente, por lo que puede realizar análisis de rama más complejos y encontrar rutas de optimización con mejor rendimiento. Cuantos más datos de análisis de rendimiento tenga, mejores serán los resultados del análisis de su aplicación. Por supuesto, realizar un análisis de rendimiento exhaustivo requiere más recursos del compilador. Por ejemplo, si la JVM usa el compilador C2, necesitará usar más ciclos de CPU, un caché de código más grande, etc.
Compilación multinivel
La compilación de varios niveles combina la compilación del lado del cliente y la compilación del lado del servidor. Azul fue el primero en implementar la compilación multicapa en su Zing JVM. Recientemente, esta tecnología ha sido adoptada por Oracle Java Hotspot JVM (después de Java SE7). La compilación multinivel combina las ventajas de los compiladores del lado del cliente y del lado del servidor. El compilador del cliente está activo en dos situaciones: cuando se inicia la aplicación y cuando los contadores de rendimiento alcanzan umbrales de nivel inferior para realizar optimizaciones de rendimiento. El compilador del cliente también inserta contadores de rendimiento y prepara el conjunto de instrucciones para su uso posterior por parte del compilador del lado del servidor para una optimización avanzada. La compilación multicapa es un método de análisis de rendimiento con una alta utilización de recursos. Debido a que recopila datos durante la actividad del compilador de bajo impacto, estos datos se pueden utilizar más adelante en optimizaciones más avanzadas. Este enfoque proporciona más información que analizar contadores utilizando código interpretativo.
La Figura 1 describe la comparación de rendimiento de intérpretes, compilación del lado del cliente, compilación del lado del servidor y compilación multicapa. El eje X es el tiempo de ejecución (unidad de tiempo) y el eje Y es el rendimiento (número de operaciones por unidad de tiempo)
Figura 1. Comparación del rendimiento del compilador
En comparación con el código puramente interpretado, el uso de un compilador del lado del cliente puede generar mejoras de rendimiento de entre 5 y 10 veces. La cantidad de ganancia de rendimiento que obtenga depende de la eficiencia del compilador, los tipos de optimizadores disponibles y qué tan bien coincide el diseño de la aplicación con la plataforma de destino. Pero para los desarrolladores de programas, este último a menudo puede ignorarse.
En comparación con los compiladores del lado del cliente, los compiladores del lado del servidor a menudo pueden aportar mejoras de rendimiento del 30% al 50%. En la mayoría de los casos, las mejoras de rendimiento suelen producirse a costa del consumo de recursos.
La compilación multinivel combina las ventajas de ambos compiladores. La compilación del lado del cliente tiene un tiempo de inicio más corto y puede realizar una optimización rápida. La compilación del lado del servidor puede realizar operaciones de optimización más avanzadas durante el proceso de ejecución posterior.
Algunas optimizaciones comunes del compilador
Hasta ahora, hemos discutido qué significa optimizar el código y cómo y cuándo la JVM realiza la optimización del código. A continuación, finalizaré este artículo presentando algunos métodos de optimización que realmente utilizan los compiladores. La optimización de JVM en realidad ocurre en la etapa de código de bytes (o etapa de representación de lenguaje de nivel inferior), pero aquí se utilizará el lenguaje Java para ilustrar estos métodos de optimización. Por supuesto, es imposible cubrir todos los métodos de optimización de JVM en esta sección. Espero que estas presentaciones lo inspiren a aprender cientos de métodos de optimización más avanzados e innovar en la tecnología de compilación.
Eliminación de código muerto
La eliminación de código muerto, como su nombre indica, consiste en eliminar código que nunca se ejecutará, es decir, código "muerto".
Si el compilador encuentra algunas instrucciones redundantes durante la operación, las eliminará del conjunto de instrucciones de ejecución. Por ejemplo, en el Listado 1, una de las variables nunca se utilizará después de una asignación, por lo que la declaración de asignación se puede ignorar por completo durante la ejecución. En correspondencia con la operación a nivel de código de bytes, el valor de la variable nunca necesita cargarse en el registro. No tener que cargar significa que se consume menos tiempo de CPU, lo que acelera la ejecución del código y, en última instancia, da como resultado una aplicación más rápida; si el código de carga se llama muchas veces por segundo, el efecto de optimización será más obvio.
El Listado 1 utiliza código Java para ilustrar un ejemplo de asignación de un valor a una variable que nunca se utilizará.
Listado 1. El código de copia del código muerto es el siguiente:
int timeToScaleMyApp(booleano interminableOfResources){
int reArquitecto =24;
int parcheByClustering =15;
int usoZing =2;
si (interminablesDeRecursos)
volver reArchitect + useZing;
demás
volver a utilizarZing;
}
Durante la fase de código de bytes, si una variable se carga pero nunca se usa, el compilador puede detectar y eliminar el código inactivo, como se muestra en el Listado 2. Si nunca realiza esta operación de carga, puede ahorrar tiempo de CPU y mejorar la velocidad de ejecución del programa.
Listado 2. El código de copia del código optimizado es el siguiente:
int timeToScaleMyApp(booleano interminableOfResources){
int reArchitect =24; //operación innecesaria eliminada aquí…
int usoZing =2;
si (interminablesDeRecursos)
volver reArchitect + useZing;
demás
volver a utilizarZing;
}
La eliminación de redundancia es un método de optimización que mejora el rendimiento de la aplicación eliminando instrucciones duplicadas.
Muchas optimizaciones intentan eliminar las instrucciones de salto a nivel de instrucción de máquina (como JMP en la arquitectura x86 cambiarán el registro del puntero de instrucción, desviando así el flujo de ejecución del programa). Esta instrucción de salto es un comando que consume muchos recursos en comparación con otras instrucciones de ASSEMBLY. Por eso queremos reducir o eliminar este tipo de instrucción. La incrustación de código es un método de optimización muy práctico y conocido para eliminar instrucciones de transferencia. Debido a que ejecutar instrucciones de salto es costoso, incorporar algunos métodos pequeños frecuentemente llamados en el cuerpo de la función traerá muchos beneficios. El Listado 3-5 demuestra los beneficios de la incrustación.
Listado 3. Código de copia del método de llamada El código es el siguiente:
int whenToEvaluateZing(int y){ return díasQuedan(y)+ díasQuedan(0)+ díasQuedan(y+1);}
Listado 4. El código de copia del método llamado es el siguiente:
int díasLeft(int x){ if(x ==0) return0; de lo contrario, return x -1;}
Listado 5. El código de copia del método en línea es el siguiente:
int cuandoToEvaluateZing(int y){
temperatura interna = 0;
si(y==0)
temperatura +=0;
demás
temperatura += y -1;
si(0==0)
temperatura +=0;
demás
temperatura +=0-1;
si(y+1==0)
temperatura +=0;
demás
temperatura +=(y +1)-1;
temperatura de retorno;
}
En el Listado 3-5 podemos ver que un método pequeño se llama tres veces en el cuerpo de otro método, y lo que queremos ilustrar es: el costo de incrustar el método llamado directamente en el código será menor que ejecutar tres saltos. transfiriendo instrucciones.
Incrustar un método que no se llama con frecuencia puede no hacer una gran diferencia, pero incorporar un método llamado "caliente" (un método que se llama con frecuencia) puede traer muchas mejoras de rendimiento. El código incrustado a menudo se puede optimizar aún más, como se muestra en el Listado 6.
Listado 6. Una vez incrustado el código, se puede lograr una mayor optimización copiando el código de la siguiente manera:
int whenToEvaluateZing(int y){ if(y ==0)return y; elseif(y ==-1)return y -1;
Optimización de bucle
La optimización del bucle juega un papel importante a la hora de reducir el coste adicional de ejecutar el cuerpo del bucle. El costo adicional aquí se refiere a saltos costosos, muchas comprobaciones de condición y canalizaciones no optimizadas (es decir, una serie de conjuntos de instrucciones que no realizan operaciones reales y consumen ciclos de CPU adicionales). Hay muchos tipos de optimizaciones de bucle. Estas son algunas de las optimizaciones de bucle más populares:
Fusión de cuerpos de bucle: cuando dos cuerpos de bucle adyacentes ejecutan el mismo número de bucles, el compilador intentará fusionar los dos cuerpos de bucle. Si dos cuerpos de bucle son completamente independientes entre sí, también se pueden ejecutar simultáneamente (en paralelo).
Bucle de inversión: en su forma más básica, reemplaza un bucle while con un bucle do- while. Este bucle do- while se coloca dentro de una declaración if. Este reemplazo reducirá dos operaciones de salto pero aumentará el juicio condicional, aumentando así la cantidad de código. Este tipo de optimización es un gran ejemplo de cómo intercambiar más recursos por código más eficiente: el compilador sopesa los costos y beneficios y toma decisiones dinámicamente en tiempo de ejecución.
Reorganice el cuerpo del bucle: reorganice el cuerpo del bucle para que todo el cuerpo del bucle pueda almacenarse en la memoria caché.
Ampliar el cuerpo del bucle: reduzca el número de comprobaciones y saltos de la condición del bucle. Puede pensar en esto como ejecutar varias iteraciones "en línea" sin tener que realizar una verificación condicional. Desenrollar el cuerpo del bucle también conlleva ciertos riesgos, porque puede reducir el rendimiento al afectar la canalización y una gran cantidad de búsquedas de instrucciones redundantes. Una vez más, depende del compilador decidir si desenrollar el cuerpo del bucle en tiempo de ejecución, y vale la pena desenrollarlo si traerá una mayor mejora en el rendimiento.
Lo anterior es una descripción general de cómo los compiladores a nivel de código de bytes (o nivel inferior) pueden mejorar el rendimiento de las aplicaciones en la plataforma de destino. Lo que hemos discutido son algunos métodos de optimización comunes y populares. Debido al espacio limitado, sólo damos algunos ejemplos sencillos. Nuestro objetivo es despertar su interés en el estudio en profundidad de la optimización a través de la sencilla discusión anterior.
Conclusión: puntos de reflexión y puntos clave
Elija diferentes compiladores según diferentes propósitos.
1. Un intérprete es la forma más sencilla de traducir código de bytes en instrucciones de máquina. Su implementación se basa en una tabla de búsqueda de instrucciones.
2. El compilador puede optimizar en función de los contadores de rendimiento, pero requiere consumir algunos recursos adicionales (caché de código, hilo de optimización, etc.).
3. El compilador del cliente puede mejorar el rendimiento de 5 a 10 veces en comparación con el intérprete.
4. El compilador del lado del servidor puede lograr una mejora del rendimiento del 30% al 50% en comparación con el compilador del lado del cliente, pero requiere más recursos.
5. La compilación multicapa combina las ventajas de ambas. Utilice la compilación del lado del cliente para obtener tiempos de respuesta más rápidos y luego utilice el compilador del lado del servidor para optimizar el código llamado con frecuencia.
Hay muchas formas posibles de optimizar el código aquí. Un trabajo importante del compilador es analizar todos los métodos de optimización posibles y luego sopesar los costos de varios métodos de optimización con la mejora del rendimiento que aportan las instrucciones finales de la máquina.