terça-feira, 12 de agosto de 2008

Tagged under: , ,

Resolvendo problemas de performance (parte 3)

Após termos visto sobre o gerenciamento de memória e memory leaks, vamos agora ver como a escolha de um Garbage Collector pode melhorar o desempenho de nossa aplicação.

Considerações iniciais

Antes de continuarmos, vamos definir alguns termos que utilizaremos mais adiante:
  • Vazão (throughput) é a porcentagem de tempo total que não foi gasto na coleta de lixo, considerado sobre longos períodos de tempo; a vazão inclui o tempo gasto em alocação de objetos (ajuste de performance de alocação geralmente não é necessário);
  • Pausas são os momentos em que a aplicação aparenta não responder porque uma coleta de lixo está acontecendo;
  • Pegada (footprint) é o conjunto de trabalho de um processo, medido em páginas de memória e cache;
  • Prontidão (promptness) é o tempo entre a morte de um objeto (a coleta dele) e quando o pedaço de memória que ele ocupava torna-se disponível.
Os desenvolvedores têm diferentes requisitos de coleta de lixo. Por exemplo, vamos considerar que a métrica correta para um servidor web seja a vazão, desde que pausas durante a coleta de lixo são toleráveis ou simplesmente abafadas por latências da rede. Contudo, a existência de pausas mesmo curtas em um programa gráfico desktop pode afetar negativamente a interação do usuário. Em sistemas com memória física limitada, ou muitos processos, a pegada pode ajudar na escalabilidade; já a prontidão é importante para sistemas distribuídos, incluindo remote method invocation (RMI).

Em geral, o dimensionamento das gerações (visto no primeiro post) passa pela avaliação dessas considerações. Por exemplo, uma geração jovem (espaços Eden e Survivor) muito grande pode maximizar a vazão, mas custará pegada, prontidão e momentos de pausas. As pausas na geração jovem podem ser minimizadas usando um espaço menor ao custo de aumentar a vazão. Vale ressaltar que o tamanho de uma geração não afeta a freqüência de coleta de lixo e as pausas de outra geração.

Como é possível perceber, não há um modo correto de dimensionar as gerações. A melhor escolha é determinada pelo modo que a aplicação usa a memória e pelos requisitos de performance impostos pelo desenvolvedor.

Dimensionando os espaços de memória

Para dimensionar os espaços de memória, utiliza-se parâmetros que são passados para a Sun JVM no momento em que iniciamos nossa aplicação. Os mais utilizados são os parâmetros -Xms e -Xmx. Eles, respectivamente, definem a quantidade de memória inicial que será garantida para a JVM e a quantidade de memória máxima que poderá ser utilizada pela JVM. Você pode especificar os valores em kilobytes (k), megabytes (m) ou gigabytes (g). Exemplo:
C:\JDK_HOME\bin\java -Xms64m -Xmx1g -jar MyApp.jar

O seguinte diagrama ilustra a diferença entre espaço garantido (commited) e espaço virtual.


Na inicialização da JVM, uma parte da memória é reservada para o heap. O tamanho do espaço reservado é especificado pelo parâmetro -Xmx. Se o valor do parâmetro -Xms é menor do que o valor de -Xmx, nem todo o espaço reservado é imediatamente garantido pela JVM. O espaço que não foi garantido é chamado "virtual" no diagrama acima. Os espaços diferentes do heap (permanent generation, tenured generation e young generation) podem crescer até o limite do espaço virtual se for necessário.

O tamanho total da memória disponível é o mais importante fator a ser considerado na performance da coleta de lixo. O segundo fator mais importante é a proporção da memória total que estará dedicada à geração jovem (young). Quanto maior for a geração jovem, menos freqüentes serão as ocorrências de coleções menores. Mais ainda, uma geração jovem grande implica em uma geração estável (tenured) menor, o que irá aumentar as ocorrências de coleções maiores. A melhor escolha dependerá dos objetos alocados pela aplicação e da vazão, pausas, pegada e prontidão consideradas.

O parâmetro que controla o tamanho da geração jovem é o -XX:NewRatio. Por exemplo, se você definir -XX:NewRatio=3 para iniciar sua aplicação, significa que a proporção (ratio) entre a geração jovem (young) e a geração estável (tenured) is 1:3. Portanto, os tamanhos somados dos espaços eden e survivor serão um quarto do tamanho total do heap.

Resumindo, você pode usar os seguintes parâmetros para iniciar a sua aplicação (outros parâmetros existem e você pode consultá-los na documentação do Java):
  • Para definir a quantidade de memória inicial da aplicação: -Xms=64m
  • Para definir a quantidade de memória máxima a ser usada : -Xmx=1024m
  • Para definir a proporção entre a geração young e tenured : -XX:NewRatio=8

Coletores disponíveis na JVM

