Blog

Por dentro de la JVM: stack, heap, recolección de basura y el JIT

Dónde guarda Java tus variables y objetos, cómo decide el recolector de basura qué liberar, por qué un programa con recolección de basura igual puede tener fugas o quedarse sin memoria, y cómo el JIT hace el código más rápido cuanto más tiempo corre.

Cuando ejecutas un programa Java, la JVM hace mucho trabajo que nunca ves. Mantiene un stack de frames para cada hilo, pone cada objeto en un heap compartido, libera los objetos que nadie puede alcanzar y compila a código máquina tus métodos más usados mientras el programa corre. Saber cómo funciona todo eso explica los desbordamientos de stack, las fugas de memoria, OutOfMemoryError y los benchmarks que mienten.

Este post cubre el stack y el heap, la alcanzabilidad, la recolección de basura generacional con G1 y ZGC, las fugas, el JIT y lo que pasa cuando se carga una clase. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java. Los logs del GC y los tiempos cambian en cada ejecución, así que se muestran como sesiones de terminal recortadas, y tus números serán distintos.

Cada hilo tiene un stack de frames

Cada vez que se llama a un método, la JVM apila un frame en el stack del hilo actual. El frame guarda los parámetros y las variables locales de esa llamada. Para un primitivo, es el valor mismo. Para un objeto, es una referencia, y el objeto vive en el heap. Cuando el método retorna, su frame se saca del stack.

int sumTo(int n) {
    if (n == 0) {
        IO.println("frame n=0: bottom, returns 0");
        return 0;
    }
    int rest = sumTo(n - 1);
    int total = n + rest;
    IO.println("frame n=" + n + ": rest=" + rest + ", returns " + total);
    return total;
}

List<String> makeNames() {
    var names = new ArrayList<String>();
    names.add("Ana");
    names.add("Ben");
    return names;
}

void main() {
    IO.println("sum: " + sumTo(3));
    var kept = makeNames();
    IO.println("the list outlived its frame: " + kept);
}

Imprime:

frame n=0: bottom, returns 0
frame n=1: rest=0, returns 1
frame n=2: rest=1, returns 3
frame n=3: rest=3, returns 6
sum: 6
the list outlived its frame: [Ana, Ben]

Hubo cuatro llamadas a sumTo en el stack al mismo tiempo, y cada una tenía su propio n, rest y total. Terminaron en orden inverso, desde el frame de más abajo hacia arriba.

makeNames muestra la otra mitad. Su variable local names desapareció apenas retornó, pero el ArrayList no estaba en el frame. Estaba en el heap, y main recibió una copia de la referencia. La parte sobre valores y referencias explica cómo se copian las referencias.

Un stack que se llena: StackOverflowError

El stack de un hilo tiene un tamaño máximo fijo, y una recursión sin caso base lo llena. Entonces la JVM lanza StackOverflowError. Es un Error, no una Exception, pero igual puedes capturarlo:

int depth = 0;

void dive() {
    depth++;
    dive();
}

void main() {
    try {
        dive();
    } catch (StackOverflowError e) {
        IO.println("caught: " + e.getClass().getName());
        IO.println("message: " + e.getMessage());
        IO.println("deeper than 1,000 calls: " + (depth > 1_000));
    }
    IO.println("main carries on");
}

Imprime:

caught: java.lang.StackOverflowError
message: null
deeper than 1,000 calls: true
main carries on

El error no tiene mensaje. Para cuando corre el bloque catch, los frames ya se sacaron, así que hay espacio para imprimir. En código real, arregla la recursión.

El programa no imprime la profundidad exacta, porque cambia de una ejecución a otra. Esto pasó cuando cambiamos el bloque catch para que imprimiera depth y lo ejecutamos varias veces:

$ java Main.java
depth 17830
$ java Main.java
depth 17699
$ java -Xint Main.java
depth 9826
$ java -Xint Main.java
depth 9826

