Um conceito comumente incompreendido nos ajustes de desempenho em .NET é a importância de evitar a alocação de memória. Pensa-se que, desde que sejam rápidas, as alocações de memória raramente terão impacto no desempenho da aplicação.
Para entender o motivo deste mal-entendido, é preciso voltar para a época do desenvolvimento em COM programando em C++ e Visual Basic 4 a 6. Com o COM, a memória era gerenciada utilizando um coletor de lixo baseado em contagem por referência. Cada vez que um objeto era atribuído a uma variável por referência, um contador oculto era incrementado. Se a variável recebesse uma nova atribuição ou perdesse o escopo, o contador era diminuído. Se o contador chegasse a zero, o objeto era removido liberando a memória para ser utilizado em outro local.
Este sistema de gerenciamento de memória é "determinístico". Através de uma análise cuidadosa, pode-se determinar exatamente quando um objeto será removido. Isto significa que é possível liberar automaticamente recursos como, por exemplo, conexões de banco de dados. Já com o .NET Framework, em que é necessário um mecanismo separado (isto é: IDisposable/using) para garantir que um recurso não relacionado na memória seja liberado em tempo hábil.
Existem três desvantagens principais em utilizar um coletor de lixo baseado em contador por referência. A primeira é que eles são suscetíveis a "referências circulares". Se dois objetos referenciam um ao outro, mesmo indiretamente, é impossível para o contador de referência chegar a zero e, então, ocorre vazamento de memória. O código deve ser cuidadosamente escrito para evitar referências circulares ou fornecer um método destrutor que possa remover de alguma forma estes objetos quando não são mais utilizados.
A outra grande desvantagem ocorre quando trabalhamos em um ambiente multi-threaded. Para evitar situações de condições de corrida, algum tipo de mecanismo de bloqueio (isto é: Interlocked.Increment, spinlock, etc.) é necessário para garantir que as contagens das referências permaneçam precisas. Estas operações são surpreendentemente caras.
Finalmente, a lista de locais de memórias disponíveis pode se tornar fragmentada com muitos espaços pequenos inutilizáveis entre os objetos vivos. Alocação de memória frequentemente envolve caminhar por uma cadeia de links de locais livres, procurando por um espaço grande o suficiente para receber o objeto desejado. (Fragmentação de memória pode também ser feita em .NET no "Larger Object Heap" ou LOH)
Em contraste, alocando memória em um coletor de lixo baseado em varredura como em .NET ou Java é uma questão simples de incremento de ponteiro. E as atribuições não são mais caras do que a atribuição de um número inteiro. É somente quando o GC é executado que o custo real é pago e, frequentemente, isto é mitigado por usar um coletor baseado em gerações.
Quando .NET era algo novo, muitas pessoas se queixavam de que o desempenho do coletor de lixo não determinístico do .NET prejudicaria o desempenho da aplicação, isto seria difícil de explicar. O contra-argumento da Microsoft na época era que, para a maioria dos casos de uso, o coletor de lixo baseado em varredura seria realmente mais rápido, apesar da execução do GC não ser contínua.
Infelizmente, ao longo do tempo a mensagem ficou um pouco distorcida. Mesmo se aceitarmos a teoria que o coletor de lixo baseado em varredura é mais rápido que o contador por referência, isto não significa necessariamente rápido em um sentido absoluto. Alocações de memória, e a pressão de memória associada, muitas vezes são causadas por problemas de desempenho difícil de detectar.
Além disso, quanto mais memória estiver sendo utilizada, menos eficiente o cache de CPU será. Embora a RAM seja tão grande que o uso de memória virtual em disco quase não seja necessário, o cache de CPU é pequeno para comparação e o tempo para popular o cache de CPU a partir da RAM pode levar dezenas ou mesmo centenas de ciclos de CPU.
Em um artigo recente, Frans Bouma identificou muitas técnicas para melhorar o uso de memória. Embora ele estivesse procurando especificamente melhorias de desempenho para ORM, as sugestões são utilizáveis em inúmeras situações. Algumas de suas sugestões incluem:
Evitar params arrays
A palavra chave params é muito útil, mas cara se comparada com uma chamada de função normal pois requer mais alocação de memória. As APIs devem fornecer sobrecargas sem params para contagens de parâmetros comumente utilizadas.
Uma sobrecarga de IEnumerable<T> ou IList<T> deve também ser fornecida para que as coleções não precisem ser desnecessariamente copiadas para um array antes de chamar a função.
Determine o tamanho das estruturas de dados quando se adiciona os dados imediatamente depois
Uma List<T> ou outra classe de coleções podem ser redimensionadas muitas vezes enquanto estão sendo populadas. Cada operação de redimensionamento aloca outro array interno que precisa ser preenchido pelo array anterior. Pode-se muitas vezes, evitar este custo fornecendo ao construtor da coleção o parâmetro de capacidade.
Inicializar os membros tardiamente
Quando se sabe que um determinado objeto não será necessário a maior parte do tempo, então deve-se utilizar inicialização tardia e evitar aloca-lo prematuramente. Usualmente isto é feito manualmente com a classe Lazy<T> requerendo a alocação.
Em 2011, informamos sobre os esforços da Microsoft para reduzir o tamanho da Task usando técnicas similares. Foi informada uma redução de 49 a 55% no tempo da criação de uma Task<Int32> e uma redução no tamanho de 52%.