Free photo Ram Module Module Memory Computer Ram Pc Circuit — Max Pixel

Heap, Stack e Garbage Collector — Um guia prático para o gerenciamento de memória no .NET

Andre Santarosa
16 min readAug 18, 2021

--

Introdução

Houve um tempo em que memória era um recuso lento, escasso e caro, de forma que era necessário escrever os códigos com o máximo de performance, tornando-os bem menos inteligíveis e com alta complexidade de manutenção.
Essa comparação fica bem evidente quando vemos que o computador da Apollo 11 tinha apenas 4kb de RAM.

Com o avanço do hardware a memória acabou virando um recurso abundante, rápido e barato e os softwares acompanharam essa evolução se tornando consumidores vorazes de memória RAM (Chrome, é você?).

Com o tempo também aparecerem as linguagens de mais alto nível, que não exigiam um conhecimento tão profundo do funcionamento do hardware para trabalhar, mas ainda assim, era preciso fazer coisas como alocar e desalocar memória manualmente (C/C++).

Por fim surgiram as linguagens ditas gerenciadas, onde a própria linguagem ou runtime faz todo o gerenciamento de memória pra você e demandam muito pouco conhecimento de como tudo isso funciona por debaixo dos panos, deixando ao programador o trabalho de… programar.

Mas, se quisermos extrair performance das nossas aplicações, é necessário entender como isso tudo acontece e vamos aqui falar especificamente do C#, entretanto alguns dos conceitos se aplicam a linguagens como Java e Go também.

A primeira coisa que devemos entender é que existem dois tipos de memória, a Stack e a Heap. Cada uma tem um papel bastante importante no funcionamento dos nossos softwares.

Vamos falar primeiro da Stack.

A Stack

A Stack é um espaço contíguo em memória, ou seja, é um bloco sequencial, sendo que em sistemas 32bits ela terá 1MB de tamanho e em sistemas 64bits ela terá 4MB, mesmo em linguagens não gerenciadas esse espaço de memória é controlado pela própria linguagem e não precisa de nenhuma intervenção manual do desenvolvedor para alocação ou desalocação de memória.

A principal funcionalidade da stack é permitir que uma função do seu sistema seja capaz de chamar outra, pegar o retorno e continuar o seu caminho de execução, mas o que acontece durante a execução de uma função?

Sempre que chamamos uma função é criada um bloco na Stack chamado Stack Frame, esse bloco contem informações sobre os parâmetros que esse método recebe, o endereço de retorno para a função chamadora e suas variáveis locais. Vamos ver o exemplo abaixo

Aqui temos 3 funções, a main que chama a somar passando 2 parâmetros que por sua vez chama a somar1 passando também 2 parâmetros. Vamos olhar como fica a alocação dos dados na stack.

Primeiro temos a alocação das duas variáveis pertencentes a função main repare que o valor2 está em cima do valor1, por quê?

Porque a Stack é uma pilha, em ingles stack é literalmente pilha. E quando empilhamos itens sempre o último item a ser empilhado vai em cima, resultando assim no esquema LIFO (Last In — First Out), pra facilitar, pense em uma pilha de pratos, sempre o ultimo a ser colocado é o primeiro a ser retirado.

Bom, com os endereços de memória referentes as variáveis da função main reservados e populados, agora temos que chamar a função somar.

Quando chamamos uma função que tem parâmetros, a primeira coisa a ser alocada no Stack Frame são os parâmetros passados, logo após é gravado o endereço para o onde o método deve retornar ao finalizar sua execução e por fim as suas variáveis locais.

O endereço de retorno é necessário para o método saber para onde deve retornar seu resultado (e a execução) quando terminar sua rotina de execução.

Repare que mais uma vez tanto os parâmetros passados e as declarações estão na ordem “inversa” da sua passagem ou declaração, ou seja, empilhadas.

Agora o método somar fará uma chamara para o somar1 novamente passando 2 parâmetros, então no final da execução teremos todos os dados na stack. Vamos ver uma animação pilha sendo populada.

Até esse momento nenhum valor foi efetivamente retornado ainda, pois a cadeia de execução está acontecendo.

As chamadas só passarão a retornar a partir do momento que a ultima linha da função soma1 for executada e então a pilha começará a ser esvaziada.

Primeiro as variáveis locais são removidas e então a execução volta para a função soma.

Opa, temos um problema aí! A execução voltou pra soma mas o que acontece com os parâmetros1 e 2 que ainda existem na stack?

