DEV Community

Cover image for Você sabe avaliar performance de suas aplicações Java?
Anderson Bosa
Anderson Bosa

Posted on

Você sabe avaliar performance de suas aplicações Java?

Recentemente, enquanto conversava com um Expert aqui no MELI (Mercado Livre), perguntei mais sobre as experiências passadas dele, curioso sobre os problemas que ele já atacou. Foi aí que ele compartilhou um cenário particular comigo que me deixou intrigado: o impacto do boxing e unboxing em algoritmos mal implementados. Não vou compartilhar o caso real dele por questões de NDA. Porém, fui atrás de estudar isso esta e, agora, trago o que aprendi nesta publicação para vocês.

Se você trabalha com Java, já lidou com coleções e autoboxing, mas será que está atento ao custo escondido dessas conversões automáticas? Espero que gostem do conteúdo, e todo feedback é bem-vindo, obrigado!

Primeiro, o que são Boxing e Unboxing?

Em Java, boxing é a conversão automática de um tipo primitivo (ex.: int) para seu equivalente wrapper (ex.: Integer). Unboxing é o processo inverso. Isso existe desde o Java 5 com o autoboxing, facilitando a vida ao usar APIs como ArrayList, que só aceitam objetos. Veja um exemplo básico:

int primitivo = 42;
Integer wrapper = primitivo; // Autoboxing
int deVolta = wrapper;       // Autounboxing
Enter fullscreen mode Exit fullscreen mode

Parece simples, mas o problema surge quando essas conversões acontecem em larga escala ou em algoritmos mal planejados.

Alguns nomes que apareceram

A Heap é a área de memória onde o Java aloca objetos e arrays. É uma área de memória global, acessível por diferentes threads da aplicação.

A Stack é uma área de memória usada para guardar variáveis locais (primitivos ou referências a objetos) e informações de execução de métodos (parâmetros, variáveis do escopo, endereço de retorno).

Já o Garbage Collector é o lixeiro automático da JVM. Ele é responsável por achar objetos na Heap que não têm mais referências ativas (ninguém mais aponta para eles). E também de liberar a memória desses objetos para que possa ser reutilizada.

Por que isso afeta a performance?

Tipos primitivos são armazenados na stack e são super leves — um int ocupa 4 bytes. Já um Integer é um objeto na heap, com overhead de metadados (pode chegar a 16 bytes ou mais, dependendo da JVM). Cada boxing cria um novo objeto, aumentando o consumo de memória e o trabalho do garbage collector. Em loops ou operações massivas, esse custo acumula rápido.

Hands-on

Comparando Boxing vs. Primitivos. Vamos testar isso com um código real. O objetivo aqui é somar 10 milhões de números inteiros de duas formas:

  1. usando uma ArrayList (com boxing)
  2. um array int.

Repositório GitHub: https://github.com/andersonbosa/boxing-performance-test/blob/main/src/BoxingPerformanceTest.java
Eu mantive no repositório para melhorar legibilidade.

O resultado no seu computador pode variar um pouco do resultado abaixo (já que o poder computacional de cada máquina varia):

Tempo com array (primitivo): 29 ms
Memória usada pelo array primitivo: 0 MB
----------------------------------------
Tempo com ArrayList (boxing): 171 ms
Memória usada pelo ArrayList: 288 MB
Enter fullscreen mode Exit fullscreen mode

Por quê? No caso da ArrayList, cada int vira um Integer (10 milhões de objetos!), enquanto o array usa apenas memória contígua (TL;DL: acesso rápido, inserção lento) para primitivos. O garbage collector ainda precisa limpar esses objetos depois, aumentando o impacto.

Certo, mas você pode estar pensando que não vai sair por aí somando 10 milhões de números, não é? Então vamos adiante, vou trazer o impacto do boxing/unboxing em algoritmos mal pensados no mundo real.

Spoiler

Experimente usar tamanho = 500_000_000 para ver a consequência!

VisualVM + IntelliJ Plugin

Cuidado com Algoritmos Mal Implementados

