Optimización del rendimiento de JVM, parte 2: compiladores

Los compiladores de Java ocupan un lugar central en este segundo artículo de la serie de optimización del rendimiento de JVM. Eva Andreasson presenta las diferentes razas de compiladores y compara los resultados de rendimiento del cliente, el servidor y la compilación por niveles. Concluye con una descripción general de las optimizaciones comunes de JVM, como eliminación de código muerto, inserción y optimización de bucles.

Un compilador de Java es la fuente de la famosa independencia de plataforma de Java. Un desarrollador de software escribe la mejor aplicación Java que puede, y luego el compilador trabaja entre bastidores para producir un código de ejecución eficiente y de buen rendimiento para la plataforma de destino deseada. Los diferentes tipos de compiladores satisfacen diversas necesidades de aplicación, lo que produce resultados de rendimiento específicos deseados. Cuanto más sepa acerca de los compiladores, en términos de cómo funcionan y qué tipos están disponibles, más podrá optimizar el rendimiento de la aplicación Java.

Este segundo artículo de la serie de optimización del rendimiento de JVM destaca y explica las diferencias entre varios compiladores de máquinas virtuales Java. También discutiré algunas optimizaciones comunes utilizadas por los compiladores Just-In-Time (JIT) para Java. (Consulte "Optimización del rendimiento de la JVM, Parte 1" para obtener una descripción general de la JVM y una introducción a la serie).

¿Qué es un compilador?

Simplemente hablando, un compilador toma un lenguaje de programación como entrada y produce un lenguaje ejecutable como salida. Un compilador comúnmente conocido es el javacque se incluye en todos los kits de desarrollo estándar de Java (JDK). javactoma el código Java como entrada y lo traduce a bytecode, el lenguaje ejecutable de una JVM. El código de bytes se almacena en archivos .class que se cargan en el tiempo de ejecución de Java cuando se inicia el proceso de Java.

Las CPU estándar no pueden leer el código de bytes y debe traducirse a un idioma de instrucción que la plataforma de ejecución subyacente pueda comprender. El componente de la JVM que se encarga de traducir el código de bytes en instrucciones de plataforma ejecutables es otro compilador más. Algunos compiladores de JVM manejan varios niveles de traducción; por ejemplo, un compilador puede crear varios niveles de representación intermedia del código de bytes antes de que se convierta en instrucciones de máquina reales, el paso final de la traducción.

Bytecode y la JVM

Si desea obtener más información sobre el código de bytes y la JVM, consulte "Conceptos básicos de código de bytes" (Bill Venners, JavaWorld).

Desde una perspectiva independiente de la plataforma, queremos mantener el código independiente de la plataforma en la medida de lo posible, de modo que el último nivel de traducción, desde la representación más baja hasta el código de máquina real, sea el paso que bloquea la ejecución en la arquitectura del procesador de una plataforma específica. . El nivel más alto de separación es entre compiladores estáticos y dinámicos. A partir de ahí, tenemos opciones según el entorno de ejecución al que nos dirigimos, los resultados de rendimiento que deseamos y las restricciones de recursos que debemos cumplir. Discutí brevemente los compiladores estáticos y dinámicos en la Parte 1 de esta serie. En las siguientes secciones explicaré un poco más.

Compilación estática vs dinámica

Un ejemplo de compilador estático es el mencionado anteriormente javac. Con los compiladores estáticos, el código de entrada se interpreta una vez y el ejecutable de salida tiene la forma que se utilizará cuando se ejecute el programa. A menos que realice cambios en su fuente original y vuelva a compilar el código (usando el compilador), la salida siempre resultará en el mismo resultado; esto se debe a que la entrada es estática y el compilador es un compilador estático.

En una compilación estática, el siguiente código Java

static int add7( int x ) { return x+7; }

resultaría en algo similar a este bytecode:

iload0 bipush 7 iadd ireturn

Un compilador dinámico se traduce de un lenguaje a otro de forma dinámica, lo que significa que sucede mientras se ejecuta el código, ¡durante el tiempo de ejecución! La compilación y la optimización dinámicas dan a los tiempos de ejecución la ventaja de poder adaptarse a los cambios en la carga de la aplicación. Los compiladores dinámicos se adaptan muy bien a los tiempos de ejecución de Java, que comúnmente se ejecutan en entornos impredecibles y cambiantes. La mayoría de las JVM utilizan un compilador dinámico como un compilador Just-In-Time (JIT). El problema es que los compiladores dinámicos y la optimización de código a veces necesitan estructuras de datos, subprocesos y recursos de CPU adicionales. Cuanto más avanzada sea la optimización o el análisis de contexto de código de bytes, más recursos consume la compilación. En la mayoría de los entornos, la sobrecarga sigue siendo muy pequeña en comparación con la ganancia de rendimiento significativa del código de salida.

Variedades de JVM e independencia de la plataforma Java