Esto nos sorprendió. -Xint usa solo el intérprete, y se detuvo en la misma profundidad cada vez. Con el JIT activado, la profundidad variaba y llegaba casi al doble. Cuando el JIT compila dive, sus frames ocupan menos espacio que los frames interpretados, y cuántas llamadas ocurren antes de ese cambio depende del momento. Más abajo hablamos del JIT.

Para darles a los hilos un stack más grande, usa -Xss. java -Xss4m Main.java fija 4 MB, y en una ejecución dejó que el mismo programa llegara a 77,347 llamadas de profundidad. El valor por defecto en este JDK de Linux de 64 bits es 1 MB (ThreadStackSize = 1024, en KB).

Un objeto es basura cuando nada puede alcanzarlo

El recolector de basura libera un objeto cuando ninguna cadena de referencias llega a él desde una raíz de GC (GC root). Las raíces son las referencias que la JVM sabe que están en uso: las variables locales de cada frame vivo de cada hilo, los campos estáticos de las clases cargadas y algunas internas. Todo lo que el recolector alcanza siguiendo referencias desde las raíces se queda. Todo lo demás es basura.

Puedes verlo con una WeakReference. Una referencia débil te deja mirar un objeto sin mantenerlo vivo. Cuando las únicas referencias que quedan son débiles, el recolector puede limpiarlas. En este programa, los dos nodos se apuntan entre sí:

import java.lang.ref.WeakReference;

class Node {
    final String name;
    Node partner;

    Node(String name) {
        this.name = name;
    }
}

String check(WeakReference<Node> ref) {
    Node node = ref.get();
    return node == null ? "collected" : node.name + " is still here";
}

void main() {
    var a = new Node("a");
    var b = new Node("b");
    a.partner = b;
    b.partner = a;
    var weakA = new WeakReference<>(a);

    System.gc();
    IO.println("while main holds a: " + check(weakA));

    a = null;
    b = null;
    System.gc();
    IO.println("after a = b = null: " + check(weakA));
}

Imprime:

while main holds a: a is still here
after a = b = null: collected

Los dos nodos todavía se referían entre sí cuando fueron recolectados. Eso no los mantiene vivos, porque el recolector no cuenta referencias. Rastrea desde las raíces, y cuando las variables locales de main quedaron en null, ningún camino llevaba a ninguno de los dos nodos.

System.gc() es solo una petición. La JVM puede ignorarla. Ejecutamos este programa 25 veces con cada uno de los recolectores Serial, Parallel, G1, ZGC y Shenandoah, y la referencia débil se limpió todas las veces. Por eso es seguro mostrarlo aquí. No es una promesa, y un flag lo demuestra:

$ java -Xlog:gc Main.java
[0.005s][info][gc] Using G1
[1.726s][info][gc] GC(0) Pause Full (System.gc()) 43M->3M(34M) 21.996ms
while main holds a: a is still here
[1.761s][info][gc] GC(1) Pause Full (System.gc()) 4M->3M(14M) 28.521ms
after a = b = null: collected
$ java -XX:+DisableExplicitGC Main.java
while main holds a: a is still here
after a = b = null: a is still here

En G1, System.gc() ejecuta una recolección completa. Con -XX:+DisableExplicitGC no hace nada, y el nodo sobrevive. El código real nunca debería depender de System.gc().

Explicado como si tuvieras diez años

El heap es un gran cuarto de juegos compartido, lleno de juguetes. Todos los niños de la casa pueden dejar juguetes ahí.

El stack es la bandeja propia de cada niño, en su escritorio. La bandeja tiene lo que está usando ahora mismo, y algunos hilos de lana. Cada hilo va de la bandeja a un juguete del cuarto de juegos. Los juguetes también pueden tener hilos hacia otros juguetes.

