Capítulo 4. Trabajar con el compilador JIT

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

El compilador justo a tiempo (JIT) es el corazón de la Máquina Virtual Java; nada controla más el rendimiento de tu aplicación que el compilador JIT.

Este capítulo trata en profundidad el compilador. Comienza con información sobre cómo funciona el compilador y analiza las ventajas e inconvenientes de utilizar un compilador JIT. Hasta la aparición del JDK 8, tenías que elegir entre dos compiladores de Java. Hoy en día, esos dos compiladores siguen existiendo, pero trabajan conjuntamente, aunque en raras ocasiones es necesario elegir uno. Por último, veremos algunos ajustes intermedios y avanzados del compilador. Si una aplicación funciona lentamente sin ninguna razón evidente, esas secciones pueden ayudarte a determinar si el compilador es el culpable.

Compiladores Justo a Tiempo: Una visión general

Empezaremos con algo de material introductorio; no dudes en saltarlo si entiendes los fundamentos de la compilación "justo a tiempo".

Los ordenadores -y más concretamente las CPU- sólo pueden ejecutar un número relativamente reducido de instrucciones específicas, que se denominan código máquina. Por tanto, todos los programas que ejecuta la CPU deben traducirse a estas instrucciones.

Lenguajes como C++ y Fortran se denominan lenguajes compilados porque sus programas se entregan como código binario (compilado): se escribe el programa y, a continuación, un compilador estático produce un binario. El código ensamblador de ese binario está dirigido a una CPU concreta. Las CPU complementarias pueden ejecutar el mismo binario: por ejemplo, las CPU de AMD e Intel comparten un conjunto básico y común de instrucciones en lenguaje ensamblador, y las versiones posteriores de las CPU casi siempre pueden ejecutar el mismo conjunto de instrucciones que las versiones anteriores de esa CPU. Lo contrario no siempre es cierto; las nuevas versiones de CPU a menudo introducen instrucciones que no se ejecutarán en versiones anteriores de CPU.

Lenguajes como PHP y Perl, en cambio, son interpretados. El mismo código fuente de programa puede ejecutarse en cualquier CPU siempre que la máquina tenga el intérprete correcto (es decir, el programa llamado php o perl). El intérprete traduce cada línea del programa a código binario a medida que se ejecuta esa línea.

Cada sistema tiene ventajas e inconvenientes. Los programas escritos en lenguajes interpretados son portátiles: puedes coger el mismo código y soltarlo en cualquier máquina con el intérprete adecuado, y se ejecutará. Sin embargo, puede que se ejecute lentamente. Como caso sencillo, considera lo que ocurre en un bucle: el intérprete volverá a traducir cada línea de código cuando se ejecute en el bucle. El código compilado no necesita hacer repetidamente esa traducción.

Un buen compilador tiene en cuenta varios factores cuando produce un binario. Un ejemplo sencillo es el orden de las sentencias binarias: no todas las instrucciones del lenguaje ensamblador tardan lo mismo en ejecutarse. Una sentencia que suma los valores almacenados en dos registros puede ejecutarse en un ciclo, pero recuperar (de la memoria principal) los valores necesarios para la suma puede llevar varios ciclos.

Por lo tanto, un buen compilador producirá un binario que ejecute la sentencia para cargar los datos, ejecute otras instrucciones y luego -cuando los datos estén disponibles- ejecute la suma. Un intérprete que sólo mira una línea de código a la vez no tiene suficiente información para producir ese tipo de código; solicitará los datos a la memoria, esperará a que estén disponibles y luego ejecutará la suma. Los malos compiladores harán lo mismo, por cierto, y no es necesariamente cierto que incluso el mejor compilador pueda evitar la espera ocasional a que se complete una instrucción.

Por estas (y otras) razones, el código interpretado casi siempre será mediblemente más lento que el código compilado: los compiladores tienen suficiente información sobre el programa para proporcionar optimizaciones al código binario que un intérprete simplemente no puede realizar.

El código interpretado tiene la ventaja de la portabilidad. Un binario compilado para una CPU ARM obviamente no puede ejecutarse en una CPU Intel. Pero un binario que utilice las últimas instrucciones AVX de los procesadores Sandy Bridge de Intel tampoco puede ejecutarse en procesadores Intel más antiguos. Por tanto, el software comercial suele compilarse para una versión bastante antigua de un procesador y no aprovecha las instrucciones más recientes de que dispone. Existen varios trucos para evitar esto, como enviar un binario con varias bibliotecas compartidas que ejecuten código sensible al rendimiento y que vengan con versiones para varios sabores de una CPU.

Java intenta encontrar aquí un término medio. Las aplicaciones Java se compilan, pero en lugar de compilarse en un binario específico para una CPU concreta, se compilan en un lenguaje intermedio de bajo nivel.Este lenguaje (conocido como código de bytes Java) se ejecuta en el binario java(del mismo modo que un script PHP interpretado se ejecuta en el binario php). Esto confiere a Java la independencia de plataforma de unlenguaje interpretado. Como está ejecutando un código binario idealizado, el programajava es capaz de compilar el código en el binario de la plataforma a medida que el código se ejecuta. Esta compilación se produce a medida que se ejecuta el programa: ocurre "justo a tiempo".

Esta compilación sigue estando sujeta a dependencias de plataforma. JDK 8, por ejemplo, no puede generar código para el último conjunto de instrucciones de los procesadores Skylake de Intel, aunque JDK 11 sí puede. Tendré más que decir al respecto en "Banderas avanzadas del compilador".

La forma en que la Máquina Virtual Java compila este código a medida que se ejecuta es el tema central de este capítulo.

Recopilación HotSpot

Como ya se dijo en el Capítulo 1, la implementación de Java que se trata en este libro es la JVM HotSpot de Oracle. Este nombre (HotSpot) proviene del enfoque que adopta para compilar el código. En un programa típico, sólo un pequeño subconjunto de código se ejecuta con frecuencia, y el rendimiento de una aplicación depende principalmente de la rapidez con que se ejecuten esas secciones de código. Estas secciones críticas se conocen como los puntos calientes de la aplicación; cuanto más se ejecuta la sección de código, más caliente se dice que es esa sección.

Por lo tanto, cuando la JVM ejecuta código, no comienza a compilarlo inmediatamente. Hay dos razones básicas para ello. En primer lugar, si el código sólo se va a ejecutar una vez, entonces compilarlo es esencialmente un esfuerzo inútil; será más rápido interpretar los bytecodes de Java que compilarlos y ejecutar (sólo una vez) el código compilado.

Pero si el código en cuestión es un método llamado con frecuencia o un bucle que ejecuta muchas iteraciones, entonces compilarlo merece la pena: los ciclos que se tarda en compilar el código se verán compensados por el ahorro en múltiples ejecuciones del código compilado más rápido. Esta compensación es una de las razones por las que el compilador ejecuta primero el código interpretado: el compilador puede averiguar qué métodos se llaman con la frecuencia suficiente para justificar su compilación.

La segunda razón es de optimización: cuantas más veces ejecute la JVM un determinado método o bucle, más información tendrá sobre ese código. Esto permite a la JVM realizar numerosas optimizaciones cuando compila el código.

Esas optimizaciones (y las formas de afectarlas) se tratan más adelante en este capítulo, pero para un ejemplo sencillo, considera el métodoequals(). Este método existe en todos los objetos Java (porque se hereda de la claseObject) y a menudose sobrescribe. Cuando el intérprete encuentra la sentenciab = obj1.equals(obj2), debe buscar el tipo (clase) deobj1para saber qué métodoequals()debe ejecutar. Esta búsqueda dinámica puede llevar mucho tiempo.

Con el tiempo, digamos que la JVM se da cuenta de que cada vez que se ejecuta esta sentencia,obj1es del tipojava.lang.String. Entonces la JVM puede producir código compilado que llame directamente al métodoString.equals(). Ahora el código es más rápido no sólo porque está compilado, sino también porque puede saltarse la búsqueda de a qué método llamar.

No es tan sencillo; es posible que la próxima vez que se ejecute el códigoobj1se refiera a algo distinto de unString. La JVM creará código compilado que se ocupe de esa posibilidad, lo que implicará desoptimizar y luego reoptimizar el código en cuestión (verás un ejemplo en "Desoptimización"). No obstante, el código compilado global será más rápido (al menos mientrasobj1siga haciendo referencia aString) porque se salta la búsqueda de qué método ejecutar. Ese tipo de optimización sólo puede hacerse después de ejecutar el código durante un tiempo y observar lo que hace: ésta es la segunda razón por la que los compiladores JIT esperan para compilar secciones de código.

Resumen rápido

  • Java está diseñado para aprovechar la independencia de plataforma de los lenguajes de programación y el rendimiento nativo de los lenguajes compilados.

  • Un archivo de clase Java se compila en un lenguaje intermedio (bytecodes Java) que, a continuación, la JVM compila en lenguaje ensamblador.

  • La compilación de los bytecodes en lenguaje ensamblador realiza optimizaciones que mejoran mucho el rendimiento.

