A Máquina por Trás da Máquina — Class Loading, Pilha, Heap e JIT

A Máquina por Trás da Máquina — Class Loading, Pilha, Heap e JIT

O que a JVM faz com o seu programa: class loaders e delegação, a inicialização preguiçosa das classes, a pilha de cada thread, o heap e o metaspace, o tamanho real de um objeto, o JIT em camadas com C1 e C2, inlining, desotimização e análise de escape, e o jcmd para examinar uma JVM rodando.
Java

• • 18 min de leitura

A primeira aula da Fase 6: o que a JVM faz com o seu programa, desde carregar as classes até transformar o bytecode em código de máquina otimizado, e onde cada coisa vive na memória.

A Fase 5 terminou diante de uma caixa-preta: a própria JVM. Desde "Do Código ao Bytecode", a JVM apareceu em forma de resumo: ela carrega as classes, verifica o bytecode, interpreta, compila com o JIT e recolhe o lixo. Ao longo do curso, esses mecanismos deixaram rastros: o StackOverflowError da recursão, os construtores <init> nos stack traces, as medições de desempenho que mudavam entre a primeira e a segunda rodada. Esta aula examina de perto o que acontece entre o java digitado no terminal e o programa rodando: como as classes chegam à memória, como a memória é organizada, e como o JIT transforma um programa interpretado em código de máquina otimizado, às vezes melhor do que um compilador tradicional conseguiria.

Carregando as classes

Um programa Java não é carregado de uma vez. A JVM carrega cada classe quando ela é necessária pela primeira vez, a partir do arquivo .class correspondente. Quem faz isso são os class loaders, objetos que sabem encontrar e ler bytecode. Há três principais, organizados numa hierarquia:

IO.println("String: " + String.class.getClassLoader());
IO.println("java.sql.Date: " + java.sql.Date.class.getClassLoader());
IO.println("Carregadores: " + Carregadores.class.getClassLoader());
IO.println("pai do app: " + Carregadores.class.getClassLoader().getParent());
String: null
java.sql.Date: jdk.internal.loader.ClassLoaders$PlatformClassLoader@14dad5dc
Carregadores: jdk.internal.loader.ClassLoaders$AppClassLoader@341d43cd
pai do app: jdk.internal.loader.ClassLoaders$PlatformClassLoader@14dad5dc
  • O bootstrap, escrito em código nativo dentro da JVM, carrega os módulos fundamentais, como java.base. Por não ser um objeto Java, ele aparece como null.
  • O platform class loader carrega os demais módulos do JDK, como java.sql.
  • O application class loader carrega as suas classes, a partir do classpath ou do module path de "Fronteiras Explícitas".

Quando um class loader recebe o pedido de uma classe, ele primeiro pergunta ao pai. Só se o pai não a encontrar, ele procura por conta própria. Esse modelo de delegação garante que uma classe como java.lang.String venha sempre do JDK. Um arquivo String.class malicioso no classpath do programa nunca seria usado. Servidores de aplicação e frameworks criam os seus próprios class loaders, para carregar e descarregar partes de um programa sem reiniciar a JVM.

Com a opção -Xlog:class+load, a JVM registra cada classe carregada. Um programa mínimo carrega centenas de classes antes de chegar ao main (pouco mais de 400 num teste com o JDK 27), a maior parte delas da biblioteca padrão. Quase todas vêm marcadas como source: shared objects file: a JVM mantém um arquivo com as classes mais usadas do JDK já pré-processadas, o CDS (Class Data Sharing), que é mapeado direto para a memória e acelera a inicialização. "Do JAR ao Executável" mostra como estender esse mecanismo às classes do seu próprio programa.

As etapas até uma classe poder ser usada