El recolector de basura es un ayudante que ordena el cuarto de juegos. El ayudante no pregunta si un juguete parece importante. Empieza por las bandejas y sigue cada hilo. Cualquier juguete al que llega siguiendo hilos se queda. Cualquier juguete del que nadie sostiene un hilo vuelve a la caja, aunque sean dos juguetes atados entre sí.

La versión precisa

El stack de un hilo guarda frames. Un frame guarda las variables locales del método y valores intermedios, incluidas referencias. Los objetos se reservan en el heap, que comparten todos los hilos. El recolector encuentra los objetos vivos rastreando: empieza en las raíces de GC y marca todo lo que se puede alcanzar a través de referencias. La memoria que ocupan los objetos no marcados se recupera. Cómo y cuándo pasa eso depende del recolector.

Dónde falla la analogía: el ayudante del cuento nunca interrumpe a nadie. Los recolectores reales detienen tu programa en pausas cortas, al menos para parte de su trabajo. También mueven juguetes. G1 y ZGC copian los objetos vivos a lugares nuevos y actualizan cada referencia a ellos, algo que nunca ves desde el código Java. Y los juguetes no desaparecen en el momento en que se corta un hilo. Esperan hasta la siguiente recolección.

La mayoría de los objetos muere joven

La mayoría de los objetos de un programa típico se usan un momento y después se descartan: un string armado para una línea de log, un iterador, un record temporal. Esto se llama la hipótesis generacional, y los recolectores de HotSpot están construidos alrededor de ella. El heap se divide en una generación joven (young generation), adonde van los objetos nuevos, y una generación vieja (old generation) para los objetos que han durado.

La generación joven tiene un espacio eden, donde se reservan los objetos, y espacios survivor. Una recolección joven copia los objetos vivos de eden a un espacio survivor y después trata todo eden como libre. Nunca visita los objetos muertos, así que cuando la mayoría están muertos, es barata. Cada recolección que un objeto sobrevive le suma uno a su edad. Cuando es lo bastante viejo, se promueve a la generación vieja, que se recolecta con menos frecuencia.

eden (joven) survivor (joven) vieja A C D E B F edad 1 edad 1 S edad 3 G el programa se ejecuta recolección joven: programa en pausa el programa se ejecuta de nuevo simplificado: lo nuevo va a eden; S viene de GC previos la mayoría muere joven: A, C, D y E son inalcanzables una recolección joven copia B y F, vivos, a survivor S sobrevivió suficientes recolecciones: pasa a vieja los muertos no se copian: se libera todo eden eden queda vacío, y objetos nuevos como G lo llenan

Una recolección joven simplificada. La mayoría de los objetos nuevos en eden mueren. La recolección copia los vivos a un espacio survivor, promueve a la generación vieja un objeto que ya sobrevivió suficientes recolecciones y libera todo eden de una vez.

Estos son los pasos en palabras, por si la animación no se reproduce:

  1. Los objetos nuevos de A a F se reservan en eden. El espacio survivor ya tiene a S, que sobrevivió a recolecciones anteriores.
  2. El programa sigue, y A, C, D y E se vuelven inalcanzables. La mayoría de los objetos muere joven.
  3. Una recolección joven pausa el programa y copia los objetos vivos, B y F, a un espacio survivor. Ahora su edad es 1.
  4. S ya sobrevivió suficientes recolecciones, así que se promueve: se copia a la generación vieja.
  5. Los objetos muertos nunca se copian ni se visitan. Todo eden se libera de una vez.
  6. La pausa termina. Eden vuelve a estar vacío, y objetos nuevos como G empiezan a llenarlo.

El dibujo está simplificado. G1 no usa tres cajas fijas. Divide el heap en regiones iguales, de 2 MB cada una en esta máquina, y marca cada región como eden, survivor u old. La edad máxima antes de la promoción es MaxTenuringThreshold, que por defecto vale 15.

G1: el recolector por defecto, y sus logs