Recopilación por niveles

Hubo un tiempo en que el compilador JIT venía en dos sabores, y tenías que instalar diferentes versiones del JDK dependiendo del compilador que quisieras utilizar. Estos compiladores se conocen como los compiladoresclientyserver. En 1996, ésta era una distinción importante; en 2020, no tanto. Hoy en día, todas las JVM que se comercializan incluyen ambos compiladores (aunque en el uso común, suelen denominarse JVM server ).

A pesar de llamarse JVM de servidor, persiste la distinción entre compiladores de cliente y de servidor; ambos compiladores están a disposición de la JVM y son utilizados por ella, por lo que conocer esta diferencia es importante para entender cómo funciona el compilador.

Históricamente, los desarrolladores de JVM (e incluso algunas herramientas) a veces se referían a los compiladores con los nombres C1 (compilador 1, compilador cliente) y C2 (compilador 2, compilador servidor ). Esos nombres son más adecuados ahora, ya que hace tiempo que desapareció cualquier distinción entre un ordenador cliente y uno servidor, por lo que adoptaremos esos nombres en todo el texto.

La principal diferencia entre los dos compiladores es su agresividad a la hora de compilar código. El compilador C1 empieza a compilar antes que el C2. Esto significa que durante el inicio de la ejecución del código, el compilador C1 será más rápido, porque habrá compilado correspondientemente más código que el compilador C2.

La compensación de ingeniería aquí es el conocimiento que el compilador C2 obtiene mientras espera: ese conocimiento permite al compilador C2 hacer mejores optimizaciones en el código compilado. En última instancia, el código producido por el compilador C2 será más rápido que el producido por el compilador C1. Desde la perspectiva del usuario, el beneficio de esa compensación se basa en cuánto tiempo se ejecutará el programa y en lo importante que sea el tiempo de inicio del programa.

Cuando estos compiladores estaban separados, la pregunta obvia era por qué tenía que haber una elección: ¿no podía la JVM empezar con el compilador C1 y luego utilizar el compilador C2 a medida que el código se calentaba? Esa técnica se conoce como compilación por niveles, y es la que utilizan ahora todas las JVM.Se puede desactivar explícitamente con la opción-XX:-TieredCompilation (cuyo valor por defecto es true); en "Banderas avanzadas del compilador" hablaremos de las ramificaciones de hacerlo.

Banderas comunes del compilador

Dos banderas de uso común afectan al compilador JIT; las veremos en esta sección.

Ajustar la caché de código

Cuando la JVM compila código, guarda el conjunto de instrucciones en lenguaje ensamblador en la caché de código. La caché de código tiene un tamaño fijo y, una vez llena, la JVM no puede compilar más código.

Es fácil ver aquí el problema potencial si la caché de código es demasiado pequeña. Algunos métodos calientes se compilarán, pero otros no: la aplicación acabará ejecutando un montón de código interpretado (muy lento).

Cuando la caché de código se llena, la JVM escupe este aviso:

Java HotSpot(TM) 64-Bit Server VM warning: CodeCache is full.
         Compiler has been disabled.
Java HotSpot(TM) 64-Bit Server VM warning: Try increasing the
         code cache size using -XX:ReservedCodeCacheSize=

A veces es fácil pasar por alto este mensaje; otra forma de determinar si el compilador ha dejado de compilar código es seguir la salida del registro de compilación que se comenta más adelante en esta sección.

Realmente no existe un buen mecanismo para averiguar cuánta caché de código necesita una aplicación concreta. Por eso, cuando necesitas aumentar el tamaño de la caché de código, es una especie de operación de acierto y error; una opción típica es simplemente duplicar o cuadruplicar el valor por defecto.

El tamaño máximo de la caché de código se establece mediante la opción-XX:ReservedCodeCacheSize=N (donde N es el valor por defecto que se acaba de mencionar para el compilador concreto). La caché de código se gestiona como la mayoría de la memoria de la JVM: hay un tamaño inicial (especificado por -XX:InitialCodeCacheSize=N ). La asignación del tamaño de la caché de código comienza en el tamaño inicial y aumenta a medida que la caché se llena. El tamaño inicial de la caché de código es de 2.496 KB, y el tamaño máximo por defecto es de 240 MB. El redimensionamiento de la caché se produce en segundo plano y no afecta realmente al rendimiento, por lo que establecer el tamaño de ReservedCodeCacheSize (es decir, establecer el tamaño máximo de la caché de código) es todo lo que se necesita generalmente.

¿Hay algún inconveniente en especificar un valor realmente grande para el tamaño máximo de la caché de código, para que nunca se quede sin espacio? Depende de los recursos disponibles en la máquina de destino. Si se especifica un tamaño de caché de código de 1 GB, la JVM reservará 1 GB de memoria nativa. Esa memoria no se asigna hasta que se necesita, pero sigue estando reservada, lo que significa que debe haber suficiente memoria virtual disponible en tu máquina para satisfacer la reserva.

Además, si todavía tienes una máquina Windows antigua con una JVM de 32 bits, el tamaño total del proceso no puede superar los 4 GB. Eso incluye la pila de Java, espacio para todo el código de la propia JVM (incluidas sus bibliotecas nativas y pilas de hilos), cualquier memoria nativa que la aplicación asigne (directamente o a través de las bibliotecas New I/O [NIO]) y, por supuesto, la caché de código.

Ésas son las razones por las que la caché de código no es ilimitada y a veces requiere un ajuste para aplicaciones grandes. En máquinas de 64 bits con memoria suficiente, es poco probable que establecer un valor demasiado alto tenga un efecto práctico en la aplicación: la aplicación no se quedará sin memoria en el espacio de proceso, y la reserva de memoria extra será generalmente aceptada por el sistema operativo.

En Java 11, la caché de código está segmentada en tres partes:

  • Código no-método

  • Código perfilado

  • Código no perfilado

Por defecto, la caché de código tiene el mismo tamaño (hasta 240 MB), y aún puedes ajustar el tamaño total de la caché de código utilizando la banderaReservedCodeCacheSize. En ese caso, al segmento de código no-método se le asigna espacio en función del número de hilos del compilador (ver "Hilos de compilación"); en una máquina con cuatro CPUs, será de unos 5,5 MB. Los otros dos segmentos se reparten a partes iguales el resto de la caché de código total; por ejemplo, unos 117,2 MB cada uno en la máquina con cuatro CPU (lo que da un total de 240 MB).

Rara vez necesitarás sintonizar estos segmentos individualmente, pero si es así, las banderas son las siguientes:

  • -XX:NonNMethodCodeHeapSize=N: para el código no-método

  • -XX:ProfiledCodeHapSize=N para el código perfilado

  • -XX:NonProfiledCodeHapSize=N para el código no perfilado

El tamaño de la caché de código (y de los segmentos del JDK 11) se puede monitorizar en tiempo real utilizando jconsole y seleccionando el gráfico Memory Pool Code Cache en el panel Memoria. También puedes activar la función de Seguimiento de Memoria Nativa de Java, como se describe en el Capítulo 8.

Resumen rápido

  • La caché de código es un recurso con un tamaño máximo definido que afecta a la cantidad total de código compilado que puede ejecutar la JVM.

  • Las aplicaciones muy grandes pueden agotar toda la caché de código en su configuración por defecto; monitoriza la caché de código y aumenta su tamaño si es necesario.

Inspeccionar el proceso de compilación

La segunda bandera no es un ajuste en sí: no mejorará el rendimiento de una aplicación. Más bien, la bandera -XX:+PrintCompilation(que por defecto es false) nos da visibilidad sobre el funcionamiento del compilador (aunque también veremos herramientas que proporcionan información similar).

SiPrintCompilationestá activado, cada vez que se compila un método (o bucle), la JVM imprime una línea con información sobre lo que se acaba de compilar.

La mayoría de las líneas del registro de compilación tienen el siguiente formato:

timestamp compilation_id attributes (tiered_level) method_name size deopt

La marca de tiempo aquí es el tiempo transcurrido desde que finalizó la compilación (respecto a 0, que es cuando se inició la JVM).

El compilation_id es un ID de tarea interno. Normalmente, este número simplemente aumentará monotónicamente, pero a veces puedes ver un ID de compilación fuera de orden. Esto ocurre con mayor frecuencia cuando hay varios hilos de compilación e indica que los hilos de compilación se están ejecutando más rápido o más lento unos respecto a otros. No concluyas, sin embargo, que una tarea de compilación en particular era de algún modo desmesuradamente lenta: normalmente es sólo una función de la programación de los hilos.

El campo attributes es una serie de cinco caracteres que indican el estado del código que se está compilando. Si un atributo concreto se aplica a la compilación dada, se imprime el carácter que aparece en la lista siguiente; de lo contrario, se imprime un espacio para ese atributo. Por lo tanto, la cadena de atributos de cinco caracteres puede aparecer como dos o más elementos separados por espacios. Los distintos atributos son los siguientes

%

La compilación es OSR.

s

El método está sincronizado.

!

El método tiene un manejador de excepciones.