Depois de carregada, uma classe passa por mais etapas:

  1. Verificação. A JVM confere se o bytecode é válido e seguro: se os tipos batem, se nenhuma instrução usa a pilha de forma incorreta, se não há saltos para fora do método. É a herança da segurança dos applets de "Uma Linguagem para Torradeiras que Conquistou o Mundo".
  2. Preparação. A memória para os campos estáticos é reservada e preenchida com valores padrão.
  3. Resolução. As referências simbólicas do bytecode, como os #7 que o javap mostrava, são transformadas em referências diretas, geralmente quando usadas pela primeira vez.
  4. Inicialização. Os valores iniciais dos campos estáticos são atribuídos, e os blocos static { ... } são executados.

A inicialização também é preguiçosa: acontece no primeiro uso real da classe. Um exemplo, com um bloco de inicialização estático, um trecho de código executado uma vez, quando a classe é inicializada:

public class Preguica {
    static class Configuracao {
        static {
            IO.println("  [inicializando Configuracao]");
        }
        static final String NOME = "sistema";
        static int acessos = 0;
    }

    static class NuncaUsada {
        static {
            IO.println("  [isto nunca aparece]");
        }
    }

    public static void main(String[] args) {
        IO.println("main começou");
        IO.println("constante: " + Configuracao.NOME);
        IO.println("antes do primeiro acesso real");
        Configuracao.acessos++;
        IO.println("acessos: " + Configuracao.acessos);
    }
}
main começou
constante: sistema
antes do primeiro acesso real
  [inicializando Configuracao]
acessos: 1

NuncaUsada nunca é inicializada, porque o programa nunca a usa. E a leitura de Configuracao.NOME também não disparou a inicialização. NOME é uma constante, um campo static final com um valor conhecido na compilação, e o compilador copiou o texto "sistema" direto para dentro de main, como fazia com "ja" + "va" em "Iguais ou o Mesmo?". Só o acesso a acessos, um campo que muda, exigiu a classe inicializada. Essa preguiça é o motivo de programas Java grandes começarem a rodar antes de ter carregado todo o seu código. Ela também é a base de uma técnica clássica para criar objetos únicos sob demanda, sem nenhuma sincronização, que aparece na Fase 7.

A memória da JVM

A JVM divide a memória que usa em várias áreas. As três que mais importam para o programador:

A pilha de cada thread (stack). Cada thread tem a sua própria pilha, onde cada chamada de método ocupa um quadro (frame) com os parâmetros, as variáveis locais e os valores intermediários das contas, a pilha de operandos que o javap mostrou em "Do Código ao Bytecode". Quando o método termina, o quadro é descartado, sem custo nenhum. As variáveis locais de tipos primitivos vivem inteiras na pilha. As de tipos de referência guardam na pilha só a referência. A pilha tem tamanho fixo:

static int profundidade = 0;

static void descer() {
    profundidade++;
    descer();
}

Com o tamanho padrão, a recursão estourou com cerca de 23 mil chamadas. Com -Xss256k, estourou com 3.600. Com -Xss4m, com mais de 100 mil. O número exato depende do tamanho de cada quadro e da máquina. Aumentar a pilha resolve uma recursão legitimamente profunda, mas o StackOverflowError de "Dividir para Resolver" quase sempre indica uma recursão infinita, e aí nenhum tamanho basta.

O heap. Todos os objetos e arrays vivem no heap, compartilhado por todas as threads. É a área gerenciada pelo coletor de lixo. O tamanho máximo é definido com -Xmx, e o padrão é um quarto da memória física da máquina. Num servidor de 8 GB, o heap pode chegar a 2 GB. Esgotá-lo produz o erro mais temido da JVM:

List<byte[]> retidos = new ArrayList<>();
try {
    while (true) {
        retidos.add(new byte[1024 * 1024]);
    }
} catch (OutOfMemoryError e) {
    int mb = retidos.size();
    retidos = null;
    IO.println("OutOfMemoryError depois de reter " + mb + " MB: " + e.getMessage());
}