Todas las implementaciones de JVM tienen una cosa en común, que es su intento de traducir el código de bytes de la aplicación en instrucciones de máquina. Algunas JVM interpretan el código de la aplicación al cargar y utilizan contadores de rendimiento para centrarse en el código "activo". Algunas JVM omiten la interpretación y se basan solo en la compilación. El uso intensivo de recursos de la compilación puede ser un gran éxito (especialmente para las aplicaciones del lado del cliente), pero también permite optimizaciones más avanzadas. Consulte Recursos para obtener más información.

Si eres un principiante en Java, las complejidades de las JVM serán mucho para entender. ¡La buena noticia es que realmente no es necesario! La JVM gestiona la compilación y optimización de código, por lo que no tiene que preocuparse por las instrucciones de la máquina y la forma óptima de escribir código de aplicación para una arquitectura de plataforma subyacente.

Del código de bytes de Java a la ejecución

Una vez que haya compilado su código Java en código de bytes, los siguientes pasos son traducir las instrucciones del código de bytes a código de máquina. Esto lo puede hacer un intérprete o un compilador.

Interpretación

La forma más simple de compilación de códigos de bytes se llama interpretación. Un intérprete simplemente busca las instrucciones de hardware para cada instrucción de código de bytes y las envía para que las ejecute la CPU.

Podría pensar en una interpretación similar a usar un diccionario: para una palabra específica (instrucción de código de bytes) hay una traducción exacta (instrucción de código de máquina). Dado que el intérprete lee y ejecuta inmediatamente una instrucción de código de bytes a la vez, no hay oportunidad de optimizar un conjunto de instrucciones. Un intérprete también tiene que hacer la interpretación cada vez que se invoca un código de bytes, lo que lo hace bastante lento. La interpretación es una forma precisa de ejecutar código, pero el conjunto de instrucciones de salida no optimizadas probablemente no será la secuencia de mayor rendimiento para el procesador de la plataforma de destino.

Compilacion

Por otro lado, un compilador carga todo el código para ejecutarlo en el tiempo de ejecución. A medida que traduce el código de bytes, tiene la capacidad de mirar el contexto de tiempo de ejecución total o parcial y tomar decisiones sobre cómo traducir realmente el código. Sus decisiones se basan en el análisis de gráficos de código, como diferentes ramas de ejecución de instrucciones y datos de contexto de tiempo de ejecución.

Cuando una secuencia de código de bytes se traduce en un conjunto de instrucciones de código de máquina y se pueden realizar optimizaciones a este conjunto de instrucciones, el conjunto de instrucciones de reemplazo (por ejemplo, la secuencia optimizada) se almacena en una estructura llamada caché de código . La próxima vez que se ejecute ese código de bytes, el código previamente optimizado se puede ubicar inmediatamente en el caché de código y usarse para la ejecución. En algunos casos, un contador de rendimiento puede activarse y anular la optimización anterior, en cuyo caso el compilador ejecutará una nueva secuencia de optimización. La ventaja de un caché de código es que el conjunto de instrucciones resultante se puede ejecutar de una vez, ¡sin necesidad de búsquedas interpretativas o compilación! Esto acelera el tiempo de ejecución, especialmente para aplicaciones Java en las que los mismos métodos se llaman varias veces.

Mejoramiento

Junto con la compilación dinámica viene la oportunidad de insertar contadores de rendimiento. El compilador podría, por ejemplo, insertar un contador de rendimientopara contar cada vez que se llama a un bloque de código de bytes (por ejemplo, correspondiente a un método específico). Los compiladores usan datos sobre cuán "caliente" es un código de bytes dado para determinar en qué parte del código las optimizaciones tendrán un mejor impacto en la aplicación en ejecución. Los datos de creación de perfiles en tiempo de ejecución permiten al compilador tomar un amplio conjunto de decisiones de optimización de código sobre la marcha, mejorando aún más el rendimiento de ejecución del código. A medida que se dispone de datos de creación de perfiles de código más refinados, se pueden utilizar para tomar decisiones de optimización adicionales y mejores, tales como: cómo secuenciar mejor las instrucciones en el lenguaje compilado, si reemplazar un conjunto de instrucciones con conjuntos más eficientes, o incluso si eliminar operaciones redundantes.

Ejemplo

Considere el código Java:

static int add7( int x ) { return x+7; }

Esto podría compilarse estáticamente javaccon el código de bytes:

iload0 bipush 7 iadd ireturn

Cuando se llama al método, el bloque de código de bytes se compilará dinámicamente en las instrucciones de la máquina. Cuando un contador de rendimiento (si está presente para el bloque de código) alcanza un umbral, también puede optimizarse. El resultado final podría parecerse al siguiente conjunto de instrucciones de máquina para una plataforma de ejecución determinada:

lea rax,[rdx+7] ret

Diferentes compiladores para diferentes aplicaciones