O método responsável por limpar essa área de memória passa ser a função para onde a execução foi retornada, ou seja, a função soma. É ela que vai fazer a limpeza das áreas que contém o parametro1 e parametro2 da função soma1 e então continuar seu caminho.

Como a área de memória é liberada ao fim da execução de uma função, podemos falar que temos aí um caso de desalocação determinística, pois sabemos exatamente o momento que essa liberação de memória será feita.

Vejamos ver uma animação da stack sendo limpa.

Lá no começo, eu falei que essa área tem um tamanho fixo, certo?

E conforme chamamos as funções de forma encadeada seus dados vão sendo alocados e só liberados quando a última chamada termina, correto?

O que aconteceria se eu chamasse tantas funções que essa área de memória acabasse?

Nesse caso, teríamos uma Exception lançada pelo ambiente de execução do C#, a famosa StackOverflowException e nosso programa seria encerrado.

Mas não se preocupe! Essas alocações e desalocações são feitas de forma tão rápida que isso é praticamente impossível de acontecer (isso se nenhum equívoco foi cometido durante a codificação do seu programa).

Seguindo essa lógica, e se uma das minhas funções declarasse uma variável tão grande que estourasse esse espaço?

Ter um tamanho tão limitado significaria que eu só posso alocar objetos pequenos na memória do meu programa?

Não!

Bom, na área da stack sim.

Pra entender melhor, temos alguns tipos específicos de dados que podem ser alocados na Stack, que são os tipos chamados Tipos por Valor.

Esses tipos têm tamanhos já conhecidos e ocupam áreas bem pequenas de memória. Podemos citar como exemplos de Tipo Valor os int (32 bits), float (32 bits), bool (8 bits), char (16 bits), long(64 bits), etc. Geralmente os Value Types são os tipos com tamanho na memória pré-determinado, embora possamos ter Value Types com tamanhos dinâmicos como por exemplo as structs, mas isso meio que foge a regra geral e é um desvio. Para todos os outros tipos nós temos a Heap.

Certo! Então isso quer dizer que eu não posso alocar variáveis de tamanho desconhecidos dentro da minha função? Não!

O que acontece é que essa variável é alocada na heap e apenas o seu endereço de memória (sua referência) é salvo pela sua variável na stack. E é exatamente por isso que chamamos os tipos de objetos que vivem dentro da heap de Tipos por Referência.

Antes de entrarmos em detalhes sobre o funcionamento da Heap, há uma crença que é preciso desmistificar.

Tipos Valor vão na Stack e Tipos referencia vão na Heap”.

Embora isso faça um pouco de sentido, essa afirmação está incorreta. Imagine que você tem uma classe chamada Livro , nessa classe Livro existe uma propriedade int chamada AnoDeLancamento . Curiosamente essa variável, apesar de ser de um Tipo Valor, ela não será alocada na Stack, visto que ela é pertence ao escopo de um Tipo Referencia, ela irá “junto” com o escopo para a Heap. Então sempre que houver uma variável do tipo valor que pertença ao escopo de um Tipo Referencia, ela será armazenada na Heap. Se por Livro fosse uma Struc ao invés de uma Classe, esse valor seria armazenado na Stack normalmente.

Dito isso, vamos pra Heap.

A Heap

Diferentemente da Stack, a heap não é um espaço contíguo. Na verdade, ela é a coleção de segmentos de memória que podem ou não estar perto um do outro e também, ao contrário da Stack, ela não tem um tamanho fixo e aumenta e diminui conforme o seu sistema aumenta ou diminui a demanda por memória.

Por que, então, não utilizamos somente a heap para que nosso sistema não tenha limitações como no caso da Stack?

Exatamente por ter um tamanho indeterminado e estar distribuída entre vários blocos, a leitura e escrita na heap acaba sendo bem mais lento, mas mesmo assim ela é vital para o funcionamento dos nossos sistemas.

Como dito acima, a heap é responsável por armazenar os tipos por referência, ou ainda variáveis, que devam sobreviver além da finalização de uma função ou que não tenham um tempo de vida determinado, podemos citar, como exemplos, de Tipo por Referência as classes, arrays, strings e delegates.

Vamos utilizar, como exemplo, as classes.

Toda vez que fazemos a instanciação de uma classe, é alocado um espaço na memória heap para conter os dados dessa classe. Essa alocação é feita pelo runtime da linguagem automaticamente quando chamamos o método construtor da nossa classe.

