MÓDULO 01 · CONCEITO 02 DE 8

Memória — stack, heap e gerenciamento

Onde os dados vivem, quem os mata, e por que isso importa em três runtimes diferentes.

Tempo de leitura ~22 min Pré-requisito Processos & Threads Próximo I/O e syscalls

Programadores de linguagens com runtime gerenciado costumam tratar memória como detalhe que "o sistema cuida". Para muitas tarefas isso funciona — até o dia em que aparece um vazamento, um pico de uso que derruba o pod, ou latência inexplicável que o profiler aponta para a coleta de lixo. Nesse momento, "o sistema cuida" deixa de ser conforto e vira prisão. Quem entende o modelo de memória da linguagem que usa consegue depurar; quem não entende, adivinha. Este conceito existe para garantir que você não fique adivinhando.

A boa notícia: você não precisa entender memória no nível de quem escreve compiladores. Precisa entender três coisas. Onde os dados vivem (stack vs heap vs estática). Quem libera memória (manual, garbage collector, contagem de referência, ownership). Como cada uma das três linguagens da formação resolve isso, e quais armadilhas idiomáticas cada decisão cria. Com essas três bases, você pode raciocinar sobre qualquer problema de memória que aparecer em um sistema real.

Stack — barata, rápida, restrita

A pilha (stack) é uma região de memória que cresce e encolhe seguindo o fluxo de chamada de funções. Quando uma função é chamada, um novo "frame" é empurrado no topo da pilha; esse frame contém os parâmetros, variáveis locais e endereço de retorno. Quando a função termina, o frame é simplesmente removido — não há nada a "limpar", o ponteiro de pilha apenas volta. Esse modelo é extraordinariamente rápido: alocar e desalocar é uma única operação aritmética sobre o registrador de stack pointer.

O preço dessa velocidade é o regime LIFO (last in, first out). Você só pode desalocar o que está no topo. Isso significa que dados na pilha têm tempo de vida estritamente atrelado à função que os criou. Quando a função retorna, os dados deixam de existir — qualquer ponteiro para eles vira lixo. Essa restrição é o que torna a pilha inviável para dados que precisam sobreviver à função que os criou.

A pilha tem tamanho fixo definido na criação da thread. Em Linux, o default é 8MB por thread. Recursão profunda (especialmente em algoritmos não otimizados para tail-call) pode estourar esse limite — o famoso stack overflow. Em linguagens como Go, o runtime mitiga isso crescendo a stack automaticamente quando perto do limite (cada goroutine começa com ~2KB e cresce conforme necessário); em C, Java, C#, a stack tem tamanho fixo e estourar é erro fatal.

Heap — caro, flexível, persistente

A heap (em português, "monte" — mas todo mundo usa o nome inglês) é uma região de memória onde dados podem ser alocados e desalocados em ordem arbitrária, com tempo de vida desacoplado das chamadas de função. Você pede ao alocador "preciso de 64 bytes"; ele encontra um espaço livre, marca como ocupado, e retorna o endereço. Quando você não precisa mais, "libera" a região — e o alocador pode reutilizá-la para alocações futuras.

Essa flexibilidade tem custo. A heap tem que gerenciar fragmentação (espaços livres entre blocos ocupados), e a alocação não é mais uma operação aritmética — envolve buscar espaço adequado, possivelmente compactar, possivelmente pedir mais memória ao SO. Em hardware moderno, alocações na heap custam dezenas a centenas de nanossegundos; alocações na stack custam menos de um nanossegundo. Para código performance-crítico, essa diferença é a diferença entre 10 milhões de ops/segundo e 100 mil.

Mas o problema mais cruel da heap não é performance — é o quem libera. Em C/C++, a resposta é "você", e errar nisso (esquecer de liberar = leak; liberar duas vezes = crash; liberar e usar = use-after-free) é a fonte de praticamente todas as vulnerabilidades graves de software por cinquenta anos. Em linguagens modernas, o problema é resolvido por um de três mecanismos: garbage collector, contagem de referência, ou ownership. Cada um tem trade-offs diferentes.

Garbage collection — quem .NET e Go usam

Garbage collector (GC) é um componente do runtime que periodicamente examina a memória, identifica o que ainda está acessível pelo programa, e libera o resto. O programa não precisa pensar em "quando liberar" — só em "quando não preciso mais", e esquecer (sair de escopo, perder a referência) é suficiente. O custo é que o GC roda em algum momento — e nesse momento, pode pausar o programa.

A história do GC nos últimos vinte anos é a história de reduzir essas pausas. GCs antigos (Java 8 com CMS, .NET Framework com Server GC) podiam pausar centenas de milissegundos; os modernos (G1, ZGC em Java; Server GC com regions em .NET 10; o GC concorrente do Go) operam em janelas de microssegundos a poucos milissegundos. Para a maioria das aplicações, GC moderno é transparente — ele simplesmente acontece e você não percebe. Para aplicações de baixa latência (trading, gaming, certos serviços sub-milissegundo), GC ainda é fonte de complicação.

Conceitos importantes do GC moderno que valem conhecer:

Contagem de referência — como Python (e Swift) operam

O modelo alternativo: cada objeto carrega um contador de quantas referências apontam para ele. Toda vez que você cria uma referência, incrementa; toda vez que destrói, decrementa. Quando o contador chega a zero, o objeto é desalocado imediatamente. Não há fase de coleta — a desalocação é síncrona e determinística.

Vantagens: previsibilidade total (você sabe exatamente quando objetos morrem), sem pausas de GC, comportamento natural com recursos externos (arquivos, conexões fecham no momento certo). Desvantagens: overhead em cada operação de referência (não-trivial em código com muitos shares), problema fundamental com ciclos de referência — dois objetos que se referenciam mutuamente nunca chegam a contador zero, mesmo sem ninguém de fora referenciá-los.

Python resolve isso com um GC cíclico auxiliar — um coletor que roda periodicamente justamente para detectar e quebrar ciclos. Então Python não tem só contagem de referência: tem contagem de referência e um GC cíclico. Para a maioria dos casos, a contagem resolve; para os casos onde ciclos aparecem (estruturas como árvores com pais apontando para filhos e vice-versa), o GC ocasional limpa.

Ownership — o caminho de Rust (referência conceitual)

Rust não está na formação, mas seu modelo é referência conceitual importante porque resolve o problema de memória em tempo de compilação, não em runtime. Cada valor tem exatamente um "dono", a transferência de propriedade é explícita, e o compilador verifica que ninguém está usando algo depois que o dono saiu de escopo. O resultado é segurança de memória sem GC e sem contagem de referência — e portanto sem custo em runtime.

A relevância para você é cultural: ownership é o modelo conceitual mais próximo de "como C e C++ deveriam ter sido". Linguagens novas (Carbon, Mojo, Vale) estão explorando variações dele. Quando você ler discussões modernas sobre design de linguagens, o vocabulário de ownership aparece — e vale entender de onde vem.

Aliasing — o conceito que une tudo

Aliasing é quando dois ou mais nomes referenciam a mesma memória. O exemplo simples: a = b = [1,2,3] em Python — a e b apontam para a mesma lista, e modificar via a é visto via b. Em programas pequenos, isso é trivial. Em programas reais, aliasing acidental é fonte abundante de bugs: passar uma estrutura por referência e modificá-la onde o caller esperava imutabilidade; compartilhar um buffer entre threads sem proteção.

Cada uma das três linguagens da formação trata aliasing de forma diferente, e isso afeta como você escreve código:

C# — value vs reference types
// struct (value type) — copiado em atribuição/passagem
public struct Point { public int X, Y; }

var p1 = new Point { X = 1, Y = 2 };
var p2 = p1;        // CÓPIA — p1 e p2 são independentes
p2.X = 99;
// p1.X ainda é 1

// class (reference type) — referência compartilhada
public class Box { public int Value; }

var b1 = new Box { Value = 1 };
var b2 = b1;        // ALIAS — b1 e b2 apontam para o mesmo objeto
b2.Value = 99;
// b1.Value agora é 99

// records (C# 9+) são reference types por default,
// records struct (C# 10+) são value types — escolha consciente

A distinção struct/class é semântica de valor vs referência. Erros clássicos: passar struct grande por valor (cópia cara), modificar field de struct numa lista (não funciona — cópia retornada).

Python — tudo é referência
# Em Python, nomes são bindings para objetos. Atribuição
# nunca copia — sempre cria nova referência.

a = [1, 2, 3]
b = a               # ALIAS — b e a apontam para a mesma lista
b.append(4)
# a agora é [1, 2, 3, 4]

# Para copiar, é explícito
b = a[:]            # cópia rasa (shallow)
import copy
b = copy.deepcopy(a) # cópia profunda

# Imutáveis (int, str, tuple) parecem "valor" mas o mecanismo
# é o mesmo — só que você não pode modificá-los, então
# aliasing é invisível
x = 5
y = x           # alias para o mesmo int 5
y = 6           # rebinding de y; x ainda é 5

O modelo "tudo é objeto, nomes são bindings" é uniforme. A confusão aparece quando programadores vindos de C/C++ tentam pensar em valor vs referência. Em Python, a distinção é entre mutável e imutável.

Go — explícito sobre cópia
// Go é explícito: passar valor copia; passar ponteiro aliasa.

type Point struct{ X, Y int }

p1 := Point{X: 1, Y: 2}
p2 := p1            // CÓPIA do struct
p2.X = 99
// p1.X ainda é 1

p3 := &p1           // ponteiro — alias
p3.X = 99
// p1.X agora é 99

// Slices, maps e channels são REFERÊNCIAS por dentro
// mesmo passando por valor.
s := []int{1, 2, 3}
copyS := s          // ainda compartilha o array subjacente
copyS[0] = 99
// s[0] agora é 99 — slice header foi copiado, dados não

// Para copiar slice de fato:
realCopy := make([]int, len(s))
copy(realCopy, s)

A regra de Go: structs são copiados; ponteiros, slices, maps, channels aliasam. Erro clássico: passar slice esperando isolamento. Novamente, a previsibilidade vem de saber o modelo, não de tentar adivinhar.