Las diferentes aplicaciones Java tienen diferentes necesidades. Las aplicaciones del lado del servidor empresarial de larga ejecución podrían permitir más optimizaciones, mientras que las aplicaciones más pequeñas del lado del cliente pueden necesitar una ejecución rápida con un consumo mínimo de recursos. Consideremos tres configuraciones diferentes del compilador y sus respectivos pros y contras.

Compiladores del lado del cliente

Un compilador de optimización conocido es C1, el compilador que se habilita a través de la -clientopción de inicio de JVM. Como sugiere su nombre de inicio, C1 es un compilador del lado del cliente. Está diseñado para aplicaciones del lado del cliente que tienen menos recursos disponibles y, en muchos casos, son sensibles al tiempo de inicio de la aplicación. C1 usa contadores de rendimiento para la creación de perfiles de código para permitir optimizaciones simples y relativamente poco intrusivas.

Compiladores del lado del servidor

Para aplicaciones de larga ejecución, como aplicaciones Java empresariales del lado del servidor, un compilador del lado del cliente puede no ser suficiente. En su lugar, podría usarse un compilador del lado del servidor como C2. C2 generalmente se habilita agregando la opción de inicio de JVM -servera su línea de comandos de inicio. Dado que se espera que la mayoría de los programas del lado del servidor se ejecuten durante mucho tiempo, habilitar C2 significa que podrá recopilar más datos de creación de perfiles que con una aplicación cliente liviana de ejecución corta. Por lo tanto, podrá aplicar técnicas y algoritmos de optimización más avanzados.

Consejo: calienta tu compilador del lado del servidor

Para las implementaciones del lado del servidor, puede tomar algún tiempo antes de que el compilador haya optimizado las partes "calientes" iniciales del código, por lo que las implementaciones del lado del servidor a menudo requieren una fase de "preparación". Antes de realizar cualquier tipo de medición de rendimiento en una implementación del lado del servidor, asegúrese de que su aplicación haya alcanzado el estado estable. ¡Permitir que el compilador tenga tiempo suficiente para compilar correctamente funcionará en su beneficio! (Consulte el artículo de JavaWorld "Observe cómo funciona el compilador HotSpot" para obtener más información sobre cómo calentar su compilador y la mecánica de creación de perfiles).

Un compilador de servidor tiene en cuenta más datos de creación de perfiles que un compilador del lado del cliente y permite un análisis de rama más complejo, lo que significa que considerará qué ruta de optimización sería más beneficiosa. Tener más datos de perfiles disponibles produce mejores resultados de aplicación. Por supuesto, hacer perfiles y análisis más extensos requiere gastar más recursos en el compilador. Una JVM con C2 habilitado utilizará más subprocesos y más ciclos de CPU, requerirá un caché de código más grande, etc.

Compilación escalonada

Compilación escalonadacombina la compilación del lado del cliente y del lado del servidor. Azul primero puso a disposición la compilación por niveles en su Zing JVM. Más recientemente (a partir de Java SE 7) ha sido adoptado por Oracle Java Hotspot JVM. La compilación por niveles aprovecha las ventajas del compilador del cliente y del servidor en su JVM. El compilador del cliente está más activo durante el inicio de la aplicación y maneja las optimizaciones desencadenadas por umbrales de contador de rendimiento más bajos. El compilador del lado del cliente también inserta contadores de rendimiento y prepara conjuntos de instrucciones para optimizaciones más avanzadas, que serán abordadas en una etapa posterior por el compilador del lado del servidor. La compilación por niveles es una forma de generación de perfiles muy eficiente en el uso de recursos porque el compilador puede recopilar datos durante la actividad del compilador de bajo impacto, que se pueden usar para optimizaciones más avanzadas más adelante.Este enfoque también proporciona más información de la que obtendrá utilizando únicamente los contadores de perfil de código interpretado.

El esquema del gráfico de la Figura 1 muestra las diferencias de rendimiento entre la interpretación pura, el lado del cliente, el lado del servidor y la compilación por niveles. El eje X muestra el tiempo de ejecución (unidad de tiempo) y el rendimiento del eje Y (operaciones / unidad de tiempo).

Figura 1. Diferencias de rendimiento entre compiladores (haga clic para ampliar)

En comparación con el código puramente interpretado, el uso de un compilador del lado del cliente conduce a aproximadamente 5 a 10 veces mejor desempeño de ejecución (en operaciones / s), mejorando así el desempeño de la aplicación. La variación en la ganancia depende, por supuesto, de cuán eficiente sea el compilador, qué optimizaciones están habilitadas o implementadas y (en menor medida) qué tan bien diseñada está la aplicación con respecto a la plataforma de ejecución de destino. Sin embargo, esto último es algo de lo que un desarrollador de Java nunca debería tener que preocuparse.

En comparación con un compilador del lado del cliente, un compilador del lado del servidor generalmente aumenta el rendimiento del código entre un 30 y un 50 por ciento. En la mayoría de los casos, la mejora del rendimiento equilibrará el costo de los recursos adicionales.