G1 es el recolector de basura por defecto de HotSpot, pero la JVM elige según la máquina. En esta, con 12 CPU y 16 GB de RAM, eligió G1:

$ java -XX:+PrintFlagsFinal -version | grep -E ' (UseG1GC|UseSerialGC|MaxHeapSize) '
   size_t MaxHeapSize                              = 4095737856                                {product} {ergonomic}
     bool UseG1GC                                  = true                                      {product} {ergonomic}
     bool UseSerialGC                              = false                                     {product} {default}
$ java -XX:ActiveProcessorCount=1 -Xlog:gc -version
[0.003s][info][gc] Using Serial
$ systemd-run --user --scope -q -p MemoryMax=1G java -Xlog:gc -version
[0.003s][info][gc] Using Serial

{ergonomic} significa que la JVM eligió el valor por su cuenta. Con una sola CPU, o dentro de un límite de memoria de 1 GB, eligió el recolector Serial. El heap máximo por defecto fue un cuarto de la memoria: unos 3.8 GB aquí, y 256 MB dentro del límite. En un contenedor, revisa qué eligió la JVM ahí.

Este programa crea 20 millones de órdenes y guarda una de cada mil:

record Order(int id, byte[] payload) {}

void main() {
    var recent = new ArrayList<Order>();
    long checksum = 0;
    for (int i = 0; i < 20_000_000; i++) {
        var order = new Order(i, new byte[256]);
        checksum += order.id() % 7;
        if (i % 1_000 == 0) {
            recent.add(order);
        }
    }
    IO.println("orders created: 20000000");
    IO.println("orders kept:    " + recent.size());
    IO.println("checksum:       " + checksum);
}

Imprime:

orders created: 20000000
orders kept:    20000
checksum:       59999997

-Xlog:gc,gc+heap hace que G1 reporte cada recolección. Limitamos el heap a 64 MB para que recolecte seguido. Estas son dos recolecciones de la mitad de una ejecución:

$ java -Xmx64m -Xlog:gc,gc+heap Main.java
[0.008s][info][gc] Using G1
...
[2.398s][info][gc,heap] GC(63) Eden regions: 37->0(37)
[2.398s][info][gc,heap] GC(63) Survivor regions: 1->1(5)
[2.398s][info][gc,heap] GC(63) Old regions: 12->13
[2.398s][info][gc     ] GC(63) Pause Young (Normal) (G1 Evacuation Pause) 48M->11M(64M) 0.944ms
...
[3.506s][info][gc,heap] GC(154) Eden regions: 37->0(37)
[3.506s][info][gc,heap] GC(154) Survivor regions: 1->1(5)
[3.506s][info][gc,heap] GC(154) Old regions: 17->17
[3.506s][info][gc     ] GC(154) Pause Young (Normal) (G1 Evacuation Pause) 52M->15M(64M) 1.813ms

Cada bloque es una recolección joven. Eden pasó de 37 regiones a 0, porque todo lo vivo se copió afuera. Alcanzó con una región survivor, ya que la mayoría de las órdenes ya estaban muertas. La mayoría de las recolecciones dejaron igual las regiones old, y más o menos una de cada veinte agregó una, a medida que las órdenes guardadas se promovían. Cada pausa tardó alrededor de un milisegundo.

G1 intenta mantener las pausas por debajo de un objetivo, MaxGCPauseMillis, que por defecto es 200 ms. Es una meta, no una garantía.

ZGC: cuando lo que más importa son las pausas

ZGC hace casi todo su trabajo mientras tu programa sigue corriendo, así que sus pausas son muy cortas, incluso con heaps grandes. Lo activas con -XX:+UseZGC:

$ java -XX:+UseZGC -Xlog:gc -version
[0.079s][info][gc] Using The Z Garbage Collector
$ java -XX:+UseZGC -XX:+ZGenerational -version 2>&1 | grep warning
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0

