Dicas para melhorar o desempenho do seu código

15/6/2020
Tieris Valente
Tieris Valente
Full-Stack Developer

Sempre em busca de conhecimento, na esperança de tornar as coisas melhores.

Está sem tempo para ler? Aperte o play para escutar o artigo.

Eventualmente todos nós passaremos por aquele momento em que o nosso código estará lento ou encontraremos algum gargalo no sistema e ouviremos nossos clientes e usuários reclamando que o sistema está lento ou que ele “fica pensando pra sempre” depois de alguma ação.

Sinceramente, creio que esse é um dos maiores pesadelos de qualquer desenvolvedor, pois é difícil encontrar os gargalos do sistema, é difícil entender as suas causas e tende a ser difícil resolvê-los. Mas calma, nem tudo está perdido, existem algumas coisas que podemos fazer para evitar esse tipo de problema.

Antes de qualquer coisa, é necessário esclarecer alguns pontos para que você saiba o que esperar desse artigo: 

  • Essas dicas são baseadas no Java, mas podem ser facilmente utilizadas em outras linguagens.
  • Não será discutido quais são as melhores ferramentas (softwares) para análise de desempenho, pois a melhor ferramenta é aquela que você sabe usar. Mas caso você não conheça nenhuma e esteja precisando, a VisualVM é um bom começo e é fornecida junto com o JDK do Java.
  • Esse não será um artigo detalhado sobre a notação Big O, mas haverá alguns exemplos para compreensão dos diferentes níveis de complexidade de um algoritmo.
  • Não existe uma bala de prata para resolver todos problemas de desempenho, mas existem algumas cerejas no bolo. 😉

Certa vez, em uma palestra, Brian Goetz, arquiteto do Java na Oracle, disse que “Não deve-se preocupar com desempenho de uma aplicação se não houver uma métrica para medir esse desempenho”, ou seja, você não deve se preocupar com o tempo de execução de um algoritmo se você não souber, ou não estiver especificado, o tempo de execução aceitável pra esse código.

É importante ter isso em mente, pois de forma geral códigos de altíssimo desempenho tendem a ser complexos, e desenvolver esses códigos custam tempo. Mas isso não significa que você deve desenvolvê-lo de qualquer jeito.

Esse será o foco deste artigo, as pequenas coisas que podemos fazer, quase de olhos fechados, para manter o nosso código com um bom desempenho sem que isso nos custe tempo do nosso projeto.

O alicerce de um sistema

O alicerce de um sistema é o hardware onde ele será executado e a linguagem em que ele será desenvolvido, e é fundamental que você conheça ambos. Você deve conhecer as peculiaridades da linguagem em que você está desenvolvendo e deve ter em mente que o sistema não rodará em um ambiente com recursos infinitos.

Você deve ter em mente que tudo o que está dentro do processador é executado muito rápido, e que qualquer coisa que você buscar na memória RAM provavelmente lhe custará 5 vezes mais tempo do que buscar algo na memória cache do processador, e que se você tiver que buscar dados no HD, o seu custo será muito maior (Tem umas referências sobre isso lá no fim).

Para se ter uma ideia desses custos, imagine uma mesa de quatro lugares, onde estão sentadas quatro pessoas, a mesa tem espaço para algumas folhas, talvez alguns livros, enfim, existem informações importantes na mesa.

Cada pessoa ali sentada pode fazer duas coisas ao mesmo tempo, utilizando as informações que estão nessa mesa. Esse é o seu processador, de quatro núcleos e oito threads. Cada ação executada por ele ocorre sempre no mesmo tempo, ou seja, calcular 1 + 1 levará o mesmo tempo que calcular 100 + 100, porém eventualmente pode acontecer de que para executar uma determinada tarefa você precise de uma informação que não está na mesa.

Nesse caso, você vai até uma escrivaninha, não muito longe de onde você está, que possui mais alguns dados espalhados por ela, e procura a informação que você precisa dentre eles.

Assim que você encontra o que precisa, você pega esse conjunto de informações e leva contigo lá para a mesa, para que você possa continuar sua atividade. A escrivaninha é a memória RAM e essa é a relação de tempo que se tem para buscar algo nela, sempre haverá uma thread esperando por essa informação para finalizar a sua função.

