Blog

Por dentro da JVM: stack, heap, garbage collection e o JIT

Onde o Java guarda suas variáveis e objetos, como o garbage collector decide o que liberar, por que um programa com garbage collection ainda pode vazar ou ficar sem memória, e como o JIT deixa o código mais rápido quanto mais ele roda.

Quando você roda um programa Java, a JVM faz muito trabalho que você nunca vê. Ela mantém uma stack de frames para cada thread, coloca todo objeto num heap compartilhado, libera os objetos que ninguém alcança e compila seus métodos mais usados para código de máquina enquanto o programa roda. Saber como isso funciona explica stack overflows, vazamentos, OutOfMemoryError e benchmarks que mentem.

Este post cobre a stack e o heap, alcançabilidade, garbage collection geracional com G1 e ZGC, vazamentos, o JIT e o que acontece quando uma classe é carregada. Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um você mesmo, salve como Main.java e execute java Main.java. Logs de GC e tempos mudam a cada execução, então aparecem como sessões de terminal resumidas, e os seus números vão ser diferentes.

Toda thread tem uma stack de frames

Cada vez que um método é chamado, a JVM empilha um frame na stack da thread atual. O frame guarda os parâmetros e as variáveis locais daquela chamada. Para um primitivo, é o próprio valor. Para um objeto, é uma referência, e o objeto mora no heap. Quando o método retorna, o frame dele é desempilhado.

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);
}

Ele 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]

Quatro chamadas a sumTo estavam na stack ao mesmo tempo, e cada uma tinha seus próprios n, rest e total. Elas terminaram na ordem inversa, do frame de baixo para cima.

makeNames mostra a outra metade. A variável local names sumiu assim que o método retornou, mas o ArrayList não estava no frame. Ele estava no heap, e main recebeu uma cópia da referência. A parte sobre valores e referências explica como referências são copiadas.

Quando a stack acaba: StackOverflowError

A stack de uma thread tem um tamanho máximo fixo, e uma recursão sem caso base enche essa stack. A JVM então lança StackOverflowError. É um Error, não uma Exception, mas você ainda pode capturá-lo:

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");
}

Ele imprime:

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

O erro não tem mensagem. Quando o bloco catch roda, os frames já foram desempilhados, então sobra espaço para imprimir. Em código de verdade, corrija a recursão.

O programa não imprime a profundidade exata, porque ela muda de uma execução para outra. Veja o que aconteceu quando trocamos o bloco catch para imprimir depth e rodamos algumas vezes:

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

Isso nos surpreendeu. -Xint roda só o interpretador, e ele parou na mesma profundidade toda vez. Com o JIT ligado, a profundidade variou e chegou quase ao dobro. Depois que o JIT compila dive, os frames dele ocupam menos espaço que frames interpretados, e quantas chamadas acontecem antes dessa troca depende do timing. O JIT aparece mais adiante.

Para dar uma stack maior às threads, use -Xss. java -Xss4m Main.java define 4 MB, e numa execução isso deixou o mesmo programa chegar a 77.347 chamadas de profundidade. O padrão neste JDK Linux de 64 bits é 1 MB (ThreadStackSize = 1024, em KB).

Um objeto vira lixo quando nada o alcança

O garbage collector libera um objeto quando nenhuma cadeia de referências leva até ele a partir de uma GC root. As roots são as referências que a JVM sabe que estão em uso: variáveis locais em todo frame vivo de toda thread, campos estáticos de classes carregadas e algumas internas. Tudo o que o coletor alcança seguindo referências a partir das roots fica. Todo o resto é lixo.

Dá para ver isso com uma WeakReference. Uma referência fraca deixa você olhar para um objeto sem mantê-lo vivo. Quando só sobram referências fracas, o coletor pode limpá-las. Neste programa, os dois nós apontam um para o outro:

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));
}

Ele imprime:

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

Os dois nós ainda se referiam um ao outro quando foram coletados. Isso não os mantém vivos, porque o coletor não conta referências. Ele rastreia a partir das roots, e depois que as variáveis locais de main viraram null, nenhum caminho levava a nenhum dos dois nós.

System.gc() é só um pedido. A JVM pode ignorá-lo. Rodamos este programa 25 vezes com cada um dos coletores Serial, Parallel, G1, ZGC e Shenandoah, e a referência fraca foi limpa todas as vezes. É isso que torna seguro mostrá-lo aqui. Não é uma promessa, e uma flag prova isso:

$ 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

No G1, System.gc() roda uma coleta completa. Com -XX:+DisableExplicitGC ele não faz nada, e o nó sobrevive. Código de verdade nunca deve depender de System.gc().

Explicado como se você tivesse dez anos

O heap é uma grande sala de brinquedos compartilhada. Toda criança da casa pode colocar brinquedos lá.

A stack é a bandeja de cada criança, em cima da mesa dela. A bandeja guarda aquilo com que ela está brincando agora, e alguns barbantes. Cada barbante vai da bandeja até um brinquedo na sala. Brinquedos também podem ter barbantes ligados a outros brinquedos.

O garbage collector é um ajudante que arruma a sala. Ele não pergunta se um brinquedo parece importante. Ele começa pelas bandejas e segue cada barbante. Todo brinquedo que ele alcança seguindo barbantes fica. Todo brinquedo que ninguém segura por um barbante volta para a caixa, mesmo dois brinquedos amarrados um no outro.

A versão precisa

A stack de uma thread guarda frames. Um frame guarda as variáveis locais e os valores intermediários do método, incluindo referências. Objetos são alocados no heap, que todas as threads compartilham. O coletor encontra os objetos vivos rastreando: ele começa nas GC roots e marca tudo o que é alcançável por referências. A memória ocupada por objetos não marcados é recuperada. Como e quando isso acontece depende do coletor.

Onde a analogia falha: o ajudante da história nunca interrompe ninguém. Coletores de verdade param o seu programa por pausas curtas, pelo menos para parte do trabalho. Eles também mudam brinquedos de lugar. G1 e ZGC copiam objetos vivos para novos lugares e atualizam toda referência a eles, algo que você nunca vê a partir do código Java. E brinquedos não somem no momento em que um barbante é cortado. Eles esperam até a próxima coleta.

A maioria dos objetos morre jovem

A maioria dos objetos num programa típico é usada rapidamente e depois descartada: uma string montada para uma linha de log, um iterador, um record temporário. Isso se chama hipótese geracional (generational hypothesis), e os coletores do HotSpot são construídos em torno dela. O heap é dividido numa young generation (geração jovem), para onde vão os objetos novos, e numa old generation (geração antiga), para objetos que duraram.

A young generation tem um espaço eden, onde os objetos são alocados, e espaços survivor. Uma coleta jovem (young collection) copia os objetos vivos do eden para um espaço survivor e depois trata o eden inteiro como livre. Ela nunca visita objetos mortos, então, quando a maioria dos objetos está morta, ela sai barata. Cada coleta que um objeto sobrevive soma um à idade dele. Quando fica velho o bastante, ele é promovido para a old generation, que é coletada com menos frequência.

eden (young) survivor (young) old A C D E B F idade 1 idade 1 S idade 3 G o programa está rodando coleta jovem: o programa está pausado o programa volta a rodar simplificado: objetos novos no eden; S sobreviveu a GCs a maioria morre jovem: A, C, D e E estão inalcançáveis a coleta jovem copia B e F, vivos, para o survivor S sobreviveu a coletas suficientes: promovido ao old objetos mortos não são copiados: o eden todo é liberado o eden esvazia, e objetos novos como G voltam a ocupá-lo

Uma coleta jovem simplificada. A maioria dos objetos novos no eden morre. A coleta copia os vivos para um espaço survivor, promove para old um objeto que sobreviveu a coletas suficientes e libera o eden inteiro de uma vez.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. Os objetos novos de A a F são alocados no eden. O espaço survivor já guarda S, que sobreviveu a coletas anteriores.
  2. O programa segue em frente, e A, C, D e E ficam inalcançáveis. A maioria dos objetos morre jovem.
  3. Uma coleta jovem pausa o programa e copia os objetos vivos, B e F, para um espaço survivor. A idade deles agora é 1.
  4. S sobreviveu a coletas suficientes, então é promovido: copiado para a old generation.
  5. Os objetos mortos nunca são copiados nem visitados. O eden inteiro é liberado de uma vez.
  6. A pausa termina. O eden está vazio de novo, e objetos novos como G começam a ocupá-lo.

O desenho é simplificado. O G1 não usa três caixas fixas. Ele divide o heap em regiões iguais, de 2 MB cada nesta máquina, e marca cada região como eden, survivor ou old. A idade máxima antes da promoção é MaxTenuringThreshold, que por padrão é 15.

G1: o coletor padrão, e seus logs

O G1 é o garbage collector padrão do HotSpot, mas a JVM escolhe com base na máquina. Nesta aqui, com 12 CPUs e 16 GB de RAM, ela escolheu o 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} quer dizer que a JVM escolheu o valor sozinha. Com uma CPU, ou dentro de um limite de memória de 1 GB, ela escolheu o coletor Serial. O heap máximo padrão foi um quarto da memória: cerca de 3,8 GB aqui, e 256 MB dentro do limite. Num container, confira o que a JVM escolheu lá.

Este programa cria 20 milhões de pedidos e guarda um a 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);
}

Ele imprime:

orders created: 20000000
orders kept:    20000
checksum:       59999997

-Xlog:gc,gc+heap faz o G1 relatar cada coleta. Limitamos o heap a 64 MB para ele coletar com frequência. Aqui estão duas coletas do meio de uma execução:

$ 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 bloco é uma coleta jovem. O eden foi de 37 regiões para 0, porque tudo o que estava vivo foi copiado para fora. Uma região survivor bastou, já que a maioria dos pedidos já estava morta. A maioria das coletas deixou as regiões old como estavam, e mais ou menos uma em cada vinte somou uma, conforme pedidos guardados eram promovidos. Cada pausa levou cerca de um milissegundo.

O G1 tenta manter as pausas abaixo de uma meta, MaxGCPauseMillis, que por padrão é 200 ms. É um objetivo, não uma garantia.

ZGC: quando o tempo de pausa é o que mais importa

O ZGC faz quase todo o trabalho enquanto o seu programa continua rodando, então as pausas dele ficam muito curtas, mesmo com heaps grandes. Você o liga com -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

O segundo comando nos surpreendeu. Guias mais antigos mandam adicionar -XX:+ZGenerational. Desde o JDK 24, o ZGC é sempre geracional, e a flag é ignorada com um aviso. Aqui está o programa de pedidos no ZGC, com as linhas de pausa separadas do -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: é a young generation e O: é a old. Nesta execução, cada pausa ficou bem abaixo de um milissegundo, enquanto a coleta inteira levou 48 ms junto com o programa.

Escolha o ZGC quando as pausas são o problema, como num serviço com heap grande e tempos de resposta rígidos. Ele faz o trabalho em threads de segundo plano, então precisa de CPU sobrando. Sem um problema de pausa, fique no G1. Para jobs em lote em que só o throughput importa, meça também -XX:+UseParallelGC.

Vazamentos de memória numa linguagem com garbage collector

Um garbage collector libera só o que é inalcançável, então um vazamento em Java é um objeto que você parou de usar mas que continua alcançável. O coletor não tem como saber que você terminou de usá-lo. Os culpados de sempre são uma coleção estática que só cresce, listeners que são adicionados e nunca removidos, e caches sem limite.

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);
}

Ele 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

Toda tela foi fechada e descartada, mas na primeira execução nenhuma pôde ser liberada. A method reference this::onEvent guarda uma referência para a sua Screen. A lista estática guarda o listener, e um campo estático é uma GC root. Então toda tela, com a sua imagem, continuou alcançável. Remover o listener ao fechar resolveu.

Caches vazam do mesmo jeito. Um map que lembra todo resultado para toda chave cresce enquanto o programa rodar. Um LinkedHashMap pode remover a entrada mais antiga quando fica cheio:

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"));
}

Ele imprime:

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

O true no construtor ordena as entradas pelo último acesso. removeEldestEntry roda depois de cada inserção, e retornar true descarta a entrada usada há mais tempo. O map sem limite guardou os 50.000 usuários, e o com limite guardou 100.

OutOfMemoryError: o heap está cheio de objetos vivos

Quando um objeto novo não cabe no heap nem depois de uma coleta, a JVM lança OutOfMemoryError. Um vazamento leva você até lá devagar. Este programa chega lá rápido, guardando cada bloco que aloca:

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]);
    }
}

Ele não roda junto com os outros, porque precisa falhar. Rode com um heap de 16 MB, e ele também imprime uma stack trace, resumida aqui:

$ 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 quer dizer que objetos comuns encheram o heap. -Xmx define o tamanho máximo do heap. -XX:+HeapDumpOnOutOfMemoryError grava um heap dump quando isso acontece, e uma ferramenta como o VisualVM ou o Eclipse MAT abre esse arquivo e mostra o que estava segurando a memória.

Também testamos um 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)

A nossa primeira linha nunca foi impressa. java Main.java compila o seu código-fonte dentro da mesma JVM antes de rodá-lo, e o compilador ficou sem heap primeiro. Os frames citam o javac, não o Main.

O JIT: o código fica mais rápido enquanto roda

A JVM começa interpretando bytecode, uma instrução por vez. Ela conta quantas vezes cada método roda. Um método que fica quente é compilado para código de máquina pelo C1, um compilador rápido que também registra dados de profiling. Se continuar quente, o C2 o compila de novo, usando esse profile para otimizar mais a fundo. Isso é a compilação em camadas (tiered compilation), e ela vem ligada por padrão.

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);
}

Ele imprime:

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

-XX:+PrintCompilation imprime uma linha para cada compilação. Aqui estão as linhas de digitSum de uma execução. Os tempos e IDs mudam a cada execução:

$ 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)

As colunas são milissegundos desde o início, um ID de compilação, flags, a camada (tier) e o método. O tier 3 é o C1 com profiling, e o tier 4 é o C2. “Made not entrant” quer dizer que a versão do C1 foi aposentada quando a do C2 ficou pronta. % marca uma on-stack replacement: uma versão compilada do laço while para a qual uma chamada que já está dentro do laço pode pular.

A execução inteira imprimiu mais de 1.700 linhas, e cerca de 1.500 compilações vieram antes das de digitSum. A maioria era do próprio javac, compilando o seu arquivo.

Warm-up, e por que micro-benchmarks mentem

Como o código começa lento e acelera, medir o tempo uma vez diz muito pouco. Esta versão mede oito rodadas iguais. Ela não roda junto com os outros, porque imprime tempos:

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

O mesmo trabalho ficou cerca de sete vezes mais rápido na rodada 8. Com -Xint, ele nunca acelerou. Em outras execuções, o salto veio uma rodada antes ou depois. Meça as primeiras rodadas e você mede o interpretador e o compilador, não o seu código. Pausas de GC e mudanças de frequência da CPU adicionam ainda mais ruído.

Para medições de verdade, use o JMH, o Java Microbenchmark Harness do projeto OpenJDK. Ele cuida do warm-up, cria JVMs novas com fork e informa margens de erro. É uma biblioteca separada, então esta série não o usa.

Escape analysis

O compilador C2 roda escape analysis (análise de escape). Ele verifica se um objeto criado em código compilado pode ser visto fora dele. Se não pode, o C2 pode pular a alocação e manter os campos do objeto em variáveis locais ou em registradores da CPU. Isso é scalar replacement (substituição escalar). Você não consegue observar isso a partir do código Java: a saída é a mesma, e nenhuma API diz que uma alocação foi removida. Só dá para ver de fora, por exemplo nos logs de GC. Um laço que criou um pequeno record Point(long x, long y) 200 milhões de vezes, sem nunca deixá-lo sair do laço, causou uma pausa de GC nesta máquina. Com -XX:-DoEscapeAnalysis, causou 17. É uma otimização que o JIT pode aplicar, não uma regra em que você pode confiar.

O que acontece quando uma classe é carregada

Antes que o código de uma classe possa rodar, a JVM a carrega (lê o bytecode), faz o linking (verifica o bytecode e prepara os campos estáticos com valores padrão) e a inicializa (roda os inicializadores de campos estáticos e os blocos static, de cima para baixo). O carregamento pode acontecer cedo, mas a inicialização espera o primeiro uso de verdade, como a leitura de um campo estático. É por isso que o bloco static da parte sobre classes rodou depois que main já tinha começado.

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");
}

Ele 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

Criar um array de Config e usar Config.class não inicializou a classe. Ler MAX_USERS e VERSION também não. Elas são constantes de tempo de compilação, então o javac copiou 100 e "2.1" direto para dentro de main. REGIONS é um campo de verdade, lido em tempo de execução, então o passo 5 disparou a inicialização, uma única vez. O passo 6 não a repetiu.

-Xlog:class+load,class+init mostra as etapas. Aqui estão as linhas sobre as nossas duas classes, com o caminho encurtado:

$ 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 foi carregada assim que main precisou do tipo do array, mas só foi verificada e inicializada no passo 5. O Main$ está ali porque um arquivo-fonte compacto embrulha tudo numa classe chamada Main.

O log completo listou cerca de 2.600 classes carregadas, cerca de 1.100 delas do jdk.compiler. Compilado com javac e rodado como java Main, o programa carregou cerca de 600, e Main foi carregada depois de 0,06 segundo em vez de 1,3.

O que lembrar

  • Cada thread tem uma stack de frames com variáveis locais e referências. Os objetos moram no heap compartilhado. Recursão profunda lança StackOverflowError, e -Xss define o tamanho da stack.
  • Um objeto é lixo quando nenhuma cadeia de referências leva até ele a partir de uma GC root. Objetos que só se referem uns aos outros continuam sendo lixo.
  • A maioria dos objetos morre jovem. Uma coleta jovem copia os poucos vivos para fora do eden e libera o resto de uma vez. Objetos de vida longa são promovidos para a old generation.
  • O G1 costuma ser o padrão, mas a JVM escolhe com base nas CPUs e na memória. Escolha o ZGC quando o tempo de pausa é o que mais importa, e confira com -Xlog:gc.
  • Um vazamento em Java é um objeto que você não usa mais e que continua alcançável: uma lista estática que cresce, um listener nunca removido, um cache sem limite.
  • O JIT compila código quente em camadas, então o código acelera enquanto roda. Não confie num laço cronometrado à mão. Use o JMH.
  • Classes são carregadas, linkadas e inicializadas sob demanda, e java Main.java roda o compilador na mesma JVM primeiro.

O garbage collector libera o que nada alcança, então um vazamento é sempre algo que você ainda está segurando.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.