Trilha de Kotlin · parte 30 de 59
Bytecode e decompilação
Aula da fase 5 da trilha de Kotlin: O que vira objeto, método estático ou classe auxiliar ao compilar Kotlin, e como confirmar isso com `javap`?
avançadoPublicada em · 15 min de leitura
Quando você escreve Kotlin, a máquina não executa Kotlin. O compilador traduz o código para bytecode, uma linguagem de instruções pequenas que a máquina virtual Java (JVM) sabe executar. Cada arquivo de código gera uma ou mais classes compiladas, com extensão .class, e é nelas que estão as decisões que de fato rodam: quais objetos são criados, quais métodos existem, o que vira campo e o que vira método estático. Esta aula mostra como ler essa tradução, como confirmar cada suspeita com javap e por que isso importa para quem diagnostica latência em um serviço Spring.
Uma analogia de cozinha. Você escreve a receita do jantar em linguagem de gente: “faça o molho”. Antes de chegar à cozinha, um tradutor reescreve a receita em passos numerados para um robô que só entende passos: “pegue o pote 3, despeje 200 ml, mexa por 40 segundos”. O prato é o mesmo. Mas se a cozinha está lenta, quem lê só a receita original nunca descobre que o robô prepara o molho duas vezes. O bytecode é a versão do robô.
o mapa da aula
Começamos pelo modelo de execução da JVM, com um método pequeno cujo bytecode cabe em quatro linhas. Depois mostramos o que cada construção comum do Kotlin vira: função de topo, classe com propriedades, object, companion object, função de extensão, parâmetro com valor padrão e lambda. A parte mais importante da aula é a seção sobre lambdas, porque é onde o Kotlin 2.x mudou a implementação e onde a intuição de quem veio do Java mais costuma falhar. Por fim, tratamos da decompilação, que é tentadora e enganosa, e fechamos com erros comuns. Todo código foi compilado com o kotlinc 2.1.21 e inspecionado com o javap do JDK 21.0.8.
1. o que a JVM executa
O bytecode é uma sequência de instruções, e cada uma delas manipula uma pilha de valores. Uma instrução de carga empilha um valor, uma aritmética desempilha dois e empilha o resultado, e uma instrução de retorno devolve o topo da pilha ao chamador. Esse é o modelo de pilha da JVM, descrito na especificação da máquina virtual, que esta aula não consultou diretamente. Para ver o modelo funcionando, considere uma função de soma:
fun soma(a: Int, b: Int): Int = a + b
Compilada para um diretório de classes, o javap mostra o método soma com este corpo:
public static final int soma(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: ireturn
A leitura é direta. iload_0 empilha o primeiro parâmetro, iload_1 empilha o segundo, iadd desempilha os dois, soma e empilha o resultado, e ireturn devolve esse valor. Nada aqui parece caro, e de fato não é. O que interessa é o hábito que essa leitura cria: antes de discutir desempenho de uma função, veja o que ela faz em instruções. Em funções pequenas, o bytecode quase sempre confirma a intuição. Em funções com lambdas, coleções e propriedades, a intuição costuma errar.
Para inspecionar qualquer classe, use javap com três opções. -c mostra as instruções de cada método, -p inclui membros privados, e -v acrescenta tabelas extras, como o pool de constantes e os métodos de bootstrap que aparecem nas lambdas. Um comando típico é javap -c -p -cp classes BytecodeKt, em que BytecodeKt é o nome da classe gerada para o arquivo Bytecode.kt. O sufixo Kt é a convenção que o Kotlin usa para as funções de topo de um arquivo, e ela aparece em quase todo exemplo desta aula.
2. o que cada construção vira
Um mesmo arquivo Kotlin pode gerar várias classes, e nenhuma delas tem exatamente a forma que o código sugere. Para ver isso, compilamos um arquivo com as construções mais comuns: uma função de topo, uma classe com propriedades, um object, um companion object com uma constante, uma função de extensão, uma função com parâmetro padrão, uma lambda e uma classe aninhada. Em seguida, listamos os membros de cada classe gerada.
A função de topo não pertence a nenhuma classe no código, mas a JVM não tem funções soltas. Por isso o compilador cria uma classe para o arquivo, BytecodeKt, e coloca a função dentro dela como método estático. O mesmo acontece com a função de extensão Pedido.resumo(), que vira um método estático que recebe o Pedido como primeiro parâmetro. Essa é uma das razões pelas quais uma extensão não substitui um método virtual, assunto da aula Funções de escopo e funções de extensão: no bytecode, a chamada vai para um método estático, sem despacho dinâmico.
A classe Pedido foi declarada com val id e var total. O javap mostra os campos privados id e total, o construtor, o método getId(), o método getTotal() e o método setTotal(int). A propriedade val não tem setter, e essa regra da linguagem aparece no bytecode. O método aplicarDesconto aparece como public final, porque a classe é final por padrão. Isso liga esta aula à de Interfaces, delegação e classes open: a finalidade da classe também aparece no bytecode, e não só no código.
O object Relogio vira uma classe com um campo estático chamado INSTANCE e um construtor privado. Não há como criar outra instância, e a chamada Relogio.agora() passa pelo campo estático. O companion object é mais sutil. A classe Catalogo ganha um campo estático Companion que aponta para a classe aninhada Catalogo$Companion, e o método criar() pertence a essa classe aninhada, não à classe externa. Já a constante const val LIMITE = 10 vira um campo estático de Catalogo, com o valor gravado no bytecode. Isso explica por que Catalogo.LIMITE é barato e Catalogo.criar() passa por um objeto. A figura resume o mapeamento.
A figura coloca cada construção do código à esquerda e o que ela vira à direita. A seta mais grossa é a da lambda, que é a única que cria um objeto em tempo de execução e merece atenção separada na próxima seção. Nas outras, a conversão é estática: o custo existe, mas está fixo no bytecode e não depende da carga.
Os parâmetros com valor padrão também geram código extra. A função saudar(nome, prefixo = "Olá") aparece no bytecode como saudar(String, String), e ao lado dela existe um método sintético chamado saudar$default, com dois parâmetros a mais, um inteiro e um Object. Quem chama saudar("Ana") passa por esse método, que preenche o valor padrão antes de chamar a função de verdade. Não inspecionamos o bit a bit do inteiro extra, então não afirmamos como ele indica quais argumentos foram omitidos. O que a aula confirma é que o método extra existe e que ele faz parte da chamada.
3. lambdas e invokedynamic
Esta é a seção que mais muda de uma versão para outra do Kotlin. Até a versão 1.x, cada lambda virava uma classe própria no bytecode. A partir do Kotlin 2.0, a documentação oficial afirma que invokedynamic passou a ser o método padrão para gerar lambdas, e que isso reduz o tamanho dos binários em comparação com a geração por classes anônimas. A mesma página lista as consequências: uma lambda gerada dessa forma não é serializável, a API experimental de reflexão não a suporta, e o toString() produz uma representação menos legível. Para voltar ao comportamento antigo, há a anotação @JvmSerializableLambda e a opção de compilador -Xlambdas=class.
Veja a função que usa a lambda:
fun executar(f: () -> String): String = f()
fun chamaLambda(): String = executar { "oi" }
O bytecode de chamaLambda tem uma única instrução de criação, invokedynamic, seguida de uma chamada estática a executar. A tabela de bootstrap de BytecodeKt, que aparece com a opção -v, aponta para java/lang/invoke/LambdaMetafactory. Esse é o método de bootstrap padrão da JVM para lambdas. Na primeira vez que o site de chamada executa, a JVM pede ao LambdaMetafactory que gere a classe e a instância. Nas execuções seguintes, o mesmo site devolve o objeto que já foi criado, quando isso for possível. A figura mostra as duas fases.
A figura separa a primeira execução, que liga o site de chamada, da segunda, que apenas reaproveita o objeto. A marcação no bootstrap indica o momento em que a JVM gera a classe, e a linha pontilhada verde mostra o reuso. O que a figura não mostra é a condição para o reuso, e é isso que testamos a seguir.
A condição é a captura de variáveis do contexto externo. Uma lambda que não usa nada de fora pode ser uma instância única. Uma lambda que captura uma variável precisa de um objeto que carregue esse valor, e por isso é criada de novo a cada chamada. Testamos as duas situações com uma comparação de identidade:
fun obterSemCaptura(): () -> String = { "x" }
fun obterComCaptura(nome: String): () -> String = { "oi $nome" }
fun main() {
println("sem captura, mesmo objeto: ${obterSemCaptura() === obterSemCaptura()}")
println("com captura, mesmo objeto: ${obterComCaptura("a") === obterComCaptura("a")}")
}
A saída é sem captura, mesmo objeto: true e com captura, mesmo objeto: false. A consequência prática é que uma lambda com captura dentro de um laço quente aloca a cada iteração, enquanto uma lambda sem captura não aloca. Essa diferença se soma ao custo da lambda não inline, que a aula de Funções inline e custo de lambdas trata com mais profundidade. Uma regra de leitura útil: se a lambda captura uma variável do contexto, espere uma alocação por chamada.
4. verificações nas fronteiras
O bytecode também mostra checagens que não estão escritas no código. Em Pedido, o construtor começa com uma chamada a Intrinsics.checkNotNullParameter para o parâmetro id, e a função contarGrandes(valores: List<Int>) faz a mesma verificação antes de iterar. As duas chamadas aparecem no bytecode com o nome do parâmetro como argumento. Não consultamos a documentação do Kotlin para descrever esse mecanismo em detalhe, então a afirmação fica restrita ao que o javap mostra: existe uma verificação de nulo no início de métodos com parâmetros não nulos. Quem chama o método a partir do Java não tem o contrato de nulidade do Kotlin no compilador, e essa verificação em tempo de execução é o que impede um nulo de entrar sem aviso.
Esse custo é pequeno e fixo, e dificilmente decide uma latência de produção. Mesmo assim, vale saber que a verificação existe quando você lê uma pilha de chamadas e encontra Intrinsics nela.
Pergunta de entrevistaComo o Kotlin compila lambdas e quando uma lambda aloca objetos?
A partir do Kotlin 2.0, segundo a documentação, invokedynamic é o método padrão para gerar lambdas, no lugar de uma classe própria para cada lambda, o que reduz o tamanho dos binários. Na primeira execução do site de chamada, a JVM pede ao LambdaMetafactory que gere a classe, e nas seguintes reaproveita o objeto. A condição para o reuso é a captura de variáveis do contexto externo. Uma lambda que não usa nada de fora pode ser uma instância única, enquanto uma lambda que captura uma variável precisa de um objeto que carregue o valor e é criada de novo a cada chamada. Por isso, uma lambda com captura dentro de um laço quente aloca a cada iteração.
5. decompilação: útil, mas não é a fonte da verdade
Decompilar é transformar bytecode de volta em algo legível. O IntelliJ oferece recursos para isso, mas não os usamos nesta aula, então tudo o que mostramos vem do javap. Ainda assim, vale entender o papel de um decompilador. Ele produz Java aproximado, e a versão aproximada raramente é idêntica ao que roda, porque o compilador reorganiza instruções e o decompilador precisa escolher uma forma para voltar. Um when pode reaparecer como cadeia de if, uma lambda pode aparecer como uma classe anônima que não existe no bytecode, e um nome de método sintético pode ficar confuso.
A regra prática é usar o decompilador para orientar a leitura e o javap para confirmar. Quando a dúvida é “isso cria um objeto?”, a resposta está na instrução new ou no invokedynamic, e não na versão em Java. Quando a dúvida é “isso é um campo ou uma propriedade?”, a resposta está na lista de membros da classe.
6. erros comuns e boas práticas
O primeiro erro é ler o código fonte e presumir o custo. Uma propriedade val parece um campo, mas é um campo privado acessado por um getter, e o runtime pode eliminar essa indireção quando o método é pequeno. O bytecode mostra o que foi gerado, e a otimização do runtime é outra etapa. A boa prática é separar as duas perguntas: o javap responde o que existe, e a medição responde o que custa.
O segundo erro é tratar object e companion object como se fossem classes estáticas comuns. Ambos viram classes com instâncias, e o companion faz a chamada passar por um campo Companion. Isso, em si, não atrapalha a execução, mas dificulta a leitura de um stack trace, porque o nome do método aparece dentro de Catalogo$Companion. Quem não conhece esse mapeamento perde tempo procurando um método que está em outra classe.
O terceiro erro é achar que todo lambda é um objeto novo a cada chamada. Como mostrou a seção 3, uma lambda sem captura reaproveita a instância. Quem chuta o custo sem conferir acaba otimizando o caso errado, e quem assume que nenhuma lambda aloca deixa passar a alocação das que capturam variáveis dentro de laços.
O quarto erro é comparar bytecode de compilações de versões diferentes do compilador como se fossem equivalentes. A mudança de geração de lambdas entre Kotlin 1.x e 2.x é um exemplo. Ao investigar um comportamento, registre a versão do Kotlin e do JDK, porque o javap de duas versões pode mostrar classes com nomes e formas diferentes.
Uma boa prática transversal é guardar a saída do javap junto da discussão. Quando alguém pergunta “isso aloca?”, a resposta com a linha da instrução é muito mais útil do que uma opinião.
Pergunta de entrevistaVocê confiaria no código Java gerado por um decompilador para entender o que o Kotlin faz?
Uso o decompilador como orientação de leitura e confirmo com o javap. Um decompilador produz Java aproximado, e essa versão raramente é idêntica ao que roda, porque o compilador reorganiza instruções e o decompilador precisa escolher uma forma plausível de escrevê-las. Por isso, para dúvidas concretas, olho o bytecode. Se a dúvida é se algo cria um objeto, procuro a instrução new ou o invokedynamic. Se a dúvida é se algo é um campo ou uma propriedade, leio a lista de membros da classe. O trade-off é que ler bytecode dá mais trabalho que ler Java decompilado, mas é o bytecode que a JVM executa de fato.
7. exercícios
- Escreva uma função de topo
fun dobro(x: Int): Int = x * 2, compile e usejavap -c -ppara confirmar o corpo. Em seguida, adicione um parâmetrofator: Int = 2e compare o novo conjunto de métodos. - Crie uma classe com
val nome: Stringevar idade: Int. Liste os membros comjavap -pe explique por quenomenão tem setter. Depois, transforme a classe emdata classe veja quais métodos novos aparecem. - Escreva uma função
fun executar(f: () -> Unit) = f()e duas chamadas. Uma passa uma lambda que captura uma variável externa, a outra não. Compare as instruções de cada chamada nojavape verifique com===se o objeto se repete.
8. glossário
Bytecode: conjunto de instruções em formato binário que a JVM executa, gravado em arquivos .class.
javap: ferramenta do JDK que lista os membros de uma classe e, com -c, mostra as instruções de cada método.
Pilha de operandos: área de trabalho de um método na JVM, onde as instruções empilham e desempilham valores.
invokedynamic: instrução de chamada cuja ligação ao alvo acontece na primeira execução, por meio de um método de bootstrap.
Bootstrap: método que a JVM chama na primeira execução de um invokedynamic para decidir qual implementação usar.
Método sintético: método gerado pelo compilador que não existe no código fonte, como saudar$default.
Receiver: no bytecode de uma função de extensão, o objeto que vem antes do ponto e que se torna o primeiro parâmetro do método estático.
fontes
Consultadas em 20/10/2026.
Documentação do Kotlin 2.0, sobre a geração de lambdas por invokedynamic como padrão e suas limitações: https://kotlinlang.org/docs/whatsnew20.html
As afirmações sobre o modelo de pilha da JVM vêm do conhecimento geral da linguagem, e a especificação da máquina virtual não foi consultada nesta aula.
Os exemplos foram compilados com o kotlinc 2.1.21 instalado junto ao IntelliJ IDEA nesta máquina, e o javap foi executado com o JDK 21.0.8 (Temurin). A existência de checkNotNullParameter e do método saudar$default foi verificada com javap em 20/10/2026, mas o mecanismo interno dessas chamadas não foi consultado em documentação oficial. Os exemplos das seções 2 e 3 foram reconstruídos a partir das construções descritas no texto, num único arquivo Bytecode.kt, e a execução desse arquivo imprimiu sem captura, mesmo objeto: true e com captura, mesmo objeto: false, como a seção 3 mostra. O javap -c -p de soma e de chamaLambda foi conferido linha a linha com o texto.
A interface do IntelliJ para decompilação não foi testada nesta aula.