segunda-feira, 7 de julho de 2014

Utilização da Memória em .NET - Conceitos importantes

Introdução

Este artigo irá debater alguns conceitos importantes no .NET:

Vamos começar por descrever o que acontece internamente na memória do computador, quando uma variável é declarada, introduzindo de imediato aos conceitos de stack e heap. De seguida vamos analisar os tipos de valor e os tipos de referência e alguns dos seus fundamentos e funcionamento. Finalmente vamos analisar as operações de boxing e unboxing e como estas afectam a performance das aplicações.

Conteúdo

Memória Stack e Heap 

Quando o computador está em operação normal a memória RAM contém o código e os dados dos componentes do sistema operativo (SO) e aplicações que estão em execução.

A memória da aplicação é a porção da memória alocada pelo SO para executar cada aplicação. Esta memória é dividida em duas “zonas”: stack e heap.

RAM do computador

stackA memória stack, segue uma sequência de alocação estática, do tipo pilha, i.e., os dados são adicionados ou removidos numa lógica LIFO (Last In First Out) – o último a entrar é o primeiro a sair.

A stack é responsável por manter o controlo da memória necessária à execução da aplicação.

A heap é uma memória de alocação dinâmica. Os dados não são alocados sequencialmente nem em nenhuma ordem específica, como no caso da stack.

O que acontece quando declaramos uma variável?

Quando declaramos uma variável numa aplicação, a Framework .NET começa por alocar um bloco de memória RAM. Este bloco de memória é identificado internamente por um endereço, mas tem mais três características definidas: o nome da variável, o tipo de dados e o valor. Declaração Variável

Isso foi apenas uma explicação muito simples do que acontece na memória. O tamanho do bloco de memória alocado, bem como o tipo de memória a utilizar (stack ou heap), dependem do tipo de dados da variável.

No exemplo acima, vamos guardar um valor do tipo int, como tal, necessitamos de um bloco de 32 bits = 4 bytes, e a variável vai ser guardada na memória stack (nas próximas secções veremos porquê).

De forma a percebermos melhor o que são a stack e a heap, vamos analisar o que acontece internamente, durante a execução deste pequeno bloco de código:

public void MethodX()
{
    int x = 5;
    int y = 10;
    Class1 obj;

    obj = new Class1();
}

Vamos analisar linha a linha, para percebermos o que acontece internamente:

Linha 1
Quando esta linha é executada, o compilador aloca uma pequena quantidade de memória na stack e guarda lá a variável x.

Linha 2
Na próxima linha de código, o compilador aloca outro bloco de memória, na stack, empilhado por cima do bloco anterior e guarda a variável y. Na stack, for causa da lógica LIFO, os blocos são alocados e desalocados sempre na mesma ponta da memória - no topo da pilha.

Linha 3
Nesta linha, o compilador aloca mais um bloco de memória na stack, onde vai guardar um ponteiro para um objecto da classe Class1. Um ponteiro não é mais que um endereço de memória.  Como a classe ainda não foi instanciada, o ponteiro tem um valor especial que é null.

stackLinha 4
O operador new dá ordem ao compilador para criar uma instância da classe, para criar um novo objecto. Isto consiste no seguinte conjunto de operações :

  • É alocado um bloco de memória na heap, que permita guardar um objecto da classe Class1 (o compilador sabe determinar quantos bytes são necessários).
  • O endereço de memória do objecto é guardado na stack, no bloco identificado pela variável obj. Para o programador, o valor concreto deste endereço não interessa, é da responsabilidade do SO. O ponteiro é simplesmente representado pela variável.

stack & heap

Final do método
Quando o compilador termina a execução do método, limpa todas as variáveis de memória que lhe estão a atribuídas na stack, ou seja, a memória é desalocada, seguindo uma lógica LIFO. Todavia, a memória heap continua alocada. A limpeza desta memória ocorre mais tarde e é da responsabilidade do Garbage Collector.

Limpar memória

Os pontos importantes a reter é que os ponteiros são guardados na stack e as instruções do tipo “Class1 obj;” não alocam memória para uma instância da classe, simplesmente alocam espaço na stack para o ponteiro e colocam o seu valor a null. Só quando é executado o operador new é que é alocada a heap, é criado o objecto e atribuído o valor ao ponteiro.

Em resumo, podemos dizer que uma instrução de declaração de uma variável provoca que a memória stack seja alocada. A memória heap só é alocada através de uma instrução com o operador new.

Agora, muitos programadores podem estar a perguntar-se do porquê de dois tipos de memória. “Não podemos simplesmente alocar tudo em apenas um tipo de memória e pronto?”

Se analisarmos com atenção, os tipos de dados primitivos não são complexos. Possuem valores únicos como "int i = 0". Os tipos de dados de objectos são complexos. Referenciam outros objetos ou outros tipos de dados primitivos. Por outras palavras, possuem referências a outros valores múltiplos e cada um destes deve ser armazenado na memória também. Tipos de objecto necessitam de memória dinâmica, enquanto os tipos primitivos necessitam de memória estática. Se a exigência for memória dinâmica ficam alocados na heap, senão, vão para a stack.

Tipos de Valor e Tipos de Referência

Depois de termos compreendido os conceitos de stack e heap e de termos analisado o que se passa na memória do computador quando se declara uma variável ou se instancia um objecto, vamos analisar os conceitos de Tipo de Valor e Tipo de Referência.

Os Tipos de Valor são aqueles que guardam os dados na mesma localização da memória referenciada pela variável. São os tipos primitivos que discutimos anteriormente, que são alocados na stack.

Os Tipos de Referência guardam um ponteiro para a localização dos dados, que estão numa zona de memória diferente. Estes são os tipos complexos, que exigem memória dinâmica. Ficam alocados na heap, mas é criado um ponteiro na stack que referencia essa localização na heap.

Vejamos então o que acontece na memória quando usamos Tipos de Valor:

public void MethodY()
{
    int i = 5;
    int k = i;
    i = 10;
}

Linha 1
O compilador aloca memória na stack e guarda a variável i.

Linha 2
O compilador aloca memória na stack, para guardar a variável k e copia para lá o valor da variável i. As variáveis têm cópias cópias diferentes.

Linha 3
O compilador actualiza, na memória, o valor da variável i. A variável k não é afectada.

Tipos de ValorE agora vejamos o que acontece com os Tipos de Referência:

public void MethodZ()
{
    Class1 ob1 = new Class1();
    Class1 ob2 = ob1;
    ob1 = null;
}

Linha 1
O compilador aloca memória na stack para o ponteiro ob1. Depois aloca memória na heap para o ojecto e insere o endereço no valor do ponteiro.

Linha 2
O compilador simplesmente aloca memória na stack, para o novo ponteiro ob2 e copia para lá o valor da variável ob1. Neste caso, somente o valor do ponteiro é copiado, i.e. o endereço de memória. O objecto em si não é copiado.

Tipos de ReferênciaLinha 3
O valor do ponteiro ob1, na stack é colocado a null. Nada acontece ao objecto, na heap. Como os ponteiros estão em blocos distintos na stack, o ponteiro ob2 também não é afectado.

ponteirosQuais são os Tipos de Valor e os de Referência?

Depois de termos compreendido o que são os Tipos de Valor e os Tipos de Referência e como funcionam em termos da memória do computador, é legítimo colocar-se uma questão: dos tipos pré-definidos na Framework .NET, quais são de valor e quais são de referência?

Tipos de Valor (C#), por categoria:

Enumerations

Strucs

Tipos de Referência (C#)

Boxing e UnBoxing

Após termos adquirido algum conhecimento sobre tipos de dados e o seu respectivo tratamento no .NET, vamos tentar perceber o impacto, em termos de performance, da transferência de dados entre a stack e a heap e vice-versa.

Quando movemos de um tipo de valor para um tipo de referência, os dados são transferidos da stack para a heap. Quando movemos de um tipo de referência para um tipo de valor, os dados são transferidos da heap para a stack. Estas operações chama-se, respectivamente, Boxing e UnBoxing.

Vamos vero que acontece na memória quando realizamos estas duas operações.

public void MethodK()
{
    int i = 5;
    object ob1 = i;
    int k = (int)ob1;
}

Linha 1
Como já sabemos, a variável i é guardada na stack.

Linha 2
O compilador guarda o ponteiro ob1 na stack e aloca memória na heap para o ojecto, para onde copia o valor que está na variável iBoxing

Linha 3
O compilador aloca memória na stack, para a variável k e copia da heap o valor que está guardado no objecto – UnBoxing

Boxing & UnBoxing

As operações de Boxing e UnBoxing têm impacto na performance da execução da operação. Por exemplo, fez-se uma experiência de executar 1000 vezes os dois métodos abaixo indicados, registando-se o tempo de execução de cada uma destas operações.

private void BoxUnbox()
{
    int i = 1234;
    object ob1 = i;
}

private void SimpleAssign()
{
    int i = 1234;
    int k = i;
}

A operação de executar 1000 vezes o primeiro método demorou cerca de 3.5 segundos enquanto o segundo método demorou apenas 2.5 segundos. Registámos assim uma degradação de performance de 40%.

Por outras palavras, há que evitar a utilização de operações de Boxing e UnBoxing. Elas têm um impacto significativo na execução das aplicações. E em projectos em que seja mesmo obrigatório fazer boxing e unboxing, usar apenas nas situações em que for absolutamente necessário.

Bibliografia recomendada

  • Farrel, Chris e Harrison, Nick, “Under the Hood of .NET Memory Management”, USA, Simple Talk Publishing, 2011, ISBN 978-1-906434-74-8

 

Nota:

- Os exemplos de código apresentados foram desenvolvidos em linguagem C#. Poderiam estar em qualquer outra linguagem do .NET. A opção pelo C# deveu-se simplesmente a uma maior facilidade pessoal e abrangência em termos de documentação disponível para consulta.

Sem comentários:

Enviar um comentário