Mas além disso, pode acontecer de você precisar de algo que não está na mesa nem na escrivaninha. Nesse caso você procurará na biblioteca da casa, ela é enorme, e tem muitos livros, todos organizados, veja bem, mas ainda assim custa tempo encontrá-los e você precisa levá-los para a escrivaninha para poder lê-los. Isso é o seu HD trabalhando.

Por fim, existe a possibilidade de a informação que você precisa não estar nem na RAM nem no HD, e você será obrigado a ligar para um amigo e pedir que ele procure isso na biblioteca dele. Esse é o cenário em que você acessa um banco de dados externo para obter a informação necessária.

Tendo isso em mente, você deve imaginar que deve evitar ao máximo buscar dados de fontes externas repetidamente. E veja bem, não estou dizendo que a partir de agora você não deve mais fazer selects e que toda informação que você for usar deve estar em memória, o que estou querendo dizer é que você não deveria fazer esse tipo de coisa dentro de um for, por exemplo, ou de uma função recursiva, pois isso irá impactar negativamente o desempenho do seu algoritmo.

De uma forma geral, os recursos disponíveis devem ser utilizados da melhor forma possível, com sabedoria, tendo em mente os benefícios e malefícios de cada ação.

Por exemplo paralelismo, em um mundo perfeito todos sistemas teriam suporte ao paralelismo e funcionariam perfeitamente bem com isso, mas na realidade sabemos que o buraco é mais embaixo, é preciso se preocupar com a sincronização de dados entre threads, garantir que nenhum processo ficará rodando infinitamente no processador e que não haverá situações de deadlocks. E às vezes toda essa complexidade não vale o preço. Mas enfim, paralelismo é assunto para outro artigo inteiro...

As peculiaridades de cada linguagem

E isso nos leva a outras situações que acontecem com frequência e acabam tendo um custo bastante elevado. Essas situações estão relacionadas às peculiaridades de cada linguagem e você deve conhecê-las da melhor forma possível.

Por exemplo, concatenação de strings. Você deve evitar realizar concatenações de string ou mesmo modificá-las, pois elas são um tipo de dados imutáveis, ou seja, toda vez que você faz uma alteração em uma string, seja para substituir um caractere ou para juntar com outra string, você estará na verdade criando uma nova string em um novo espaço de memória.

Como solução, utilize um StringBuilder sempre que souber que será necessário concatenar strings. E utilize um StringBuffer quando você for fazer esse tipo de operação em um ambiente multi-thread.

Outra coisa importante é a estrutura de dados que você utilizará. E isso, de forma geral, será igual ou bastante semelhante na maioria das linguagens. Por exemplo, se você for fazer um sistema financeiro, precisará ter uma boa precisão numérica para todos os cálculos que for fazer e para atingir essa precisão em Java, você utilizará BigDecimal ou BigInt.

Esses são ótimos tipos de dados para essa situação, mas em contrapartida, eles usam muita memória. De uma forma geral, não terá como fugir disso, então você precisará pensar em estratégias de como manter essa informação em memória, e como buscá-la do banco de dados. Isso será um problema a partir do momento em que você possuir algumas dezenas ou centenas de milhares de instâncias desse tipo.

A minha dica aqui é fazer isso em partes, pois quando você tem um problema de memória, você tem duas opções: você altera o tipo de dados para algo mais leve ou manipula as informações em pequenas partes por vez, o que por consequência sacrificará o desempenho do código, mas impedirá que o seu sistema obtenha um Out of Memory Exception.


Manipulação de dados

E por fim, entramos nos tipos de listas e maps.

O tempo de acesso direto a qualquer item de um List ou Map é sempre constante. O tempo que você levará para acessar primeiro item de uma lista será o mesmo que para acessar o milésimo item dela. Assim como o tempo de acesso para qualquer key de um Map também será o mesmo. LinkedList tem um tempo maior na hora de acessar a informação e SortedList tem um tempo maior na hora de armazenar os dados.

Nesse ponto a dica é utilizar as ferramentas que o Java lhe proporciona. Ou seja, se você precisa ordenar os seus dados, opte por implementar um Comparable e utilizar uma SortedList ou algo do tipo, pois isso certamente será muito mais eficiente do que ordenar os dados manualmente com dois fors.