b

La compilación se produjo en modo de bloqueo.

n

Se ha producido la compilación de una envoltura de un método nativo.

El primero de estos atributos se refiere a la sustitución en pila (OSR). La compilación JIT es un proceso asíncrono: cuando la JVM decide que un determinado método debe ser compilado, ese método se coloca en una cola. En lugar de esperar a la compilación, la JVM sigue interpretando el método, y la próxima vez que se llame al método, la JVM ejecutará la versión compilada del método (suponiendo que la compilación haya terminado, claro).

Pero considera un bucle de larga ejecución. La JVM se dará cuenta de que el propio bucle debe ser compilado y pondrá en cola ese código para su compilación. Pero eso no es suficiente: la JVM tiene que tener la capacidad de empezar a ejecutar la versión compilada del bucle mientras el bucle sigue ejecutándose -sería ineficaz esperar hasta que el bucle y el método que lo encierra salgan (lo que puede que ni siquiera ocurra). Por lo tanto, cuando el código del bucle haya terminado de compilarse, la JVM sustituye el código (en la pila), y la siguiente iteración del bucle ejecutará la versión compilada del código, mucho más rápida. Esto es OSR.

Los dos atributos siguientes deberían explicarse por sí mismos. La bandera de bloqueo nunca se imprimirá por defecto en las versiones actuales de Java; indica que la compilación no se produjo en segundo plano (para más detalles, consulta "Hilos de compilación" ). Por último, el atributo nativo indica que la JVM generó código compilado para facilitar la llamada a un método nativo.

Si se ha desactivado la compilación por niveles, el siguiente campo (tiered_level) estará en blanco. De lo contrario, será un número que indicará qué nivel ha completado la compilación.

A continuación viene el nombre del método que se está compilando (o el método que contiene el bucle que se está compilando para OSR), que se imprime como ClassName::method.

Lo siguiente es el size (en bytes) del código que se está compilando. Éste es el tamaño de los bytecodes de Java, no el tamaño del código compilado (por lo que, desgraciadamente, no puede utilizarse para predecir el tamaño de la caché de código).

Por último, en algunos casos un mensaje al final de la línea de compilación indicará que se ha producido algún tipo dedesoptimización; suelen ser las frases made not entrant o made zombie. Consulta"Desoptimización" para más detalles.

El registro de compilación también puede incluir una línea parecida a ésta:

timestamp compile_id COMPILE SKIPPED: reason

Esta línea (con el texto literal COMPILE SKIPPED) indica que algo ha ido mal en la compilación del método dado. En dos casos es lo esperado, dependiendo del motivo especificado:

Caché de código llena

Es necesario aumentar el tamaño de la caché de código utilizando la banderaReservedCodeCache.

Carga simultánea de clases

La clase se modificó mientras se compilaba. La JVM volverá a compilarla más tarde; deberías esperar ver el método recompilado más tarde en el registro.

En todos los casos (salvo que se llene la caché), debe reintentarse la compilación. Si no se hace, un error impide la compilación del código. A menudo se trata de un error del compilador, pero el remedio habitual en todos los casos es refactorizar el código en algo más sencillo que el compilador pueda manejar.

Aquí tienes algunas líneas de salida de la activación de PrintCompilation en la aplicación REST estándar:

  28015  850       4     net.sdo.StockPrice::getClosingPrice (5 bytes)
  28179  905  s    3     net.sdo.StockPriceHistoryImpl::process (248 bytes)
  28226   25 %     3     net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
  28244  935       3     net.sdo.MockStockPriceEntityManagerFactory$\
                             MockStockPriceEntityManager::find (507 bytes)
  29929  939       3     net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
 106805 1568   !   4     net.sdo.StockServlet::processRequest (197 bytes)

Esta salida incluye sólo algunos de los métodos relacionados con las acciones (y no necesariamente todas las líneas relacionadas con un método concreto). Hay que señalar algunas cosas interesantes: el primero de esos métodos no se compiló hasta 28 segundos después de que se iniciara el servidor, y antes de él se compilaron 849 métodos. En este caso, todos esos otros métodos eran métodos del servidor o del JDK (filtrados de esta salida). El servidor tardó unos 2 segundos en iniciarse; los 26 segundos restantes antes de que se compilara nada más fueron esencialmente ociosos, ya que el servidor de aplicaciones esperaba peticiones.

Las líneas restantes se incluyen para señalar características interesantes. El método process()está sincronizado, por lo que los atributos incluyen un s. Las clases internas se compilan como cualquier otra clase y aparecen en la salida con la nomenclatura habitual de Java: outer-classname$inner-classname. El método processRequest()aparece con el manejador de excepciones, como era de esperar.

Por último, recuerda la implementación del constructor StockPriceHistoryImpl, que contiene un gran bucle:

public StockPriceHistoryImpl(String s, Date startDate, Date endDate) {
    EntityManager em = emf.createEntityManager();
    Date curDate = new Date(startDate.getTime());
    symbol = s;
    while (!curDate.after(endDate)) {
         StockPrice sp = em.find(StockPrice.class, new StockPricePK(s, curDate));
         if (sp != null) {
            if (firstDate == null) {
                firstDate = (Date) curDate.clone();
            }
            prices.put((Date) curDate.clone(), sp);
            lastDate = (Date) curDate.clone();
        }
        curDate.setTime(curDate.getTime() + msPerDay);
    }
}

El bucle se ejecuta más a menudo que el propio constructor, por lo que el bucle está sujeto a la compilación OSR. Observa que ese método tardó un tiempo en compilarse; su ID de compilación es 25, pero no aparece hasta que se están compilando otros métodos del rango 900. (Es fácil leer líneas OSR como las de este ejemplo como 25% y preguntarse por el otro 75%, pero recuerda que el número es el ID de compilación, y el % sólo significa compilación OSR). Esto es típico de la compilación OSR; el reemplazo de la pila es más difícil de configurar, pero mientras tanto pueden continuar otras compilaciones.

Niveles de compilación escalonados

El registro de compilación de un programa que utiliza la compilación por niveles muestra el nivel al que se compila cada método. En la salida de ejemplo, el código se compiló en el nivel 3 ó 4, aunque hasta ahora sólo hemos hablado de dos compiladores (más el intérprete). Resulta que hay cinco niveles de compilación, porque el compilador C1 tiene tres niveles. Así pues, los niveles de compilación son los siguientes:

0

Código interpretado

1

Código compilado C1 simple

2

Código compilado C1 limitado

3

Código compilado C1 completo

4

Código compilado C2

Un registro de compilación típico muestra que la mayoría de los métodos se compilan primero en el nivel 3: compilación C1 completa. (Todos los métodos empiezan en el nivel 0, por supuesto, pero eso no aparece en el registro). Si un método se ejecuta con suficiente frecuencia, se compilará en el nivel 4 (y el código del nivel 3 se convertirá en no entrante). Éste es el camino más frecuente: el compilador C1 espera a compilar algo hasta que tiene información sobre cómo se utiliza el código que puede aprovechar para realizar optimizaciones.

Si la cola del compilador C2 está llena, los métodos se sacarán de la cola C2 y se compilarán en el nivel 2, que es el nivel en el que el compilador C1 utiliza los contadores de invocación y de perímetro posterior (pero no requiere información sobre el perfil). De este modo, el método se compila más rápidamente; posteriormente, el método se compilará en el nivel 3, después de que el compilador C1 haya recopilado información sobre el perfil, y finalmente se compilará en el nivel 4, cuando la cola del compilador C2 esté menos ocupada.

Por otra parte, si la cola del compilador C1 está llena, un método que está programado para ser compilado en el nivel 3 puede pasar a ser apto para ser compilado en el nivel 4 mientras sigue esperando a ser compilado en el nivel 3. En ese caso, se compila rápidamente en el nivel 2 y luego pasa al nivel 4.

Los métodos triviales pueden empezar en el nivel 2 o 3, pero luego pasan al nivel 1 debido a su naturaleza trivial. Si el compilador C2, por alguna razón, no puede compilar el código, también pasará al nivel 1. Y, por supuesto, cuando el código se desoptimiza, pasa al nivel 0.

Los indicadores controlan parte de este comportamiento, pero esperar resultados cuando se ajusta a este nivel es optimista. El mejor caso para el rendimiento ocurre cuando los métodos se compilan como se espera: nivel 0 → nivel 3 → nivel 4. Si los métodos se compilan con frecuencia en el nivel 2 y se dispone de ciclos de CPU extra, considera aumentar el número de hilos del compilador; eso reducirá el tamaño de la cola del compilador C2. Si no dispones de ciclos de CPU adicionales, lo único que puedes hacer es intentar reducir el tamaño de la aplicación.

Desoptimización

En la discusión sobre la salida de la banderaPrintCompilationse mencionaron dos casos de desoptimización del código por parte del compilador. Desoptimizar significa que el compilador tiene que "deshacer" una compilación anterior. El efecto es que se reducirá el rendimiento de la aplicación, al menos hasta que el compilador pueda volver a compilar el código en cuestión.

