Skip to content

Instantly share code, notes, and snippets.

@MarcosYonamine963
Last active May 22, 2024 02:21
Show Gist options
  • Save MarcosYonamine963/910591f11cc6a30e5762275b0255087b to your computer and use it in GitHub Desktop.
Save MarcosYonamine963/910591f11cc6a30e5762275b0255087b to your computer and use it in GitHub Desktop.
Heap e Stack

Heap e Stack - Gerenciamento de Memória

Este gist aborda os conceitos de memória Heap e Stack que fui aprendendo conforme as necessidades. Este gist não tem o objetivo de explicar tudo sobre o assunto. É apenas uma documentação do que eu aprendi, para fins pessoais.

Introdução

O Sistema Operacional, ao carregar um programa na memória, disponibiliza ao programa um espaço de endereçamento. Esse espaço é a memória disponível para aquele programa, e é gerenciado pelo Sistema Operacional. A memória pode ser dividida da seguinte forma:

  • .text - Código fonte do programa
  • .rodata (Read Only Data) - Dados constantes
  • .data - Variáveis inicializadas
  • .bss (Block Started by Symbol) - Variáveis não incializadas
  • .heap - Memória alocada dinamicamente
  • .stack - Memória alocada automaticamente

Os dados .text, .rodata, .data e .bss, após serem carregados na memória, não são alterados de tamanho. Assim, essa parte da memória é considerada memória estática. Já, o .heap e o .stack podem variar de tamanho, dependendo da demanda da aplicação. Em geral, o tamanho do Stack é de alguns MB (no Windows), enquanto que o Heap pode chegar a alguns GB.

A Memória Stack (Pilha) é uma forma otimizada para organizar dados na memória alocados em sequência, seguindo a lógica LIFO (Last In First Out), ou seja, o último dado alocado é o primeiro a ser desalocado. Já a Memória Heap (Monte) é uma organização de memória mais flexível que permite a alocação dinâmica de memória. Observe as imagens abaixo para uma analogia.

Na Stack, a adição de um elemento é feita sempre no topo da pilha. Para retirar um elemento, deve-se sempre retirar o último elemento colocado. Pode-se observar claramente que a organização da memória na Stack é eficiente e ordenado. Já na Heap, aparentemente não existe uma organização, e os dados são alocados nos espaços disponíveis (não são necessariamente sequenciais). Portanto, na Heap, quanto mais ciclos de alocação/desalocação de dados, maior será a fragmentação da memória, contendo espaços desalocados intercalados entre outros dados alocados. Quando a Heap se apresenta muito fragmentada, uma ferramenta chamada Garbage Collector (GC) realiza a desfragmentação e realocação dos dados.

Como funciona uma aplicação

Quando é realizado o deploy de uma aplicação em um servidor, dispositivo móvel, ou no desktop, parte da aplicação é carregada na memória RAM do dispositivo. Durante a execução do programa, o processador precisa armazenar informações temporariamente na RAM para realizar as funções do software. Para se ter um melhor gerenciamento da memória, esta é dividida basicamente em duas partes: Heap e Stack.

Stack e Value Types

A Pilha de Funções (Stack) funciona como uma estrutura de dados LIFO (Last In First Out), em que o último dado inserido na pilha é o primeiro a ser removido.

O espaço em memória dedicado para a Stack é muito menor comparado ao Heap, e isso tem um motivo. Basicamente a memória Stack é onde ficam as variáveis de tipos primitivos da linguagem (int, float, structs, ponteiros, etc). Quando o processador precisa acessar o dado contido na Stack, ele acessa diretamente o endereço na memória Stack. O valor armazenado nesse endereço é um Value Type.

Em resumo, a memória Stack armazena valores de tipos primitivos e Value Types são os valores armazenados na memória Stack que são acessados diretamente pelo processador.

Veja um exemplo em C#:

...

int salario = 1200;

...

Porém, existem tipos de dados mais pesados que os primitivos. Por exemplo, as classes (em linguagens OOP). As classes são armazenadas na memória Heap, mas para acessá-la, é preciso uma referência na memória Stack a partir de um ponteiro. Vale ressaltar que não são somente as classes que são armazenados na Heap, como será visto mais adiante.