Se você for manipular um conjunto de dados e realizar uma ação diferente de acordo com alguma propriedade contida nesses objetos, opte por agrupá-los em um HashMap, de forma que a propriedade que lhe é importante seja a key do map e o seu value seja uma lista com os objetos que contém aquela propriedade.

Assim você pode separar isso em diferentes funções para cada situação que você encontrar.

Por exemplo, imagine montar o seguinte fluxo de caixa, em um primeiro momento isso parece simples, pois haverá uma base de dados com as informações necessárias, e você só precisará fazer um select para obter isso.

Mas na verdade, uma base de dados real de qualquer sistema financeiro não possuirá seus valores separados e totalizados, e muito provavelmente sequer terá a totalização desses valores para cada uma das suas categorias. No geral, você terá uma base imensa cheia de lançamentos de receitas e despesas e precisará transformar isso em um fluxo de caixa bastante sucinto.

melhorar desempenho do código


Em casos como esses um hashMap poderá ser consideravelmente eficiente, pois você poderá montá-lo de tal forma que cada key seja uma categoria e o seu value seja uma lista de valores ordenados representando cada período (dias, meses, trimestres, anos...).

Isso por si só não irá melhorar o desempenho, mas lhe dará condições de fazê-lo. Ou seja, com essa estrutura você tem condições de contabilizar cada linha separadamente, isso lhe permitirá utilizar multi-threads se necessário, ou executar funções serveless (lambda, por exemplo) se sua arquitetura permitir.

Na verdade, o simples fato de fazer as coisas em pequenas funções, separadamente, já podem trazer um ganho significativo de desempenho (comentarei sobre isso mais adiante).

A regra aqui é seguir o exemplo da Roma antiga e dividir para conquistar. Um bom exemplo disso é a diferença de desempenho entre o algoritmo Bubble Sort e o Merge Sort.

O bubble sort percorre toda lista duas vezes para ordenar qualquer conjunto de dados, sua complexidade é quadrática, ou O(n²), enquanto o merge sort utiliza o conceito de dividir para conquistar e possui uma complexidade logarítmica de O(n log n), sendo muito mais eficiente.

dicas código


Notação Big O

A notação Big O é utilizada para classificar os algoritmos pela forma como eles se comportam de acordo com a quantidade de dados que é manipulado. Saber esse tipo de comportamento de um algoritmo lhe permitirá identificar a partir de qual momento uma função passará a ser um gargalo, permitirá que você compreenda a razão entre o tempo de execução de um algoritmo e a quantidade de dados manipulados.

A melhor forma de compreender isso é através da seguinte imagem:

big o


As classes de complexidades mostradas na imagem anterior são os principais tipos de complexidade que um algoritmo pode ter. Segue exemplos de cada situação:

  • O(1): Complexidade Constante. Nesse caso o tempo de execução para uma tarefa será sempre o mesmo, independente da quantidade de dados manipulada. Um exemplo dessa situação é o acesso à um item de um HashMap, ou uma função que verifique se um número é par ou ímpar.
  • O(log n): Complexidade Logarítmica. Essa é uma situação que ocorre principalmente em buscas binárias, em árvores ou matrizes ordenadas. Para pequenas quantidades de dados ela é extremamente rápida, e seu crescimento é bastante lento.
  • O(n): Complexidade Linear. Nesse caso o tempo de execução cresce linearmente de forma constante. É o caso que ocorre ao percorrer um array, cada item a mais nessa lista aumentará o seu tempo de execução na mesma proporção.
  • O(n log n): Complexidade Log-linear. Esse é o caso de algoritmos que iteram em funções de complexidade logarítmica. Ou seja, é como se você estivesse executando um O(log n) n vezes. Um exemplo disso é o algoritmo Merge Sort.
  • O(n²): Complexidade Quadrática. Essa é a situação quando você tem dois laços encadeados. É o caso do Bubble Sort ou do Shell Sort.
  • O(2n): Complexidade Exponencial. Essa situação tende a ocorrer quando é utilizada recursividade dentro de um for.
  • O(n!): Complexidade Fatorial. Esse é basicamente o pior caso que um algoritmo pode chegar. Isso ocorre ao tentar encontrar a solução para algum problema por força bruta, testando todas as possibilidades, como por exemplo, a solução para o problema do caixeiro viajante.

