Compilación JIT en Java
Cómo el compilador Just-In-Time de la JVM optimiza el bytecode Java en código máquina nativo en tiempo de ejecución.
Java es famoso por "compilar una vez, ejecutar en cualquier lugar", pero esa es solo la mitad de la historia. El compilador javac convierte tu código fuente en bytecode, no en código máquina nativo, y la JVM comienza interpretando ese bytecode instrucción por instrucción. La pieza que hace rápido a Java es el compilador JIT (Just-In-Time): mientras tu programa se ejecuta, la JVM observa qué métodos se invocan con más frecuencia y compila esos métodos "calientes" en código nativo optimizado sobre la marcha.
Este capítulo explica cómo funciona el modelo de compilación en dos etapas, qué hace el compilador escalonado de HotSpot y por qué un programa Java se acelera cuanto más tiempo se ejecuta. Se apoya en cómo la JVM carga y ejecuta tu código — consulta Arquitectura de la JVM y Compilar y ejecutar un programa Java para el panorama general.
Dos compiladores, dos tareas
En realidad existen dos compiladores en el mundo Java, y confundirlos es un error común entre principiantes.
| Compilador | Cuándo se ejecuta | Entrada | Salida |
|---|---|---|---|
javac (AOT) | En tiempo de compilación | Código fuente .java | Bytecode .class portable |
| JIT (HotSpot) | En tiempo de ejecución, dentro de la JVM | Bytecode | Código máquina nativo |
javac se ejecuta una vez y produce bytecode independiente de la plataforma. El JIT vive dentro de la JVM en ejecución y produce código máquina específico para la CPU, adaptado al procesador exacto en el que te encuentras. Por eso el mismo .jar funciona en cualquier lugar y aún puede alcanzar velocidades cercanas al código nativo.
// Build time: javac Hello.java -> Hello.class (bytecode)
// Run time: java Hello -> JVM interprets, then JIT-compiles hot methods
public class Hello {
public static void main(String[] args) {
System.out.println("Bytecode now, native code soon.");
}
}Primero el intérprete, luego el JIT
Cuando un método se ejecuta por primera vez, la JVM lo interpreta: no hay coste de compilación, por lo que el arranque es rápido, pero cada bytecode es lento de ejecutar. La JVM mantiene un contador de invocaciones por método (y un contador de aristas hacia atrás para los bucles). Una vez que un método es invocado con suficiente frecuencia para superar un umbral, la JVM lo entrega al JIT para compilarlo en código nativo, y las llamadas futuras saltan directamente a esa versión rápida.
Por eso un servidor de larga ejecución mejora su rendimiento tras el calentamiento: los métodos en su ruta caliente eventualmente se compilan, mientras que el código poco usado permanece interpretado (por lo que no se desperdicia esfuerzo de compilación en él).
// 'process' is on the hot path. After enough calls it gets JIT-compiled;
// 'logRareError' may stay interpreted forever because it almost never runs.
void handleRequest(Request r) {
process(r); // hot: many invocations -> compiled
if (r.isMalformed()) {
logRareError(r); // cold: rarely called -> stays interpreted
}
}Compilación escalonada: C1 y C2
HotSpot moderno utiliza compilación escalonada, que combina dos compiladores JIT para obtener un arranque rápido y rendimiento máximo:
- C1 (el compilador cliente) compila rápidamente con optimizaciones ligeras. Lleva los métodos calientes a código nativo con rapidez e inserta contadores de perfilado.
- C2 (el compilador servidor) compila más lentamente pero optimiza de forma agresiva, usando el perfil recopilado por C1 (inlining, desenrollado de bucles, análisis de escape, eliminación de código muerto).
Un método asciende a través de los niveles a medida que se vuelve más caliente:
| Nivel | Qué ejecuta el código | Compensación |
|---|---|---|
| Nivel 0 | Intérprete | Sin coste de compilación, ejecución más lenta |
| Nivel 3 | C1 con perfilado | Rápido de producir, velocidad moderada, recopila datos |
| Nivel 4 | C2 completamente optimizado | Lento de producir, ejecución más rápida |
Dado que C2 optimiza basándose en el comportamiento observado, puede hacer apuestas que el compilador estático javac nunca podría — por ejemplo, hacer inlining de una llamada virtual porque en la práctica solo aparece una implementación.
// C2 can speculatively inline this even though 'pay' is virtual,
// because profiling showed every call so far used CreditCard.
abstract class Payment { abstract void pay(int cents); }
class CreditCard extends Payment { void pay(int cents) { /* ... */ } }
void checkout(Payment p) {
p.pay(1999); // megamorphic in theory; monomorphic in practice -> inlined
}Desoptimización: deshacer una apuesta
Las optimizaciones especulativas pueden resultar incorrectas. Si C2 hizo inlining de CreditCard.pay y luego llega un objeto PayPal, el código optimizado ya no es válido. HotSpot maneja esto con desoptimización: descarta el código nativo incorrecto, vuelve al intérprete para ese método y puede recompilarlo más tarde con la nueva información. Esta red de seguridad es lo que permite al JIT optimizar agresivamente sin producir nunca resultados incorrectos.
// First 100000 calls: only CreditCard -> C2 inlines aggressively.
// Call 100001 passes a PayPal -> the assumption breaks ->
// HotSpot deoptimizes, reverts to interpreter, and recompiles later.
checkout(new CreditCard());
checkout(new PayPal()); // triggers deoptimization of the inlined versionObservando los niveles con un ejemplo ejecutable
Un benchmark de calentamiento real necesita millones de iteraciones de bucle, algo que un entorno sandbox no puede ejecutar. En cambio, el programa a continuación modela la decisión de promoción que toma HotSpot — clasificando un método según cuántas veces ha sido invocado respecto a los umbrales de nivel predeterminados — y lee datos JIT reales de la JVM en ejecución a través de CompilationMXBean. Ejecútalo y observa cómo un método avanza de interpretado, a C1, a C2 a medida que su contador de llamadas aumenta.
Lo que hay que extraer de la ejecución:
- El JIT se identifica como HotSpot 64-Bit Tiered Compilers (a través de
CompilationMXBean.getName()), confirmando que tanto C1 como C2 están activos en un lanzamiento normal dejavaen una JVM HotSpot. - Los métodos invocados solo
1o500veces permanecen en el Nivel 0 (interpretado) — el JIT no desperdicia esfuerzo en código frío. - Cruzar el umbral de
2000promueve el método al Nivel 3 (compilado por C1), la versión nativa rápida de producir que también realiza perfilado. - Cruzar
10000(y100000) lo promueve al Nivel 4 (C2), el código completamente optimizado que entrega la velocidad máxima. CompilationMXBean.getTotalCompilationTime()expone la actividad JIT real desde dentro de Java, demostrando que la compilación ocurre mientras el programa se ejecuta, no por adelantado.
Ver el JIT por ti mismo
En un lanzamiento real de java (fuera de un sandbox) puedes observar cómo HotSpot compila en tiempo real con indicadores de línea de comandos:
# Print each method as it is compiled, with its tier number in the second column.
java -XX:+PrintCompilation MyApp
# Dump a one-line summary of every compilation method HotSpot supports.
java -XX:+PrintFlagsFinal -version | grep -i tierAlgunas conclusiones prácticas:
- Calienta antes de hacer benchmarks. Medir un método en su primera ejecución mide el intérprete, no el código optimizado. Los microbenchmarks deben ejecutar miles de iteraciones primero (herramientas como JMH lo gestionan por ti) para que C2 haya compilado la ruta caliente.
- El arranque frente a la velocidad máxima es un compromiso real. Los programas de corta duración (herramientas CLI, funciones serverless) pueden terminar antes de que C2 entre en acción, por lo que se ejecutan principalmente interpretados o compilados por C1. Los servidores de larga duración alcanzan el rendimiento máximo tras el calentamiento.
- Rara vez necesitas ajustar los umbrales. Los valores predeterminados funcionan bien para la mayoría de las cargas de trabajo. Los indicadores anteriores son para comprensión y diagnóstico, no para el código cotidiano.
La compilación JIT y el recolector de basura son los dos sistemas en tiempo de ejecución que dan a la JVM su rendimiento; ambos funcionan automáticamente mientras tu programa se ejecuta.