Heap e Reference Types

Enquanto na Stack são armazenados os dados primitivos, na Heap, são armazenados os dados mais complexos, como os objetos e strings.

Como a memória Heap é muito maior que a memória Stack, a busca de um dado leva muito mais tempo. Para agilizar o processo de busca, são utilizados Reference Types. Basicamente, o endereço de memória do objeto presente na Heap é armazenado na Stack na forma de um ponteiro. Assim, quando o processador precisar do dado presente num objeto, ele não fará a varredura pela Heap inteira, mas através da Stack, ele saberá exatamente o endereço na Heap que ele deve acessar. Assim, o ponteiro para o objeto é um Reference Type.

Portanto, ao instanciar um objeto, armazenamos os dados do objeto na memória Heap, e guardamos um ponteiro para o endereço dele na memória Stack.

Veja um exemplo em C#:

public class Pessoa{
  public int Id { get; set; }
  public int Nome { get; set; }
  public int Salario { get; set; }
}

var pessoa = new Pessoa {
  Id = 2,
  Nome = "Gabriel",
  Salario = 2000
}

Layout da Memória de um Programa

Por convenção, um layout de memória segue o padrão mostrado na imagem abaixo, em que a seção text está localizado no menor endereço, e a seção stack no maior endereço da memória.

Portanto, na representação, a Stack cresce do maior endereço para o menor, ou seja, cresce para baixo. Porém, para facilitar a compreensão e análise, constuma-se representar a Stack crescendo para cima (em analogia a uma pilha). Vale ressaltar que em algumas arquiteturas de processadores, a Stack pode crescer do menor para o maior endereço, como no microcontrolador piccolo.

A Memória Stack

A principal funcionalidade da Stack é permitir que uma função do seu programa seja capaz de chamar outra (inclusive a si mesma), pegar o retorno e continuar o seu caminho de execução.

Quando uma função é chamada durante a execução de um programa, um bloco de memória, chamado Stack Frame, é empilhado no topo da Stack. Esse bloco contém informações sobre os parâmetros que essa função recebe, o endereço de retorno para a função chamadora, o endereço do antigo topo da pilha, e as variáveis locais da função. Ao término da execução da função, esse bloco é desalocado.

O runtime de cada linguagem junto ao sistema operacional irá controlar o tamanho máximo dessa pilha. Imagine um programa recursivo que faz uma chamada da função foo() dentro da própria função foo(). Isso irá fazer inúmeros empilhamentos nessa pilha de funções. Se essa recursão não se encerrar, ocorrerá o consumo total da memória stack, ocasionando no famoso Stack Overflow.

Temos então que para cada função chamada empilhamos um stack frame no topo da pilha de funções. O topo dessa pilha sempre contém o menor endereço de memória. Dessa forma se o topo da pilha for o endereço X, qualquer endereço menor do que X é inválido e qualquer endereço maior do que X é um Stack Frame válido (visto que a pilha cresce do maior endereço para o menor).

Na imagem acima, é possível notar a variável Stack Pointer (SP), que aponta para o topo da pilha. Durante a execução da função, esse ponteiro é atualizado, pois criamos e usamos variáveis.

Já, o Frame Pointer, é responsável por guardar a última referência do topo da pilha antes da chamada da função. Sua posição dentro do Stack Frame é sempre no mesmo lugar, facilitando a localização dos parâmetros, endereço de retorno e até das variáveis locais.

Veja um exemplo de um código:

A Memória Heap

A Memória Heap é uma área de alocação dinâmica de variáveis. Se um programa utiliza uma lista encadeada, por exemplo, ele aloca essa estrutura que cresce dinamicamente na Heap. Na linguagem C/C++, para alocar memória na Heap manualmente, utilizamos as funções malloc, calloc, realloc e new (para C++). Para desalocar variáveis na Heap usamos as funções free e delete (para C++).

