O desempenho do Java tem a reputação de ser complexa, parte dessa concepção é devida à sofisticação da plataforma que é difícil de explicar em muitos casos. Entretanto, historicamente sempre existiu uma tendência por técnicas de otimização de performance em Java baseadas na sabedoria popular, em vez de estatísticas aplicadas e fundamentação empírica. Neste artigo esperamos esclarecer alguns desses "mitos técnicos" mais notórios.
1. O Java é lento
De todas as falácias mais desatualizadas sobre o desempenho do Java, essa é provavelmente a mais óbvia.
É claro, por volta dos anos 90 e começo de 2000, o Java era lento às vezes.
No entanto, nos últimos 10 anos houve muita melhoria na máquina virtual e no compilador JIT. Hoje em dia, o desempenho geral do Java é absurdamente rápido.
Em seis benchmarks de desempenho Web distintos, os frameworks Java ficaram com 22 das 24 primeiras posições.
O uso de profiling pela JVM otimiza apenas as porções de código altamente utilizadas trazendo bastante melhoria de desempenho. Atualmente, o código Java compilado com JIT é tão rápido quanto C++ em um grande (e crescente) número de casos.
Mesmo assim, a percepção de que o Java é uma plataforma lenta ainda continua, muito provavelmente por causa de um histórico negativo de pessoas que tiveram experiências com as primeiras versões da plataforma.
Sugerimos que sejam objetivos e analisem os resultados de avaliações de desempenho mais recentes antes de tirar conclusões.
2. Uma linha de código Java não significa nada se analisada de forma isolada
Considere a seguinte linha de código:
MyObject obj = new MyObject();
Para um desenvolvedor Java, parece óbvio que essa linha de código vai alocar um objeto e executar o construtor apropriado.
A partir disso podemos começar a argumentar a respeito das fronteiras de desempenho. Sabemos que há uma quantidade finita de trabalhos que devem acontecer, então podemos tentar calcular o impacto no desempenho baseado nas suposições.
Essa é uma tendência cognitiva que pode nos enganar, a priori, qualquer trabalho será realizado até o fim.
A realidade é que, tanto o compilador javac quanto o JIT, podem otimizar e remover códigos inutilizados. No caso do compilador JIT, o código pode ser otimizado de forma especulativa, baseando-se em dados de profiling. Nesses casos, a linha de código não irá ser executada e, dessa forma, não causará impacto no desempenho.
Além disso, em algumas JVMs (como o JRockit) o compilador JIT pode decompor as operações de objetos para que as alocações sejam evitadas, mesmo se o trecho de código não for completamente inutilizado.
Moral da história: é importante considerar o contexto quando fizermos análises sobre o desempenho do Java, e que otimizações prematuras podem produzir resultados contra intuitivos. Para melhores resultados, não tente otimizar prematuramente. Em vez disso, sempre construa seu código e use técnicas de tuning para localizar e corrigir seus hot spots de desempenho.
3. Um micro benchmark significa o que você acha
Como vimos anteriormente, tirar conclusões sobre uma pequena parte do código é menos preciso que analisar o desempenho geral do aplicativo.
Richard Feynman disse certa vez que: "O primeiro princípio é que você não deve enganar a si mesmo - e você é a pessoa mais fácil de ser enganada". Em nenhum lugar isso é mais verdade que escrevendo micro benchmarks Java.
Escrever bons micro benchmarks é uma tarefa profundamente difícil. A plataforma Java é sofisticada e complexa, e muitos micro benchmarks conseguem apenas medir efeitos temporários ou outros aspectos não intencionais da plataforma.
Por exemplo, um micro benchmark escrito de forma ingênua irá frequentemente acabar medindo o tempo do subsistema ou, talvez, do garbage collector, em vez do efeito que ele estava tentando capturar.
Somente desenvolvedores e equipes que têm real necessidade devem escrever micro benchmarks. Esses benchmarks devem ser publicados em sua totalidade (incluindo o código-fonte), e devem ser reproduzíveis e sujeitos a uma análise profunda e revisão pelos seus colegas.
As várias otimizações da plataforma Java influenciam nas estatísticas de execuções individuais. Um único benchmark precisa ser executado várias vezes e os resultados devem ser agregados para se obter uma resposta realmente confiável.
Caso queira escrever micro benchmarks, então um bom lugar para começar é lendo o artigo "Statistically Rigorous Java Performance Evaluation" (Avaliação Estatisticamente Rigorosa de Performance do Java) escrito por Georges, Buytaert e Eeckhout. Sem o tratamento apropriado das estatísticas, é muto fácil ser enganado.
Existem boas ferramentas e comunidades em torno delas (por exemplo, Caliper do Google) - se for necessário escrever um micro benchmarks, então não o faça por conta própria - utilize também o ponto de vista e da experiência de seus colegas.
4. A lentidão dos algoritmos é a causa mais comum de problemas de desempenho
Um erro muito comum entre os desenvolvedores (e entre os humanos em geral) é assumir que as partes que eles controlam em um sistema são as mais importantes.
Em se tratando de Java, isso se manifesta por desenvolvedores que acreditam que a qualidade algorítmica é a causa dominante de problemas de desempenho. Como os desenvolvedores pensam somente sobre código, eles têm uma tendência natural a pensar em seus próprios algoritmos.
Na prática, quando lidamos com um conjunto de problemas de performance em situações reais, o design de algoritmo é a causa de apenas 10% dos casos.
Em contra partida, o garbage collection, o acesso a banco de dados e configurações incorretas são causas muito mais prováveis da lentidão em aplicações.
A maioria das aplicações lidam com quantidades relativamente pequenas de dados. Então, mesmo as maiores ineficiências algorítmicas normalmente não levam a problemas críticos de performance. Estamos considerando que a qualidade desses algoritmos estão abaixo do ideal. Mesmo assim, a quantidade de ineficiência que esses algoritmos acrescentam é pequena em relação a outros problemas de performance muito maiores em demais partes da aplicação.
Sendo assim, nosso melhor conselho é usar dados empíricos de produção para descobrir as causas reais de problemas de desempenho. Mensure, não faça suposições!
5. Cache resolve qualquer coisa
"Qualquer problema na Ciência da Computação pode ser resolvido adicionando mais uma camada de indireção".
Esse ditado de programador foi atribuído a David Wheeler, é surpreendentemente comum, especialmente entre os desenvolvedores Web.
Normalmente essa falácia surge devido à paralisia de análise, quando confrontada com uma arquitetura existente e mal entendida.
Em vez de lidar com um sistema intimidador, o desenvolvedor frequentemente escolherá por se esconder dele, colocando um intermediário na frente e torcendo pelo melhor acontecer. É claro que essa abordagem apenas complica a arquitetura geral e torna a situação ainda pior para o próximo desenvolvedor que tentar entender o estado atual de produção.
Grandes e abrangentes arquiteturas são escritas de linha em linha e um subsistema por vez. No entanto, em muitos casos, arquiteturas mais simples e refatoradas são mais performáticas - e elas são quase sempre mais simples de entender.
Quando estiver avaliando se uma camada de cache é realmente necessária, planeje coletar estatísticas básicas de uso (taxa de perda, hit rate, etc) para provar que essa camada está, de fato, agregando valor.
6. Todas as aplicações precisam se preocupar com o Stop-The-World do garbage collector
Um fato que certo dentro da plataforma Java é que todas as threads da aplicação devem parar periodicamente para permitir que o garbage collectior execute. Isso é por vezes colocado como um ponto fraco grave, mesmo sem qualquer evidência real.
Estudos empíricos mostraram que os seres humanos normalmente não conseguem perceber mudanças em dados numéricos (por exemplo, alterações de preço) que ocorrem em uma frequência superior a uma ocorrência a cada 200 milissegundos.
Consequentemente, para aplicações que tem um humano como seu principal usuário, uma regra básica é que a pausa causada pelo Stop-The-World (STW) dure no máximo 200 milissegundos sem causar nenhuma preocupação. Algumas aplicações (streaming de vídeo, por exemplo) precisam de interrupções menores do que essa, mas muitas outras não.
Existe uma minoria de aplicações que uma pausa de 200ms é inaceitável. A menos que sua aplicação esteja nessa minoria, é muito improvável que seus usuários irão perceber qualquer impacto causado pelo garbage collector.
Também vale a pena mencionar que em qualquer sistema que exista mais threads de aplicação que núcleos físicos de processamento, o agendador do sistema operacional terá que intervir para dividir o tempo de acesso às CPUs. O "Stop-The-World" parece assustador, mas na prática cada aplicação (JVM ou não) tem que lidar com a disputa por recursos computacionais escassos.
Sem a realização de medições, não fica fácil perceber se a abordagem da JVM causa algum impacto significativo na performance da aplicação.
Em resumo, ative os logs do garbage collector para determinar se os períodos de pausa estão realmente afetando a aplicação. Analise os logs (à mão ou com alguma ferramenta/script) para identificar e determinar os períodos de pausa. Então decida se eles realmente representam um problema para seu domínio de aplicação. Mais ainda: pergunte a si mesmo se algum usuário já reclamou sobre isso.
7. Pool "caseiro" de objetos é apropriado para um amplo conjunto de aplicações
Uma resposta comum à sensação de que as pausas do "Stop-The-World" são de alguma forma ruins é inventar as próprias técnicas de gerenciamento de memória dentro da heap Java. Muitas vezes isso se resume a uma implementação de pool de objetos
Essa técnica é quase sempre equivocada. Suas raízes estão ligadas a um passado distante, quando a alocação de objetos era uma tarefa cara e mutabilidade não era considerada importante. O mundo é bastante diferente hoje.
Hardwares modernos são incrivelmente eficientes na alocação de memória. O tamanho de banda de acesso à memória é de pelo menos 2 a 3GB em desktops mais novos ou em servidores. Esse é um número bastante grande; exceto em casos específicos, não é fácil fazer aplicações reais que consumam toda essa banda.
Os pools de objetos geralmente são difíceis de implementar corretamente (especialmente quando há múltiplas threads trabalhando) e possuem vários questões negativas que os tornam uma má escolha para uso geral:
- Todos os desenvolvedores que colocam a mão no código devem estar ciente da existência do pool e controlar isso corretamente;
- A fronteira entre "código ciente" e "código não-ciente" deve ser conhecida e documentada;
- Toda essa complexidade adicional deve ser mantida atualizada e regularmente revisada;
- Se alguma dessas coisas falhar, o risco de corrupção silenciosa (semelhante ao reuso de ponteiro em C) é reintroduzido.
Em resumo, o pool de objetos apenas deve ser usado quando as pausas do garbage collector forem inaceitáveis, e tentativas inteligentes de otimização e refatoração tenham sido incapazes de reduzir as pausas para um nível aceitável.
8. O coletor CMS (Concurrent-Mark-Sweep) sempre é a melhor escolha para garbage collection
Por padrão, o JDK da Oracle usa um coletor stop-the-world paralelo para coletar os objetos na área old generation.
Uma escolha alternativa é o coletor Concurrent-Mark-Sweep (CMS). Ele permite que as threads da aplicação continuem executando durante a maior parte do ciclo de garbage collection. Mas ele vem com um preço e traz algumas ressalvas.
Ao permitir que as threads da aplicação executem ao mesmo tempo que as threads do garbage collection, invariavelmente isso causa a mutação do grafo de objetos e afeta o ciclo de vida dos objetos. Depois disso tudo precisa ser limpo e, por esse motivo, o CMS tem duas fases (geralmente muito curtas) de stop-the-world.
Isso tem várias consequências:
- Todas as threads de aplicação têm que ser trazidas para pontos seguros e paradas duas vezes por ciclo completo do garbage collection;
- Embora o garbage collector esteja executando simultaneamente, o rendimento do aplicativo é reduzido (geralmente em 50%);
- O tempo total (e ciclos de CPU) que a JVM gasta para realizar o garbage collection via CMS é consideravelmente maior que a coleta paralela.
Dependendo das circunstâncias da aplicação, vale a pena pagar esse preço ou não. Mas não existe nada que seja de graça. O coletor CMS é um peça de engenharia notável, mas também não é a solução de todos os problemas.
Então, antes de concluir que o CMS é sua estratégia correta de garbage collection, determine se as pausas "stop-the-world" do coletor Parallel Old são inaceitáveis e não podem ser otimizadas. Por fim, tenha certeza de que todas as métricas são obtidas a partir de um sistema equivalente ao de produção.
9. Aumentar o tamanho do heap resolve seus problemas de memória
Quando uma aplicação está com problemas e o garbage collector é o suspeito, muitos irão dizer para aumentar o tamanho do heap. Sob algumas circunstâncias, isso pode produzir resultados a curto prazo e dar tempo para uma correção mais apropriada. No entanto, sem um completo entendimento das causas do problema de desempenho, essa estratégia pode na verdade tornar o problema ainda pior.
Considere uma aplicação mal construída que cria muitos objetos de domínio com um tempo de vida médio de dois a três segundos. Se a taxa de alocação é alta o suficiente, o garbage collection pode ocorrer tão rapidamente que os objetos são promovidos para a área tenured (ou old) generation. Uma vez nessa área, os objetos de domínio morrem quase que imediatamente, mas eles não são coletados até o próximo garbage collection completo.
Se aumentássemos o tamanho do heap para essa aplicação, o que estaríamos na verdade fazendo é adicionando mais espaço para objetos de domínio com um tempo de vida relativamente curto se propagar e morrer. Isso pode tornar o tamanho das pausas do stop-the-world ainda pior e sem nenhum benefício para a aplicação.
Entender o funcionamento da alocação de objetos e o seu tempo de vida antes de mudar o tamanho do hep ou ajustar outros parâmetros é essencial. Fazer essas coisas sem nenhuma avaliação pode tornar o problema ainda pior. A informação sobre a distribuição de objetos na área tenured pelo garbage collector é muito importante.
Conclusão
Em se tratando de otimização de performance em Java, a intuição é muitas vezes enganosa. Necessitamos de dados empíricos e ferramentas para nos ajudar a visualizar e compreender o comportamento da plataforma.
O garbage collections é, talvez, o melhor exemplo disso. O subsistema do garbage collection tem um incrível potencial para otimização e para gerar dados que guiam essas otimizações. Em contra partida, os dados produzidos pelas aplicações de produção são muito difíceis de fazerem sentido sem recorrer a ferramentas
O padrão para a execução de qualquer processo Java (em desenvolvimento ou produção) deve ser sempre com pelo menos essas flags:
-verbose:gc (imprime os logs do GC)
-Xloggc: (para um log mais completo do GC)
-XX:+PrintGCDetails (para uma saída mais detalhada)
-XX:+PrintTenuringDistribution (imprime os limites de tenuring assumidos pela JVM)
e então usar uma ferramenta para analizar os logs - tanto scripts escritos à mão quanto geração de gráficos, ou uma ferramenta visual como o GCViewer (open-source) ou Censum jClarity.
Sobre o autor:
Ben Evans é o CEO da jClarity, uma startup que desenvolve ferramentas de desempenho para ajudar equipes de desenvolvimento e operação. Ele é um dos organizadores do LJC (London JUG) e membro do Comitê Executivo do JCP, no qual ajuda a definir padrões para o ecossistema Java. Ele também é um Java Champion; JavaOne Rockstar; co-autor do livro "The Well-Grounded Java Developer" e palestra regularmente sobre a plataforma Java, performance, concorrência e tópicos relacionados.