A cereja no bolo

Certa vez contribuí com o desenvolvimento de um sistema financeiro cuja principal funcionalidade era realizar o fluxo de caixa da empresa, de forma altamente personalizável e com base em lançamentos financeiros extraídos do ERP do cliente.

A busca de dados de outros sistemas tende a ser bastante trabalhosa e pouco eficiente, principalmente no momento de transformar a informação bruta e transformar isso nas entidades e objetos utilizados no sistema. Mas nesse caso houve uma situação interessante no fluxo de caixa em si.

De forma que isso possa ser compreendido, eu devo dizer que um dos maiores diferenciais do fluxo de caixa desse sistema era o fato de que o usuário tinha a autonomia de criar o modelo do fluxo de caixa e definir linhas específicas com fórmulas bastante personalizáveis, de tal forma que ele poderia escolher simplesmente somar o resultado de outras linhas ou fazer cálculos complexos utilizando lançamentos com características específicas. Ou seja, o céu era o limite.

E a primeira implementação disso era muito lenta, ao ponto de levar mais de hora para fazer o fluxo de dois anos, com algumas dezenas de milhares de lançamentos. Para tentar resolver isso foram eliminadas as consultas ao banco de dados desnecessárias, alguns dados foram cacheados na memória e, de uma forma geral, os gargalos foram resolvidos, porém a sua execução ainda levava aproximadamente 30 minutos para uma base de dados de tamanho comparável aos casos reais que os clientes teriam.

Nesse ponto, já sem ideias, eu me lembrei da teoria de autômatos, bastante utilizada para o desenvolvimento de compiladores ou máquinas de estados.

A teoria em si não vem ao caso, o importante é a estrutura que os autômatos acabam tendo. Para o seu entendimento, imagine um autômato como algo cuja única função é reconhecer uma linguagem, baseado em um alfabeto e gramática específica. Esse reconhecimento é feito por meio de estados, que são facilmente representados por grafos, como demonstrado a seguir:



Nesse caso, o autômato reconheceria qualquer palavra que inicie com “abc” e que seja seguida das letras “a”, “b” ou “c” em qualquer quantidade.

Ou seja, “abca” e “abcaaaa” seriam reconhecidos, mas “abcde” ou “abcf” não seriam.

Mas o importante mesmo é a sua implementação. Ao codificar isso, cada estado acaba sendo uma função, que tem por objetivo reconhecer apenas aquele estado e determinar se pode seguir para o próximo estado ou se o reconhecimento deve ser interrompido. Ou seja, esse mesmo autômato poderia ser implementado da seguinte forma:

import java.util.Scanner;

 

public class App

{    

   public static void main( String[] args )

   {

       Scanner in = new Scanner(System.in);        

       System.out.println( "Informe a palavra para ser reconhecida: ");

       String str = in.next();

       System.out.println("Resultado: " + initialState(str));

       in.close();

   }

 

   public static boolean initialState(String word){

       int count = 0;

 

       if(count < word.length() && word.charAt(0) == 'a')

           return stateA(count, word);

 

       return false;

   }

 

   public static boolean stateA(int count, String word){

       if(++count < word.length() && word.charAt(count) == 'b')

           return stateB(count, word);

 

       return false;

   }

 

   public static boolean stateB(int count, String word){

       if(++count < word.length() && word.charAt(count) == 'c')

           return stateC(count, word);

 

       return false;

   }

 

   public static boolean stateC(int count, String word){

       if(++count < word.length() &&

           (word.charAt(count) == 'a' || word.charAt(count) == 'b'|| word.charAt(count) == 'c'))

               return stateFinal(count, word);

       

       return false;

   }

 

   public static boolean stateFinal(int count, String word){

       if(count + 1 < word.length())

           return stateC(count, word);

 

       return true;

   }

}


Vendo essa implementação, percebe-se uma característica muito importante, que é o fato de cada função executar apenas uma coisa, apenas o que é importante para aquele momento.

E, quando eu percebi isso, e ainda considerando que não tinha mais ideias de como resolver o problema de desempenho, resolvi reescrever o código relacionado às validações dos campos com expressões dinâmicas seguindo esse exemplo. Com isso o tempo de execução diminuiu de mais de 30 minutos para menos de 5.

