Trilha de Kotlin · parte 33 de 59
JIT e análise de escape
Aula da fase 5 da trilha de Kotlin: Quando o C2 remove uma alocação, e como escrever código que facilita essa otimização?
avançadoPublicada em · 15 min de leitura
Um cozinheiro novo segue a receita passo a passo, com a página aberta do lado. Depois de preparar o mesmo prato cem vezes, ele deixa de ler a receita e passa a fazer atalhos: pega a faca de que precisa sem procurar, pula a etapa de lavar a tigela que nunca sai da bancada. A JVM faz algo parecido com o código que roda muito. Começa interpretando, observa quais métodos são chamados sem parar, compila esses métodos com otimizações e, em alguns casos, elimina trabalho que o código original fazia sem que ninguém precisasse dele. O componente que faz isso é o compilador JIT (just-in-time), e a otimização mais curiosa dele é a análise de escape, que consegue remover a alocação de um objeto.
Essa otimização importa para um backend porque muda o custo de escrever código idiomático. Um objeto temporário criado dentro de um método quente pode não custar nada, enquanto o mesmo objeto guardado numa lista custa uma alocação real a cada chamada. Esta aula mostra as camadas de execução, o que a análise de escape faz e não faz, e um experimento em que a mesma forma de código aloca 0 bytes ou 24 bytes por iteração, dependendo de um detalhe que só o compilador enxerga. O código foi compilado com o kotlinc 2.1.21 e executado com o Temurin 21.0.8.
o mapa da aula
Começamos pelas camadas de execução da JVM, que decidem quando um método passa a ser compilado e com que nível de otimização. Depois explicamos a análise de escape, com a definição da documentação da Oracle e a correção de um equívoco comum sobre alocação em pilha. A seção central é o experimento, com dois métodos quase idênticos que se comportam de forma diferente. Em seguida tratamos de inlining, porque sem ele a análise de escape não enxerga o objeto. Por fim, mostramos uma armadilha de medição que apareceu nesta própria aula e os erros mais comuns ao escrever código pensando no JIT.
1. interpretar, compilar e voltar
A JVM não compila tudo de uma vez. Ela começa interpretando o bytecode, instrução por instrução, o que é lento mas começa imediatamente. Quando um método ou um laço é executado muitas vezes, o runtime passa a compilá-lo para código de máquina. A documentação de tuning da Oracle descreve essa estratégia como compilação em camadas (tiered compilation), e a descreve com os nomes de dois compiladores: o compilador cliente, que compila depressa e coleta informações sobre o próprio método, e o compilador servidor, que usa essas informações para gerar código mais otimizado. A página que consultamos não usa os nomes C1 e C2, mas são esses os nomes que aparecem no log de compilação, e é deles que falamos a partir daqui.
Para ver as camadas, ligamos -XX:+PrintCompilation e executamos o experimento desta aula. O log mostra, para cada método, a ordem das compilações e o nível de cada uma. Algumas linhas, reduzidas ao que importa, ficaram assim:
64 134 2 EscapeCapKt::distancia2 (40 bytes)
65 137 4 EscapeCapKt::distancia2 (40 bytes)
68 138 % 3 EscapeCapKt::semEscape @ 4 (44 bytes)
69 140 % 4 EscapeCapKt::semEscape @ 4 (44 bytes)
71 141 4 EscapeCapKt::semEscape (44 bytes)
72 139 3 EscapeCapKt::semEscape (44 bytes) made not entrant
As linhas são a saída real de uma execução. Os números de ID mudam a cada execução, e o nome EscapeCapKt aparece porque o código foi compilado numa cópia com a lista pré-dimensionada, que a seção 3 explica. Na sua execução os nomes serão EscapeKt, e a ordem das linhas pode variar.
O número 3 é o nível do compilador cliente com perfil, que coleta estatísticas sobre o próprio método. O número 4 é o nível do compilador servidor, o mais otimizado. A marca % indica compilação por substituição no meio de um laço (OSR), que troca o código de um laço em execução sem esperar que o método termine. A expressão made not entrant significa que uma versão deixou de ser usada e foi substituída pela de nível superior. Na prática, a mesma função passa por três estados: interpretada, compilada com perfil e compilada com otimização.
A figura resume a escada e mostra onde a medição desta aula foi feita.
A figura lê da esquerda para a direita a trajetória de um método quente. O bytecode entra no interpretador, que o executa sem otimizar. Quando o laço fica quente, o nível 3 coleta perfil, e quando o perfil está pronto, o nível 4 gera código otimizado. A seta grossa indica o salto que faz diferença. O quadro de baixo registra o resultado: no interpretador, a alocação aparece por completo, e no nível 4 ela desaparece para um dos casos, como veremos.
Esse caminho tem uma consequência para a medição. Qualquer número de desempenho que venha de uma execução curta pode estar medindo o interpretador ou o nível 3, e não o código otimizado. Por isso o experimento aquece os métodos antes de medir, com vinte rodadas de cem mil iterações.
2. o que a análise de escape faz
A análise de escape pergunta se um objeto criado num método pode sair dele. Se nenhuma referência ao objeto chega a um campo, a uma lista, a outro thread ou ao valor de retorno, então o compilador sabe que o objeto morre junto com o método. A documentação de tuning da Oracle define a técnica dessa forma: é uma forma pela qual o compilador servidor analisa o escopo de uso de um novo objeto e decide se o objeto deve ser alocado no heap Java.
O que acontece depois da decisão é a parte que mais se erra. Quando o objeto não escapa, o compilador pode fazer a substituição escalar (scalar replacement): em vez de criar o objeto, ele mantém os campos como variáveis locais, e a alocação some do código gerado. A mesma página da Oracle diz que, depois da análise de escape, o compilador servidor elimina as alocações de objetos substituíveis por escalares, e também as travas associadas a eles. A documentação ainda faz uma correção explícita: a JVM não troca uma alocação no heap por uma alocação na pilha de chamadas para objetos que não escapam globalmente. Ou seja, quem imagina que a JVM “coloca o objeto na stack” está imaginando algo que a documentação desmente. O objeto não é criado, e os campos viram valores soltos.
Essa distinção muda a pergunta que um engenheiro deve fazer. Em vez de perguntar “isso vai para a stack ou para o heap?”, a pergunta útil é “esse objeto escapa do método depois que o JIT inlinou o que estava em volta?”. A resposta depende de código que o compilador vê na hora, e não só da forma escrita.
3. o experimento
Para ver a diferença, escrevemos dois métodos com o mesmo cálculo. O primeiro cria dois pontos temporários e os descarta. O segundo guarda um dos pontos numa lista global, e por isso o objeto escapa.
class Ponto(val x: Int, val y: Int)
fun distancia2(a: Ponto, b: Ponto): Int {
val dx = a.x - b.x
val dy = a.y - b.y
return dx * dx + dy * dy
}
// O ponto criado aqui não sai do método.
fun semEscape(n: Int): Long {
var total = 0L
for (i in 0 until n) {
total += distancia2(Ponto(i, i + 1), Ponto(0, 0))
}
return total
}
// Aqui o ponto é guardado numa lista global: ele escapa do método.
val guardados = ArrayList<Ponto>()
fun comEscape(n: Int): Long {
var total = 0L
for (i in 0 until n) {
val p = Ponto(i, i + 1)
guardados.add(p)
total += distancia2(p, Ponto(0, 0))
}
return total
}
Para medir a alocação, usamos o contador de bytes alocados por thread, exposto pelo ThreadMXBean da própria JVM. A leitura é feita antes e depois de cada chamada, e a diferença é dividida pelo número de iterações. A medição foi feita com dois milhões de iterações depois de 20 rodadas de aquecimento de 100 mil iterações cada, com a lista guardados já dimensionada para que o crescimento do ArrayList não entre na conta. Os resultados foram estes:
| Configuração | semEscape (bytes por iteração) | comEscape (bytes por iteração) |
|---|---|---|
| C2 com análise de escape (padrão) | 0,00 | 24,00 |
C2 com -XX:-DoEscapeAnalysis | 48,00 | 48,00 |
Só interpretador (-Xint, 200 mil iterações, sem aquecimento) | 48,00 | 48,00 |
Os números batem com a leitura do código. Com a análise de escape ligada, semEscape não aloca nada, porque os dois pontos são eliminados. comEscape aloca 24 bytes por iteração, que correspondem ao ponto guardado na lista, e o outro ponto, o temporário, desaparece. Sem a análise de escape, e no interpretador, os dois métodos alocam 48 bytes, o equivalente a dois objetos de 24 bytes. Os 24 bytes batem com a conta usual do layout de um objeto no HotSpot de 64 bits: cabeçalho de 12 bytes com ponteiros de classe comprimidos, dois inteiros de 4 bytes e alinhamento a múltiplos de 8, o que dá 20 e arredonda para 24. Essa conta é conhecimento geral, não verificado na especificação da JVM, mas o valor medido confirma o número.
Um detalhe de medição muda o resultado da coluna comEscape. Sem dimensionar a lista antes, a mesma medição deu 32,20 bytes por iteração. Os 8,2 bytes a mais são o crescimento do array interno do ArrayList, que é recopiado em tamanhos maiores e conta como alocação real. Por isso a tabela usa a lista já dimensionada, e o número que representa o objeto é 24,00. Num código de produção, esse crescimento também existe, e ele é um dos motivos para dimensionar listas quando o tamanho é conhecido.
O bytecode do método semEscape, visto com javap, contém duas instruções new Ponto dentro do laço, cada uma seguida de uma chamada ao construtor. A otimização não está no código gerado pelo kotlinc. Ela acontece depois, quando o C2 compila o método. A figura mostra a bifurcação.
A figura separa os dois casos pela pergunta que o compilador responde. No caminho da esquerda, o ponto não sai do método, a resposta é não, e o resultado é zero bytes. No caminho da direita, a lista global recebe a referência, a resposta é sim, e uma alocação permanece. A caixa embaixo lembra que, com a análise desligada, os dois casos caem para 48 bytes, o que confirma que a diferença vem da análise, e não do código.
Pergunta de entrevistaO que é escape analysis?
É uma análise do JIT que descobre se um objeto escapa do método, ou seja, se outra parte do código guarda a referência dele. Se não escapa, o JIT pode nem alocá-lo, substituindo os campos por variáveis locais. Isso depende de inlining: sem inlining, o objeto aparece para quem chama e escapa. A otimização não é garantida, e muda com a versão do JVM e com o código ao redor. Por isso meço com JFR ou benchmark, e não presumo.
4. inlining é pré-requisito
A análise de escape só funciona bem quando o compilador enxerga o objeto por inteiro. Se distancia2 ficasse num método separado que a JVM não consegue inlinar, o ponto passado como argumento sairia do alcance da análise, e a alocação permaneceria. Para verificar se isso acontece, usamos -XX:+PrintInlining com as opções de diagnóstico desbloqueadas. O log mostra distancia2 e o construtor de Ponto marcados como inline (hot), o que indica que foram incorporados ao método quente. O mesmo log também registra recusas, como callee is too large, em outros pontos de chamada, porque o compilador tem limites de tamanho para o que inlina.
Essa dependência explica uma regra prática que aparece em várias aulas desta trilha. Métodos pequenos, que deixam o caminho do objeto à mostra, ajudam a análise. Métodos grandes que recebem o objeto e o repassam para estruturas externas tornam a análise impossível. Não é uma garantia: o compilador decide com base no tamanho do código, no perfil e na versão da JVM, e a mesma linha pode ser otimizada num caso e não em outro.
5. uma armadilha de medição
A eliminação de alocações também causou um problema nesta própria aula, e vale contar. Na primeira versão do experimento de GC, a carga criava um bloco de 256 bytes por iteração e o descartava logo em seguida, sem guardá-lo em nenhum lugar. O C2 eliminou a maior parte dessas alocações. Medimos com ThreadMXBean: sem o anel, a carga alocou 10,4 bytes por iteração, contra 282 com o anel, e o G1 teve 5 ciclos de coleta, contra 106. Com -XX:-DoEscapeAnalysis, a alocação continuou em 10,4 bytes por iteração, e com o compilador C1 apenas (-XX:TieredStopAtLevel=1) ela voltou a 282. Ou seja, a eliminação depende do C2, mas não foi só a análise de escape que a seção 2 descreve: desligá-la não a impediu. Não identificamos, com as flags testadas, o mecanismo exato. A correção foi guardar cada bloco num anel de 1024 posições, o que faz o bloco ser usado depois e impede a eliminação. A lição é genérica: um benchmark que descarta o resultado pode medir a ausência de trabalho, e não o trabalho em si.
Nesta aula, a medição de alocação por thread é segura porque o valor lido é o que a JVM de fato alocou, então a eliminação aparece como zero, e isso é o resultado correto. Mesmo assim, para comparar desempenho de funções, o caminho recomendado é um benchmark dedicado, como o JMH, que a aula de inline já sugeriu. Não usamos o JMH aqui, e as medições desta aula são de alocação, não de tempo.
6. erros comuns e boas práticas
O primeiro erro é achar que a JVM sempre remove objetos temporários. A análise depende de inlining, de perfil e do formato do código, e o experimento mostrou que a mesma estrutura pode alocar ou não. A boa prática é tratar a eliminação como bônus, e não como garantia, e medir quando a alocação importa.
O segundo erro é guardar objetos temporários em campos ou coleções por hábito. Uma lista que recebe objetos de cada requisição faz esses objetos escaparem, e a alocação passa a ser real. Quando o objeto não precisa viver além do método, mantenha-o local.
O terceiro erro é medir código que ainda não aqueceu. Uma execução curta pode medir o interpretador, e os números desta aula só se estabilizaram depois do aquecimento. Antes de qualquer conclusão, confirme no log de compilação que o método chegou ao nível 4.
O quarto erro é medir um resultado que o compilador pode descartar. A armadilha da seção 5 mostra o caso mais comum: um cálculo cujo resultado não é usado pode ser removido por completo. A boa prática é devolver ou gravar o resultado num lugar que o compilador não consegue provar inútil.
O quinto erro é confiar numa conclusão sem o log de inlining. Quando um método grande não é inlinado, a análise de escape pode deixar de funcionar, e o sintoma é uma alocação que parece inexplicável. -XX:+PrintInlining mostra o motivo.
7. exercícios
- Repita o experimento com a função
distancia2marcada como@Suppresse um corpo maior, com cálculos extras que não mudam o resultado. Verifique com-XX:+PrintInliningse ela deixa de ser inlinada e meça a alocação desemEscapede novo. - Escreva uma função que cria um objeto temporário e o devolve ao chamador. Meça a alocação com e sem a análise de escape. Explique por que o objeto escapa nesse caso, mesmo que o chamador o use só localmente.
- Use
-XX:+PrintCompilationpara encontrar o momento em que um método chega ao nível 4 no seu experimento. Compare esse momento com a primeira medição feita depois do aquecimento e explique se o aquecimento foi suficiente.
8. glossário
JIT: compilador que transforma bytecode em código de máquina durante a execução, a partir do comportamento observado.
Interpretador: componente que executa o bytecode instrução por instrução, sem otimizações de código de máquina.
Compilação em camadas: estratégia em que um método passa por níveis de compilação, com o nível mais alto aplicando mais otimizações.
Análise de escape: análise que decide se um objeto criado num método pode sair dele.
Substituição escalar: otimização em que um objeto que não escapa deixa de existir e seus campos viram variáveis locais.
Inlining: incorporação do corpo de um método no ponto de chamada, o que amplia o alcance da análise.
OSR (on-stack replacement): troca do código de um laço em execução por uma versão compilada, sem esperar que o método termine.
Desotimização (made not entrant): marcação de uma versão compilada que deixa de ser usada, geralmente porque uma versão mais otimizada a substituiu.
fontes
Consultadas em 20/10/2026.
Oracle, Java SE 21, The Java HotSpot Virtual Machine Performance Enhancements, seções “Tiered Compilation” e “Escape Analysis”, que definem a análise de escape, a substituição escalar, a eliminação de travas e a ausência de alocação em pilha para objetos que não escapam globalmente: https://docs.oracle.com/en/java/javase/21/vm/java-hotspot-virtual-machine-performance-enhancements.html
A página não usa os nomes C1 e C2, e a associação entre os níveis 3 e 4 e esses compiladores foi feita pela leitura do log de -XX:+PrintCompilation nesta máquina.
Documentação da classe Integer do Java SE 21, que não é usada nesta aula, mas serve de referência para os valores em cache: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Integer.html
Os números e os logs foram medidos em 20/10/2026, com o kotlinc 2.1.21 e o Temurin 21.0.8 em um Mac arm64 (Apple M1 Max). O coletor padrão dessa máquina é o G1, selecionado por ergonomia (confirmado com -XX:+PrintFlagsFinal), e nenhuma medição desta aula trocou de coletor. Os valores de -XX:-DoEscapeAnalysis, -Xint, -XX:TieredStopAtLevel=1 e -XX:+PrintInlining foram usados como indicados em cada seção. Os 24 bytes por objeto foram medidos com a lista pré-dimensionada, e a conta do layout do objeto é conhecimento geral, não consultado na especificação.
relacionados
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- Funções inline e custo de lambdas
- Bytecode e decompilação
- Heap, GC e pausas
- Boxing e alocações escondidas
- Profiling com JFR e startup