MÓDULO 06 · CONCEITO 08 DE 12

Alocação & pressão de GC

Stack vs heap, escape analysis, generational GC. Span<T>, ArrayPool<T>, sync.Pool. Quando alocar é o gargalo invisível — e por que P99 do seu sistema pode ser dominado por GC pause sem ninguém perceber.

Tempo de leitura ~22 min Pré-requisito Conceito 07 (CPU cache, locality) Próximo HTTP cache

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.

C# — dotnet-counters, MemoryDiagnoser, dotnet-trace
# 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.

Python — tracemalloc, scalene, gc module
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.

Go — pprof heap, runtime stats, b.ReportAllocs
// 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:

armadilha em produção

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.

heurística do sênior

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

  1. 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.
  2. 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.
  3. 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

  1. livro The Garbage Collection Handbook (2ª ed.) — Richard Jones, Antony Hosking, Eliot Moss (CRC Press, 2023). A bíblia acadêmica de GC. Cobre todos os algoritmos modernos com matemática rigorosa. Para sêniores que querem profundidade total.
  2. livro Pro .NET Memory Management — Konrad Kokosa (Apress, 2018). A referência canônica de GC em .NET. Cap. 7-9 cobrem o GC moderno em detalhe; cap. 13 cobre técnicas para reduzir alocação.
  3. livro Java Performance: The Definitive Guide (2ª ed.) — Scott Oaks (O'Reilly, 2020). Cap. 5-6 cobrem JVM GC com profundidade. ZGC e Shenandoah cobertos.
  4. livro High Performance Go — Bob Strecansky (Packt, 2020). Cap. sobre GC e runtime cobrem o modelo Go com clareza prática. Inclui padrões de redução de alocação.
  5. artigo Go GC Guide — Michael Knyszek (go.dev/doc/gc-guide, 2022+). Documentação oficial atualizada do GC em Go. O author é maintainer principal. Tratamento sistemático de tuning.
  6. artigo .NET Memory Performance Analysis — Maoni Stephens (devblogs.microsoft.com). Stephens é arquiteta principal do GC do .NET. Posts dela são canônicos para entender comportamento e tuning.
  7. artigo Adventures in the Land of Go's Garbage Collection — Vincent Blanchon (várias publicações 2018+). Série de posts didáticos sobre internals do Go GC. Útil para entender o que GOGC faz por baixo.
  8. artigo The Tail at Scale — Dean & Barroso (CACM, 2013). Já citado no conceito 04. Aqui relevante porque articula como GC pause é uma das fontes mais comuns de tail latency.
  9. docs Microsoft.IO.RecyclableMemoryStream. github.com/microsoft/Microsoft.IO.RecyclableMemoryStream — Implementação canônica de memory pool em .NET. Lê código fonte é didático.
  10. docs Span<T> and Memory<T>. learn.microsoft.com/en-us/dotnet/standard/memory-and-spans — Documentação canônica do tipo central de zero-alloc em .NET moderno.
  11. docs sync.Pool. pkg.go.dev/sync#Pool — Documentação curta e crítica. Inclui aviso sobre quando NÃO usar pool.
  12. vídeo Maoni Stephens — .NET GC talks. YouTube. Stephens apresenta em .NET Conf, MS Build, e dotnetos sobre internals do GC. Dá visão privilegiada da implementação.