Imagino que você deve estar pensando que isso é impossível, que certamente foi mudado algo a mais. Certo? Mas existe uma explicação bastante simples para isso.

Lembra que lá no início eu disse que você deve compreender a arquitetura de onde o sistema será executado?

Pois bem, hoje em dia a maioria dos processadores possuem arquitetura de 32 ou 64 bits, isso significa que eles são capazes de executar instruções de até 32 ou 64 bits, respectivamente. Mas o que acontece se uma instrução for menor que o tamanho suportado pelo processador?

Nesses casos, o processador poderá executar mais instruções até chegar ao limite dele e aproveitar bem esse recurso proporcionando uma melhora de desempenho. Mas, se o código anterior fazia exatamente a mesma coisa que o código novo, eles deveriam ter a mesma quantidade de instruções, certo?

Errado, pois a forma como você implementa o seu código altera a forma com que esse código é interpretado e compilado, que por consequência altera as instruções que serão utilizadas pelo processador.

O simples fato de mudar uma função enorme, para várias menores, cada uma responsável por uma atividade e que retornarão seus pequenos resultados para uma função “pai”, que reunirá isso para chegar ao resultado final, acaba por economizar recursos e ainda permite que o compilador otimize o código de tal forma que diferentes funções, independentes entre si, possam ser executadas paralelamente no processador.

Em uma função grandona isso normalmente não aconteceria, pois assume-se que o código que está sendo executado nesse instante seja dependente do código contido na linha seguinte.

Em contrapartida, se houver várias funções o compilador poderá assumir que elas sejam independentes entre si e que talvez não precisem ser executadas em sequência, principalmente se elas não compartilharem parâmetros entre si. E mesmo que você possua funções encadeadas, dependentes entre si, como o caso do autômato acima, você também terá benefícios, pois provavelmente reduzirá a busca de dados na memória principal, uma vez que a informação solicitada acabou de ser processada.

Recapitulando

Por fim, segue o resumão do que foi visto por aqui:

1) Se você não possui uma medida ou requisito que determine o tempo de resposta esperado, então você também não tem problema de desempenho.

2) Conheça o ambiente em que o sistema executará, tanto a arquitetura física quanto a arquitetura do sistema, pois arquiteturas diferentes demandam implementações e cuidados diferentes;

3) Não concatene Strings, utilize um StringBuilder ou StringBuffer para isso. A exceção para essa regra são as quebras de linha (para melhor entendimento humano) na definição do valor da variável, pois o compilador compilará isso como uma única String.

4) Escolha bem os tipos de dados que serão utilizados, levando em conta que tipos mais precisos ocupam mais memória e tipos primitivos são mais eficientes.

5) Escolha bem a estrutura de dados para a sua aplicação na hora de criar suas entidades e objetos, tenha em mente que você eventualmente buscará esses dados em grandes quantidades e que nem sempre você precisará da entidade inteira para fazer o que você precisa

6) Utilize bem as ferramentas que a linguagem tem a lhe oferecer.

7) Dívida para conquistar, economize recursos, mas lembre-se que recurso ocioso é recurso desperdiçado.

8) Não crie funções Severino (que fazem de tudo), faça funções pequenas responsáveis por apenas uma atividade;


Algumas referências:

NORVIG, Peter. Teach Yourself programming in ten years. <https://norvig.com/21-days.html>

SCOTT, Collin. Latency numbers every programmer should know. <https://colin-scott.github.io/personal_website/research/interactive_latency.html>

LEVINTHAL, David. Performance Analysis Guide for Intel® Core™ i7 Processor and Intel® Xeon™ 5500 processors. <https://software.intel.com/sites/products/collateral/hpc/vtune/performance_analysis_guide.pdf>

VALADARES, Otavio. Entendendo o Big O. <https://medium.com/@Otaviopvaladares/https-medium-com-otaviopvaladares-entendendo-o-big-o-9cb55297d2f8>


Ainda tem dúvida sobre o assunto ou quer compartilhar conhecimento também? Escreva nos comentários! Essa troca é muito importante pra nossa comunidade.

O que você achou deste conteúdo?
Quer receber nossos conteúdos?
Seu cadastro foi efetuado com sucesso! Enviaremos as novidades no seu email.
Oops! Something went wrong while submitting the form.