Trilha de Kotlin · parte 31 de 59
Boxing e alocações escondidas
Aula da fase 5 da trilha de Kotlin: Por que `Int` vira `Integer` em coleções genéricas e em tipos nuláveis, e onde isso aparece em laços quentes?
avançadoPublicada em · 15 min de leitura
Imagine que você precisa guardar números numa prateleira, mas a prateleira só aceita caixas. Cada número vai para dentro de uma caixinha própria antes de subir. Quando você tira o número de volta, a caixa é aberta e o número sai. O número é o mesmo, mas a caixa teve de ser fabricada, e fabricar caixas custa tempo e espaço. Em Kotlin essa caixa se chama boxing, e ela aparece sem aviso quando você põe um Int numa List<Int> ou quando declara um Int?. Esta aula mostra onde ela nasce, quanto custa em bytes e em quais casos o compilador e o JIT a tiram do caminho.
O assunto importa para um backend porque a caixa é invisível no código. Um contador num HashMap<String, Int>, uma lista de ids num cache local ou um valor opcional devolvido por uma função podem criar um objeto por operação, e o profiler mostra isso como alocação, não como lógica. Todas as medições desta aula foram feitas com o kotlinc 2.1.21 e o Temurin 21.0.8, em 20/10/2026, e os números são de uma máquina e de uma carga sintética. Eles servem para entender o mecanismo e para calibrar a intuição, não para prever o custo de um serviço.
o mapa da aula
Começamos pela diferença entre um int primitivo e um Integer objeto, e por que o Kotlin precisa escolher uma das duas representações em cada uso. Depois mostramos o cache de Integer, que evita criar objetos para valores pequenos, e a medição que separa os casos. A seção central traz uma tabela de alocações por operação, e uma conta que explica cada número. Em seguida tratamos de Int? e de funções genéricas, onde o JIT às vezes remove a caixa e às vezes não. Fechamos com um contador em HashMap, que é o caso mais comum em serviço, e com os erros típicos.
1. primitivo ou objeto: quem decide
A JVM tem dois tipos de valor. Um int é um número de 4 bytes guardado diretamente onde o código o usa, numa variável local, num campo ou dentro de um array. Um java.lang.Integer é um objeto, com cabeçalho e uma referência para ele. Quando uma variável guarda um Integer, ela guarda a referência, e o objeto vive no heap.
O Kotlin escolhe a representação pela forma como o tipo é usado. Um Int comum, numa conta local ou como parâmetro de uma função que não é genérica, compila para int. Mas List<Int> é um tipo genérico, e a JVM guarda genéricos como referências para Object, sem saber qual é o tipo. Por isso uma lista de inteiros só consegue guardar objetos. O mesmo vale para Int?, porque null não cabe num int, e para qualquer parâmetro de tipo T que receba um Int. Em todos esses casos, o compilador gera uma chamada a Integer.valueOf, que devolve o objeto.
Dá para ver isso no bytecode. Esta é uma parte da saída real de javap -c -p para a função que preenche uma lista:
public static final java.util.ArrayList<java.lang.Integer> listaComValoresGrandes(int);
...
21: invokestatic #41 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
24: invokevirtual #45 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
A instrução Integer.valueOf aparece antes de cada add, e é aí que a caixa nasce. A função ateCentena, que devolve Int?, tem a mesma chamada no ramo em que o valor não é nulo. O código fonte não escreve Integer em nenhum lugar, e o bytecode mostra exatamente onde ele aparece.
2. o cache de Integer
Criar um objeto por número custaria caro em qualquer programa que use muitos valores pequenos, e a JVM evita parte desse custo com um cache. A documentação de Integer.valueOf(int) da versão 21 diz que o método “sempre faz cache dos valores de -128 a 127, inclusive, e pode fazer cache de outros valores fora desse intervalo”. Ou seja, a faixa de -128 a 127 é garantida, e o restante é uma decisão da implementação. Esta aula não conta com valores fora dessa faixa estarem no cache.
Na prática, um valor dentro da faixa é devolvido do cache e nenhuma caixa nova nasce. Um valor fora dela gera um objeto de 16 bytes, que é o tamanho de um Integer nesta JVM. O experimento abaixo compara os dois casos. A primeira versão usa valores de 1000 para cima, que ficam fora do cache, e a segunda usa valores de 0 a 99, que ficam dentro dele.
fun listaComValoresGrandes(n: Int): ArrayList<Int> {
val lista = ArrayList<Int>()
for (i in 0 until n) lista.add(i + 1000)
return lista
}
fun listaComValoresDoCache(n: Int): ArrayList<Int> {
val lista = ArrayList<Int>()
for (i in 0 until n) lista.add(i % 100)
return lista
}
O cache reduz a alocação, mas não a elimina, porque a referência continua no array da lista. Essa observação aparece nos números da próxima seção.
3. medindo a alocação por operação
Para medir, usamos ThreadMXBean.getThreadAllocatedBytes, que informa quantos bytes a thread alocou desde que começou. A medição lê o contador antes e depois de um bloco de operações, subtrai e divide pelo número de operações. Antes de medir, cada função roda um décimo das rodadas para aquecer o JIT. Cada configuração teve 200 rodadas de 100 mil operações.
| Operação | Bytes por operação |
|---|---|
ArrayList<Int>.add, valores fora do cache, sem pré-dimensionar | 28,81 |
ArrayList<Int>.add, valores de 0 a 99, sem pré-dimensionar | 12,81 |
ArrayList<Int>.add, valores fora do cache, lista pré-dimensionada | 20,00 |
ArrayList<Int>.add, valores de 0 a 99, lista pré-dimensionada | 4,00 |
IntArray(n) preenchido com 100 mil valores | 4,00 |
ateCentena(1000) devolvendo Int? | 16,00 |
id<T>(i) genérico em laço, com JIT | 0,00 |
id<T>(i) genérico em laço, com -Xint | 16,00 |
soma direta com Int, sem genérico | 0,00 |
somaLista(List<Int>), só leitura da lista | 0,00 |
HashMap<String, Int>, contagem por palavra | 15,92 |
Os números se explicam uns pelos outros, e essa é a parte mais útil da tabela. A diferença entre 28,81 e 12,81 é 16 bytes, exatamente o tamanho de um Integer fora do cache. Com a lista pré-dimensionada, o valor fora do cache cai para 20,00, que é 16 bytes do objeto mais 4 bytes da referência guardada no array. Sobram 8,81 bytes por elemento em 28,81, e esses 8,81 aparecem igualmente no caso do cache (4,00 mais 8,81 dá 12,81). Eles são o crescimento do array interno do ArrayList, que é recopiado em tamanhos maiores à medida que a lista cresce. Ou seja, a lista sem pré-dimensionar custa mais por causa da cópia, e o boxing custa os 16 bytes por objeto fora do cache.
O IntArray tem 4 bytes por elemento, sem objeto e sem referência, porque o inteiro fica direto no array. A diferença para a lista de valores fora do cache, que é de 20 bytes contra 4 com a lista já dimensionada, é a caixa e a referência. Esse número é o motivo prático para preferir arrays de primitivos em caminhos quentes, quando o tipo permite. A figura resume o caminho de cada caso.
A figura separa os dois caminhos que o mesmo add pode tomar. À esquerda, o ArrayList<Int> passa pelo Integer.valueOf, e a seta vermelha marca o caso fora do cache, que cria o objeto de 16 bytes no heap. A seta verde pontilhada mostra o caminho do cache, que não cria nada. O caminho do IntArray, embaixo, não encosta em nenhum objeto. A caixa de crescimento, à direita, lembra que a lista sem pré-dimensionar paga um custo a mais que não tem relação com o boxing.
Pergunta de entrevistaPor que Integer 127 == 127 dá true e 128 dá false em Java, e como isso aparece em Kotlin?
Integer.valueOf guarda em cache os valores de -128 a 127, então dois boxings desse intervalo são o mesmo objeto. Acima disso, cada boxing cria um objeto novo, e a comparação por identidade dá false mesmo com o valor igual. Por isso comparo valores com ==, que chama equals, e uso identidade só quando ela importa. Em Kotlin, um Int sem nulo vira primitivo, enquanto Int? é boxed, e é aí que a armadilha aparece.
4. Int? e funções genéricas: quando o JIT tira a caixa
A tabela tem dois resultados que parecem contraditórios. A função ateCentena, que devolve Int?, aloca 16 bytes por chamada mesmo com o JIT ligado. Já a função genérica id<T>, que só devolve o que recebe, aloca 0 bytes com o JIT e 16 bytes com -Xint. A diferença está em o objeto sair ou não do método.
No caso de ateCentena, o Integer é criado dentro da função e devolvido ao chamador. Para o compilador deixar de criá-lo, precisaria ver a função inteira e o chamador ao mesmo tempo, e no experimento isso não acontece. O objeto escapa pelo retorno, e a alocação permanece. A regra prática é que um Int? devolvido por uma função que não é inlinada tem custo de caixa a cada chamada, e isso vale tanto para o JIT quanto para o interpretador.
No caso de id<T>, a função é pequena, e o JIT a incorpora no laço. Depois disso, o Integer criado para passar pelo parâmetro genérico é usado só para voltar a ser um int na soma, e nada dele sai do método. O C2 então elimina a caixa, e o contador do ThreadMXBean registra zero bytes. Com -Xint, não há compilação, a função roda como chamada real, e a caixa aparece. Não confirmamos na documentação oficial o nome exato da otimização que o C2 aplica aqui, e a aula registra só o resultado medido.
Há um detalhe que o teste confirma. A função nuloso, que tem Int? dentro de um laço e usa ?: 0 para tratar o nulo, também deu 0 bytes com JIT. O valor nulável não cria objeto quando a função é inlinada no laço e o objeto não sai dele. Comparando nuloso com ateCentena, o resultado sugere que pesa mais se o compilador enxerga o objeto inteiro, do que o fato de o tipo ser nulável.
A figura mostra o mesmo Integer.valueOf(1000) seguindo para quatro destinos, e a cor de cada um diz se o objeto permanece.
O caminho vermelho para o retorno de Int? é o que sobra mesmo com o JIT. A verde para o C2 é o que o compilador consegue apagar quando o objeto não sai do laço. O caminho pontilhado para o cache não cria nada, porque 1000 não está nele, e o nó do cache serve de contraste para o leitor lembrar que a faixa é de -128 a 127. O caminho tracejado do interpretador mostra que a mesma linha de código pode custar 16 bytes ou zero, dependendo de quem a executa.
O mesmo raciocínio vale para value classes. Quando um value class é usado como tipo genérico, como interface ou como tipo nulável, a documentação do Kotlin diz que ele é encaixotado, e a regra prática é que ele é encaixotado sempre que é usado como outro tipo. O exemplo da documentação mostra os casos asGeneric(f), asInterface(f) e asNullable(f), todos marcados como boxed, e a aula sobre data class, enum e value class trata desse caso em detalhe. Um UserId numa List<UserId> cria uma caixa por elemento, pelo mesmo motivo que um Int numa lista.
5. o contador em HashMap, o caso comum
O caso que mais aparece em serviço é um contador num mapa. Um HashMap<String, Int> guarda o valor como Integer, e cada incremento cria um valor novo. O código mapa[p] = (mapa[p] ?: 0) + 1 faz exatamente isso. A leitura devolve o objeto que já está no mapa, e a soma cria outro, que substitui o anterior.
A medição deu 15,92 bytes por operação, e a conta fecha com precisão. O experimento conta 100 mil palavras entre quatro chaves, então cada chave recebe 25 mil incrementos, com valores de 1 a 25 mil. Os valores de 1 a 127 saem do cache, e isso acontece em 127 incrementos por chave, o que dá 508 operações no total. As outras 99 492 operações criam um Integer de 16 bytes. A média é 16 vezes 99 492 dividido por 100 mil, que dá 15,92. Quando um contador passa de 127, cada incremento vira uma caixa.
Em um serviço Spring, esse padrão aparece em contadores locais de métricas, em caches de frequência e em agregações feitas em memória antes de enviar um lote. Não medimos nenhum desses casos, e a aula não estima o custo em produção. O que se pode dizer é que o contador tem um custo por operação que o código não mostra, e que ele cresce com o número de incrementos de cada chave, porque os valores altos são os que saem do cache.
6. erros comuns e boas práticas
O primeiro erro é usar Int? onde um valor padrão resolve. Um Int? devolvido por uma função que não é inlinada cria uma caixa a cada chamada, e a medição de ateCentena mostra isso. Quando o valor ausente tem um significado claro, como zero ou uma constante, use Int com esse valor, ou modele a ausência com um tipo que o leitor entenda. Não é uma regra universal, porque um null que representa um estado real precisa existir, mas é um custo que vale ser escolhido de propósito.
O segundo erro é colocar List<Int> ou Map<String, Int> num caminho quente sem medir. A tabela mostra que a lista custa 20 bytes por elemento fora do cache, e o IntArray custa 4. Quando o tamanho é conhecido e os valores são primitivos, um array de primitivos é a escolha que o compilador consegue tratar sem caixa. A boa prática é tratar a coleção genérica como fronteira, e manter o trabalho pesado nos arrays dentro do método.
O terceiro erro é confiar no JIT para remover a caixa. Como o caso de id<T> mostrou, a eliminação só acontece quando a função é inlinada e o objeto não sai do método. Com -Xint, ou quando o método é grande demais para ser inlinado, a caixa volta. A boa prática é medir a alocação no caminho real e não no benchmark de uma função pequena.
O quarto erro é medir sem pré-dimensionar e atribuir o custo ao boxing. A diferença de 8,8 bytes por elemento na tabela é o crescimento do array, e não a caixa. Se o tamanho da lista é conhecido, ArrayList(n) evita a cópia, e a medição separa os dois efeitos. Esse é o mesmo cuidado que a aula sobre JIT e análise de escape pede para comparar alocações.
O quinto erro é achar que o cache resolve. O cache cobre de -128 a 127 e cria objetos para os demais valores. Um contador, um id numérico ou um valor de preço em centavos quase sempre passa dessa faixa, e a caixa aparece em todas as operações.
7. exercícios
- Repita a medição com
ArrayList<Long>e valores grandes. OLongtem cache? Consulte a documentação deLong.valueOfantes de prever o resultado e confirme com a medição. - Refaça o contador do HashMap com um
IntArrayindexado por um número que identifica cada palavra. Meça a alocação e explique por que o resultado muda. - Escreva uma função
fun primeiro(lista: List<Int>): Int? = lista.firstOrNull()e meça com e sem JIT. Depois usejavappara localizar oInteger.valueOfno bytecode, ou para confirmar que ele não está lá, porque o valor já existe na lista.
8. glossário
Boxing: criação de um objeto que envolve um valor primitivo, feita pelo compilador ou pela JVM quando um tipo genérico ou nulável exige uma referência.
Unboxing: leitura do valor primitivo de dentro de um objeto que o envolve, como a soma de um Integer numa variável int.
Cache de Integer: conjunto de objetos já criados para valores pequenos, devolvido por Integer.valueOf sem criar um novo. A faixa de -128 a 127 é garantida pela documentação.
Apagamento de tipo (type erasure): regra da JVM pela qual os genéricos são guardados como referências para Object, sem o tipo real em tempo de execução.
Pré-dimensionamento: criação de uma coleção com capacidade inicial, como ArrayList(n), para evitar cópias do array interno durante o crescimento.
Escape: situação em que um objeto criado num método passa a ser usado fora dele, por exemplo por ser devolvido. Um objeto que escapa precisa existir de fato no heap.
fontes
Consultadas em 20/10/2026.
Oracle, Java SE 21, Javadoc de java.lang.Integer, método valueOf(int), com a regra de cache de -128 a 127: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Integer.html
Kotlin, documentação de value classes (inline classes), com a regra de que o tipo é encaixotado quando usado como outro tipo: https://kotlinlang.org/docs/inline-classes.html
A página de tipos básicos do Kotlin, que trataria de Int? e da representação em tempo de execução, não foi consultada com sucesso nesta sessão. A afirmação de que Int? é encaixotado vem da medição, e não de uma citação oficial.
A explicação do apagamento de tipo na JVM é conhecimento geral, sem consulta à especificação. A otimização que remove a caixa no caso de id<T> foi observada pelo resultado de alocação, e o nome exato dela não foi confirmado em documentação.
Todas as medições desta aula foram feitas nesta sessão com o kotlinc 2.1.21 (instalado junto ao IntelliJ IDEA) e o Temurin 21.0.8, em um Mac com processador arm64. O código de medição usa ThreadMXBean.getThreadAllocatedBytes, e a saída do javap foi obtida com o JDK 21.0.8.