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:
- Generational hypothesis: a maioria dos objetos morre jovem. GCs modernos dividem a heap em "gerações" e coletam jovens com mais frequência (rápido, alta liberação) e velhos raramente (caro, mas incomum). É a otimização mais importante de GC moderno.
- Stop-the-world: pausa de todas as threads do app durante coleta. Em GCs modernos, é a fase mais curta possível — a maior parte do trabalho é feita concorrentemente.
- Concurrent / Incremental: GC roda em paralelo ao app, em threads próprias. Pausa só ao finalizar fases que precisam de consistência.
- Compacting: GC move objetos vivos para o início da heap, eliminando fragmentação. Caro, mas evita degradação de longo prazo.
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:
// 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).
# 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: 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:
-
Reduzir alocação: object pooling, struct em vez de class
(em C#),
sync.Poolem Go, evitar criação de objetos intermediários. Geralmente o de maior ROI. - Tunar o GC: cada runtime tem flags para ajustar threshold, tamanho de gerações, modo concorrente. Documentação oficial é o lugar.
- Trocar de runtime/linguagem: para casos extremos. Sistemas de trading frequentemente são em C++, Rust, Java com ZGC. É escolha consciente, não default.
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:
- Caches sem TTL: dictionaries que crescem indefinidamente. Resolva com TTL, LRU, ou limites explícitos.
- Listeners não-removidos: callbacks registrados em event emitters que nunca são desregistrados. Nas linguagens modernas, padrão para pegar isso é difícil — auditoria manual.
- Closures que capturam contexto grande: uma callback pequena que captura, sem perceber, um objeto enorme. Ele não pode ser coletado enquanto a callback existir.
- Goroutines que nunca terminam (Go): cada uma carrega seu stack. Goroutines bloqueadas em channels que nunca recebem são vazamento clássico em Go.
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.
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
- 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.
-
Cause um leak via cache sem TTL. Implemente um cache em
dicionário/map que cresce indefinidamente. Monitore memória com
topouhtop. Veja-a crescer monotonicamente. Implemente TTL ou LRU; veja estabilizar. -
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
- livro The Garbage Collection Handbook — Jones, Hosking, Moss (2nd ed., 2023).
- livro Computer Systems: A Programmer's Perspective (3rd ed.) — Bryant & O'Hallaron.
- livro Operating Systems: Three Easy Pieces — Arpaci-Dusseau.
- artigo The Tail at Scale — Jeff Dean & Luiz Barroso (2013).
- artigo Why is GC bad for low-latency apps? — Aleksey Shipilëv.
- artigo Getting to Go: The Journey of Go's Garbage Collector — Rick Hudson (2018).
- docs .NET Garbage Collector Fundamentals.
- docs Go Garbage Collector.
- docs Python data model — reference counting.
- docs Memray — Python memory profiler.
- vídeo Modern Garbage Collection — Aleksey Shipilëv.
- paper A Generational Mostly-Concurrent Garbage Collector — Hudson, Moss (1992).