E como é que o runtime sabe o tamanho necessário para guardar a nossa classe?

Acontece que quando compilamos o nosso código C#, vários metadados são gerados a respeito do código compilado e uma dessas informações é qual é a quantidade necessária de memória para que uma instância dessa classe possa ser armazenada.

A memória heap pode ser representada da seguinte forma:

Todo o bloco em azul é espaço ainda disponível para alocação. O bloco em amarelo representa nosso objeto armazenado na heap.

Repare que temos uma seta na última posição.

Essa seta é um ponteiro indicando o endereço onde o próximo objeto será alocado. Isso facilita a vida do runtime, pois ele sabe exatamente onde alocar o próximo item e não precisa ficar buscando na memória um lugar para isso.

Alocando mais alguns objetos, ficaríamos com o seguinte desenho:

Repare que o espaço ocupado pelos objetos diferem, e isso é devido ao seu tamanho em memória.

Esse tamanho vai variar de acordo com a quantidade de propriedades da nossa classe, tamanho do nosso array e assim por diante.

Bom, legal! Falamos sobre como os objetos vão para na heap, mas não discutimos sobre como eles saem de lá, então antes de entrar no assunto vamos olhar o código abaixo:

Analisando esse código, pela lógica, quando a próxima passada do for for executada, a variável minhaClasse perderia seu escopo e seria removida da heap, certo? Errado! A limpeza da heap não é tão simples assim.

A desalocação dos objetos na heap é realizada de forma não determinística, ou seja, não sabemos ao certo quando esses objetos serão removidos mesmo quando eles saem de contexto e não são mais necessários.

Mas como essa remoção é feita? Quem é o responsável?

Como veremos logo adiante, o responsável pela limpeza é uma ferramenta chamada Garbage Collector. É ele quem faz o trabalho pesado pra gente e permite que possamos focar mais no “o que fazer” do que no “como fazer” (quando estamos falando de gerenciar memória, é claro).

O Garbage Collector

Falamos mais acima que a desalocação dos objetos da heap é feita em um momento não determinístico pelo Garbage Collector, mas como o Garbage Collector sabe a hora de iniciar?

Ele tem dois gatilhos: quando a quantidade de objetos na heap ultrapassa o limite do aceitável ou quando o sistema operacional dispara um aviso de potencial falta de memória.

O segundo caso é simples: o Sistema Operacional informa e o Garbage Collector (vamos chama-lo de GC pra abreviar as coisas) é disparado.

Quando falamos do primeiro, utilizamos o termo “limite do aceitável”, mas qual o limite e quem define isso?

Bem, o limite a gente não sabe. Ele é calculado a todo momento pelo runtime da linguagem, que tem uma série de parâmetros configurados de forma heurística e atualizados de tempos em tempos.

Se tivéssemos valores fixos pra isso poderíamos ter grandes problemas, pois as mesmas configurações de um aplicativo de bloco de notas seria aplicado para um sistema com milhares de usuários simultâneos, então esses valores são ajustados pela própria ferramenta em tempo de execução.

Mas nós também temos a possibilidade de forçar a execução do GC na mão chamando o comando GC.Collect() e isso é uma péssima ideia!

Eu disse a pouco que o GC roda baseado em uma série de parâmetros auto-gerenciados e, falar pro GC executar na mão, é dizer para o runtime que esses parâmetros estão errados e, devido a forma com a qual o GC trabalha, isso pode ter implicações graves na performance da sua aplicação, ou seja, só chame o Collect() se você tiver certeza absoluta do que está fazendo.

Vamos fazer um deep-dive pra entender o porquê.

Quando o GC é acionado 4 coisas acontecem

1-Suspensão: Ele suspende a execução da sua aplicação INTEIRA. Isso mesmo, sua aplicação é congelada completamente quando a execução do GC tem início.

Isso é para prevenir a não concorrência da memória e garantir que nada novo será colocado em nenhum ponto da heap. Por isso o GC causa um grande impacto na performance da sua aplicação e, roda-lo sempre pode não ser uma boa ideia.

Dependendo da forma que seu código está estruturado, o GC pode ser acionado de centenas a milhares de vezes em poucos segundos.

O GC é um pedaço de código extraordinário e realmente rápido, mas sejamos cautelosos com a sua utilização. =)

2-Marcação:O GC começa a varrer a heap procurando pelos objetos que perderam suas referencias e, portanto, não serão mais necessários para sua aplicação e começa a removê-los.