El segundo comando nos sorprendió. Las guías más viejas te dicen que agregues -XX:+ZGenerational. Desde el JDK 24, ZGC siempre es generacional, y el flag se ignora con una advertencia. Este es el programa de órdenes en ZGC, con sus líneas de pausa sacadas de -Xlog:gc*:

$ java -XX:+UseZGC -Xlog:gc* Main.java
[1.678s][info][gc          ] GC(0) Major Collection (Warmup)
[1.679s][info][gc,phases   ] GC(0) Y: Pause Mark Start (Major) 0.032ms
[1.702s][info][gc,phases   ] GC(0) Y: Pause Mark End 0.039ms
[1.704s][info][gc,phases   ] GC(0) Y: Pause Relocate Start 0.047ms
[1.712s][info][gc,phases   ] GC(0) O: Pause Mark End 0.033ms
[1.726s][info][gc,phases   ] GC(0) O: Pause Relocate Start 0.032ms
[1.726s][info][gc          ] GC(0) Major Collection (Warmup) 356M(9%)->42M(1%) 0.048s

Y: es la generación joven y O: es la vieja. En esta ejecución, cada pausa duró bastante menos de un milisegundo, mientras que la recolección completa tomó 48 ms en paralelo con el programa.

Elige ZGC cuando el problema son las pausas, como en un servicio con un heap grande y tiempos de respuesta estrictos. Hace su trabajo en hilos de fondo, así que necesita CPU libre. Si no tienes un problema de pausas, quédate con G1. Para trabajos batch donde solo importa el throughput, mide también -XX:+UseParallelGC.

Fugas de memoria en un lenguaje con recolector de basura

Un recolector de basura libera solo lo inalcanzable, así que una fuga en Java es un objeto que ya no usas pero que sigue siendo alcanzable. El recolector no puede saber que terminaste con él. Los culpables de siempre son una colección estática que solo crece, listeners que se agregan y nunca se quitan, y cachés sin límite.

import java.lang.ref.WeakReference;
import java.util.function.Consumer;

class EventBus {
    static final List<Consumer<String>> listeners = new ArrayList<>();

    static void subscribe(Consumer<String> listener) {
        listeners.add(listener);
    }

    static void unsubscribe(Consumer<String> listener) {
        listeners.remove(listener);
    }
}

class Screen {
    final byte[] image = new byte[10_000];
    final Consumer<String> listener = this::onEvent;

    void open() {
        EventBus.subscribe(listener);
    }

    void close(boolean unsubscribe) {
        if (unsubscribe) {
            EventBus.unsubscribe(listener);
        }
    }

    void onEvent(String event) {
    }
}

int stillAlive(List<WeakReference<Screen>> refs) {
    System.gc();
    int alive = 0;
    for (var ref : refs) {
        if (ref.get() != null) {
            alive++;
        }
    }
    return alive;
}

void run(boolean unsubscribe) {
    var refs = new ArrayList<WeakReference<Screen>>();
    for (int i = 0; i < 1_000; i++) {
        var screen = new Screen();
        screen.open();
        screen.close(unsubscribe);
        refs.add(new WeakReference<>(screen));
    }
    IO.println("unsubscribe on close: " + unsubscribe);
    IO.println("  listeners held: " + EventBus.listeners.size());
    IO.println("  screens the GC couldn't free: " + stillAlive(refs));
}

void main() {
    run(false);
    EventBus.listeners.clear();
    run(true);
}

Imprime:

unsubscribe on close: false
  listeners held: 1000
  screens the GC couldn't free: 1000
unsubscribe on close: true
  listeners held: 0
  screens the GC couldn't free: 0

Cada pantalla se cerró y se descartó, pero en la primera ejecución no se pudo liberar ninguna. La referencia a método this::onEvent guarda una referencia a su Screen. La lista estática guarda el listener, y un campo estático es una raíz de GC. Así que cada pantalla, con su imagen, siguió siendo alcanzable. Quitar el listener al cerrar lo arregló.