Quando GC dói — e o que fazer

Em 95% das aplicações backend, o GC é invisível — ele acontece, recolhe lixo, ninguém percebe. Os 5% onde ele dói são os casos onde latência consistente importa: real-time, baixa latência, e aplicações onde alocações são altíssimas (parsing de JSON em alta vazão, geração de strings em loops apertados, criação de objetos curta-vida em microsserviços de alta carga).

O sintoma típico é latência p99 muito acima da p50. Você espera responder em 5ms; a maioria das requisições responde em 5ms; algumas respondem em 200ms. Esses outliers são quase sempre pausas de GC. As respostas possíveis, em ordem de invasividade:

Vazamentos de memória mesmo com GC

"Tem GC, então não vaza memória" é meia verdade. GC libera o que não é alcançável a partir das raízes (variáveis globais, frames de stack, registros de CPU). Se algo continua alcançável mas não é mais necessário, o GC não pode liberar — é o que se chama retenção, ou vazamento lógico. Padrões clássicos:

A ferramenta de diagnóstico é o profiler de memória. Cada runtime tem o seu: dotnet-dump + dotnet-gcdump em .NET, tracemalloc + memray em Python, pprof em Go. Aprender a usá-los é parte do ofício — você só vai precisar nos momentos ruins, mas nesses momentos não vai ter tempo de aprender.

armadilha em produção

Apps em containers veem só a memória do container, não da máquina inteira. Se você não setar limits.memory no Kubernetes, o app pode usar tudo e ser OOMKilled sem aviso. Alguns runtimes (Go até recentemente, Java em versões antigas) não respeitavam cgroups e achavam que tinham toda a RAM da máquina. Modernamente isso melhorou, mas vale verificar — em Java, use -XX:+UseContainerSupport; em Go 1.19+, é automático via GOMEMLIMIT.

Como praticar

  1. Provoque um GC observável. Em qualquer das três linguagens, escreva um loop que aloca milhões de objetos curtos. Observe métricas de GC (em .NET: dotnet-counters; em Python: gc.get_stats(); em Go: runtime.ReadMemStats). Veja como pausa, frequência e tempo total se comportam.
  2. Cause um leak via cache sem TTL. Implemente um cache em dicionário/map que cresce indefinidamente. Monitore memória com top ou htop. Veja-a crescer monotonicamente. Implemente TTL ou LRU; veja estabilizar.
  3. Compare alocação stack vs heap. Em Go, escreva uma função simples que cria struct e retorna por valor; depois retorne por ponteiro. Compile com go build -gcflags="-m" para ver as decisões de escape analysis — quando o compilador decide colocar na heap em vez da stack. É exercício direto sobre a fronteira.

Referências para aprofundar

  1. livro The Garbage Collection Handbook — Jones, Hosking, Moss (2nd ed., 2023). Bíblia de GC. Para quem quer profundidade real. Não é leitura casual.
  2. livro Computer Systems: A Programmer's Perspective (3rd ed.) — Bryant & O'Hallaron. Capítulo 9 (Virtual Memory). Explica como SO e hardware compõem o que você experimenta como "memória".
  3. livro Operating Systems: Three Easy Pieces — Arpaci-Dusseau. Caps. 13-23 (Memória virtual). Gratuito online. Os melhores capítulos sobre memória que existem.
  4. artigo The Tail at Scale — Jeff Dean & Luiz Barroso (2013). Paper clássico sobre por que pausas (incluindo GC) destroem latência p99 em sistemas distribuídos. ACM, gratuito.
  5. artigo Why is GC bad for low-latency apps? — Aleksey Shipilëv. shipilev.net — autor do Shenandoah GC explica trade-offs. Em Java, mas conceitualmente universal.
  6. artigo Getting to Go: The Journey of Go's Garbage Collector — Rick Hudson (2018). go.dev/blog/ismmkeynote — história e design do GC do Go. Eye-opening sobre por que a latência é tão estável.
  7. docs .NET Garbage Collector Fundamentals. learn.microsoft.com/en-us/dotnet/standard/garbage-collection — referência oficial. Veja seções sobre Server GC, regions (.NET 10), e profiling.
  8. docs Go Garbage Collector. tip.golang.org/doc/gc-guide — guia oficial 2023. Inclui GOMEMLIMIT, GC tuning, e como decidir trade-offs.
  9. docs Python data model — reference counting. docs.python.org/3/reference/datamodel.html — fonte primária para como Python pensa objetos e referências.
  10. docs Memray — Python memory profiler. bloomberg.github.io/memray — ferramenta moderna de profiling. Substitui tracemalloc para casos sérios.
  11. vídeo Modern Garbage Collection — Aleksey Shipilëv. YouTube. Hora de palestra densa sobre o estado da arte. Em Java, conceitualmente aplicável.
  12. paper A Generational Mostly-Concurrent Garbage Collector — Hudson, Moss (1992). Paper-base do GC moderno. Explica por que generational hypothesis funciona e como fazer concorrente.