Mais que nunca, já está na hora de aprendermos a escolher um garbage collector apropriado que garantirá melhor performance à nossa aplicação. A Sun JVM, até a versão 6, possui três algoritmos diferentes de coleta de lixo:
  1. O coletor serial (serial collector) usa uma única thread para realizar o trabalho de coleta de lixo, e o faz com relativa eficiência. É indicado para máquinas mono-processadas pois não consegue utilizar as vantagens de máquinas multi-processadas, contudo pode ser usado nestas máquinas para aplicação que manipulem pequenos conjuntos de dados. Esse coletor é selecionado por padrão pela JVM quando detecta um hardware compatível ou através do parâmetro -XX:+UseSerialGC.
  2. O coletor paralelo (parallel collector) realiza coleções menores paralelamente para diminuir o overhead gerado pela coleta de lixo. É indicado para aplicações que manipulem conjuntos de dados maiores executando sobre máquinas multi-processadas. Este coletor foi introduzido no Java 5 e melhorado no Java 6 para permitir realizar coleções maiores em paralelo. Lembre-se disso quando for escolher esse coletor nessas versões da JVM. Esse coletor é selecionado por padrão pela JVM (5+) quando detecta um hardware compatível ou através do parâmetro -XX:+UseParallelGC.
  3. O coletor concorrente (concurrent collector) realiza toda a sua tarefa concorrentemente - enquanto a aplicação executa - para manter as pausas da coleta de lixo em patamares mínimos. É indicado para os mesmos casos do coletor paralelo e quando o tempo de resposta é mais importante do que a vazão, já que as técnicas de minimização de pausas podem reduzir um pouco a velocidade da aplicação. Esse coletor só é selecionado através do parâmetro -XX:+UseConcMarkSweepGC.
O coletor serial é o mais conhecido dos desenvolvedores Java. É o coletor padrão que acompanha o Java desde o início e tem sido constantemente melhorado a cada versão nova que é lançada. Se sua aplicação não estiver funcionando bem com esse coletor, mesmo após você ter feito o dimensionamento de memória, é recomendável que tente os outros coletores de acordo com as características de sua aplicação.

O coletor paralelo é similar ao coletor serial; a diferença é que ele usa múltiplas threads para aumentar a velocidade da coleta de lixo. Se sua JVM for da versão 5, somente coleções menores são realizadas em paralelo; se sua JVM for da versão 6, as coleções maiores também poderão serão realizadas em paralelo (mas isso não é padrão). Para forçar a coleção maior, você tem que adicionar o parâmetro -XX:+UseParallelOldGC.

Se muito tempo estiver sendo gasto na coleta de lixo pelo coletor paralelo, ele lançará uma exceção do tipo OutOfMemoryError (se 98% do tempo total for gasto em coleta de lixo e menos do que 2% da memória for recuperada). Se necessário, esse comportamente pode ser desabilitado pelo parâmetro -XX:-UseGCOverheadLimit.

O coletor concorrente é indicado para aplicações que preferem pausas muito curtas e podem compartilhar recursos do processador com o GC. Contudo, esse coletor diminui um pouco a velocidade da aplicação em máquinas com 1 processador (mesmo que dual core). Para minimizar esse impacto, é recomendado mudar o modo de trabalho desse coletor para incremental, usando o parâmetro -XX:+CMSIncrementalMode. Nesse modo incremental, o coletor concorrente realiza a sua tarefa em ciclos incrementais para diminuir o impacto na velocidade da aplicação. As estatísticas de desempenho mantidas pela JVM são utilizadas para prever os ciclos e podem ser ativadas nesse modo através do parâmetro -XX:+CMSIncrementalPacing.

Receita de bolo para a escolha do GC

1) Se a máquina que executa a aplicação tiver apenas 1 processador:

1.1) Se seu processador é single core:

1.1.1) Se sua aplicação for pequena (até 100 MB de dados manipulados na memória), use o coletor serial:
C:\JDK_HOME\bin\java -XX:+UseSerialGC -jar MyApp.jar
1.1.2) Se sua aplicação for maior, use o coletor paralelo:
C:\JDK_HOME\bin\java -XX:+UseParallelGC -XX:+UseParallelOldGC -jar MyApp.jar

1.2) Se seu processador é dual core:

1.2.1) Use o coletor paralelo:
C:\JDK_HOME\bin\java -XX:+UseParallelGC -XX:+UseParallelOldGC -jar MyApp.jar
1.2.2) Se a aplicação parecer congelar quando estiver executando (pausas), troque para o coletor concorrente:
C:\JDK_HOME\bin\java -XX:+UseConcMarkSweepGC -XX:+CMSIncrementalMode -XX:+CMSIncrementalPacing -jar MyApp.jar

2) Se a máquina tiver mais de 1 processador:

2.1) Use o coletor concorrente:
C:\JDK_HOME\bin\java -XX:+UseConcMarkSweepGC -XX:+CMSIncrementalMode -XX:+CMSIncrementalPacing -jar MyApp.jar

Escolher um GC adequado e dimensionar os espaços de memória não é suficiente se você "sujar" seu código injetando memory leaks. Antes de mexer com os parâmetros de inicialização da JVM, tente resolver os problemas de seu código observando se objetos não estão ficando presos na geração velha (old generation).

Até em breve

No próximo e último post, falarei do espaço de memória que até então não estávamos tratando: a geração permanente (permanent generation).

2 comentários:

Unknown disse...

Muito legal essa serie de artigos Alexandre, parabéns!

robdsc disse...

muito bom!