Com -Xmx64m, o programa parou com OutOfMemoryError: Java heap space depois de reter apenas 31 MB, menos da metade do máximo. Por que não 64 é uma pergunta que a próxima aula responde, e diz muito sobre como o coletor organiza o heap. Capturar um OutOfMemoryError, como aqui, só faz sentido em demonstrações: é um Error, e depois dele o estado do programa não é confiável.

O metaspace. Guarda as informações sobre as classes: a estrutura de cada uma, os métodos e o bytecode. Fica fora do heap e cresce conforme as classes são carregadas. Programas que geram classes dinamicamente sem descartá-las podem esgotá-lo, com OutOfMemoryError: Metaspace.

O que ocupa um objeto

Cada objeto no heap tem, além dos campos, um cabeçalho com informações da JVM: uma referência à classe, o hashCode de identidade, os bits usados pelo coletor e pelas travas da Fase 7. Na configuração padrão, o cabeçalho ocupa 12 bytes, e o tamanho total do objeto é arredondado para um múltiplo de 8. Um Integer, com um único int de 4 bytes, ocupa 16 bytes. Por isso, o autoboxing de "Escreva uma Vez para Todo Tipo" custa memória, e não só tempo. O Java 25 trouxe como recurso definitivo os cabeçalhos compactos (opção -XX:+UseCompactObjectHeaders), que reduzem o cabeçalho a 8 bytes e economizam memória em programas com muitos objetos pequenos.

O JIT: do bytecode ao código de máquina

A JVM começa executando o bytecode num interpretador, que lê e executa uma instrução por vez. Interpretar é simples e começa na hora, mas é lento. Enquanto interpreta, a JVM conta quantas vezes cada método é chamado e quantas voltas dá cada laço. Quando um trecho se mostra "quente", ele é entregue ao compilador JIT (just-in-time), que o transforma em código de máquina nativo.

A HotSpot, a JVM do OpenJDK, tem dois compiladores JIT e usa os dois em camadas (tiered compilation):

  • C1, o compilador cliente, compila rápido, com otimizações simples, e acrescenta ao código contadores que coletam o perfil do método: quais ramos do if são tomados, que tipos de objetos chegam a cada chamada.
  • C2, o compilador servidor, entra depois para os trechos que continuam quentes. Leva mais tempo, e usa o perfil coletado para otimizar agressivamente.

A opção -XX:+PrintCompilation mostra o processo acontecendo:

26    8       3       Aquecimento::somaDosDivisores (28 bytes)
27    9 %     4       Aquecimento::somaDosDivisores @ 4 (28 bytes)
28   10       4       Aquecimento::somaDosDivisores (28 bytes)
31    8       3       Aquecimento::somaDosDivisores (28 bytes)   made not entrant: not used

A primeira coluna é o tempo em milissegundos desde o início, e a terceira coluna numérica é a camada: 3 é o C1 com perfil, e 4 é o C2. Aos 26 ms, o método foi compilado pelo C1. Aos 27 ms, o C2 compilou o laço enquanto ele ainda rodava (o % indica uma compilação on-stack replacement: a execução troca do código antigo para o novo no meio do laço). Aos 28 ms, o C2 compilou o método inteiro, e aos 31 ms a versão do C1 foi aposentada. Tudo isso no primeiro décimo de segundo.

O efeito aparece no tempo. Um programa que repete o mesmo cálculo em rodadas:

static long somaDosDivisores(int n) {
    long soma = 0;
    for (int d = 1; d <= n; d++) {
        if (n % d == 0) {
            soma += d;
        }
    }
    return soma;
}
rodada 1: 552 µs
rodada 2: 379 µs
rodada 3: 326 µs
...
rodada 8: 203 µs

Com -Xint, que desliga o JIT e deixa só o interpretador, todas as rodadas ficaram em torno de 900 µs. A primeira rodada com JIT paga o custo de interpretar, coletar o perfil e compilar, e as seguintes colhem o resultado. Esse período até o programa atingir a velocidade máxima se chama aquecimento (warm-up), e é o motivo pelo qual qualquer medição de desempenho em Java precisa descartar as primeiras execuções, como mostra "Medir Antes de Otimizar".