La desoptimización se produce en dos casos: cuando el código es made not entrant y cuando el código es made zombie.

Código no participante

Hay dos cosas que hacen que el código no sea entrante. Una se debe a la forma en que funcionan las clases y las interfaces, y la otra es un detalle de implementación de la compilación por niveles.

Veamos el primer caso. Recuerda que la aplicación stock tiene una interfaz StockPriceHistory. En el código de ejemplo, esta interfaz tiene dos implementaciones: una básica (StockPriceHistoryImpl ) y otra que añade registro (Stock​PriceHistoryLogger) a cada operación. En el código REST, la implementación utilizada se basa en el parámetro log de la URL:

StockPriceHistory sph;
String log = request.getParameter("log");
if (log != null && log.equals("true")) {
    sph = new StockPriceHistoryLogger(...);
}
else {
    sph = new StockPriceHistoryImpl(...);
}
// Then the JSP makes calls to:
sph.getHighPrice();
sph.getStdDev();
// and so on

Si se hacen un montón de llamadas a http://localhost:8080/StockServlet(es decir, sin el parámetro log ), el compilador verá que el tipo real del objeto sph esStockPriceHistoryImpl. A continuación, inlineará código y realizará otras optimizaciones basándose en ese conocimiento.

Más tarde, digamos que se hace una llamada ahttp://localhost:8080/StockServlet?log=true. Ahora la suposición que hizo el compilador sobre el tipo del objeto sph es incorrecta; las optimizaciones anteriores ya no son válidas. Esto genera una trampa de desoptimización, y se descartan las optimizaciones anteriores. Si se realizan muchas llamadas adicionales con el registro activado, la JVM acabará rápidamente compilando ese código y realizando nuevas optimizaciones.

El registro de compilación de ese escenario incluirá líneas como las siguientes:

 841113   25 %           net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                 made not entrant
 841113  937  s          net.sdo.StockPriceHistoryImpl::process (248 bytes)
                                 made not entrant
1322722   25 %           net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                 made zombie
1322722  937  s          net.sdo.StockPriceHistoryImpl::process (248 bytes)
                                 made zombie

Ten en cuenta que tanto el constructor compilado en OSR como los métodos compilados en estándar se han hecho no entrantes, y algún tiempo después, se hacen zombis.

La desoptimización suena como algo malo, al menos en términos de rendimiento, pero no es necesariamente así. La Tabla 4-1 muestra las operaciones por segundo que consigue el servidor REST en escenarios de desoptimización.

Tabla 4-1. Rendimiento del servidor con desoptimización
Escenario OPS

Aplicación de las normas

24.4

Implementación estándar tras la desopt

24.4

Implementación del registro

24.1

Impl mixto

24.3

La implementación estándar nos dará 24,4 OPS. Supongamos que, inmediatamente después de esa prueba, se ejecuta una prueba que activa la rutaStockPriceHistoryLogger, que es el escenario que se ejecutó para producir los ejemplos de desoptimización que acabamos de enumerar. La salida completa dePrintCompilationmuestra que todos los métodos de la claseStockPriceHistoryImplse desoptimizan cuando se inician las peticiones de la implementación de registro. Pero después de la desoptimización, si se vuelve a ejecutar la ruta que utiliza la implementaciónStockPriceHistoryImpl, ese código se recompilará (con suposiciones ligeramente diferentes), y seguiremos viendo unos 24,4 OPS (después de otro periodo de calentamiento).

Ése es el mejor de los casos, por supuesto. ¿Qué ocurre si las llamadas se entremezclan de tal manera que el compilador nunca puede suponer realmente qué camino tomará el código? Debido al registro adicional, el camino que incluye el registro obtiene unos 24,1 OPS a través del servidor. Si las operaciones se mezclan, obtenemos unos 24,3 OPS: justo lo que cabría esperar de una media. Así que, aparte de un punto momentáneo en el que se procesa la trampa, la desoptimización no ha afectado al rendimiento de forma significativa.

La segunda cosa que puede hacer que el código no entre es la forma en que funciona la compilación por niveles. Cuando el código es compilado por el compilador C2, la JVM debe sustituir el código ya compilado por el compilador C1. Para ello, marca el código antiguo como no entrante y utiliza el mismo mecanismo de desoptimización para sustituir el código recién compilado (y más eficiente). Por lo tanto, cuando se ejecuta un programa con compilación por niveles, el registro de compilación mostrará un montón de métodos que se convierten en no entrantes. No te asustes: esta "desoptimización" está, de hecho, haciendo que el código sea mucho más rápido.

La forma de detectarlo es prestar atención al nivel de nivel en el registro de compilación:

  40915   84 %     3       net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
  40923 3697       3       net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
  41418   87 %     4       net.sdo.StockPriceHistoryImpl::<init> @ 48 (156 bytes)
  41434   84 %     3       net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                      made not entrant
  41458 3749       4       net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
  41469 3697       3       net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
                                      made not entrant
  42772 3697       3       net.sdo.StockPriceHistoryImpl::<init> (156 bytes)
                                      made zombie
  42861   84 %     3       net.sdo.StockPriceHistoryImpl::<init> @ -2 (156 bytes)
                                      made zombie

Aquí, el constructor primero se compila OSR en el nivel 3 y luego se compila completamente también en el nivel 3. Un segundo después, el código OSR pasa a ser apto para la compilación de nivel 4, por lo que se compila en el nivel 4 y el código OSR de nivel 3 se convierte en no entrante. A continuación, se produce el mismo proceso para la compilación estándar, y finalmente el código de nivel 3 se convierte en zombi.

Desoptimización del código zombi

Cuando el registro de compilación informa de que ha hecho código zombi, está diciendo que ha recuperado código anterior que se hizo no entrante. En el ejemplo anterior, después de ejecutar una prueba con la implementaciónStockPriceHistoryLogger, el código de la claseStockPriceHistoryImplse hizo no entrante. Pero los objetos de la claseStockPriceHistoryImplpermanecieron. Finalmente, todos esos objetos fueron reclamados por GC. Cuando eso ocurrió, el compilador se dio cuenta de que los métodos de esa clase eran ahora susceptibles de ser marcados como código zombi.

Para el rendimiento, esto es algo bueno. Recuerda que el código compilado se guarda en una caché de código de tamaño fijo; cuando se identifican métodos zombis, el código en cuestión puede eliminarse de la caché de código, dejando espacio para que se compilen otras clases (o limitando la cantidad de memoria que la JVM tendrá que asignar más tarde).

El posible inconveniente es que si el código de la clase se convierte en zombi y más tarde se vuelve a cargar y se utiliza mucho de nuevo, la JVM tendrá que recompilar y reoptimizar el código. Aun así, eso es exactamente lo que ocurrió en el escenario anterior, en el que la prueba se ejecutó sin registro, luego con registro y después sin registro; el rendimiento en ese caso no se vio afectado de forma notable. En general, las pequeñas recompilaciones que se producen cuando se recompila código zombi no tendrán un efecto apreciable en la mayoría de las aplicaciones.

Resumen rápido

  • La mejor manera de obtener visibilidad sobre cómo se está compilando el código es habilitandoPrintCompilation.

  • La salida de la activación de PrintCompilation se puede utilizar para asegurarse de que la compilación se está realizando según lo esperado.

  • La compilación por niveles puede funcionar en cinco niveles distintos entre los dos compiladores.

  • La desoptimización es el proceso por el que la JVM sustituye el código previamente compilado. Esto suele ocurrir en el contexto de que el código C2 sustituye al código C1, pero puede suceder por cambios en el perfil de ejecución de una aplicación.

Banderas avanzadas del compilador

Esta sección cubre algunas otras banderas que afectan al compilador. Principalmente, esto te da la oportunidad de entender aún mejor cómo funciona el compilador; estas banderas no deberían utilizarse generalmente. Por otra parte, otra razón por la que se incluyen aquí es que en su día fueron lo suficientemente comunes como para ser de uso generalizado, así que si te las has encontrado y te preguntas qué hacen, esta sección debería responder a esas preguntas.

Umbrales de compilación

Este capítulo ha sido algo vago a la hora de definir qué desencadena la compilación del código. El factor principal es la frecuencia con que se ejecuta el código; una vez que se ejecuta un determinado número de veces, se alcanza su umbral de compilación, y el compilador considera que tiene suficiente información para compilar el código.

Los ajustes afectan a estos umbrales. Sin embargo, esta sección está realmente diseñada para darte una mejor idea de cómo funciona el compilador (e introducir algunos términos); en las JVM actuales, ajustar el umbral nunca tiene realmente sentido.

La compilación se basa en dos contadores de la JVM: el número de veces que se ha llamado al método, y el número de veces que se ha bifurcado cualquier bucle del método. La bifurcación hacia atrás puede considerarse como el número de veces que un bucle ha finalizado su ejecución, ya sea porque ha llegado al final del propio bucle o porque ha ejecutado una sentencia de bifurcación como continue.

Cuando la JVM ejecuta un método Java, comprueba la suma de esos dos contadores y decide si el método es apto para la compilación. Si lo es, el método se pone en cola para su compilación (consulta "Hilos de compilación" para más detalles sobre las colas). Este tipo de compilación no tiene nombre oficial, pero a menudo se denomina compilación estándar .

Del mismo modo, cada vez que un bucle completa una ejecución, el contador de bifurcaciones se incrementa y se inspecciona. Si el contador de bifurcaciones ha superado su umbral individual, el bucle (y no todo el método) pasa a ser apto para la compilación.

Los ajustes afectan a estos umbrales.Cuando la compilación por niveles está desactivada, la compilación estándar se activa por el valor de la bandera-XX:CompileThreshold=N bandera. El valor por defecto de N es 10.000. Cambiar el valor de la bandera CompileThreshold hará que el compilador decida compilar el código antes (o después) de lo que lo haría normalmente. Ten en cuenta, sin embargo, que aunque aquí hay una bandera, el umbral se calcula sumando la suma del contador del bucle de perímetro posterior más el contador de entrada del método.

A menudo puedes encontrar recomendaciones para cambiar la banderaCompileThreshold, y varias publicaciones de puntos de referencia de Java utilizan esta bandera (por ejemplo, frecuentemente después de 8.000 iteraciones). Algunas aplicaciones todavía se envían con esa bandera activada por defecto.

Pero recuerda que he dicho que esta bandera funciona cuando la compilación por niveles está desactivada, lo que significa que cuando la compilación por niveles está activada (como ocurre normalmente), esta bandera no hace nada en absoluto. El uso de esta bandera es, en realidad, un vestigio del JDK 7 y de épocas anteriores.

Esta bandera solía recomendarse por dos razones: en primer lugar, bajarla mejoraría el tiempo de inicio de una aplicación que utilizara el compilador C2, ya que el código se compilaría más rápidamente (y normalmente con la misma eficacia). En segundo lugar, podía hacer que se compilaran algunos métodos que, de otro modo, nunca se habrían compilado.

Este último punto es una peculiaridad interesante: si un programa se ejecuta eternamente, ¿no esperaríamos que todo su código acabara compilándose? No funciona así, porque los contadores que utilizan los compiladores aumentan a medida que se ejecutan métodos y bucles, pero también disminuyen con el tiempo. Periódicamente (concretamente, cuando la JVM alcanza un punto seguro), el valor de cada contador se reduce.

En la práctica, esto significa que los contadores son una medida relativa de la reciente calentura del método o bucle. Un efecto secundario es que el código ejecutado con cierta frecuencia puede no ser compilado nunca por el compilador C2, incluso para programas que se ejecutan eternamente.Estos métodos se denominan a veces tibios (en contraposición a calientes). Antes de la compilación por niveles, éste era un caso en el que reducir el umbral de compilación era beneficioso.

Hoy, sin embargo, incluso los métodos tibios se compilarán, aunque quizá podrían mejorar ligeramente si consiguiéramos que los compilara el compilador C2 en lugar del C1. El beneficio práctico es escaso, pero si realmente te interesa, prueba a cambiar las banderas-XX:Tier3InvocationThreshold=N (por defecto 200) para que C1 compile un método más rápidamente, y-XX:Tier4InvocationThreshold=N (por defecto 5000) para que C2 compile un método más rápidamente. Existen banderas similares para el umbral del perímetro posterior.

Resumen rápido

  • Los umbrales a los que se compilan los métodos (o bucles) se establecen mediante parámetros sintonizables.

  • Sin la compilación por niveles, a veces tenía sentido ajustar esos umbrales, pero con la compilación por niveles, este ajuste ya no es recomendable.

Hilos recopilatorios

"Umbrales de compilación" mencionaba que cuando un método (o bucle) pasa a ser apto para la compilación, se pone en cola para la compilación. Esa cola es procesada por uno o varios hilos en segundo plano.

Estas colas no son estrictamente "primero en entrar, primero en salir"; los métodos cuyos contadores de invocación son más altos tienen prioridad. Así que, incluso cuando un programa empieza a ejecutarse y tiene mucho código que compilar, este orden de prioridad ayuda a garantizar que el código más importante se compilará primero. (Esta es otra razón por la que el ID de compilación en la salida PrintCompilation puede aparecer desordenado).

Los compiladores C1 y C2 tienen colas diferentes, cada una de las cuales es procesada por (potencialmente múltiples) hilos diferentes. El número de hilos se basa en una fórmula compleja de logaritmos, perola Tabla 4-2 enumera los detalles.

Tabla 4-2. Número predeterminado de hilos de compilación C1 y C2 para la compilación por niveles
CPUs Hilos C1 Hilos C2

1

1

1

2

1

1

4

1

2

8

1

2

16

2

6

32

3

7

64

4

8

128

4

10

El número de subprocesos del compilador puede ajustarse mediante la opción-XX:CICompilerCount=N bandera. Es el número total de hilos que la JVM utilizará para procesar la(s) cola(s); para la compilación por niveles, un tercio (pero al menos uno) se utilizará para procesar la cola del compilador C1, y los hilos restantes (pero también al menos uno) se utilizarán para procesar la cola del compilador C2. El valor por defecto de esa bandera es la suma de las dos columnas de la tabla anterior.

Si la compilación por niveles está desactivada, sólo se inicia el número dado de hilos del compilador C2.

¿Cuándo podrías considerar ajustar este valor? Dado que el valor por defecto se basa en el número de CPUs, éste es un caso en el que ejecutar con una versión antigua de JDK 8 dentro de un contenedor Docker puede hacer que el ajuste automático salga mal. En tal circunstancia, tendrás que ajustar manualmente esta bandera al valor deseado (utilizando los objetivos de la Tabla 4-2 como orientación basada en el número de CPUs asignadas al contenedor Docker).

Del mismo modo, si un programa se ejecuta en una máquina virtual de una sola CPU, tener sólo un hilo compilador puede ser ligeramente beneficioso: la CPU disponible es limitada, y tener menos hilos disputándose ese recurso ayudará al rendimiento en muchas circunstancias. Sin embargo, esa ventaja se limita sólo al periodo inicial de calentamiento; después de eso, el número de métodos elegibles a compilar no causará realmente contención por la CPU. Cuando se ejecutó la aplicación de lotes de acciones en una máquina con una sola CPU y se limitó a uno el número de hilos del compilador, los cálculos iniciales fueron aproximadamente un 10% más rápidos (ya que no tenían que competir por la CPU tan a menudo). Cuantas más iteraciones se ejecutaban, menor era el efecto global de ese beneficio inicial, hasta que se compilaron todos los métodos calientes y se eliminó el beneficio.

Por otro lado, el número de hilos puede saturar fácilmente el sistema, sobre todo si se ejecutan varias JVM a la vez (cada una de las cuales iniciará muchos hilos de compilación). Reducir el número de hilos en ese caso puede ayudar al rendimiento general (aunque, de nuevo, con el posible coste de que el periodo de calentamiento dure más).

Del mismo modo, si se dispone de muchos ciclos de CPU adicionales, teóricamente el programa se beneficiará -al menos durante su periodo de calentamiento- cuando se aumente el número de hilos del compilador. En la vida real, ese beneficio es extremadamente difícil de obtener. Además, si todo ese exceso de CPU está disponible, es mucho mejor que pruebes algo que aproveche los ciclos de CPU disponibles durante toda la ejecución de la aplicación (en lugar de limitarte a compilar más rápido al principio).

Otro ajuste que se aplica a los hilos de compilación es el valor de la bandera-XX:+BackgroundCompilation, que por defecto es true. Ese ajuste significa que la cola se procesa de forma asíncrona, como acabamos de describir. Pero esa bandera puede fijarse en false, en cuyo caso, cuando un método sea apto para la compilación, el código que quiera ejecutarlo esperará hasta que se compile de hecho (en lugar de seguir ejecutándose en el intérprete). La compilación en segundo plano también se desactiva cuando se especifica-Xbatch.

Resumen rápido

  • La compilación se produce de forma asíncrona para los métodos que se colocan en la cola de compilación.

  • La cola no está estrictamente ordenada; los métodos calientes se compilan antes que otros métodos de la cola. Esta es otra razón por la que los ID de compilación pueden aparecer desordenados en el registro de compilación.

Enlazando

Una de las optimizaciones más importantes que realiza el compilador consiste en inlinear métodos. El código que sigue un buen diseño orientado a objetos suele contener atributos a los que se accede mediante getters (y quizás setters):

public class Point {
    private int x, y;

    public void getX() { return x; }
    public void setX(int i)  { x = i; }
}

La sobrecarga de invocar una llamada a un método como éste es bastante elevada, especialmente en relación con la cantidad de código del método. De hecho, en los primeros días de Java, los consejos sobre rendimiento a menudo argumentaban en contra de este tipo de encapsulación precisamente por el impacto en el rendimiento de todas esas llamadas a métodos. Afortunadamente, ahora las JVM realizan rutinariamente la inlining de código para este tipo de métodos. Por lo tanto, puedes escribir este código:

Point p = getPoint();
p.setX(p.getX() * 2);

El código compilado ejecutará esencialmente esto:

Point p = getPoint();
p.x = p.x * 2;

El inlining está activado por defecto. Puede desactivarse mediante la bandera-XX:-Inline, aunque es una mejora del rendimiento tan importante que en realidad nunca lo harías (por ejemplo, desactivar el inlining reduce el rendimiento de la prueba de dosificación de acciones en más de un 50%). Aun así, como el inlining es tan importante, y quizás porque tenemos muchos otros mandos que girar, a menudo se hacen recomendaciones sobre cómo ajustar el comportamiento de inlining de la JVM.

Desgraciadamente, no existe una visibilidad básica de cómo la JVM inlinea el código. Si compilas la JVM desde el código fuente, puedes producir una versión de depuración que incluya la bandera-XX:+PrintInliningEsa bandera proporciona todo tipo de información sobre las decisiones de inlining que toma el compilador). Lo mejor que se puede hacer es mirar los perfiles del código, y si algún método sencillo cerca de la parte superior de los perfiles parece que debería estar inlineado, experimenta con las banderas de inlineado.

La decisión básica sobre si alinear o no un método depende de lo "caliente" que sea y de su tamaño. La JVM determina si un método es "caliente" (es decir, si se llama con frecuencia) basándose en un cálculo interno; no está sujeto directamente a ningún parámetro ajustable. Si un método es apto para ser inlineado porque se llama con frecuencia, sólo se inlineará si su tamaño en código de bytes esinferior a 325 bytes (o lo que se especifique con la bandera-XX:MaxFreqInlineSize=N ). En caso contrario, sólo se podrá inlinear si es inferior a 35 bytes (o lo que se especifique como bandera-XX:MaxInlineSize=N ).

A veces verás recomendaciones de que se aumente el valor de la banderaMaxInlineSize Un aspecto de esta relación que a menudo se pasa por alto es que establecer el valorMaxInlineSize por encima de 35 significa que un método puede ser inlineado cuando se llama por primera vez. Sin embargo, si el método se llama con frecuencia -en cuyo caso su rendimiento importa mucho más-, entonces se habrá inlineado en algún momento (suponiendo que su tamaño sea inferior a 325 bytes). Por lo demás, el efecto neto de ajustar el indicadorMaxInlineSize es que puede reducir el tiempo de calentamiento necesario para una prueba, pero es poco probable que tenga un gran impacto en una aplicación de larga ejecución.

Resumen rápido

  • Inlining es la optimización más beneficiosa que puede hacer el compilador, sobre todo para el código orientado a objetos en el que los atributos están bien encapsulados.

  • Rara vez es necesario ajustar los indicadores de inlining, y las recomendaciones para hacerlo no suelen tener en cuenta la relación entre el inlining normal y el inlining frecuente. Asegúrate de tener en cuenta ambos casos cuando investigues los efectos del inlining.

Análisis de la fuga

El compilador C2 realiza optimizaciones agresivas si está activado el análisis de escapes(-XX:+DoEscapeAnalysis, que es true por defecto). Por ejemplo, considera esta clase para trabajar con factoriales:

public class Factorial {
    private BigInteger factorial;
    private int n;
    public Factorial(int n) {
        this.n = n;
    }
    public synchronized BigInteger getFactorial() {
        if (factorial == null)
            factorial = ...;
        return factorial;
    }
}

Para almacenar los 100 primeros valores factoriales en una matriz, se utilizaría este código:

ArrayList<BigInteger> list = new ArrayList<BigInteger>();
for (int i = 0; i < 100; i++) {
    Factorial factorial = new Factorial(i);
    list.add(factorial.getFactorial());
}

Sólo se hace referencia al objetofactorialdentro de ese bucle; ningún otro código puede acceder nunca a ese objeto. Por lo tanto, la JVM es libre de realizar optimizaciones en ese objeto:

  • No necesita obtener un bloqueo de sincronización al llamar al métodogetFactorial().

  • No necesita almacenar el campo n en memoria; puede guardar ese valor en un registro. Del mismo modo, puede almacenar la referencia al objetofactorialen un registro.

  • De hecho, no es necesario que asigne un objeto factorial real; puede limitarse a hacer un seguimiento de los campos individuales del objeto.

Este tipo de optimización es sofisticado: en este ejemplo es bastante sencillo, pero estas optimizaciones son posibles incluso con código más complejo. Dependiendo del uso del código, no todas las optimizaciones serán necesariamente aplicables. Pero el análisis de escape puede determinar cuáles de esas optimizaciones son posibles y hacer los cambios necesarios en el código compilado.

El análisis de escape está activado por defecto. En raras ocasiones, se equivocará. Normalmente es poco probable, y en las JVM actuales es realmente raro. Aun así, debido a que en su día se produjeron algunos errores de gran repercusión, a veces verás recomendaciones para desactivar el análisis de escape. Es probable que ya no sean apropiadas, aunque, como ocurre con todas las optimizaciones agresivas del compilador, no es descartable que desactivar esta función pueda dar lugar a un código más estable. Si descubres que es así, lo mejor es simplificar el código en cuestión: un código más simple compilará mejor. (No obstante, se trata de un error y debes notificarlo).

Resumen rápido

  • El análisis de escape es la más sofisticada de las optimizaciones que puede realizar el compilador. Es el tipo de optimización que con frecuencia hace que los microcomprobadores vayan mal.

Código específico de la CPU

Antes mencioné que una ventaja del compilador JIT es que podía emitir código para distintos procesadores según el lugar donde se estuviera ejecutando. Esto supone que la JVM se construye con el conocimiento del procesador más reciente, por supuesto.

Eso es exactamente lo que hace el compilador para los chips Intel. En 2011, Intel introdujo Advanced Vector Extensions (AVX2) para los chips Sandy Bridge (y posteriores). La compatibilidad de JVM con esas instrucciones no tardó en llegar. Más tarde, en 2016, Intel amplió este soporte para incluir las instrucciones AVX-512, presentes en los chips Knights Landing y posteriores. Estas instrucciones no son compatibles con JDK 8, pero sí con JDK 11.

Normalmente, esta función no es algo de lo que debas preocuparte; la JVM detectará la CPU en la que se está ejecutando y seleccionará el conjunto de instrucciones adecuado. Pero, como ocurre con todas las funciones nuevas, a veces las cosas se tuercen.

La compatibilidad con las instrucciones AVX-512 se introdujo por primera vez en JDK 9, aunque no estaba activada por defecto. En un par de inicios falsos, se habilitó por defecto y luego se deshabilitó por defecto. En JDK 11, esas instrucciones se habilitaron por defecto. Sin embargo, a partir del JDK 11.0.6, esas instrucciones vuelven a estar desactivadas por defecto. Por tanto, incluso en el JDK 11, esto sigue siendo un trabajo en curso. (Esto, por cierto, no es exclusivo de Java; muchos programas han luchado para conseguir el soporte de las instrucciones AVX-512 exactamente correcto).

Por eso, en algunos equipos Intel más nuevos, al ejecutar algunos programas, puedes descubrir que un juego de instrucciones anterior funciona mucho mejor. Los tipos de aplicaciones que se benefician del nuevo juego de instrucciones suelen implicar más cálculos científicos que los que suelen hacer los programas Java.

Estos conjuntos de instrucciones se seleccionan con el-XX:UseAVX=N donde N es el siguiente:

0

No utilices instrucciones AVX.

1

Utiliza instrucciones Intel AVX de nivel 1 (para procesadores Sandy Bridge y posteriores).

2

Utiliza instrucciones Intel AVX de nivel 2 (para procesadores Haswell y posteriores).

3

Utiliza instrucciones Intel AVX-512 (para procesadores Knights Landing y posteriores).

El valor por defecto de esta bandera dependerá del procesador que ejecute la JVM; la JVM detectará la CPU y elegirá el valor más alto soportado que pueda. Java 8 no soporta un nivel 3, por lo que 2 es el valor que verás utilizado en la mayoría de los procesadores. En Java 11, en los procesadores Intel más recientes, el valor por defecto es 3 en las versiones hasta 11.0.5, y 2 en las versiones posteriores.

Ésta es una de las razones por las que mencioné en el Capítulo 1 que es una buena idea utilizar las últimas versiones de Java 8 o Java 11, ya que las correcciones importantes como ésta se encuentran en esas últimas versiones. Si tienes que utilizar una versión anterior de Java 11 en los últimos procesadores Intel, prueba a activar la opción-XX:UseAVX=2 que, en muchos casos, mejorará el rendimiento.

Hablando de madurez del código: para completar, mencionaré que la bandera -XX:UseSSE=N admite las extensiones Intel Streaming SIMD (SSE) uno a cuatro. Estas extensiones son para la línea de procesadores Pentium. Ajustar esta bandera en 2010 tenía cierto sentido, ya que se estaban resolviendo todas las permutaciones de su uso. Hoy en día, en general, podemos confiar en la robustez de esa bandera.

Compromisos de la compilación por niveles