Los cachés tienen fugas de la misma forma. Un mapa que recuerda cada resultado para cada clave crece mientras el programa siga corriendo. Un LinkedHashMap puede desalojar su entrada más vieja cuando está lleno:

Map<String, String> unbounded = new HashMap<>();

Map<String, String> bounded = new LinkedHashMap<>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > 100;
    }
};

String render(String userId) {
    return "<profile of " + userId + ">";
}

void main() {
    for (int i = 0; i < 50_000; i++) {
        String userId = "user-" + i;
        unbounded.computeIfAbsent(userId, this::render);
        bounded.computeIfAbsent(userId, this::render);
    }
    IO.println("unbounded cache entries: " + unbounded.size());
    IO.println("bounded cache entries:   " + bounded.size());
    IO.println("newest still cached: " + bounded.containsKey("user-49999"));
    IO.println("oldest still cached: " + bounded.containsKey("user-0"));
}

Imprime:

unbounded cache entries: 50000
bounded cache entries:   100
newest still cached: true
oldest still cached: false

El true del constructor ordena las entradas por último acceso. removeEldestEntry corre después de cada inserción, y devolver true elimina la entrada usada hace más tiempo. El mapa sin límite guardó los 50,000 usuarios, y el limitado guardó 100.

OutOfMemoryError: el heap está lleno de objetos vivos

Cuando un objeto nuevo no cabe en el heap ni siquiera después de una recolección, la JVM lanza OutOfMemoryError. Una fuga te lleva ahí despacio. Este programa llega rápido, porque guarda cada bloque que reserva:

List<byte[]> kept = new ArrayList<>();

void main() {
    IO.println("keeping 1 MB blocks until the heap runs out");
    while (true) {
        kept.add(new byte[1_000_000]);
    }
}

No se ejecuta con los demás, porque tiene que fallar. Ejecútalo con un heap de 16 MB, y también imprime un stack trace, recortado aquí:

$ java -Xmx16m Main.java
keeping 1 MB blocks until the heap runs out
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

Java heap space significa que objetos comunes llenaron el heap. -Xmx fija el tamaño máximo del heap. -XX:+HeapDumpOnOutOfMemoryError escribe un heap dump cuando esto pasa, y una herramienta como VisualVM o Eclipse MAT puede abrirlo para mostrar qué estaba ocupando la memoria.

También probamos con un heap de 8 MB:

$ java -Xmx8m Main.java
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.base/java.util.EnumMap.values(EnumMap.java:423)
	at jdk.compiler/com.sun.tools.javac.util.Log.flush(Log.java:486)

Nuestra primera línea nunca se imprimió. java Main.java compila tu código fuente dentro de la misma JVM antes de ejecutarlo, y el compilador se quedó sin heap primero. Los frames mencionan javac, no Main.

El JIT: el código se vuelve más rápido mientras corre

La JVM empieza interpretando bytecode, una instrucción a la vez. Cuenta cuántas veces corre cada método. Un método que se vuelve caliente lo compila a código máquina C1, un compilador rápido que además registra datos de perfilado. Si sigue caliente, C2 lo compila otra vez y usa ese perfil para optimizar más a fondo. Esto es la compilación por niveles (tiered compilation), y está activada por defecto.

int digitSum(int n) {
    int sum = 0;
    while (n > 0) {
        sum += n % 10;
        n /= 10;
    }
    return sum;
}

void main() {
    long total = 0;
    for (int i = 0; i < 5_000_000; i++) {
        total += digitSum(i);
    }
    IO.println("sum of all digits below 5,000,000: " + total);
}

Imprime:

sum of all digits below 5,000,000: 145000000

-XX:+PrintCompilation imprime una línea por cada compilación. Estas son las líneas de digitSum de una ejecución. Los tiempos y los IDs cambian en cada ejecución:

$ java -XX:+PrintCompilation Main.java | grep digitSum
1115 1544       3       Main::digitSum (23 bytes)
1116 1545       4       Main::digitSum (23 bytes)
1118 1544       3       Main::digitSum (23 bytes)   made not entrant: not used
1124 1548 %     4       Main::digitSum @ 2 (23 bytes)

Las columnas son los milisegundos desde el inicio, un ID de compilación, flags, el nivel y el método. El nivel 3 es C1 con perfilado, y el nivel 4 es C2. “Made not entrant” significa que la versión de C1 se retiró cuando la de C2 estuvo lista. % marca un reemplazo en el stack (on-stack replacement): una versión compilada del bucle while a la que puede saltar una llamada que ya está dentro del bucle.

La ejecución completa imprimió más de 1,700 líneas, y unas 1,500 compilaciones llegaron antes que la de digitSum. La mayoría eran del propio javac, compilando tu archivo.

Calentamiento, y por qué los micro-benchmarks mienten

Como el código empieza lento y se acelera, medirlo una sola vez te dice muy poco. Esta versión mide ocho rondas iguales. No se puede ejecutar con los demás, porque imprime tiempos:

int digitSum(int n) {
    int sum = 0;
    while (n > 0) {
        sum += n % 10;
        n /= 10;
    }
    return sum;
}

void main() {
    for (int round = 1; round <= 8; round++) {
        long start = System.nanoTime();
        long total = 0;
        for (int i = 0; i < 50_000; i++) {
            total += digitSum(i);
        }
        long micros = (System.nanoTime() - start) / 1_000;
        IO.println("round " + round + ": " + micros + " microseconds, total " + total);
    }
}
$ java Main.java
round 1: 4776 microseconds, total 1000000
round 2: 3878 microseconds, total 1000000
round 3: 3254 microseconds, total 1000000
round 4: 3717 microseconds, total 1000000
round 5: 3669 microseconds, total 1000000
round 6: 3696 microseconds, total 1000000
round 7: 1539 microseconds, total 1000000
round 8: 659 microseconds, total 1000000
$ java -Xint Main.java
round 1: 11436 microseconds, total 1000000
...
round 8: 11645 microseconds, total 1000000

El mismo trabajo se volvió unas siete veces más rápido para la ronda 8. Con -Xint nunca se aceleró. En otras ejecuciones el salto llegó una ronda antes o después. Si mides las primeras rondas, mides el intérprete y el compilador, no tu código. Las pausas del GC y los cambios de frecuencia de la CPU agregan más ruido.

Para mediciones reales, usa JMH, el Java Microbenchmark Harness del proyecto OpenJDK. Se encarga del calentamiento, lanza JVMs nuevas y reporta márgenes de error. Es una biblioteca aparte, así que esta serie no la usa.

Análisis de escape

El compilador C2 hace análisis de escape (escape analysis). Revisa si un objeto creado en código compilado puede verse fuera de él. Si no puede, C2 puede saltarse la reserva y mantener los campos del objeto en variables locales o en registros de la CPU. Eso es el reemplazo escalar (scalar replacement). No puedes observarlo desde el código Java: la salida es la misma, y ninguna API dice que se eliminó una reserva. Solo puedes verlo desde afuera, por ejemplo en los logs del GC. Un bucle que creó un pequeño record Point(long x, long y) 200 millones de veces, sin dejar que saliera del bucle, provocó una pausa del GC en esta máquina. Con -XX:-DoEscapeAnalysis, provocó 17. Es una optimización que el JIT puede aplicar, no una regla en la que puedas confiar.

Qué pasa cuando se carga una clase

Antes de que el código de una clase pueda correr, la JVM la carga (lee el bytecode), la enlaza (verifica el bytecode y prepara los campos estáticos con valores por defecto) y la inicializa (ejecuta los inicializadores de campos estáticos y los bloques static, de arriba hacia abajo). La carga puede ocurrir temprano, pero la inicialización espera al primer uso real, como leer un campo estático. Por eso el bloque static de la parte sobre clases corrió después de que main había empezado.