Loops aninhados (loop dentro de loop) manipulando dados de requisições, operações de autenticação ou cálculos de hashing processando grandes volumes de entrada. O que eles tem em comum? O overhead de memória e CPU pode facilmente escalar, transformando um código funcional em um gargalo. Aqui eu trago alguns exemplos práticos que aprendi durante minha pesquisa ao ir atrás dos meus colegas, que tiveram essas experiências em sistemas reais:

  1. Autenticação: Verificação de IDs em Lote

Uma API REST valida IDs de usuários de um JWT contra uma lista autorizada.

import java.util.List;
import java.util.Set;
import java.util.HashSet;

public class AuthService {
    private static final Set<Integer> AUTORIZADOS = new HashSet<>(List.of(1001, 1002, 1003));

    public boolean validarIds(List<Integer> userIds) {
        for (Integer id : userIds) { // unboxing
            if (!AUTORIZADOS.contains(id)) { // mais unboxing
                return false;
            }
        }
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Em uma requisição com milhares de IDs (ex.: validação em lote), cada Integer exige unboxing. A alternativa aqui seria usar int[] e IntHashSet (da biblioteca FastUtil):

  1. Processamento de Requisições: Filtro de Status

Imagine que você tem um endpoint que filtra os de status HTTP (ex.: 200, 404, etc) de logs recebidos.

import java.util.ArrayList;
import java.util.List;

public class LogProcessor {
    public List<Integer> filtrarStatus(List<Integer> statusCodes) {
        List<Integer> filtrados = new ArrayList<>();
        for (Integer code : statusCodes) { // Unboxing
            if (code >= 200 && code < 300) { // Mais unboxing
                filtrados.add(code); // Boxing
            }
        }
        return filtrados;
    }
}
Enter fullscreen mode Exit fullscreen mode

Com milhares de requisições e cada filtragem gerando boxing/unboxing isso causaria degradação da performance. Usar int[] já resolveria o problema.

  1. Criptografia/Hashing: Validação de Integridade de Dados

Por exemplo, você tem um serviço web que calcula e compara hashes SHA256 de partes de um arquivo enviado via requisição para verificar integridade (ex.: em um upload em chunks).

import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;

public class HashValidator {
    public List<Integer> calcularHashBytes(byte[] chunk) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(chunk);
        List<Integer> hashValues = new ArrayList<>();
        for (byte b : hash) {
            hashValues.add((int) b); // Boxing para Integer
        }
        return hashValues; // Retorna para comparação
    }
}
Enter fullscreen mode Exit fullscreen mode

Como poderíamos melhorar aqui? Cada byte do hash (32 bytes para SHA-256) é convertido em um Integer, gerando 32 objetos por chamada. Simulei com 10.000 chunks de 1 KB: o boxing aumentou o uso de memória em 20% e adicionou overhead ao garbage collector, impactando a latência do endpoint. Alternativa? Manter como byte[] ou usar int[] se conversão for necessária

Aprendizados

  • Prefira primitivos: Se não precisa de objetos, use int, double, etc. Arrays como int[] são seus amigos em operações intensivas.
  • Evite boxing em loops: Cada iteração com autoboxing é um objeto a mais na heap.
  • Conheça alternativas! Bibliotecas como Trove ou Eclipse Collections oferecem coleções otimizadas para primitivos.
  • "Perfilize" seu código: Ferramentas como VisualVM ou JProfiler mostram onde boxing está custando caro.

Minha conclusão

Boxing e unboxing são mecanismos úteis, mas seu uso indiscriminado pode comprometer severamente a performance, especialmente em trechos críticos ou pouco testados. Como desenvolvedores e engenheiros, nosso papel vai além de simplesmente garantir que o código funcione. Devemos buscar soluções que não só resolvam o problema, mas que também sejam eficientes, sustentáveis em termos de uso de recursos e resilientes a falhas.

Teste, compare e otimize — seu código e seus usuários vão agradecer (e sua carreira tbm rsrs).

Top comments (0)