He mencionado varias veces que la JVM funciona de forma diferente cuando la compilación por niveles está desactivada. Dadas las ventajas de rendimiento que proporciona, ¿hay alguna razón para desactivarla?

Una de esas razones podría ser cuando se ejecuta en un entorno con memoria limitada. Seguro que tu máquina de 64 bits tiene una tonelada de memoria, pero puede que estés ejecutando en un contenedor Docker con un límite de memoria pequeño o en una máquina virtual en la nube que simplemente no tiene suficiente memoria. O puede que estés ejecutando docenas de JVMs en tu gran máquina. En esos casos, puede que quieras reducir la huella de memoria de tu aplicación.

El Capítulo 8 ofrece recomendaciones generales al respecto, pero en esta sección veremos el efecto de la compilación por niveles en la caché de código.

La Tabla 4-3 muestra el resultado de iniciar NetBeans en mi sistema, que tiene un par de docenas de proyectos que se abrirán al arrancar.

Tabla 4-3. Efecto de la compilación por niveles en la caché de código
Modo compilador Clases recopiladas Caché de código comprometido Tiempo de arranque

+Compilación en capas

22,733

46,5 MB

50,1 segundos

-Compilación en capas

5,609

10,7 MB

68,5 segundos

El compilador C1 compiló unas cuatro veces más clases y, como era de esperar, necesitó unas cuatro veces más memoria para la caché de código. En términos absolutos, es poco probable que ahorrar 34 MB en este ejemplo suponga una gran diferencia. Ahorrar 300 MB en un programa que compila 200.000 clases podría ser una opción diferente en algunas plataformas.

¿Qué perdemos desactivando la compilación por niveles? Como muestra la tabla, empleamos más tiempo en iniciar la aplicación y cargar todas las clases del proyecto. Pero, ¿qué ocurre con un programa de larga duración, en el que esperarías que se compilaran todos los puntos calientes?

En ese caso, dado un periodo de calentamiento suficientemente largo, la ejecución debería ser aproximadamente la misma cuando se desactiva la compilación por niveles.La Tabla 4-4 muestra el rendimiento de nuestro servidor REST estándar tras periodos de calentamiento de 0, 60 y 300 segundos.

Tabla 4-4. Rendimiento de las aplicaciones de servidor con compilación por niveles
Periodo de calentamiento -XX:-TieredCompilation -XX:+TieredCompilation

0 segundos

23.72

24.23

60 segundos

23.73

24.26

300 segundos

24.42

24.43

El periodo de medición es de 60 segundos, por lo que incluso cuando no hay calentamiento, los compiladores han tenido la oportunidad de obtener suficiente información para compilar los puntos calientes; por lo tanto, hay poca diferencia incluso cuando no hay periodo de calentamiento. (Además, se compiló mucho código durante el arranque del servidor.) Observa que, al final, la compilación por niveles sigue siendo capaz de obtener una pequeña ventaja (aunque es poco probable que se note). Ya hemos explicado el motivo al hablar de los umbrales de compilación: siempre habrá un pequeño número de métodos compilados por el compilador C1 cuando se utiliza la compilación por niveles que no serán compilados por el compilador C2.

La GraalVM

La GraalVM es una nueva máquina virtual. Proporciona un medio para ejecutar código Java, por supuesto, pero también código de muchos otros lenguajes. Esta máquina virtual universal también puede ejecutar JavaScript, Python, Ruby, R y bytecodes JVM tradicionales de Java y otros lenguajes que compilan a bytecodes JVM (por ejemplo, Scala, Kotlin, etc.). Graal se presenta en dos ediciones: una Community Edition (CE) completa de código abierto y una Enterprise Edition (EE) comercial. Cada edición tiene binarios compatibles con Java 8 o Java 11.

La GraalVM tiene dos contribuciones importantes al rendimiento de la JVM. En primer lugar, una tecnología complementaria permite a la GraalVM producir binarios totalmente nativos; lo examinaremos en la siguiente sección.

En segundo lugar, la GraalVM puede funcionar en un modo como una JVM normal, pero contiene una nueva implementación del compilador C2. Este compilador está escrito en Java (a diferencia del compilador C2 tradicional, que está escrito en C++).

La JVM tradicional contiene una versión del JIT de GraalVM, dependiendo de cuándo se construyó la JVM. Estas versiones JIT proceden de la versión CE de GraalVM, que son más lentas que la versión EE; también suelen estar desactualizadas en comparación con las versiones de GraalVM que puedes descargar directamente.

Dentro de la JVM, el uso del compilador GraalVM se considera experimental, por lo que, para habilitarlo, necesitas proporcionar estas banderas:-XX:+UnlockExperimentalVMOptions,-XX:+EnableJVMCIy-XX:+UseJVMCICompilerEl valor por defecto para todas estas opciones es false.

La Tabla 4-5 muestra el rendimiento del compilador estándar de Java 11, el compilador Graal de la versión EE 19.2.1 y el GraalVM integrado en Java 11 y 13.

Tabla 4-5. Rendimiento del compilador Graal
JVM/compilador OPS

JDK 11/Estándar C2

20.558

JDK 11/Graal JIT

14.733

Graal 1.0.0b16

16.3

Graal 19.2.1

26.7

JDK 13/Estándar C2

21.9

JDK 13/Graal JIT

26.4

Este es, una vez más, el rendimiento de nuestro servidor REST (aunque en un hardware ligeramente diferente al anterior, por lo que el OPS de referencia es sólo de 20,5 OPS en lugar de 24,4).

Es interesante observar la progresión aquí: El JDK 11 se creó con una versión bastante temprana del compilador Graal, por lo que su rendimiento es inferior al del compilador C2. El compilador Graal mejoró a través de sus versiones de acceso temprano, aunque incluso su última versión de acceso temprano (1.0) no era tan rápida como la VM estándar. Sin embargo, las versiones de Graal de finales de 2019 (publicadas como versión de producción 19.2.1) se hicieron sustancialmente más rápidas. La versión de acceso anticipado de JDK 13 tiene una de esas versiones posteriores y consigue casi el mismo rendimiento con el compilador Graal, aunque su compilador C2 sólo ha mejorado ligeramente desde JDK 11.

Precompilación

Empezamos este capítulo hablando de la filosofía de un compilador "justo a tiempo". Aunque tiene sus ventajas, el código sigue estando sujeto a un periodo de calentamiento antes de ejecutarse. ¿Y si en nuestro entorno funcionara mejor un modelo de compilación tradicional: un sistema embebido sin la memoria extra que requiere el JIT, o un programa que se completa antes de tener la oportunidad de calentarse?

En esta sección, veremos dos características experimentales que abordan ese escenario. La compilación anticipada es una característica experimental del JDK 11 estándar, y la capacidad de producir un binario totalmente nativo es una característica de la Graal VM.

Recopilación anticipada

La compilación por adelantado (AOT) estuvo disponible por primera vez en JDK 9 sólo para Linux, pero en JDK 11 está disponible en todas las plataformas. Desde el punto de vista del rendimiento, sigue siendo un trabajo en curso, pero esta sección te dará un vistazo.1

La compilación AOT te permite compilar parte (o toda) tu aplicación antes de ejecutarla. Este código compilado se convierte en una biblioteca compartida que la JVM utiliza al iniciar la aplicación. En teoría, esto significa que no es necesario que intervenga el JIT, al menos en el arranque de tu aplicación: tu código debería ejecutarse inicialmente al menos tan bien como el código compilado C1 sin tener que esperar a que se compile ese código.

En la práctica, es un poco diferente: el tiempo de inicio de la aplicación se ve muy afectado por el tamaño de la biblioteca compartida (y, por tanto, por el tiempo de carga de esa biblioteca compartida en la JVM). Eso significa que una aplicación sencilla como una aplicación "Hola, mundo" no se ejecutará más rápido cuando utilices la compilación AOT (de hecho, puede ejecutarse más lentamente en función de las opciones elegidas para precompilar la biblioteca compartida). La compilación AOT está orientada a algo como un servidor REST que tiene un tiempo de inicio relativamente largo. Así, el tiempo de carga de la biblioteca compartida se compensa con el largo tiempo de inicio, y AOT produce un beneficio. Pero recuerda también que la compilación AOT es una función experimental, y los programas más pequeños pueden beneficiarse de ella a medida que evolucione la tecnología.

Para utilizar la compilación AOT, utilizas la herramienta jaotc para producir una biblioteca compartida que contenga las clases compiladas que selecciones. A continuación, esa biblioteca compartida se carga en la JVM mediante un argumento de tiempo de ejecución.

La herramienta jaotc tiene varias opciones, pero la forma en que producirás la mejor biblioteca es algo así:

$ jaotc --compile-commands=/tmp/methods.txt \
    --output JavaBaseFilteredMethods.so \
    --compile-for-tiered \
    --module java.base

Este comando utilizará un conjunto de comandos de compilación para producir una versión compilada del módulo java.base en el archivo de salida dado. Tienes la opción de que AOT compile un módulo, como hemos hecho aquí, o un conjunto de clases.