class Config {
    static final int MAX_USERS = 100;
    static final String VERSION = "2.1";
    static final List<String> REGIONS = List.of("eu", "us");

    static {
        IO.println("  Config is being initialised");
    }
}

void main() {
    IO.println("1. declare an array of Config");
    Config[] slots = new Config[3];
    IO.println("2. use the class literal: " + Config.class.getSimpleName());
    IO.println("3. read a constant int: " + Config.MAX_USERS);
    IO.println("4. read a constant String: " + Config.VERSION);
    IO.println("5. read a List field: " + Config.REGIONS);
    IO.println("6. read it again: " + Config.REGIONS.size() + " regions, "
            + slots.length + " slots");
}

Imprime:

1. declare an array of Config
2. use the class literal: Config
3. read a constant int: 100
4. read a constant String: 2.1
  Config is being initialised
5. read a List field: [eu, us]
6. read it again: 2 regions, 3 slots

Crear un array de Config y usar Config.class no inicializó la clase. Leer MAX_USERS y VERSION tampoco. Son constantes de tiempo de compilación, así que javac copió 100 y "2.1" directamente en main. REGIONS es un campo real que se lee en tiempo de ejecución, así que el paso 5 disparó la inicialización, una sola vez. El paso 6 no la repitió.

-Xlog:class+load,class+init muestra los pasos. Estas son las líneas sobre nuestras dos clases, con la ruta acortada:

$ java -Xlog:class+load,class+init Main.java
[1.341s][info][class,load] Main source: file:/home/you/Main.java
1. declare an array of Config
[1.344s][info][class,load] Main$Config source: file:/home/you/Main.java
2. use the class literal: Config
3. read a constant int: 100
4. read a constant String: 2.1
[1.345s][info][class,init] Start class verification for: Main$Config
[1.345s][info][class,init] End class verification for: Main$Config
  Config is being initialised
5. read a List field: [eu, us]

Main$Config se cargó apenas main necesitó el tipo array, pero se verificó e inicializó recién en el paso 5. El Main$ está ahí porque un archivo fuente compacto envuelve todo en una clase llamada Main.

El log completo listó unas 2,600 clases cargadas, unas 1,100 de ellas de jdk.compiler. Compilado con javac y ejecutado como java Main, el programa cargó unas 600, y Main se cargó a los 0.06 segundos en lugar de 1.3.

Qué recordar

  • Cada hilo tiene un stack de frames con variables locales y referencias. Los objetos viven en el heap compartido. Una recursión profunda lanza StackOverflowError, y -Xss fija el tamaño del stack.
  • Un objeto es basura cuando ninguna cadena de referencias llega a él desde una raíz de GC. Los objetos que solo se refieren entre sí siguen siendo basura.
  • La mayoría de los objetos muere joven. Una recolección joven copia los pocos vivos fuera de eden y libera el resto de una vez. Los objetos de larga vida se promueven a la generación vieja.
  • G1 es el valor por defecto habitual, pero la JVM elige según las CPU y la memoria. Elige ZGC cuando lo que más importa son las pausas, y compruébalo con -Xlog:gc.
  • Una fuga en Java es un objeto con el que ya terminaste y que sigue siendo alcanzable: una lista estática que crece, un listener que nunca se quita, un caché sin límite.
  • El JIT compila el código caliente por niveles, así que el código se acelera mientras corre. No confíes en un bucle cronometrado a mano. Usa JMH.
  • Las clases se cargan, enlazan e inicializan de forma perezosa, y java Main.java primero ejecuta el compilador en la misma JVM.

El recolector de basura libera lo que nada puede alcanzar, así que una fuga siempre es algo que todavía estás sosteniendo.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.