Um analisador JAR parece uma ferramenta simples.
Você fornece um arquivo .jar, ele abre o arquivo, lê as classes, analisa as dependências e produz algum tipo de relatório. Então, quando ele trava com:
java.lang.OutOfMemoryError: espaço de heap Java
a primeira coisa que muitos desenvolvedores fazem é culpar o JAR.
Talvez o arquivo seja muito grande. Talvez contenha muitas classes. Talvez o JAR esteja corrompido.
Às vezes isso é verdade. Mas muitas vezes, o JAR está perfeitamente bem.
O verdadeiro problema é como o analisador o processa.
Um JAR surpreendentemente pequeno pode causar um enorme pico de memória se o analisador carregar tudo na memória, criar muitos objetos ou manter referências por mais tempo do que o necessário.
Vejamos por que isso acontece e, mais importante, como consertar.
Um dos erros mais fáceis é observar o tamanho do arquivo JAR e presumir que é aproximadamente a quantidade de memória que o analisador precisa.
Não é.
Um JAR é essencialmente um arquivo ZIP. Dentro dele, você pode ter milhares de arquivos .class, recursos, metadados, arquivos de configuração e, às vezes, arquivos aninhados.
Imagine um JAR com 100 MB em disco.
Isso faz não significa que o analisador precisa de 100 MB de heap.
Se o analisador extrair as entradas, converter bytecode em objetos, construir gráficos de dependência, armazenar strings, criar árvores de sintaxe abstratas e manter referências para tudo, o requisito de memória poderá se tornar várias vezes maior que o arquivo original.
Por exemplo:
JAR no disco
↓
Entradas ZIP
↓
Matrizes de bytes de classe
↓
Objetos de classe analisados
↓
Métodos + campos + anotações
↓
Gráfico de dependência
↓
Resultados da análise
Cada etapa pode criar objetos adicionais.
O arquivo pode ser inocente. A estratégia de processamento é o que causa a explosão.
Problema nº 1: Carregando todo o JAR na memória
Uma implementação comum começa com algo assim:
byte() dados = Files.readAllBytes(jarPath);
Parece conveniente.
Também é potencialmente caro.
Se o JAR for grande, você alocou imediatamente uma matriz de bytes grande. O analisador então precisa criar objetos adicionais enquanto processa esses dados.
Uma abordagem melhor é processar a entrada do arquivo por entrada.
Por exemplo:
tente (JarFile jar = new JarFile(jarPath.toFile())) {
Entradas de enumeração = jar.entries();
enquanto (entries.hasMoreElements()) {
Entrada JarEntry = entradas.nextElement();
if (entry.getName().endsWith(“.class”)) {
tente (InputStream in = jar.getInputStream(entry)) {
analisarClass(in);
}
}
}
}
Agora o analisador pode trabalhar em uma classe por vez, em vez de manter o arquivo inteiro na memória.
Isso não torna o aplicativo automaticamente eficiente em termos de memória, mas remove uma fonte desnecessária de pressão de memória.
Problema nº 2: Construindo um Gráfico de Objeto Gigante
Ler o JAR, uma entrada por vez, é apenas metade da solução.
A próxima pergunta é:
O que acontece com os objetos depois que cada classe é analisada?
Suponha que seu analisador crie um objeto para cada classe, método, campo, anotação e dependência.
Se você armazenar todos eles em coleções como estas:
Listar classes = new ArrayList<>();
Mapa
Mapa
o coletor de lixo não pode recuperar esses objetos enquanto eles ainda estiverem referenciados.
Eventualmente, a pilha fica cheia.
Isto é especialmente comum em ferramentas de análise de dependência.
Um desenvolvedor pode pensar:
“Vou apenas armazenar tudo e construir o relatório no final.”
Essa abordagem funciona bem com pequenos arquivos de teste.
Pode ser doloroso apontar o analisador para um JAR de uma grande empresa.
Em vez disso, pergunte-se se cada objeto realmente precisa sobreviver até o final da análise.
Se você precisar apenas de uma contagem, armazene uma contagem.
Se você precisar apenas de algumas propriedades, não mantenha toda a classe analisada.
Se o relatório final puder ser gerado de forma incremental, escreva os resultados à medida que avança.
Quanto menos você mantiver vivo, menos trabalho o coletor de lixo terá que fazer.
Problema nº 3: duplicação de strings
Strings são outro consumidor silencioso de memória.
Os arquivos de classe contêm muitas informações repetidas:
java/lang/Objeto
java/lang/String
java/util/Lista
obterNome
toString
Se o seu analisador criar novos objetos String repetidamente, um projeto grande poderá gerar milhões de objetos que contêm texto igual ou semelhante.
Isso fica pior quando o analisador cria strings para nomes de classes totalmente qualificados, assinaturas de métodos, anotações, caminhos de dependência e relatórios gerados.
Uma técnica útil é evitar a criação desnecessária de strings duplicadas.
Dependendo do seu design, você pode manter um dicionário controlado de valores comumente repetidos ou usar IDs internamente.
Por exemplo, em vez de armazenar o mesmo nome de classe em vários lugares:
“com.example.UserService”
“com.example.UserService”
“com.example.UserService”
você pode atribuir a ele um ID interno:
42
e mantenha o nome em uma tabela central.
A solução exata depende do analisador, mas o princípio é simples:
Não pague milhares de vezes pelas mesmas informações se não for necessário.
Problema nº 4: os gráficos de dependência podem crescer muito rapidamente
É aqui que as coisas ficam interessantes.
Suponha que seu analisador não esteja apenas inspecionando classes. Também está construindo um gráfico de dependência.
Você pode começar com:
A→B
B → C
C → D
Isso parece inofensivo.
Mas uma aplicação real pode ter milhares de classes e dezenas ou centenas de milhares de relacionamentos.
Se você representar todos os relacionamentos usando vários objetos Java, o uso de memória poderá aumentar rapidamente.
Por exemplo:
Mapa
parece razoável.
Mas cada ClassInfo, Set, entrada de mapa, objeto wrapper e referência consome memória.
Um gráfico pode se tornar uma das maiores estruturas de toda a aplicação.
Antes de armazenar tudo, decida o que a análise realmente precisa.
Você precisa do gráfico completo?
Ou você só precisa de:
-
contagens de dependência?
-
dependências circulares?
-
dependências diretas?
-
aulas não utilizadas?
-
uma lista de pacotes afetados?
Perguntas diferentes requerem estruturas de dados diferentes.
Um bom analisador não constrói o maior modelo possível.
Ele constrói o menor modelo que pode responder à pergunta.
Problema nº 5: Objetos temporários podem criar pressão no GC
Às vezes, o analisador não apresenta um grande vazamento de memória.
Ele simplesmente cria objetos com extrema rapidez.
Imagine processar 50.000 classes e criar objetos temporários para cada método, instrução, anotação e descritor.
O coletor de lixo tem que continuar limpando esses objetos.
Você poderá ver registros mostrando atividades frequentes do GC seguidas por:
Erro OutOfMemory
Isso não significa necessariamente que o coletor de lixo esteja quebrado.
Isso pode significar que o aplicativo está produzindo objetos mais rápido do que a JVM pode recuperá-los com eficiência.
Uma maneira útil de investigar isso é com um dump de heap.
Por exemplo, você pode iniciar o aplicativo com:
-XX:+HeapDumpOnOutOfMemoryError
Quando a JVM fica sem heap, ela pode produzir um dump de heap que pode ser inspecionado com ferramentas como Eclipse MAT ou VisualVM.
Em vez de adivinhar, você pode perguntar:
Quais objetos estão realmente consumindo o heap?
Essa questão é muito mais útil do que simplesmente aumentar -Xmx.
Você deve apenas aumentar a pilha?
Às vezes, sim.
Por exemplo:
java -Xmx4g -jar analisador.jar
pode dar ao seu analisador espaço suficiente para terminar.
Mas não trate isso como a primeira ou única solução.
Se o analisador tiver um modelo de memória ineficiente, aumentar o heap simplesmente dará mais espaço ao problema.
Uma ferramenta que trava com 2 GB pode sobreviver com 4 GB.
Mas eventualmente alguém lhe dará um JAR ainda maior.
Então você estará de volta onde começou.
Aumentar o heap é útil quando a carga de trabalho realmente requer mais memória. Não substitui a compreensão do comportamento da memória do aplicativo.
Meça antes de alterar o código
Um dos melhores hábitos ao depurar OutOfMemoryError é parar de adivinhar.
Meça a memória durante a análise.
Você pode ter uma ideia aproximada usando:
Tempo de execução tempo de execução = Runtime.getRuntime();
muito usado =
runtime.totalMemory() – runtime.freeMemory();
System.out.println(“Memória usada: ” + usada / (1024 * 1024) + ” MB”);
Isso não substitui um criador de perfil adequado, mas pode ajudar a identificar onde a memória aumenta repentinamente.
Para uma investigação mais séria, use um criador de perfil ou inspecione um dump de heap.
Procure por objetos que dominam a pilha.
Se os objetos byte() dominarem, você pode estar carregando muitos dados brutos.
Se as strings dominarem, seu analisador poderá estar duplicando o texto.
Se as coleções dominarem, você poderá reter muitos dados de análise.
Se suas próprias classes de modelo dominarem, sua representação interna poderá simplesmente ser muito pesada.
O heap informa o que o programa está realmente fazendo.
Uma arquitetura melhor: transmitir, analisar, descartar
Um analisador JAR compatível com memória geralmente segue um padrão simples:
Abrir JAR
↓
Leia uma entrada
↓
Analisar entrada
↓
Mantenha apenas o resultado necessário
↓
Liberar objetos temporários
↓
Leia a próxima entrada
Em vez de:
Abrir JAR
↓
Carregue tudo
↓
Analise tudo
↓
Construa tudo
↓
Gerar relatório
O primeiro design torna o uso da memória muito mais previsível.
Também torna o analisador mais fácil de dimensionar.
É claro que algumas análises requerem informações globais. A detecção de certos relacionamentos de dependência, por exemplo, pode exigir a manutenção de algum estado.
Isso é bom.
O objetivo não é eliminar o uso de memória.
O objetivo é controlar o que fica na memória e por quanto tempo.
Não se esqueça dos JARs aninhados
Os aplicativos Java modernos podem conter JARs aninhados.
Frameworks e sistemas de empacotamento de aplicativos às vezes colocam dependências dentro de outros arquivos.
Se o seu analisador extrair recursivamente todos os arquivos aninhados para a memória, você poderá criar outro sério problema de memória.
Tenha cuidado com o processamento recursivo.
Processe entradas aninhadas como fluxos sempre que possível e evite manter cópias extraídas, a menos que sejam realmente necessárias.
Considere também definir limites para estruturas de arquivo incomuns ou maliciosas se o seu analisador aceitar arquivos JAR de fontes não confiáveis.
A verdadeira lição
Quando um analisador JAR lança OutOfMemoryError, a conclusão mais fácil é:
“O JAR é muito grande.”
Mas muitas vezes isso é apenas parte da história.
As perguntas mais úteis são:
-
Quantos dados estou carregando de uma vez?
-
Quantos objetos estou criando?
-
Quais objetos permanecem referenciados?
-
Estou duplicando strings?
-
Meu gráfico de dependência é maior que o necessário?
-
Estou retendo dados de análise temporários?
-
O que o heap dump realmente mostra?
Depois que você começar a fazer essas perguntas, OutOfMemoryError se tornará muito mais fácil de entender.
O JAR em si geralmente é apenas a entrada.
Seu analisador decide o quão cara essa entrada se tornará.
E em muitos casos, a melhor solução não é adicionar outro gigabyte à JVM.
Está mudando o que seu aplicativo escolhe manter na memória.
Considerações Finais
Java oferece aos desenvolvedores um coletor de lixo poderoso, mas a coleta de lixo não pode remover objetos que seu aplicativo ainda precisa — ou acha que ainda precisa.
É por isso que os problemas de memória nos analisadores JAR geralmente se resumem ao design do aplicativo e não ao arquivo em si.
Leia de forma incremental. Evite a criação desnecessária de objetos. Mantenha as estruturas de dados focadas. Meça o uso de memória. Use heap dumps quando o problema não for óbvio.
E antes de alcançar:
-Xmx8g
dê uma olhada mais de perto no que realmente está dentro desses gigabytes.
Você pode descobrir que seu JAR não é o problema.