El tiempo de carga de la biblioteca compartida depende de su tamaño, que es un factor del número de métodos de la biblioteca. También puedes cargar varias bibliotecas compartidas que precompilen distintas partes del código, lo que puede ser más fácil de gestionar pero tiene el mismo rendimiento, así que nos concentraremos en una sola biblioteca.

Aunque tengas la tentación de precompilarlo todo, obtendrás un mejor rendimiento si precompilas juiciosamente sólo subconjuntos del código. Por eso esta recomendación consiste en compilar únicamente el módulo java.base.

Los comandos de compilación (en el archivo /tmp/methods.txt en este ejemplo) también sirven para limitar los datos que se compilan en la biblioteca compartida. Ese archivo contiene líneas parecidas a éstas

compileOnly java.net.URI.getHost()Ljava/lang/String;

Esta línea indica a jaotc que cuando compile la clase java.net.URI, incluya sólo el método getHost(). Podemos tener otras líneas que hagan referencia a otros métodos de esa clase para incluir también su compilación; al final, sólo se incluirán en la biblioteca compartida los métodos enumerados en el archivo.

Para crear la lista de comandos de compilación, necesitamos una lista de todos los métodos que utiliza realmente la aplicación. Para ello, ejecutamos la aplicación así:

$ java -XX:+UnlockDiagnosticVMOptions -XX:+LogTouchedMethods \
      -XX:+PrintTouchedMethodsAtExit <other arguments>

Cuando el programa salga, imprimirá las líneas de cada método que el programa haya utilizado en un formato como éste:

java/net/URI.getHost:()Ljava/lang/String;

Para producir el archivo methods.txt, guarda esas líneas, antepón a cada una la directiva compileOnly y elimina los dos puntos que preceden inmediatamente a los argumentos del método.

Las clases precompiladas por jaotc utilizarán una forma del compilador C1, por lo que en un programa de larga duración no se compilarán de forma óptima. Así que la última opción que necesitaremos es --compile-for-tiered. Esa opción arregla la biblioteca compartida para que sus métodos sigan siendo aptos para ser compilados por el compilador C2.

Si utilizas la compilación AOT para un programa de corta duración, está bien omitir este argumento, pero recuerda que el conjunto de aplicaciones objetivo es un servidor. Si no permitimos que los métodos precompilados sean aptos para la compilación C2, el rendimiento en caliente del servidor será más lento de lo que es posible en última instancia.

Tal vez no te sorprenda que, si ejecutas tu aplicación con una biblioteca que tenga activada la compilación por niveles y utilizas el indicador-XX:+PrintCompilation verás la misma técnica de sustitución de código que hemos observado antes: la compilación AOT aparecerá como otro tier en la salida, y verás que los métodos AOT se convierten en no entrantes y se sustituyen a medida que el JIT los compila.

Una vez creada la biblioteca, utilízala con tu aplicación de la siguiente manera:

$ java -XX:AOTLibrary=/path/to/JavaBaseFilteredMethods.so <other args>

Si quieres asegurarte de que se está utilizando la biblioteca, incluye la bandera-XX:+PrintAOTen tus argumentos de la JVM; esa bandera es false por defecto. Al igual que la bandera-XX:+PrintCompilation, la bandera-XX:+PrintAOTproducirá una salida siempre que la JVM utilice un método precompilado. Una línea típica tiene este aspecto:

    373  105     aot[ 1]   java.util.HashSet.<init>(I)V

La primera columna son los milisegundos transcurridos desde el inicio del programa, por lo que pasaron 373 milisegundos hasta que el constructor de la clase HashSet se cargó desde la biblioteca compartida y comenzó a ejecutarse. La segunda columna es un ID asignado al método, y la tercera columna nos dice de qué biblioteca se cargó el método. Esta bandera también imprime el índice (1 en este ejemplo):

18    1     loaded    /path/to/JavaBaseFilteredMethods.so  aot library

JavaBaseFilteredMethods.so es la primera (y única) biblioteca cargada en este ejemplo, por lo que su índice es 1 (la segunda columna) y las referencias posteriores a aot con ese índice remiten a esta biblioteca.

Compilación nativa de GraalVM

La compilación AOT era beneficiosa para programas relativamente grandes, pero no ayudaba (y podía entorpecer) a los programas pequeños de ejecución rápida. Esto se debe a que aún es una función experimental y a que su arquitectura hace que la JVM cargue la biblioteca compartida.

En cambio, GraalVM puede producir ejecutables nativos completos que se ejecutan sin la JVM. Estos ejecutables son ideales para programas de corta duración. Si has ejecutado los ejemplos, habrás notado referencias en algunas cosas (como errores ignorados) a clases de GraalVM: La compilación AOT utiliza GraalVM como base. Se trata de una característica Early Adopter de GraalVM; puede utilizarse en producción con la licencia adecuada, pero no está sujeta a garantía.

La GraalVM produce binarios que arrancan bastante rápido, sobre todo si se comparan con los programas en ejecución de la JVM. Sin embargo, en este modo, la GraalVM no optimiza el código de forma tan agresiva como el compilador C2, por lo que, dada una aplicación de ejecución suficientemente larga, la JVM tradicional acabará imponiéndose. A diferencia de la compilación AOT, el binario nativo de GraalVM no compila clases utilizando C2 durante la ejecución.

Del mismo modo, la huella de memoria de un programa nativo producido desde la GraalVM comienza siendo significativamente menor que la de una JVM tradicional. Sin embargo, en el momento en que un programa se ejecuta y expande la pila, esta ventaja de memoria se desvanece.

También existen limitaciones sobre qué características de Java pueden utilizarse en un programa compilado en código nativo. Estas limitaciones incluyen las siguientes:

  • Carga dinámica de clases (por ejemplo, llamando a Class.forName()).

  • Finalizadores.

  • El gestor de seguridad de Java.

  • JMX y JVMTI (incluida la creación de perfiles JVMTI).

  • El uso de la reflexión suele requerir una codificación o configuración especial.

  • El uso de proxies dinámicos suele requerir una configuración especial.

  • El uso de JNI requiere una codificación o configuración especial.

Podemos ver todo esto en acción utilizando un programa de demostración del proyecto GraalVM que cuenta recursivamente los archivos de un directorio. Con unos pocos archivos que contar, el programa nativo producido por la GraalVM es bastante pequeño y rápido, pero a medida que se realiza más trabajo y entra en acción el JIT, el compilador tradicional de la JVM genera mejores optimizaciones de código y es más rápido, como vemos en la Tabla 4-6.

Tabla 4-6. Tiempo de recuento de archivos con código nativo y compilado JIT
Número de archivos Java 11.0.5 Aplicación nativa

7

217 ms (36K)

4 ms (3K)

271

279 ms (37K)

20 ms (6K)

169,000

2,3 s (171K)

2,1 s (249K)

1,3 millones

19,2 s (212K)

25,4 s (269K)

Los tiempos aquí indicados son el tiempo de recuento de los archivos; la huella total de la ejecución (medida al finalizar) se indica entre paréntesis.

Por supuesto, la propia GraalVM evoluciona rápidamente, y cabe esperar que las optimizaciones dentro de su código nativo también mejoren con el tiempo.

Resumen

Este capítulo contiene muchos antecedentes sobre el funcionamiento del compilador. Para que puedas entender algunas de las recomendaciones generales del Capítulo 1 sobre métodos pequeños y código sencillo, y los efectos del compilador en los microcomprobadores descritos en el Capítulo 2. En concreto:

  • No tengas miedo de los métodos pequeños -y, en particular, de los getters y setters- porque se pueden inlinear fácilmente. Si tienes la sensación de que la sobrecarga de los métodos puede ser costosa, tienes razón en teoría (hemos demostrado que eliminar el inlining degrada significativamente el rendimiento). Pero no es así en la práctica, ya que el compilador soluciona ese problema.

  • El código que necesita ser compilado se sitúa en una cola de compilación. Cuanto más código haya en la cola, más tardará el programa en alcanzar un rendimiento óptimo.

  • Aunque puedes (y debes) dimensionar la caché de código, sigue siendo un recurso finito.

  • Cuanto más simple sea el código, más optimizaciones podrán realizarse en él. La retroalimentación de perfil y el análisis de escape pueden producir un código mucho más rápido, pero las estructuras de bucle complejas y los métodos grandes limitan su eficacia.

Por último, si haces un perfil de tu código y encuentras algunos métodos sorprendentes en la parte superior de tu perfil -métodos que esperas que no deberían estar ahí-, puedes utilizar la información de aquí para investigar lo que está haciendo el compilador y asegurarte de que puede manejar la forma en que está escrito tu código.

1 Una de las ventajas de la compilación AOC es un inicio más rápido, pero la compartición de datos de clases de aplicación ofrece -al menos por ahora- mayores ventajas en cuanto al rendimiento del inicio y es una función totalmente compatible; consulta "Compartición de datos de clases" para obtener más detalles.

Get Rendimiento de Java, 2ª Edición now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.