Comece com a Perfilação de Alocação
Outras línguas: English Español 한국어 中文
Muitas vezes nos encontramos em situações em que o código não está funcionando corretamente, e não temos ideia de por onde começar a investigar.
Não seria melhor apenas olhar para o código até que a solução eventualmente surgisse? Claro, mas este método provavelmente não funcionará sem um conhecimento profundo do projeto e muito esforço mental. Uma abordagem mais inteligente seria usar as ferramentas que temos à mão. Elas podem apontar a direção certa.
Neste post, vamos ver como podemos perfilar alocações de memória para resolver um problema em tempo de execução.
O problema
Comecemos por clonar o seguinte repositório: https://github.com/flounder4130/party-parrot.
Execute a aplicação usando a configuração Parrot incluída com o projeto. A aplicação parece funcionar bem: você pode ajustar a cor e a velocidade da animação. No entanto, não demora muito para que as coisas comecem a dar errado.
Após trabalhar por algum tempo, a animação congela sem indicação do que causa o problema.
O programa pode, às vezes, mostrar um erro OutOfMemoryError
, cuja pilha de rastreamento não nos diz nada sobre a origem do problema.
Não há uma maneira confiável de dizer exatamente como o problema se manifestará. O interessante sobre este congelamento da animação é que ainda podemos usar o resto da interface do usuário depois que isso acontece.
Eu usei o Amazon Corretto 11 para executar este aplicativo. O resultado pode diferir em outras JVMs ou mesmo na mesma JVM se ela usar uma configuração diferente.
O depurador
Parece que temos um erro. Vamos tentar usar o depurador! Execute a aplicação no modo de depuração, espere até que a animação congele, e então pressione Pause Program.
Infelizmente, isso não nos diz muito porque todas as threads envolvidas na festa do papagaio estão no estado de espera. Ao inspecionar suas pilhas, não há indicação de por que o congelamento ocorreu. Claramente, precisamos tentar outra abordagem.
Monitorar o uso de recursos
Como estamos recebendo um erro OutOfMemoryError
,
um bom ponto de partida para análise são os CPU and Memory Live Charts.
Eles nos permitem visualizar o uso dos recursos em tempo real para os processos que estão em execução.
Vamos abrir os gráficos da nossa aplicação parrot e ver se conseguimos identificar algo quando a animação congela.
De fato, vemos que o uso da memória está aumentando continuamente antes de atingir um platô. Esse é o momento exato em que a animação fica inerte, e depois parece que vai ficar inerte para sempre.
Isso nos dá uma pista. Normalmente, a curva de uso de memória tem formato de serra: o gráfico sobe quando novos objetos são alocados e periodicamente cai quando a memória é recuperada após a coleta de objetos não utilizados. Você pode ver um exemplo de um programa funcionando normalmente na figura abaixo:
Se os dentes da serra se tornam muito frequentes, significa que o coletor de lixo está trabalhando intensivamente para liberar a memória. Um platô significa que ele não consegue liberar nada.
Podemos testar se a JVM é capaz de fazer uma coleta de lixo solicitando explicitamente uma:
O uso de memória não diminui depois que nosso aplicativo atinge o platô, mesmo que solicitamos manualmente que ele libere um pouco de memória. Isso suporta nossa hipótese de que não há objetos elegíveis para coleta de lixo.
Uma solução ingênua seria apenas adicionar mais memória. Para isso, adicione a opção VM -Xmx500m
à configuração de execução.
Para acessar rapidamente as configurações da configuração de execução selecionada atualmente, mantenha pressionada a tecla ‘Shift’ e clique no nome da configuração de execução na barra de ferramentas principal.
Independente da memória disponível, o papagaio acaba por usar tudo de qualquer maneira. Novamente, vemos a mesma imagem. O único efeito visível da memória extra foi que conseguimos atrasar o fim da “festa”.
Perfilação de alocação
Uma vez que sabemos que nossa aplicação nunca tem memória suficiente, é razoável suspeitar de um vazamento de memória e analisar seu uso de memória.
Para isso, podemos coletar um dump de memória usando a opção VM -XX:+HeapDumpOnOutOfMemoryError
.
Esta é uma abordagem perfeitamente aceitável para inspecionar o heap; no entanto, ele não apontará para o código responsável pela criação desses objetos.
Podemos obter essa informação a partir de uma snapshot do profiler: não só fornecerá estatísticas sobre os tipos dos objetos, mas também revelará os rastreamentos de pilha correspondentes ao momento em que foram criados. Embora este seja um pouco não convencional para a criação de perfis de alocação, nada nos impede de usar isso para identificar o problema.
Vamos executar a aplicação com IntelliJ Profiler anexado. Durante a execução, o profiler registra periodicamente o estado das threads e coleta dados sobre eventos de alocação de memória. Esses dados são então agregados em uma forma legível para o ser humano para nos dar uma ideia do que a aplicação estava fazendo ao alocar esses objetos.
Depois de executar o profiler por algum tempo, vamos abrir o relatório e selecionar Memory Allocations:
Existem várias visualizações disponíveis para os dados coletados. Neste tutorial, usaremos o gráfico de chamas. Ele agrega as pilhas coletadas em uma única estrutura semelhante a uma pilha, ajustando a largura do elemento de acordo com o número de amostras. Os elementos mais largos representam os tipos mais massivamente alocados durante o período de criação de perfil.
Uma coisa importante a notar aqui é que muitas alocações não indicam necessariamente um problema. Um vazamento de memória acontece somente se os objetos alocados não forem coletados como lixo. Embora a criação de perfis de alocação não nos diga nada sobre a coleta de lixo, ele ainda pode nos dar dicas para uma investigação futura.
Vamos ver de onde vêm os dois elementos mais massivos,
byte[]
e int[]
.
A parte superior da pilha nos diz que esses arrays são criados durante o processamento da imagem pelo código do pacote java.awt.image
.
A parte inferior da pilha nos diz que tudo isso acontece em uma thread separada gerenciada por um serviço de executor.
Não estamos à procura de erros no código da biblioteca, então vamos olhar o código do projeto que está no meio.
Indo de cima para baixo, o primeiro método da aplicação que vemos é
recolor()
, que por sua vez é chamado por
updateParrot()
.
Julgando pelo nome, este método é exatamente o que faz nosso papagaio se mover.
Vamos ver como isso é implementado e por que precisa de tantos arrays.
Clicando no quadro somos levamos ao código fonte do método correspondente:
public void updateParrot() {
currentParrotIndex = (currentParrotIndex + 1) % parrots.size();
BufferedImage baseImage = parrots.get(currentParrotIndex);
State state = new State(baseImage, getHue());
BufferedImage coloredImage = cache.computeIfAbsent(state, (s) -> Recolor.recolor(baseImage, hue));
parrot.setIcon(new ImageIcon(coloredImage));
}
Parece que updateParrot()
pega uma imagem base e depois a recolori.
Para evitar trabalho extra, a implementação tenta recuperar a imagem de algum cache.
A chave para a recuperação é um objeto State
, cujo construtor
pega uma imagem base e uma tonalidade:
public State(BufferedImage baseImage, int hue) {
this.baseImage = baseImage;
this.hue = hue;
}
Analisar fluxo de dados
Usando o analisador estático incorporado, podemos rastrear o intervalo de valores de entrada para a chamada
do construtor State
.
Clique com o botão direito do mouse no argumento do construtor baseImage
,
e no menu, selecione Analyze | Data Flow to Here.
Expanda os nós e preste atenção no ImageIO.read(path.toFile())
.
Isso nos mostra que as imagens base vêm de um conjunto de arquivos.
Se clicarmos duas vezes nesta linha e olharmos a constante PARROTS_PATH
que está próxima, descobrimos a localização dos arquivos:
public static final String PARROTS_PATH = "src/main/resources";
Navegando até este diretório, podemos ver o seguinte:
São dez imagens base que correspondem às possíveis posições do papagaio.
Bem, e o argumento do construtor hue
?
Se inspecionarmos o código que modifica a variável de hue
,
vemos que ele tem um valor inicial de 50
.
Em seguida, é definido com um slider ou atualizado automaticamente a partir do método updateHue()
.
De qualquer forma, é sempre dentro do intervalo de 1
a 100
.
Portanto, temos 100 variantes de tonalidade e 10 imagens de base, o que deve garantir que o cache nunca cresça mais de 1000 elementos. Vamos verificar se isso é verdade.
Breakpoints condicionais
Aqui é onde o depurador pode ser útil. Podemos verificar o tamanho do cache com um breakpoint condicional.
Definir um breakpoint condicional no código quente pode diminuir significativamente a execução da aplicação de destino.
Vamos definir um breakpoint na ação de atualização e adicionar uma condição para que ela possa suspender a aplicação quando o tamanho do cache ultrapassar 1000 elementos.
Agora execute o aplicativo no modo de depuração.
De fato, nós paramos neste breakpoint após executar o programa por algum tempo, o que significa que o problema está realmente no cache.
Inspecione o código
Cmd + B
no cache
nos leva ao local de sua declaração:
private static final Map<State, BufferedImage> cache = new HashMap<>();
Se verificarmos a documentação para HashMap
.
encontraremos que sua implementação depende dos métodos equals()
e hashcode()
,
e o tipo usado como chave deve sobrepô-los corretamente.
Vamos verificar. Cmd + B
no State
nos leva à definição da classe.
class State {
private final BufferedImage baseImage;
private final int hue;
public State(BufferedImage baseImage, int hue) {
this.baseImage = baseImage;
this.hue = hue;
}
public BufferedImage getBaseImage() { return baseImage; }
public int getHue() { return hue; }
}
Parece que encontramos o culpado: a implementação de equals()
e hashcode()
não apenas é incorreta. Está completamente ausente!
Substituir métodos
Escrever implementações para equals()
e hashcode()
é uma tarefa mundana.
Felizmente, as ferramentas modernas podem gerá-las para nós.
Enquanto estiver na classe State
,
pressione Cmd + N
e selecione equals() and hashcode().
Aceite as sugestões e clique em Next até que os métodos apareçam no cursor.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
State state = (State) o;
return hue == state.hue && Objects.equals(baseImage, state.baseImage);
}
@Override
public int hashCode() {
return Objects.hash(baseImage, hue);
}
Verifique a correção
Vamos reiniciar o aplicativo e ver se as coisas melhoraram. Novamente, podemos usar CPU and Memory Live Charts para isso:
Isso é muito melhor!
Resumo
Neste post, observamos como podemos começar com os sintomas gerais de um problema e então, usando nosso raciocínio e a variedade de ferramentas disponíveis para nós, reduzir o escopo da pesquisa passo-a-passo até encontrarmos a linha de código exata que está causando o problema. Mais importante, garantimos que a festa do papagaio continuará, não importa o que aconteça!
Como sempre, ficarei feliz em ouvir seu feedback! Feliz profiling!