A Heap é responsável por armazenar dados que devam sobreviver além da finalização de uma função. Podemos considerar que tais dados são de escopo global, enquanto que os dados na Stack são de escopo local (são desalocados quando a função se encerra).

Além disso, a Heap também armazena os dados que não são do tipo primitivo, como por exemplo, as classes, as strings, arrays e delegates (C#).

Em linguagens gerenciadas (como Java, por exemplo), a desalocação de memória na Heap pode ser feita por uma ferramenta gerenciada pelo sistema operacional, chamada Garbage Collector (GC). Sempre que o sistema identificar uma possível falta de memória na Heap, O GC é acionado para realizar a "limpeza" da memória. Porém, em linguagens não gerenciadas (como C/C++), a desalocação deve ser realizada manualmente (como por exemplo, o delete em C++).

Ao desalocar memória da Heap, a área volta a estar disponível para novas alocações. À medida que muitas alocações/desalocações ocorrem no Heap, ele sofre muita fragmentação. Isso gera impacto na performance e na eficiência de como o programa aloca memória.

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

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.

Vamos supor que os Objetos 1 e 3 sejam desalocados:

Só é possível alocar um objeto nos espaços desalocados se houver espaço suficiente. Caso não seja, o Garbage Collector deverá realizar uma realocação dos dados, ou, o tamanho da Heap deverá ser aumentado (se possível).

Desalocação Automática e Linguagens Gerenciadas

Em linguagens de programação não gerenciadas, a alocação e desalocação dinâmica de memória deve ser realizada manualmente de forma explícita no código. Por exemplo, em C, existem as funções malloc(), calloc() e realloc() para alocar memória, e a função free() para desalocar.

Uma das recomendações é usar as duas funções (malloc() e free()) em conjunto dentro do mesmo escopo para evitar se perder e esquecer de desalocar, ou pior ainda, desalocar o que ainda não pode e ainda será usado pela aplicação.

Em C++ isso fica ainda mais complicado porque mesmo que esteja na mesma função, no mesmo escopo uma exceção pode ser lançada e desviar o fluxo do programa sem você saber, e uma chamada à função free() ou uso do operador delete pode não ser executado, como seria esperado. Daí um gerenciamento um pouco mais automático passa a ser fundamental.

Em alguns casos esse gerenciamento pode ser o Garbage Collector (GC), mas não costuma ser usado em C/C++ já que a linguagem não ajuda.

Em C++ até existe alternativa. Na verdade, é recomendado usar o delete (que internamente acaba usando o free() mas de uma forma mais organizada).

Na verdade, em C++ o free() deve ser evitado em favor do operador delete ou mesmo delete[] que desaloca arrays. A função original do C é muito bruta para os padrões do C++. Ela ainda pode ser usada, já que precisa de compatibilidade com C, precisa se comunicar com o SO e em alguns casos uma flexibilidade maior pode ser necessária (bem raro).

Mais ainda, é recomendado deixar o gerenciamento semiautomático usando classes próprias para controlar o gerenciamento da memória. Estas classes fazem a alocação e sabem quando precisam desalocar deixando o programador livre desta decisão. Estas classes são chamadas de smart pointers. Elas ajudam muito, mas não fazem milagres. Se você esquecer de usá-las ou usar a classe errada para isto poderá obter resultados indesejados.

Garbage Collector

O Garbage Colletor (GC)é um mecanismo complexo de gerenciamento de memória responsável pela alocação e liberação da memória.

Ele é acionado por dois fatores: 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 "limite do aceitável" da quantidade de objetos é calculado periodicamente pelo runtime da linguagem, que tem uma série de parâmetros configurados de forma heurística e automatizada, e seu valor é atualizado em tempo de execução do programa.

É possível executar o GC manualmente (ex, no .NET, tem-se o método GC.Collect()). Porém, isso é uma péssima ideia, pois ao fazer isso, você estará atualizando os parâmetros previamente calculados, informando que ao runtime que tais valores estão errados, podendo causar uma queda no desempenho do sistema.

Em resumo, o Garbage Collector é uma ferramenta de "Coleta de Lixo", que é executada de tempos em tempos, acionado pelo SO ou pelo runtime, que desaloca objetos que não estão sendo utilizados na Heap, e faz a campactação da Heap, liberando espaço para novas alocações.

O funcionamento do GC se divide em quatro etapas:

  • Suspensão
  • Marcação
  • Compactação
  • Retomada

1. Suspensão

Assim que o GC é acionado, ele suspende a execução da sua aplicação INTEIRA. 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, rodá-lo sempre pode não ser uma boa ideia.

2. Marcação

Nesta etapa, o GC percorre a Heap procurando por objetos que perderam suas referências e, portanto, não serão mais utilizados, e começa a removê-los.

Com a remoção dos objetos, diversos blocos de espaços de memória disponíveis se abrem entre outros blocos utilizados, causando uma fragmentação na memória.

3. Compactação

Existem diversas técnicas de compactação da memória framentada. A ideia é posicionar os objetos um ao lado do outro, eliminando-se a fragmentação, e criando um bloco maior de memória livre.

Veja algumas das técnicas de compactação. O .NET utiliza uma técnica similar ao Copying Collector, com conceitos de gerações de objetos (referente ao life-time do objeto). Objetos da Geração 0 são os mais novos (recém alocados). Após uma execução do GC, os objetos sobreviventes (ainda ativos) são "promovidos" para a Geração 1. Numa nova execução, objetos sobreviventes da Geração 1 são "promovidos" para a Geração 2, que é a última geração. Em geral, o GC é executado apenas na Geração 0. Quando a quantidade de objetos na Geração 1 se torna grande, o GC é executado nas Gerações 1 e 0. O mesmo vale para a Geração 2. O .NET possui uma área para Objetos Grandes, a (LOH, Large Object Heap), que será discutida mais adiante.

4. Retomada

Após a compactação, finalmente, a aplicação é liberada para continuar sua execução.

LOH (Large Object Heap)

Para mover os objetos na Heap, deve-se realizar uma cópia de todo o conteúdo do objeto, além de atualizar todas as referências dele na Stack (pois o endereço do objeto foi alterado). Isso não é um problema para pequenos objetos. Porém, mover um objeto grande leva muito mais tempo, interrompendo a execução da aplicação por muito mais tempo. O .NET utiliza uma área da Heap especialmente para alocar objetos grandes (maiores que 85000 bytes). Esta área é chamada de LOH (Large Object Heap). Na LOH, o Garbage Collector até faz a desalocação dos objetos, porém, ele não realiza a compactação. Caso um novo objeto grande precise ser alocado na LOH, inicialmente, tenta-se alocar nos espaços disponíveis. Caso o objeto seja maior que os espaços, então, o tamanho da LOH é extendido para comportar o novo objeto.

Referências

https://blog.pantuza.com/artigos/heap-vs-stack

https://pt.stackoverflow.com/questions/3797/o-que-s%C3%A3o-e-onde-est%C3%A3o-a-stack-e-heap

https://www.youtube.com/watch?v=pVcuigMNFgA

https://eschechola.com.br/2022/03/18/net-que-voce-nao-ve-heap-vs-stack

https://andresantarosa.medium.com/heap-stack-e-garbage-collector-um-guia-pr%C3%A1tico-para-o-gerenciamento-de-mem%C3%B3ria-no-net-3faf6c4cd0ed

https://medium.com/@shoheiyokoyama/understanding-memory-layout-4ef452c2e709

https://www.tabnews.com.br/R1ck/low-level-memoria-stack-e-heap

https://pt.stackoverflow.com/questions/43766/qual-a-finalidade-da-fun%c3%a7%c3%a3o-free

https://pt.stackoverflow.com/questions/255769/o-que-%c3%a9-garbage-collector-e-como-ele-funciona

https://www.youtube.com/watch?v=s5-uC-taIi4

Links úteis

https://www.javatpoint.com/stack-vs-heap

https://www.youtube.com/watch?v=wDi5QA_JkjU

https://eximia.co/stack-heap-garbage-collector-performance-arquitetura-de-software/

https://spin.atomicobject.com/visualizing-garbage-collection-algorithms/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment