Artigo Java Magazine 35 - Otimizando Código Java

Você precisa estar logado para dar um feedback. Clique aqui para efetuar o login
Para efetuar o download você precisa estar logado. Clique aqui para efetuar o login
Confirmar voto
0
 (0)  (0)

Artigo publicado pela Java Magazine 35.

Esse artigo faz parte da revista Java Magazine edição 35. Clique aqui para ler todos os artigos desta edição

Atenção: por essa edição ser muito antiga não há arquivo PDF para download.Os artigos dessa edição estão disponíveis somente através do formato HTML. 

Otimizando Código Java

Truques e Dicas para Obter o Máximo de Desempenho

 

O tema de otimização de código é inesgotável, e nesta coluna já falamos de desempenho em inúmeras oportunidades. Vimos desde otimizações implementadas pelas JVMs (como algoritmos de compilação JIT ou de garbage collection, recentemente na Edição 30) até APIs especificamente orientadas a programação de alto desempenho (como a java.util.concurrent, na Edição 20). Também mostramos técnicas de otimização específicas a certos domínios, como XML (Edição 22) ou JDBC (Edições 25 e 26).

Por outro lado, no dia-a-dia de todos os desenvolvedores Java, é comum trabalharmos com códigos que apresentam necessidades de alto desempenho, sem que existam opções da JVM ou APIs especiais à disposição para ajudar. Nestes casos, devemos utilizar técnicas mais gerais de otimização de algoritmos. São técnicas que precedem o Java em décadas, mas a cada nova geração de tecnologias, linguagens e programadores, elas devem ser continuamente lembradas e adaptadas.

Muito dessa teoria geral de otimização faz parte de cursos e livros sobre programação, algoritmos, e outros domínios da computação. Por exemplo, ao estudar muitas linguagens de programação, aprendemos que é mais eficiente fazer cálculos com variáveis inteiras do que com ponto flutuante[1]. Em algoritmos, aprendemos que um quicksort é mais eficiente que um bubblesort; em estruturas de dados, que uma lista encadeada é melhor que um array para inserções; e num domínio como bancos relacionais, aprendemos a utilizar índices, fazer joins eficientes etc. Mas não vamos falar dessas técnicas clássicas, que são muito bem cobertas por cursos e livros tradicionais.

Ao invés disso, vamos abordar a otimização de código com foco na exposição de princípios fundamentais, que são independentes de algoritmos ou de domínios específicos. O artigo usará dicas e exemplos em Java, explorando alguns "padrões" de ineficiência que são encontrados com freqüência em código Java escrito sem suficiente cuidado com questões de desempenho computacional[2]. Mas estes exemplos somente ilustram princípios sistêmicos que se aplicam a qualquer linguagem ou ambiente de programação.

 

Leis fundamentais da otimização de código

Para começar, vamos formalizar alguns "mandamentos" principais. Pesquisei e não encontrei nenhuma lista de "leis" desse tipo, portanto o que se segue é uma tentativa de organização de princípios fundamentais.

1. Otimização PREMATURA é a fonte de todo o mal.

Esta lei, já famosa, foi enunciada por Tony Hoare (o criador do algoritmo quicksort, entre outras técnicas de alto desempenho). Também foi divulgada por Donald Knuth (o autor do clássico The Art of Computer Programming, uma série de livros com grande foco no desempenho de algoritmos). Vindo dessas fontes insuspeitas, é algo a ser levado muito a sério!

O problema é que otimizar código é uma atividade que implica em certos custos, perigos e possíveis efeitos colaterais. Um código otimizado pode sofrer de vários poréns:

·         Ainda não foi testado, enquanto a versão não-otimizada pode já ser um código "maduro".

·         É geralmente mais complexo, resultando em maior probabilidade de ocorrência de bugs.

·         É freqüentemente mais frágil. Muitas técnicas de otimização confiam em pressupostos "otimistas" sem os quais não são seguras. (Teremos exemplos disso neste artigo).

·         É mais difícil de dar manutenção. E com freqüência códigos de "gurus" responsáveis pelas otimizações têm que ser alterados por programadores menos experientes, já com o criador não mais disponível.

 

A conclusão desses itens pode ser resumida da seguinte forma: o custo de otimizar algum código não é somente o esforço da sua execução inicial. Bons coordenadores de projeto sabem disso, o que é importante, pois quando perguntamos a um programador quanto tempo precisa para implementar certa funcionalidade, é comum que o programador (daqueles que gosta de otimização) responda algo como: "hmm... uma versão simplesinha em um dia, mas uma versão ultra turbinada, com muito mais desempenho, em três dias". A verdade mais completa é que o código otimizado poderá ter outros custos e riscos, além desse tempo adicional de implementação.

O custo de otimizar algum código não é somente o esforço da sua execução inicial. A versão "ultra turbinada" poderá dar mais retrabalho de testes e correções, e ao longo dos anos exigirá mais esforço de manutenção.

Devemos fazer, no entanto, duas ressalvas:

a)     Nem toda otimização torna o código mais complexo. Muitas otimizações consistem simplesmente em usar recursos disponíveis de forma mais inteligente, e podem até mesmo simplificar o programa (por exemplo, explorando uma API especializada, que permite implementar certa funcionalidade com mais eficiência e também com menos código).

b)     O maior problema é a otimização prematura. O dito de Hoare e Knuth é frequentemente citado de forma incompleta, como somente "Otimização é a fonte de todo o mal". Otimização exige equilibrar fatores conflitantes, como o esforço de desenvolvimento e o desempenho da aplicação resultante. Deve-se otimizar quando o custo de otimizar é menor do que o custo de não otimizar.

Otimização envolve fatores conflitantes, como o esforço de desenvolvimento e o desempenho da aplicação. Deve-se otimizar quando o custo de otimizar é menor do que o custo de não otimizar.

2. O que não é medido não pode nem deve ser otimizado.

Esta regra incorpora duas recomendações também bastante conhecidas. O primeiro é o de utilizar técnicas ou ferramentas de profiling: medir o desempenho real do código que você suspeita ser lento e importante para o desempenho da aplicação. Veja o quadro "Utilizando profilers".

Antes da otimização, o profiling ajuda a identificar os códigos ineficientes, o que nem sempre é óbvio. Por exemplo, é muito comum que códigos aparentemente ineficientes executem bem devido a otimizações feitas automaticamente pelo compilador. Não faz sentido se esforçar para fazer uma melhoria de desempenho que a JVM já faz de graça para você. Isso é especialmente verdadeiro para otimizações de baixo nível, de "velha escola". Por exemplo, substituir "x * 8" por "x << 3" (para x inteiro) é uma bobagem: torna seu código mais obscuro a troco de nada, pois é uma otimização trivial que qualquer compilador moderno faz certamente.

O profiling é igualmente importante para identificar trechos de código que valem a pena otimizar. Uma otimização que torne um método um minuto mais rápido tem pouco valor se o método for executado só uma vez por dia: uma economia de 1 minuto de 24*60, ou 1/1.440 = somente 0,0007% da sua cota diária de CPU. Já uma outra otimização que acelere em apenas 20 milissegundos um método que é executado dez vezes por segundo, economizará 20% dos seus recursos de CPU. Então, se é para correr os custos e riscos de otimizar código, deve-se fazê-lo preferencialmente nos casos que prometem alto retorno.

Após a otimização, também é importante medir o desempenho do novo código que você acredita ser mais eficiente que o anterior. Não é raro "otimizar" algum algoritmo e descobrir que o resultado é pior que a versão inicial. Às vezes isso acontece porque a versão otimizada é mais complexa. (Dissemos que código otimizado geralmente é mais complexo, mas a recíproca nem sempre é verdadeira.) O tamanho de um código tem relação com seu tempo de execução, e código complexo é mais difícil de analisar, podendo ocultar equívocos na sua análise do problema. Em outros casos, o pecado é a falta de informações de contexto; para mais detalhes veja a terceira lei, a seguir.

O segundo mandamento incorporado por esta segunda regra vem das disciplinas de qualidade de software, especialmente em Extreme Programming e Test-Driven Development: código que não é testado, por definição, não está correto. Se processos de desenvolvimento como XP insistem no testes unitários de alta granularidade de todo o código, é por boas razões. Muitos bugs surgem das causas mais tolas, como um "escorregão" no teclado que resulta no operador errado (como um '==' no lugar de um '+=') ou, com o auxílio de modernos editores com "auto-completar", na invocação de métodos equivocados. Aliás, às vezes é mais fácil se criar bugs em métodos triviais, de uma linha de código, do que em métodos complexos e difíceis, nos quais prestamos mais atenção.

Código que não é testado, por definição, não está correto. Se você quer otimizar um código, ele deve ser medido quantitativamente por um profiler, e qualitativamente por testes unitários.

Se você quer otimizar um código, este deve ser medido não só quantitativamente (por um profiler), mas também qualitativamente (por testes unitários). O teste unitário é o melhor amigo do programador adepto da otimização, pois o protege de muitos daqueles itens que citamos na primeira lei – até melhor que o profiling, que eu classificaria como o segundo melhor amigo.

Código complexo, porém devidamente coberto por testes unitários, dá muito menos trabalho "imprevisto" na hora dos testes funcionais e de homologação, ou na hora da manutenção. Mesmo no cenário em que um programador menos experiente herda algoritmos "envenenados" por gurus, se estes códigos estiverem bem cobertos por testes, será muito menos arriscado modificá-los. Também será menos difícil fazer as mudanças, pois um dos benefícios menos divulgados dos testes unitários é que servem como documentação complementar, ajudando a entender o comportamento do que está sendo testado. Uma sessão de depuração com bons testes unitários vale por mil páginas de documentação imposta por processos pesados.

3. Desempenho raramente é um fator simples.

Mesmo quando medimos o desempenho do nosso código, não devemos ter confiança demasiada em números simples e brutos, sem muito contexto. Por exemplo, converter um bubblesort em um quicksort pode não ser bom, se em 99% dos casos a coleção a ser ordenada tiver pouquíssimos elementos – pois nestes casos o algoritmo de bolha é mais rápido[3].

Uma solução para esse problema é fazer o profiling com massas de dados realistas. Infelizmente, "testar com dados reais" às vezes é mais fácil de dizer do que de fazer. Podemos ser obrigados a fazer testes (unitários, funcionais, de desempenho) com dados mais limitados que os reais em tamanho, complexidade ou distribuição. Mesmo quando os dados estão disponíveis, o volume dos dados pode tornar testes freqüentes proibitivos, devido ao tempo e o esforço necessários.

Quando for muito difícil fazer testes próximos dos cenários reais, uma alternativa é instrumentar a aplicação para fazer profiling em produção, gerando logs de desempenho de operações críticas.

 

public Image geraRelatorio (... dados ...) {

  long tempo = System.nanoTime();

  Image relatorio = // ... gera o relatório ...

  tempo = System.nanoTime() - tempo;

  if (tempo > 10000000) // Evita logar se < 0,01s

    Logger.getLogger.debug("calcula() demorou " +

      tempo / 1000000000 + "s");

  return relatorio;

}

 

Como mostra o exemplo, este profiling ad-hoc, improvisado, é simples, feito preferencialmente com a API System.nanoTime() (JSE 5). Em versões anteriores do JSE, deve-se usar System.currentTimeMillis(), que é bem menos precisa: para métodos que executem em menos de ~50 milissegundos[4], a precisão de currentTimeMillis() é pequena demais para gerar resultados úteis, o que nos obriga a executar o método repetidamente em loops, algo comum em benchmarks mas inviável em código de produção. Uma dica é criar um método utilitário que pode ser implementado com nanoTime() ou currentTimeMillis(), conforme a versão do JSE. Isso retorna um valor padronizado: um double com o número de segundos.

Em muitos casos, o log de desempenho pode já estar disponível. Por exemplo, no domínio de bancos de dados, a maioria dos SGBDs, drivers JDBC e ferramentas de mapeamento objeto-relacional pode ser configurada para gerar logs que revelam queries muito lentas. Containers J2EE também podem oferecer opções para registrar o desempenho de execução de requisições HTTP, invocações a EJBs e outras operações gerenciadas pelo container. Na sua aplicação, não é difícil fazer o mesmo para operações "macro" como geração de relatórios. Mas é difícil fazer o mesmo para operações "micro", mais simples, pois a quantidade de código necessária para medir e logar o desempenho de cada pequeno pedacinho de código (como todos os métodos individuais) seria excessiva[5].

Outra regra importante é tentar prever todas as possibilidades do comportamento de um código, inclusive cenários extremos (ainda que raros), e pensar não só no desempenho médio, mas também na escalabilidade e tolerância a falhas do sistema. Por exemplo, para ordenar 100 registros de um relatório diário, não se perceberá grande diferença entre diversos algoritmos de ordenação. Mas se uma vez por ano, tivermos que gerar um relatório consolidado de 36.500 registros, o algoritmo de bolha exigiria 36.5002 = 1.332.250.000 operações, provavelmente "travando" a aplicação e causando falhas como timeouts de requisições de um servidor. Já o quicksort só precisaria de uma média de 36.500 * log2(36.500) = ~553.179 operações (mais de 2 mil vezes menos).

 

Técnicas de otimização

Nesta seção vamos listar algumas dicas práticas de natureza "fundamental": técnicas que podem ser realizadas por milhares de otimizações diferentes. Ao invés de decorar truques específicos, é muito mais produtivo adquirirmos o conhecimento de como criar novas otimizações conforme necessário.

Evite cópias

Essa regra vem dos tempos do ábaco, mas ignorá-la ainda é uma dos descuidos de desempenho mais comuns. Evite copiar coisas: objetos, arrays, coleções, estruturas de dados.

 

Examinemos a Listagem 1. Programadores conscientes com desempenho usam StringBuilder (JSE 5) ou StringBuffer (versões anteriores) para compor strings complexas – especialmente quando isso requer loops, para evitar o custo de concatenações que criam novas strings, copiando todos os caracteres das strings concatenadas. (Para expressões simples, como em Satelite.toXML(), uma seqüência única de concatenações sem nenhuma string temporária explícita, o compilador javac já faz esta otimização automaticamente.) Isso é bom, mas não é suficiente.

No exemplo, temos uma estrutura de dados hierárquica, com planetas que possuem satélites, de forma que a composição da representação XML da estrutura como um todo exige várias invocações a "

A exibição deste artigo foi interrompida :(
Este post está disponível para assinantes MVP

 
Você precisa estar logado para dar um feedback. Clique aqui para efetuar o login
Receba nossas novidades
Ficou com alguma dúvida?