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.

A animação do papagaio está congelada

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.

Info icon

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.

A visualização de threads no depurador mostra uma pilha, que parece não estar relacionada ao erro A visualização de threads no depurador mostra uma pilha, que parece não estar relacionada ao erro

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.

O gráfico de uso de memória mostra que a quantidade de memória usada aumenta e depois estabiliza O gráfico de uso de memória mostra que a quantidade de memória usada aumenta e depois estabiliza

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:

Captura de tela de um gráfico de uso de memória onde a memória usada continuamente aumenta, mas depois cai regularmente Captura de tela de um gráfico de uso de memória onde a memória usada continuamente aumenta, mas depois cai regularmente

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:

'Perform GC' botão na barra de ferramentas de 'CPU and Memory Live Charts' 'Perform GC' botão na barra de ferramentas de 'CPU and Memory Live Charts'

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.

Adicionando a opção VM -Xmx500m na janela do dialogo 'Run/Debug Configurations' Adicionando a opção VM -Xmx500m na janela do dialogo 'Run/Debug Configurations'
Tip icon

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”.

O gráfico de uso de memória mostra que agora há 500M de memória disponível, mas o aplicativo usa tudo de qualquer forma O gráfico de uso de memória mostra que agora há 500M de memória disponível, mas o aplicativo usa tudo de qualquer forma

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:

O item 'Alocações de Memória' no menu 'Mostrar' no canto superior direito da janela de ferramentas 'Profiler' O item 'Alocações de Memória' no menu 'Mostrar' no canto superior direito da janela de ferramentas 'Profiler'

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.

Os dois maiores quadros no gráfico de alocação são int[] e byte[] Os dois maiores quadros no gráfico de alocação são int[] e byte[]

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.

Apontando para o método updateParrot() no flame graph Apontando para o método updateParrot() no flame graph

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.

A janela de ferramentas 'Analise o fluxo de dados para' mostra possíveis fontes de valores como nós A janela de ferramentas 'Analise o fluxo de dados para' mostra possíveis fontes de valores como nós

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:

10 arquivos de imagem em src/main/java na janela de ferramentas 'Projeto' 10 arquivos de imagem em src/main/java na janela de ferramentas 'Projeto'

São dez imagens base que correspondem às possíveis posições do papagaio. Bem, e o argumento do construtor hue ?

A janela de ferramentas 'Analise o fluxo de dados para' mostra possíveis fontes de valores como nós A janela de ferramentas 'Analise o fluxo de dados para' mostra possíveis fontes de valores como nós

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.

Info icon

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.

Breakpoint settings dialog with the condition 'cache.size() > 1000' Breakpoint settings dialog with the condition 'cache.size() > 1000'

Agora execute o aplicativo no modo de depuração.

Uma linha destacada indica que o breakpoint funcionou e o depurador suspendeu o aplicativo Uma linha destacada indica que o breakpoint funcionou e o depurador suspendeu o aplicativo

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:

O gráfico em 'Gráficos ao Vivo de CPU e Memória' não mais se aplaina e desce regularmente O gráfico em 'Gráficos ao Vivo de CPU e Memória' não mais se aplaina e desce regularmente

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!

all posts ->