Isso resulta em vários espaços desalocados na memória, criando uma alta fragmentação, como podemos ver abaixo:

Com a marcação e exclusão executadas temos o seguinte cenário na heap:

Reparou na quantidade de espaço em branco não utilizado?

E se entre o Objeto2 e o Objeto4 quiséssemos alocar um novo objeto maior? Ele teria que ir pro final da heap e ficaríamos com esse espaço não utilizado.

Isso se não tivéssemos o terceiro passo.

3-Compactação: Nesse passo, o GC começa a remover todos os espaços em branco, fazendo o deslocamento de todos esses objetos na memória (e isso é bem custoso, mas necessário) para que os espaços que antes estavam no meio, sejam jogados pra depois do último objeto alocado, e alocação de novos objetos possa ser sequencial.

4-Retomada: Por fim, nossa aplicação volta a ser executada e continua a fazer o que a programamos pra fazer.

Como vimos, a execução do GC é necessária, embora seja uma coisa bem custosa e de alto impacto pro nosso sistema, de forma que os engenheiros (não só do C#) tiveram que pensar em maneiras de deixar esse processo mais inteligente, para que pudesse ser executado menos vezes.

Por isso o GC trabalha com o conceito de áreas onde são alocados os objetos. Essas áreas são classificadas por gerações, existindo 3: Gen0, Gen1 e Gen2 e as gerações têm relação direta com o tempo de vida dos objetos na memória.

Gen0 - É na Gen0 que estão os objetos mais jovens. Eles são colocados nessa área, pois quanto mais jovem é um objeto, maior a chance de ele não ser necessário a longo prazo.

Imagine a quantidade de vezes que criamos variáveis de apoio ou fazemos uma simples consulta ao banco de dados para pegar um valor que servirá exclusivamente para cálculo de alguma outra coisa. Pense em como os objetos tendem a ser criados com mais frequência e sobrevivem menos tempo.

É aqui onde o GC é mais executado para atuar na limpeza e compactação das áreas de memória. Agora, se um objeto sobrevive a limpeza da Gen0, ele é promovido e passa a ser alocado na Gen1.

Gen1 - A coleta na Gen1 só é executada quando a execução na Gen0 não foi capaz de liberar memória o suficiente. Ele funciona como se fosse uma área de buffer entre a Gen0 e a Gen2.

Os objetos alocados na Gen0 e Gen1 são classificados como objetos efêmeros e vale notar que sempre que uma coleta de Gen1 é executada, obrigatoriamente a de Gen0 também é.

Abaixo, temos uma tabela com o tamanho da área de objetos efêmeros (Gen0 e Gen1), de acordo com a plataforma e tipo de GC (falaremos disso adiante):

E se o objeto sobreviver a Gen1 o que acontece com ele? Exato, ele vai pra Gen2 que é a nossa área para objetos mais “antigos”.

Gen2 - Se o seu objeto sobreviveu a coleta da Gen0 e da Gen1 é pra cá que ele veio.

Ah, e se o seu objeto for estático (nasce e morre junto com a aplicação) é aqui que ele será alocado também. Mas nem só de objetos promovidos e estáticos vive a Gen2.

A Gen2 também é responsável por gerenciar uma área crítica. Lembra que eu disse que logo após uma coleta na heap, é feita uma compactação? Bem, não é assim!

Isso é feito apenas na área que chamamos de Small Objetcs Heap(SOH), que aloca apenas objetos menores que 85.000 bytes. Para objetos maiores temos Large Objects Heap (LOH).

Mas antes, vamos falar mais um pouco sobre a alocação de memória.

Se, mesmo após a coleta, a heap ainda precisar crescer mais, um novo bloco de memória é alocado para comportar esses objetos.

Mas apenas os objetos efêmeros são movidos pra lá. Nesse caso, a área de geração 2 tem uma diminuição e os objetos efêmeros são movidos para essa nova área.

Agora vamos falar da Large Objetc Heap.

Large Object Heap

A LOH (vamos chama-la assim) funciona de uma forma um pouco diferente da SOH. Ela é específica para objetos maiores que 85.000 bytes. Pode parecer pouco, mas acredite, dificilmente um objeto excede esse tamanho.

Os casos mais comuns de objetos que vão pra LOH são as strings e os arrays.

Uma das fases mais críticas da execução do GC é a compactação de memória, que implica em ficar movendo os objetos. E objetos maiores são consideravelmente mais custosos de se realocar, chegando a um ponto que isso teria tanta implicação de performance que para de valer a pena e é aqui que eles vêm parar.

Quando um objeto da LOH é coletado, o seu espaço fica em branco. Nenhum tipo de deslocamento é feito, mas isso não significa que esse espaço vai ficar vazio.

Existe uma tabela de “espaços em branco na LOH” e, ao criar um novo objeto na LOH, essa tabela é verificada para saber se esse espaço vazio consegue comportá-lo de forma a preencher esses espaços e baixar o consumo por alocação de mais blocos.

O problema acontece quando o espaço restante entre objetos é menor que 85.000 bytes. Aí não tem o que fazer. Essa área não será alocada, não ao menos até um dos objetos adjacentes ser coletado também, e o endereçamento de memória disponível naquele espaço “se expandir para as laterais”.

Entretanto, quase sempre o runtime prefere alocar esses novos objetos no fim do endereçamento, visto que o trabalho de consultar a tabela pode não compensar e, forçar uma coleta de Gen2 pra abrir espaço adicional nessas áreas é muito custoso.

Então a dica aqui é: cuidado com objetos muito grandes!

Como tudo na área da computação, quase nada é escrito em pedra e a partir do .NET 4.5 temos a opção de habilitar uma flag para forçar a execução da compactação na LOH, embora isso não seja aconselhável devido ao alto impacto em potencial.

Dicas

Ao trabalhar com leitura de arquivos de texto multi-linhas, se possível, prefira utilizar a leitura linha a linha ao invés de importar tudo de uma vez, visto que a string gerada durante a leitura da linha vai pra SOH por ser potencialmente menor e poderá ser coletada e ter seu espaço em memória compactado.

Objetos que tem finalizers, ou destrutores, (métodos que são executados durante a finalização de uma classe) nunca são coletados na Gen0, pois o código dentro do finalizer pode ser lento e impactar na coleta de Gen0 que deve ser o mais performática possível. Se a limpeza foi previamente executada, podemos informar ao GC para não rodar o finalizer com o comando GC.SupressFinalizer().

Cuidado com chamadas a códigos não gerenciados. Chamadas a objetos COM+ ou a recursos do Sistema Operacional podem não contar com a ajuda do Garbage Collector e seu programa nunca liberará aquela memória alocada.

Não utilizar o GC.Collect(), a melhor forma de gerenciar a memória é deixar o próprio runtime fazer isso pela gente.

Exemplo de impacto:

Aqui temos um código simples que concatena uma string 50.000 vezes e retorna esse valor.

Eu sei, é um exemplo meio bobo, mas pense em um código que faz isso muito menos vezes só que em uma escala muito maior.

String são tipos imutáveis, ou seja, se quisermos alterar seu valor, precisamos criar outra string com o valor que queremos, a referência em memória é re-apontada e o GC faz o trabalho de coleta, já que strings são tipos por referência e vivem dentro da heap.

As execuções do GC são classificadas pela ferramenta de benchmark em x coletas a cada 1000 execuções.

Lembre-se que o “.” ali é um separador decimal e não de milhar =)

Internamente, o StringBuilder trabalha com um array de strings e só gera realmente a string que queremos apresentar quando efetuamos um sb.ToString().

Isso ajuda muito a evitar o overhead de alocações consecutivas de strings e acionamentos do GC, como no primeiro exemplo, e é por isso que esse método é muito mais performático. O número de coletas do GC e a alocação (1GB vs 457KB) falam por si só.

Referencias

https://docs.microsoft.com/pt-br/dotnet/standard/garbage-collection/fundamentals

https://docs.microsoft.com/pt-br/dotnet/standard/garbage-collection/workstation-server-gc

https://docs.microsoft.com/pt-br/dotnet/standard/garbage-collection/large-object-heap

https://docs.microsoft.com/pt-br/dotnet/standard/garbage-collection/performance

https://www.red-gate.com/products/dotnet-development/ants-memory-profiler/learning-memory-management/memory-management-fundamentals

https://www.red-gate.com/products/dotnet-development/ants-memory-profiler/learning-memory-management/misconceptions

https://www.red-gate.com/products/dotnet-development/ants-memory-profiler/learning-memory-management/memory-management-gotchas

--

--

Andre Santarosa

C#/.Net Senior Developer @Viatel Ireland. Likes to cook on the weekends and to play old songs on the guitar.