Em runtimes com garbage collection — .NET, JVM,
Go, Python, JavaScript —, alocação de memória parece
grátis. Você escreve new T(), ou
List<int>(), ou
{"key": "value"}, e o runtime entrega o
objeto. Não há malloc manual, não há
free explícito; o GC se encarrega de
reciclar quando o objeto não é mais alcançável.
Conveniente — e perigoso quando se ignora o que
acontece debaixo. Em hot paths, alocação é
provavelmente o segundo maior fator de performance
depois de cache CPU.
O problema é tríplice. Primeiro, alocação em si custa tempo — não é grátis, mesmo se for rápida. Segundo, alocação cria trabalho futuro: cada objeto alocado precisará ser coletado eventualmente. Terceiro, GC introduz pauses imprevisíveis — momentos onde o programa congela enquanto o coletor rastreia objetos. Em sistemas com SLO de baixa latência, GC pause é a fonte mais comum de tail latency (conceito 04 do módulo).
A boa notícia é que GCs modernos são impressionantemente bons em geral. .NET tem GC concurrent generational, Go tem GC pausa < 1 ms, JVM tem ZGC e Shenandoah com pauses sub-milissegundo. Para a maioria das aplicações, configuração default basta. Mas em hot paths e em sistemas com SLO agressivo (P99.9 baixo), entender o modelo de alocação e como reduzir pressão é diferencial. Este conceito articula o modelo, mostra ferramentas para medir alocação, e apresenta padrões idiomáticos para reduzir.
O escopo é deliberado: aplicação prática, não teoria profunda de GC. Estudo aprofundado de algoritmos de GC (mark-sweep, generational, concurrent, ZGC) cabe em livros inteiros (Richard Jones, The Garbage Collection Handbook); aqui o foco é o que o engenheiro de aplicação precisa saber para operar e otimizar.
Stack vs heap — onde os objetos vivem
Toda linguagem com runtime moderno separa duas regiões de memória onde objetos podem viver. Saber a diferença define muito sobre performance.
Stack: memória associada a cada thread. Crescimento e contração são triviais — apenas mover um ponteiro de stack pointer. Alocação custa ~1 ciclo. Sem necessidade de GC: variáveis locais "morrem" naturalmente quando a função retorna. Limites claros: tamanho fixo (tipicamente 1 MB por thread; em Go, goroutines começam com 8 KB e crescem). Apenas objetos pequenos e de tamanho conhecido em compile time.
Heap: memória compartilhada gerenciada pelo runtime. Pode hospedar objetos de qualquer tamanho. Alocação envolve mais trabalho (achar bloco livre, atualizar metadata, possivelmente expandir região). Reciclagem via GC ou referência manual.
A intuição em runtimes managed: classes são heap, structs (ou value types) tendem a stack, mas há nuances importantes.
Em .NET: classes (class)
sempre vão para heap. Structs (struct) vão
para stack quando são variáveis locais; vão para heap
se são campos de classe (mora dentro do objeto pai),
ou se são boxed para interface ((IDisposable)
myStruct aloca caixa no heap). ref struct
(.NET Core 2.1+) obriga stack-only.
Em Go: o compilador faz escape
analysis e decide. Variável local que "escapa"
(referenciada via ponteiro retornado, ou compartilhada
entre goroutines) vai para heap; do contrário fica em
stack. Escape analysis é determinístico —
go build -gcflags='-m' mostra a decisão.
Em Java/JVM: tudo (com exceção de tipos primitivos) vai para heap. JIT moderno pode fazer escape analysis e "scalar replace" objetos curtos em stack, mas não há tipo de valor explícito (Project Valhalla está mudando isso, mas em 2026 ainda em preview).
Em Python: tudo é objeto, tudo é heap. CPython tem reference counting + cycle collector. Não há controle.
Generational GC — a heurística que faz GC moderno funcionar
A maioria dos GCs modernos é generational, baseada em uma observação empírica chamada generational hypothesis: a maior parte dos objetos morre jovem. Strings temporárias, iteradores, builders, request locals — todos viram lixo logo após criados. Poucos objetos sobrevivem muito (caches, configurações, conexões pooladas).
O GC explora isso dividindo o heap em gerações. Em .NET: Gen 0 (objetos novos), Gen 1 (sobreviventes de uma coleta), Gen 2 (objetos que sobreviveram a duas coletas, considerados "longa duração"), e LOH (Large Object Heap, objetos > 85 KB). Em Java: Eden, Survivor S0/S1, Old gen, Metaspace. Em Go: o GC moderno (não-generational desde Go 1.5, com discussão sobre voltar) tem outro modelo.
A consequência operacional: coleta de Gen 0 é barata, coleta de Gen 2 (Old) é cara. Gen 0 é frequentemente coletada e a maior parte dos objetos simplesmente desaparece. Gen 2 é coletada raramente mas demora mais — é onde GC pauses longas vivem.
A regra prática: alocação de objetos curtos é relativamente barata; alocação que sobrevive para Gen 2 é cara. Reduzir alocação total é bom; reduzir alocação que vai parar em Gen 2 é melhor.
O que cada runtime entrega
Os GCs modernos têm capacidades distintas que definem o tipo de SLO viável.
.NET — Server GC, Workstation GC, Concurrent
.NET tem dois modos: Workstation (default em desktop) e Server (default em ASP.NET Core). Server GC tem heap segregado por core, paralelo, e é otimizado para throughput. Em modo concurrent (default em Server), Gen 2 é coletada em background com pause curto. .NET 5+ trouxe regions e melhorou drasticamente latência. .NET 8+ tem DATAS (Dynamically Adapting To Application Sizes) que ajusta o heap conforme uso. Pause típica em sistema bem ajustado: P99 de GC pause em < 10 ms.
Go — concurrent GC com targets de latência
Go fez decisão consciente desde 1.5: GC concurrent,
não-generational, com SLO interno de pause < 1 ms.
O trade-off: throughput marginalmente menor que GCs
generational, mas previsibilidade absoluta de
latência. Em produção, GC pause em Go é tipicamente
indetectável em métricas de aplicação. GOGC
(default 100) controla o trade-off de heap vs CPU
gasto em GC.
JVM — múltiplas opções, cada uma para um perfil
A JVM oferece variedade. G1 (default desde Java 9) é generational com pause target. ZGC (production-ready desde JDK 15) tem pause < 10 ms mesmo em heaps de TB. Shenandoah (Red Hat) similar a ZGC. Epsilon é "no-op GC" — sem coleta, para benchmarks. Em produção moderna com latência crítica, ZGC ou Shenandoah são default.
Python — reference counting + cycle collector
CPython conta referências em cada objeto e libera imediatamente quando contagem chega a zero (sem pause). Cycle collector roda periodicamente para pegar referências circulares. Sem GC tradicional, mas reference counting tem overhead em cada assignment/argument-pass — Python paga performance em todas as operações em troca de previsibilidade. PyPy tem GC tracing real, melhor para hot path. Python 3.13 free-threaded está reformulando o modelo.
Medindo alocação — onde está
Antes de otimizar, precisa medir. Cada linguagem tem ferramenta canônica.
# counters em tempo real (alocações por segundo, GC stats)
dotnet-counters monitor --process-id $PID System.Runtime
# inclui:
# gen-0-gc-count, gen-1-gc-count, gen-2-gc-count
# gen-0-size, gen-1-size, gen-2-size
# loh-size
# alloc-rate
# gc-fragmentation
# time-in-gc
# em BenchmarkDotNet — alocação por operação no microbench
[MemoryDiagnoser]
public class MyBench
{
[Benchmark]
public string Concat() {
var s = "";
for (var i = 0; i < 100; i++) s += "x";
return s;
}
}
// output reporta "Allocated" coluna — bytes por op
// allocation profiling em produção via dotnet-trace
dotnet-trace collect --process-id $PID \
--providers Microsoft-Windows-DotNETRuntime:0x1:5
# converter e abrir com PerfView para análise por tipo
System.Runtime counters em
dotnet-counters dá a visão "ao vivo" de pressão
de GC. [MemoryDiagnoser] é o caminho
micro; dotnet-trace + PerfView é o
caminho produção/profile profundo.
import tracemalloc
tracemalloc.start()
# código aqui
do_work()
# snapshot de top alocações
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics("lineno")
for stat in top[:10]:
print(stat)
# scalene (mais ergonômico, mostra alocação por linha)
# pip install scalene
# scalene minha_app.py
# gera HTML com tempo CPU + tempo GPU + memória + alocação por linha
# gc module (CPython internals)
import gc
gc.set_debug(gc.DEBUG_STATS) # log cada GC
print(gc.get_count()) # contador atual de objetos por geração
gc.collect() # força coleta
# memory_profiler
# pip install memory-profiler
# python -m memory_profiler minha_app.py
Python tem ferramental fragmentado.
tracemalloc é builtin mas verboso;
scalene é a melhor experiência prática;
memory_profiler mais clássico.
// pprof heap profile (snapshot)
go tool pprof -http=:8080 \
http://localhost:6060/debug/pprof/heap
// alocações em curso (não in-use)
go tool pprof -http=:8080 \
http://localhost:6060/debug/pprof/allocs
// runtime stats em tempo real
import "runtime"
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %v MiB", m.Alloc/1024/1024)
fmt.Printf("TotalAlloc = %v MiB", m.TotalAlloc/1024/1024)
fmt.Printf("NumGC = %v", m.NumGC)
fmt.Printf("PauseTotalNs = %v", m.PauseTotalNs)
// em benchmark — alocações por iteração
func BenchmarkConcat(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < 100; j++ { s += "x" }
_ = s
}
}
// go test -bench=. -benchmem
// reporta: 100 ns/op, 5000 B/op, 50 allocs/op
Go tem o melhor ferramental builtin entre as três.
-benchmem em go test -bench
é canônico. Heap profile via pprof mostra hot paths
de alocação por linha de código.
Padrões para reduzir alocação
Quando o profile aponta alocação como problema, há cinco padrões idiomáticos para atacar.
Object pooling
Reutilizar objetos em vez de criar novos. Em .NET,
ArrayPool<T> e
ObjectPool<T> (Microsoft.Extensions.ObjectPool).
Em Go, sync.Pool. Em Python, geralmente
não é necessário/possível idiomatically.
// .NET — ArrayPool para evitar alocação repetida
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(4096);
try {
// usa buffer
stream.Read(buffer, 0, 4096);
}
finally {
pool.Return(buffer); // devolve ao pool
}
// Go — sync.Pool para buffers temporários
var bufferPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func processRequest(r io.Reader) error {
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()
_, err := io.Copy(buf, r)
// ... processar
return err
}
Pool tem custo: complica código, e se o pool cresce sem cuidado, retém memória que não voltaria com alocação normal. Use quando o profile mostra alocação dominante e o objeto é caro de inicializar (buffer grande, conexão).
Stack allocation explícita
Em .NET, stackalloc cria array no stack:
Span<byte> buffer = stackalloc byte[256];
// buffer no stack — sem alocação heap, sem GC
ReadInto(buffer);
ProcessBuffer(buffer);
// buffer "morre" quando função retorna
Restrição: tamanho fixo, máximo de ~1 KB tipicamente (limite de stack). Mas para buffers pequenos e temporários, é zero alocação.
Span<T> e ReadOnlySpan<T> (.NET)
Span<T> é uma view sobre memória
contígua — pode apontar para array, stack, ou memória
não-managed. Permite operar sobre subset de dados sem
alocar nova array.
// alocação tradicional
string Truncate(string s) => s.Substring(0, 10); // aloca nova string
// Span — sem alocação
ReadOnlySpan<char> Truncate(ReadOnlySpan<char> s) => s.Slice(0, 10);
// ainda sem alocação
ReadOnlySpan<char> input = "hello world".AsSpan();
ReadOnlySpan<char> truncated = Truncate(input);
Span<T> é ref struct
em C# — só pode viver em stack, não pode capturar
em closure ou em campo de classe. Restrição
protege a invariante de "memória apontada é válida".
Struct em vez de class
Quando o objeto é pequeno (até ~16 bytes) e
imutável, struct em C# evita alocação no
heap. Vetores 2D/3D, IDs tipados, value objects são
candidatos clássicos.
// caro — alocação no heap
public class Vec2 {
public double X { get; }
public double Y { get; }
public Vec2(double x, double y) { X = x; Y = y; }
}
// barato — stack
public readonly struct Vec2 {
public double X { get; }
public double Y { get; }
public Vec2(double x, double y) { X = x; Y = y; }
}
Cuidado: structs grandes copiam a cada passagem (O(n) em tamanho). E em coleções genéricas, structs podem ser boxed (mover para heap) — perdendo o benefício.
String building com StringBuilder ou equivalente
Concatenação de string com += ou +
em loop aloca string nova a cada iteração — armadilha
clássica. StringBuilder usa buffer interno.
Em .NET 6+, string.Create e
DefaultInterpolatedStringHandler oferecem
caminhos ainda mais eficientes para padrões
conhecidos.
O que evitar — alocação invisível
Várias operações alocam silenciosamente. Reconhecê-las é parte da disciplina.
Closures. Em .NET, lambda que captura variável local cria objeto-closure no heap. Em hot path, evite ou converta para método estático.
Boxing. (object)42
aloca um System.Int32 boxed. Coleções
não-genéricas (ArrayList) e
conversão para interface implícita são fontes
comuns.
String formatting.
string.Format,
$"...", StringBuilder.Append
todos alocam. Para logs estruturados, isso é
especialmente caro — preferir source-generated logging
(conceito 06 do módulo 05).
LINQ. Cada operação LINQ
tipicamente aloca enumerador.
list.Where(x => x > 0).Select(x => x *
2).ToArray() aloca ao menos três objetos. Em
hot path, foreach manual frequentemente vence.
Iterator em Go. strings.Split
aloca slice de strings; regexp.FindAllString
idem. Em hot path, considere
strings.IndexByte + parsing manual.
O que faz P99 explodir — GC pause
O efeito mais visível de pressão de GC é tail latency. Quando o GC roda, o sistema faz pause — e se essa pause cair em momento de request crítica, latência individual sobe. Em sistemas com SLO de P99 baixo, GC pause é tipicamente o gargalo.
Em .NET, System.Runtime counter
time-in-gc mostra fração de tempo gasto
em GC. Acima de 5% sustentado é sinal de pressão
alta. Acima de 10% é problema sério. Em Go,
runtime.MemStats.PauseTotalNs e
NumGC mostram o histórico.
Otimizações para reduzir pause:
- Reduzir alocação total — menos objetos = GC menos frequente.
- Manter objetos curtos — Gen 0 coleta é barata; objetos que sobrevivem vão para Gen 2 e tornam coleta cara.
- Evitar Large Object Heap (.NET) ou objetos grandes — > 85 KB em .NET vai para LOH, que tem coleta diferente, mais cara.
-
Configurar GC para latência. Em
.NET,
System.Runtime.GCSettings.LatencyMode = Sustained.LowLatencyreduz pauses ao custo de mais memória. Em Java, ZGC ou Shenandoah. Em Go, ajustarGOGC.
ArrayPool / sync.Pool com retenção excessiva de
memória. Time identifica alocação dominante e
introduz pool. Benchmark melhora 30%. Em produção,
memória da aplicação cresce 3× e estabiliza —
o pool reteve buffers em pico que nunca são
liberados. Sintoma: RSS de produção mais alto que
esperado, sem crescimento (não é leak), mas plateau
elevado. Causa: pool não tem política de eviction,
e em pico cresce até cobrir a demanda máxima.
Defesa: sync.Pool em Go libera em GC
(mais agressivo); em .NET, ArrayPool
com tamanhos limitados; sempre validar
comportamento em soak test.
Quando alocação não é o problema
Para a maioria das aplicações, alocação não é o gargalo. Otimizar prematuramente vira dívida sem retorno. Pista para reconhecer:
Profile não mostra alocação dominante.
Se hot paths são CPU bound em lógica sua, e
time-in-gc é < 2%, alocação não é o
problema.
RSS estável e razoável. Sistema que mantém memória estável sob carga (sem crescimento) e dentro do orçamento dimensional não tem problema agudo.
SLO de latência confortável. Se P99 está bem abaixo do SLO, GC pause não está dominando.
Em todos esses casos, tempo do engenheiro vai melhor empregado em outro lugar. Cache CPU (conceito 07), cache de aplicação (conceito 10 do módulo 05), ou banco (conceito 11 deste módulo) frequentemente são gargalos com mais retorno.
Antes de mexer em alocação, responda três perguntas.
"O profile mostra GC ou alocação dominantes?" Sem
evidência, otimização é especulativa. "time-in-gc
está acima de 5%?" Abaixo disso, ganho é marginal.
"P99 está sendo dominado por pause?" Verifique
correlação entre pause de GC e spikes de latência —
se não correlaciona, GC não é o vilão. Quando as três
respostas batem em "sim", então pool, struct,
Span, e companhia entram no menu. Em todos os outros
casos, código idiomático e limpo ganha.
Por que importa para a sua carreira
Conhecimento de alocação separa sêniores que tocam sistemas críticos de sêniores que ficam na borda. Em entrevistas para sistemas com SLO agressivo (financeiro, gaming, ad tech), perguntas sobre GC e alocação são reais — não acadêmicas. Em pos-mortems de "P99 explodiu sem motivo claro", correlacionar com GC pause é trabalho de senior; reduzir pressão de alocação no hot path é solução. Em revisão de código de hot path, perceber alocação evitável (closure desnecessária, LINQ em loop) e propor alternativa mensurada é serviço ao time. E em discussão sobre escolha de runtime para sistema novo (.NET vs Go vs Java), articular trade-offs de GC é vocabulário maduro — não é "Go é mais rápido", é "Go GC tem pause target sub-ms; .NET e JVM modernas chegam lá com ZGC e DATAS, com trade-off de memória".
Como praticar
-
Medindo alocação em um endpoint.
Pegue um endpoint não-trivial em projeto seu.
Capture
time-in-gc, alocações por request, e GC count em um período de carga sustentada (use dotnet-counters, pprof, ou scalene). Anote os números. Identifique a operação que mais aloca via heap profile. Esse exercício, em projeto novo, calibra rapidamente o que esperar. -
Pool em hot path. Em algum projeto,
identifique uma alocação grande recorrente (buffer
de leitura, lista temporária, struct grande).
Implemente pool (
ArrayPool,sync.Pool, ou equivalente). Mensure antes/depois com benchmark e com soak test (memória sob carga sustentada). Documente o trade-off — é raro pool ser ganho puro. - Audit de alocação invisível. Pegue algum hot path em projeto seu (ou em projeto aberto) e use heap profile para listar todas as alocações. Para cada uma, identifique a fonte: closure? LINQ? string format? boxing? Para pelo menos uma, proponha refatoração para zero alocação. Esse exercício treina o olho para ver alocação onde ela está escondida.
Referências para aprofundar
- livro The Garbage Collection Handbook (2ª ed.) — Richard Jones, Antony Hosking, Eliot Moss (CRC Press, 2023).
- livro Pro .NET Memory Management — Konrad Kokosa (Apress, 2018).
- livro Java Performance: The Definitive Guide (2ª ed.) — Scott Oaks (O'Reilly, 2020).
- livro High Performance Go — Bob Strecansky (Packt, 2020).
- artigo Go GC Guide — Michael Knyszek (go.dev/doc/gc-guide, 2022+).
- artigo .NET Memory Performance Analysis — Maoni Stephens (devblogs.microsoft.com).
- artigo Adventures in the Land of Go's Garbage Collection — Vincent Blanchon (várias publicações 2018+).
- artigo The Tail at Scale — Dean & Barroso (CACM, 2013).
- docs Microsoft.IO.RecyclableMemoryStream.
- docs Span<T> and Memory<T>.
- docs sync.Pool.
- vídeo Maoni Stephens — .NET GC talks.