O que o JIT sabe que um compilador comum não sabe

Um compilador tradicional, como o de C, otimiza antes da execução, e precisa ser conservador: o código gerado tem de funcionar para qualquer entrada possível. O JIT otimiza durante a execução, com o perfil do que de fato está acontecendo. Isso permite otimizações especulativas. As mais importantes:

Inlining. Copiar o corpo de um método pequeno para o lugar da chamada, eliminando o custo da chamada e abrindo caminho para outras otimizações. Getters, setters e métodos curtos praticamente desaparecem no código compilado. É por isso que o conselho de "Dividir para Resolver", de escrever métodos pequenos, não custa desempenho em Java.

Desvirtualização. Em "Um Tipo, Várias Formas", cada chamada polimórfica consultava uma tabela para descobrir qual versão executar. Se o perfil mostra que, num ponto do programa, todos os objetos são da mesma classe, o JIT chama aquela versão diretamente e pode até copiá-la no lugar da chamada. Se um dia chegar um objeto de outra classe, a suposição é desfeita: o código compilado é descartado (desotimização), a execução volta ao interpretador, e o trecho é recompilado com o perfil novo. Esse descarte também aparece como "made not entrant", a mesma marca que, na saída acima, aposentou a versão do C1.

Análise de escape. Se o JIT prova que um objeto criado dentro de um método nunca é visto fora dele, isto é, que ele não escapa, o objeto nem precisa ir para o heap: os seus campos viram variáveis locais. Um teste:

record Ponto(double x, double y) { }

static double distancia(double x1, double y1, double x2, double y2) {
    Ponto a = new Ponto(x1, y1);
    Ponto b = new Ponto(x2, y2);
    return Math.hypot(a.x() - b.x(), a.y() - b.y());
}

Chamando esse método 50 milhões de vezes, o que em tese cria 100 milhões de objetos, o log do coletor de lixo não registrou nenhuma coleta, e a execução levou cerca de 340 ms. Com a análise de escape desligada (-XX:-DoEscapeAnalysis), o mesmo programa teve 25 coletas e levou cerca de 780 ms. Os objetos Ponto nunca existiram de verdade no heap. Isso muda a forma de pensar desempenho em Java: criar objetos pequenos e de vida curta, como records temporários, costuma ser muito mais barato do que parece.

Inspecionando uma JVM em execução

O JDK traz ferramentas para examinar uma JVM que está rodando. A mais versátil é o jcmd. Sem argumentos, ele lista as JVMs da máquina, com o identificador de cada processo. Com o identificador e um comando, ele consulta a JVM:

$ jcmd 1186 Thread.print
"dorminhoca" #21 [1206] prio=5 os_prio=0 cpu=0.26ms elapsed=3.32s ... waiting on condition
   java.lang.Thread.State: TIMED_WAITING (sleeping)
	at java.lang.Thread.sleepNanos0(java.base@25.0.4.1/Native Method)
	at java.lang.Thread.sleepNanos(java.base@25.0.4.1/Thread.java:509)
	at java.lang.Thread.sleep(java.base@25.0.4.1/Thread.java:540)

$ jcmd 1186 GC.heap_info
garbage-first heap   total reserved 1533952K, committed 98304K, used 2467K
 region size 1024K, 2 young (2048K), 0 survivors (0K)

Thread.print mostra o que cada thread está fazendo naquele instante, com a pilha de chamadas de cada uma. É a primeira ferramenta a usar quando um programa "trava": um travamento aparece como threads paradas no mesmo ponto. GC.heap_info mostra o estado do heap. jcmd <pid> help lista todos os comandos disponíveis. Essas ferramentas funcionam em produção, sem reiniciar o programa, uma das vantagens da JVM apontadas em "Uma Linguagem para Torradeiras que Conquistou o Mundo".

Esta aula mostrou onde os objetos vivem e como a JVM executa o código, mas deixou de lado a pergunta mais importante sobre o heap: como a memória dos objetos que ninguém mais usa é recuperada, sem que o programa precise fazer nada. Também deixou uma pendência: por que o programa que retinha arrays de 1 MB esgotou um heap de 64 MB com apenas 31 MB de dados. As duas respostas estão no coletor de lixo.

Fontes e leituras recomendadas

  • The Java Virtual Machine Specification, Java SE 25, capítulo 5, "Loading, Linking, and Initializing": https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-5.html. As regras formais do carregamento, da verificação e da inicialização das classes.
  • Oracle, "Java Virtual Machine Guide": https://docs.oracle.com/en/java/javase/25/vm/index.html. O guia da HotSpot, com capítulos sobre o CDS, os compiladores e as opções da JVM.
  • Aleksey Shipilëv, "JVM Anatomy Quarks": https://shipilev.net/jvm/anatomy-quarks/. Uma série de artigos curtos, cada um explicando um detalhe da JVM com experimentos, incluindo análise de escape, cabeçalhos de objetos e inlining.
  • JEP 519, "Compact Object Headers": https://openjdk.org/jeps/519. A proposta que tornou os cabeçalhos compactos um recurso definitivo no Java 25, com a explicação do layout dos objetos.
  • Scott Oaks, Java Performance, 2ª edição (O'Reilly, 2020): o livro de referência sobre desempenho na JVM, com capítulos detalhados sobre o JIT e a memória.

Exercícios

Exercício 1

No exemplo da preguiça, a leitura de Configuracao.NOME não inicializou a classe, mas a de Configuracao.acessos inicializou. Se NOME fosse declarado como static final String NOME = System.getenv("USER");, o que mudaria? Por quê?

Ver resposta

✓ Resposta: Mudaria a ordem da saída: "[inicializando Configuracao]" apareceria antes de "constante: ...", na primeira leitura de NOME. Com "sistema", o campo é uma constante de compilação: static final, de tipo primitivo ou String, com um valor que o compilador consegue calcular. O compilador copia o valor para cada ponto de uso, e a leitura no main nem toca na classe Configuracao. Com System.getenv("USER"), o valor só é conhecido na execução, então o campo deixa de ser uma constante de compilação: o bytecode passa a ler o campo da classe, e esse acesso exige a classe inicializada. Uma consequência prática dessa cópia: se uma biblioteca muda o valor de uma constante e o seu código não é recompilado, ele continua com o valor antigo, que foi copiado para o seu bytecode. Por isso, valores que podem mudar entre versões de uma biblioteca não deveriam ser constantes públicas.

Exercício 2

Um programa recursivo que percorre uma árvore de pastas funciona em testes e lança StackOverflowError em produção, numa estrutura de pastas muito profunda. Proponha duas soluções e diga qual prefere.

Ver resposta

✓ Resposta: A primeira é aumentar o tamanho da pilha, com -Xss na linha de comando, ou criando a thread que faz o percurso com um tamanho de pilha maior, pelo construtor de Thread que aceita esse parâmetro. É rápida, mas só adia o problema: uma estrutura ainda mais profunda volta a estourar, e o valor afeta todas as threads, se usado como opção global. A segunda é reescrever o percurso sem recursão, usando uma pilha explícita, um ArrayDeque de "Listas, Conjuntos e Mapas": empilham-se as pastas a visitar e, a cada volta de um laço, desempilha-se uma, processa-se e empilham-se as suas subpastas. A pilha explícita vive no heap, que é muito maior e cresce conforme a necessidade. A segunda solução é preferível, porque elimina o limite em vez de ajustá-lo. Para esse caso específico, a biblioteca já oferece Files.walk, que percorre árvores de pastas sem recursão na pilha de chamadas.

Exercício 3

O mesmo programa com JIT e com -Xint rodou com velocidades muito diferentes. Explique por que a primeira rodada com JIT foi mais lenta que as seguintes, e por que, com -Xint, todas tiveram mais ou menos o mesmo tempo.

Ver resposta

✓ Resposta: Com o JIT, a primeira rodada começa interpretando o método, enquanto a JVM conta as chamadas e coleta o perfil. Quando o método fica quente, é compilado pelo C1 e depois pelo C2, e a compilação em si consome tempo de CPU, parte dele dentro da primeira rodada. As rodadas seguintes já executam o código de máquina otimizado pelo C2, e ficam mais rápidas. Pequenas variações entre elas vêm de recompilações, do coletor e do sistema operacional. Com -Xint, não há compilação: o método é sempre interpretado, instrução por instrução, e cada rodada faz exatamente o mesmo trabalho da mesma forma, com tempo estável e alto. A diferença entre as duas colunas é o ganho do JIT, e o formato da curva, lenta no começo e rápida depois, é o aquecimento que todo benchmark em Java precisa respeitar.

Exercício 4

Explique por que, no teste da análise de escape, o programa criou 100 milhões de objetos Ponto e nenhuma coleta de lixo aconteceu. Dê um exemplo de alteração em distancia que impediria a otimização.

Ver resposta

✓ Resposta: Os dois objetos Ponto são criados dentro de distancia, usados só para ler os campos e nunca armazenados em lugar nenhum, nem devolvidos, nem passados a outro método que o JIT não consiga analisar. O JIT prova que eles não escapam do método e aplica a substituição escalar: em vez de alocar os objetos, trata x e y como variáveis locais, em registradores do processador. Sem alocação no heap, não há lixo para recolher. A otimização seria impedida por qualquer coisa que fizesse um Ponto escapar: guardá-lo num campo estático ou numa lista, devolvê-lo como resultado do método, ou passá-lo para um método grande demais para ser copiado no lugar da chamada, como um que registra os pontos num log. Por exemplo, acrescentar ULTIMO = a;, com ULTIMO sendo um campo estático, obrigaria o objeto a existir no heap, porque outro código poderia lê-lo depois.

Exercício 5

Um programa "travou": continua rodando, mas não responde. Descreva como usar o jcmd para investigar, e o que procurar na saída.

Ver resposta

✓ Resposta: Primeiro, jcmd sem argumentos, para descobrir o identificador do processo da JVM. Depois, jcmd <pid> Thread.print, de preferência duas ou três vezes, com alguns segundos de intervalo, e comparar as saídas. O que procurar: threads em estado BLOCKED, esperando uma trava que outra thread segura, e o Thread.print detecta e relata explicitamente os deadlocks clássicos, em que duas threads esperam uma pela outra, assunto de "O Cadeado e a Memória"; threads em WAITING ou TIMED_WAITING paradas sempre no mesmo ponto, como uma chamada de rede ou de banco de dados sem tempo limite; ou uma thread em RUNNABLE sempre no mesmo laço, que indica um laço infinito. Se as pilhas não mudam entre as capturas, o ponto em que as threads estão paradas é a pista. Complementarmente, jcmd <pid> GC.heap_info mostra se o heap está quase cheio, porque um programa que passa quase todo o tempo coletando lixo também parece travado.

Comentários

Mais em Java

Da Primeira Classe à JVM: o Mapa da Jornada em Java
Da Primeira Classe à JVM: o Mapa da Jornada em Java

O mapa do curso de Java: setenta e oito aulas em nove fases e três trilhas, do…

Muitos Valores sob um Nome — Arrays
Muitos Valores sob um Nome — Arrays

Arrays em Java: criação, índices, valores padrão, limites vigiados pela JVM…

Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes
Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes

Quando o synchronized pesa demais, o java.util.concurrent oferece peças sob…