Garbage Collection em .NET Core: Ciclo de Vida, Erros Comuns e GC.SuppressFinalize
Como o GC do .NET usa gerações, coleta e promove objetos, os erros que causam vazamento de memória e quando GC.SuppressFinalize é de fato necessário.

Neste artigo · 11 seções
- Introdução
- O Modelo Generacional: Por que Existe
- Ciclo de Vida de um Objeto: Desde Alocação até Destruição
- Ciclo de Vida Completo - Mermaid Diagram
- Erros Comuns Que Cada Desenvolvedor Comete
- GC.SuppressFinalize: O Misconception Crítico
- Diferenciando Vazamento Real vs Pico Normal de Memória
- Problema vs Solução de Memory Leak - Mermaid
- Estratégias Práticas Para Prevenir Memory Leaks
- Conclusão
- Insights & Takeaways
Introdução
O garbage collector do .NET Core moderno é uma das tecnologias mais sofisticadas de gerenciamento de memória já criadas. Diferentemente de linguagens como C/C++ onde você gerencia manualmente cada alocação, ou de linguagens mais antigas onde GC é invisível e complexo, o .NET oferece equilíbrio refinado entre automatismo e controle fino.
Para desenvolvedores .NET, compreender como o GC opera não é curiosidade acadêmica - é essencial. Decisões arquiteturais baseadas em equívocos sobre GC frequentemente resultam em aplicações que parecem "vazar memória", experimentam pausas impredizíveis, ou desperdiçam CPU desnecessariamente.
Este artigo explora o garbage collector de .NET Core moderno desde fundamentos até armadilhas práticas, incluindo quando (e quando definitivamente NÃO) usar GC.SuppressFinalize, erros comuns que cada desenvolvedor comete, e estratégias práticas para prevenir vazamentos reais de memória.
O Modelo Generacional: Por que Existe
A Realidade Empírica por trás de Gerações
O garbage collector do .NET reconhece uma realidade comprovada empiricamente através de décadas: objetos jovens morrem rapidamente; objetos velhos vivem muito tempo.
Esta observação é tão confiável que o heap gerenciado é dividido intencionalmente em três gerações completamente separadas:
Geração 0 (Gen 0): Objetos recém-criados - variáveis temporárias em loops, resultados intermediários de expressões, alocações de uma única função. Gen 0 é pequena (~256 KB em aplicações típicas) e coletada centenas de vezes por segundo.
Geração 1 (Gen 1): Buffer intermediário - objetos que sobreviveram exatamente uma coleta Gen 0, mas ainda relativamente novos. Tamanho médio ~2 MB, coletada dezenas de vezes na vida da aplicação.
Geração 2 (Gen 2): Objetos long-lived - dados estáticos, contexto da aplicação, objetos que persistem durante execução. Tamanho gigantesco (gigabytes em aplicações grandes), coletada poucas vezes na vida da aplicação.
LOH (Large Object Heap): Objetos > 85KB são alocados separadamente em heap especial para evitar fragmentação de heap principal.
Por que Isto Importa para Performance
O impacto prático desta estratégia é extraordinário. Em aplicação típica:
- Gen 0 collections: Extremamente rápidas (~microsegundos) porque heap é pequena e objetos frequentemente já estão mortos
- Gen 1 collections: Moderadamente rápidas porque ainda relativamente pequenas
- Gen 2 collections: Lentas (~milissegundos) porque checam heap gigantesca
Isto explica por que variáveis temporárias não degradam performance - morrem rapidamente em Gen 0. Mas objetos long-lived desnecessários armazenados em coleções estáticas degradam TUDO porque permanecem em Gen 2 indefinidamente.
Ciclo de Vida de um Objeto: Desde Alocação até Destruição
Fase 1: Alocação em Gen 0 - O(1)
var obj = new MyClass(); // Objeto alocado em Gen 0
Este é operação extraordinariamente rápida. .NET mantém simples apontador "allocation pointer" que avança sequencialmente:
// Pseudocódigo do que acontece internamente
private static IntPtr allocationPointer = startOfGen0;
public object Allocate(int size)
{
object obj = (object)allocationPointer; // Pega endereço atual
allocationPointer += size; // Avança pointer
return obj; // Retorna
}
// Isto é O(1) - apenas aritmética de ponteiro!
Alocação é 4-10 ciclos de CPU. Comparar isto com C malloc() que precisa buscar em lista de blocos livres - completamente diferente.
Fase 2: Coleta Gen 0 - Mark, Sweep, Compact
Quando Gen 0 fica cheia (tipicamente após ~256 KB alocado), GC inicia coleta. O processo tem três fases:
Mark Phase: GC começa em "roots" (variáveis locais no stack, static fields, handles) e segue cada referência recursivamente. Tudo que consegue alcançar é marcado como "vivo".
Sweep Phase: GC varre toda Gen 0 e identifica objetos não-marcados como "mortos". Seu espaço agora é reciclável.
Compact Phase: GC move todos objetos vivos juntos, eliminando gaps fragmentados. Isto é o segredo de performance - após compact, heap está perfeitamente contíguo.
Visualizando as fases:
// ANTES de collect
[OBJ1][ DEAD ][OBJ2][ DEAD ][OBJ3][DEAD]
// Após Mark: identifica OBJ1, OBJ2, OBJ3 como vivos
// Após Sweep: marca posições DEAD como reciclável
// Após Compact:
[OBJ1][OBJ2][OBJ3][ ESPAÇO LIVRE]
// Agora allocation pointer apenas incrementa neste espaço contíguo
A razão que .NET é tão eficiente: alocação é pointer increment, não busca em blocos fragmentados como C malloc.
Fase 3: Promoção Entre Gerações
Objetos que sobrevivem coleta Gen 0 são automaticamente promovidos a Gen 1:
var persistentObj = new MyClass();
// Gen 0 collection ocorre
// Se persistentObj ainda é referenciado, sobrevive → promovido a Gen 1
// Posteriormente, Gen 1 collection ocorre
// Se persistentObj ainda é referenciado, sobrevive → promovido a Gen 2
// Agora persistentObj só é coletado se Gen 2 collection ocorrer
// O que é raro - talvez poucas vezes em horas/dias
Este design é elegante: objetos que vivem mais são colocados em gerações coletadas menos frequentemente, economizando CPU.
Fase 4: Finalização - O Componente Complexo
Se objeto tem finalizer (destrutor C# ~ClassName), o GC o trata especialmente:
public class ResourceHolder
{
~ResourceHolder() // Isto é um finalizer
{
// Cleanup code - mas NÃO é chamado imediatamente!
}
}
var holder = new ResourceHolder();
// GC registra: este objeto tem finalizer
// Se holder virar garbage:
// 1. Não é coletado imediatamente
// 2. É adicionado a "finalization queue"
// 3. Thread finalizer roda: ~ResourceHolder()
// 4. DEPOIS disso, objetos é coletado na próxima coleta
Objetos com finalizers têm ciclo de vida muito mais longo e são significativamente mais caros. Isto é problema crítico que abordaremos.
Ciclo de Vida Completo - Mermaid Diagram
Erros Comuns Que Cada Desenvolvedor Comete
Erro 1: Static Event Handlers Não Unsubscribed - A Armadilha Clássica
Este é problema número um de memory leak em .NET:
// ❌ VAZA MEMÓRIA
public class NotificationService
{
public static event EventHandler<DataEventArgs> OnDataReceived;
}
public class DataProcessor : IDisposable
{
public DataProcessor()
{
// Subscreve em evento static
NotificationService.OnDataReceived += HandleData;
// PROBLEMA: Evento static mantém referência a 'this'
}
private void HandleData(object sender, DataEventArgs e)
{
// Processa dados
}
public void Dispose()
{
// SEM unsubscribe!
}
}
// Em produção
for (int i = 0; i < 1_000_000; i++)
{
using (var processor = new DataProcessor())
{
// Usar processor
}
// processor.Dispose() foi chamado
// MAS NotificationService.OnDataReceived ainda referencia cada processor!
// VAZAMENTO: 1 milhão de processors na memória
}
Por que isto é tão perigoso: Evento static é "root" do GC - tudo que referencia é mantido vivo indefinidamente.
Solução correta:
// ✅ CORRETO
public class DataProcessor : IDisposable
{
private bool _disposed;
public DataProcessor()
{
NotificationService.OnDataReceived += HandleData;
}
private void HandleData(object sender, DataEventArgs e)
{
// Processa dados
}
public void Dispose()
{
if (_disposed) return;
NotificationService.OnDataReceived -= HandleData; // ✅ CRUCIAL!
_disposed = true;
}
}
Erro 2: Coleções de Cache Sem Limite
// ❌ VAZA
private static Dictionary<int, ExpensiveData> _cache = new();
public void AddToCache(int id, ExpensiveData data)
{
_cache[id] = data; // Armazenado em dictionary static
// Se nunca remove de _cache, objeto nunca é coletado!
}
// Após chamar isto 1 milhão de vezes:
// _cache tem 1 milhão de objetos
// Tudo vivo, tudo em Gen 2
// Memória cresce monotonicamente
Solução: Implementar limite com política de evicção
// ✅ CORRETO
private static Dictionary<int, ExpensiveData> _cache = new();
private const int MAX_CACHE_SIZE = 10_000;
public void AddToCache(int id, ExpensiveData data)
{
_cache[id] = data;
if (_cache.Count > MAX_CACHE_SIZE)
{
// Remove entradas antigas (LRU, FIFO, etc)
var oldestId = _cache.Keys.First();
_cache.Remove(oldestId);
}
}
Ou usar ConditionalWeakTable:
// ✅ Melhor ainda - usa weak references automaticamente
private static ConditionalWeakTable<object, ExpensiveData> _cache = new();
// Objetos são automaticamente removidos quando coletados
Erro 3: Não Implementar IDisposable Corretamente
// ❌ VAZA RECURSOS
public class FileProcessor
{
private FileStream _file;
public FileProcessor(string path)
{
_file = new FileStream(path, FileMode.Open); // Arquivo aberto
}
// SEM Dispose! Arquivo fica aberto indefinidamente
// Se abrir 10.000 files, SO limita a ~1000 e crash!
}
// Em produção - CRASH!
for (int i = 0; i < 10_000; i++)
{
var processor = new FileProcessor($"file{i}.txt");
// processor sai de escopo
// MAS arquivo continua aberto! (finalizer não roda imediatamente)
}
Solução correta - padrão IDisposable:
// ✅ CORRETO
public class FileProcessor : IDisposable
{
private FileStream _file;
private bool _disposed;
public FileProcessor(string path)
{
_file = new FileStream(path, FileMode.Open);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // Explicaremos isto!
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Liberar recursos gerenciados
_file?.Dispose();
}
// Liberar recursos não-gerenciados se houver
_disposed = true;
}
~FileProcessor() // Finalizer como backup
{
Dispose(false); // Só libera não-gerenciados
}
}
// Uso
using (var processor = new FileProcessor("data.txt"))
{
processor.ProcessFile();
}
// Saindo de using, Dispose é chamado IMEDIATAMENTE
// Arquivo é fechado imediatamente, não espera por GC!
Erro 4: Allocating Huge Objects Desnecessariamente
// ❌ INEFICIENTE
public byte[] ProcessLargeData(int sizeInMB)
{
var buffer = new byte[sizeInMB * 1024 * 1024]; // > 85KB → vai para LOH!
// Processa dados
// ... trabalho ...
return buffer; // Retorna para caller
}
// Caller chamando isto frequentemente
for (int i = 0; i < 1000; i++)
{
var buffer = ProcessLargeData(500); // 500 MB cada vez!
// ... use buffer ...
// buffer sai de escopo e vai para Gen 2
// Gen 2 não compacta, fragmentação LOH cresce
// Memória não consegue ser reciclada eficientemente
}
Solução: Reusar buffers com ArrayPool
// ✅ CORRETO
public void ProcessLargeData(int sizeInMB)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(sizeInMB * 1024 * 1024);
try
{
// Processa dados
// ... trabalho ...
}
finally
{
// Retorna buffer para pool, não aloca novo
ArrayPool<byte>.Shared.Return(buffer);
}
}
// Reutilizar buffers evita alocações repetidas
Erro 5: Calling GC.Collect() Desnecessariamente
Este é sin capital em .NET:
// ❌ HORRÍVEL EM PRODUÇÃO
for (int i = 0; i < 1_000_000; i++)
{
DoSomething();
GC.Collect(); // Força GC a cada iteração - CATASTRÓFICO!
}
Por que é péssimo:
- Força GC a parar completamente o programa (pausa síncrona)
- GC já sabe quando coletar - você não consegue fazer melhor
- Chamar
GC.Collect()frequentemente causa aplicação travar constantemente - Gen 2 collections são MUITO caras e você está forçando frequentemente
- Em .NET Core moderno, GC é tão bom que
GC.Collect()é praticamente nunca necessário
Quando GC.Collect() é aceitável:
// ✅ ACEITÁVEL - apenas em circunstâncias específicas
// 1. Após operação massiva ONE-TIME
public void ProcessHugeFile(string filePath)
{
var data = LoadLargeFile(filePath); // 500 MB alocado
ProcessData(data);
// Após terminar, força UMA coleta ONE-TIME
GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized);
GC.WaitForPendingFinalizers();
}
// 2. Shutdown de aplicação long-running
public static void Shutdown()
{
GC.Collect(); // Cleanup antes de encerrar
}
// 3. Apenas em testes/diagnóstico de performance
// Nunca em hot loops de produção!
GC.SuppressFinalize: O Misconception Crítico
O que Muitos Desenvolvedores Fazem (ERRADO)
// ❌ COMPLETAMENTE ERRADO
public class MySimpleClass : IDisposable
{
public void Dispose()
{
// Nenhum código de cleanup
GC.SuppressFinalize(this); // ❌ Inútil! Classe não tem finalizer
}
}
Por que isto é errado: Se classe não tem finalizer (~ClassName), SuppressFinalize é noop - operação vazia. Você está chamando função que literalmente não faz nada.
Este padrão se tornou cargo-cult - desenvolvedores cegamente copiando código sem entender.
Quando SuppressFinalize É REALMENTE Necessário
SuppressFinalize é apenas necessário em classes que têm AMBAS:
- IDisposable implementado
- Finalizer explícito (
~ClassName)
Este é cenário raro:
// ✅ CORRETO - só neste cenário específico
public class UnmanagedResourceHolder : IDisposable
{
private IntPtr _nativeMemory; // Recurso não-gerenciado
private bool _disposed;
public UnmanagedResourceHolder()
{
_nativeMemory = Marshal.AllocHGlobal(1024 * 1024); // Aloca memória nativa
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // ✅ NECESSÁRIO!
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
// Liberar memória nativa
if (_nativeMemory != IntPtr.Zero)
{
Marshal.FreeHGlobal(_nativeMemory);
_nativeMemory = IntPtr.Zero;
}
_disposed = true;
}
~UnmanagedResourceHolder() // Finalizer existe!
{
Dispose(false); // Backup cleanup
}
}
Por Que SuppressFinalize Importa Aqui
Sem SuppressFinalize:
- Você cria objeto e chama
Dispose() - Dispose libera memória nativa com
Marshal.FreeHGlobal() - Objeto é coletado por GC
- GC vê que tem finalizer e o executa
- Finalizer tenta liberar memória que já foi liberada
- Crash ou comportamento undefined
Com SuppressFinalize:
- Você cria objeto e chama
Dispose() - Dispose libera memória nativa
SuppressFinalizediz ao GC: "não execute finalizer, tudo foi limpo"- Quando objeto é coletado, finalizer não roda
- Sem double-cleanup, sem crash
Diferenciando Vazamento Real vs Pico Normal de Memória
Pico Normal - Dente de Serra
// NORMAL - pico depois volta
for (int i = 0; i < 100_000_000; i++)
{
var temp = new byte[1024]; // Alocação temporária
ProcessData(temp);
// temp sai de escopo aqui
}
// Padrão de memória:
// [Cresce] [GC Gen 0] [Cai drasticamente] [Cresce] [GC Gen 0] [Cai]
// "Dente de serra" é PADRÃO NORMAL!
Vazamento Real - Linha Reta para Cima
// VAZA - nunca desce
var leakyList = new List<byte[]>();
for (int i = 0; i < 100_000_000; i++)
{
leakyList.Add(new byte[1024]); // Armazenado em lista static!
}
// Padrão de memória:
// [Cresce] [GC tenta] [cresce mais] [GC tenta] [cresce mais]
// Linha reta para cima = VAZAMENTO!
Ferramentas de Diagnóstico
Visual Studio Debugger - Built-in
No Visual Studio, Debug > Windows > Diagnostic Tools
Tira snapshots de memória
Compare snapshots - objetos que nunca desaparecem = vaza
dotMemory (JetBrains) - Professional
- Snapshots de heap
- Análise de referências
- Detecção automática de leaks
- Visualização de Gen 0/1/2
PerfView (Microsoft) - Gratuito
# ETW tracing, visualiza coletas de GC
perfview /Run meuapp.exe
Problema vs Solução de Memory Leak - Mermaid
Estratégias Práticas Para Prevenir Memory Leaks
1. Sempre Implementar IDisposable Quando Tiver Recursos
// Template padrão - copie isto!
public class ResourceManager : IDisposable
{
private UnmanagedResource _resource;
private bool _disposed;
public ResourceManager()
{
_resource = AllocateResource();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // Apenas se tiver finalizer!
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_resource?.Dispose();
}
_disposed = true;
}
~ResourceManager()
{
Dispose(false);
}
}
2. Unsubscribe de Static Events Sempre
// Padrão simples
EventClass.StaticEvent += MyHandler;
// Depois, quando terminar:
EventClass.StaticEvent -= MyHandler;
// Melhor ainda - em IDisposable
public class MyClass : IDisposable
{
public MyClass()
{
EventClass.StaticEvent += MyHandler;
}
public void Dispose()
{
EventClass.StaticEvent -= MyHandler;
}
}
3. Use using Statements Religiosamente
// Velho C# 7
using (var resource = new ResourceManager())
{
resource.DoWork();
} // Dispose chamado automaticamente
// Novo C# 8+
using var resource = new ResourceManager();
resource.DoWork();
// Dispose chamado ao fim do scope
4. Use ArrayPool Para Grandes Buffers
// Ao invés de:
// byte[] buffer = new byte[10_000_000]; // LOH allocation
// Use:
byte[] buffer = ArrayPool<byte>.Shared.Rent(10_000_000);
try
{
// Use buffer
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
5. Nunca Chame GC.Collect() em Hot Paths
// ❌ Nunca
for (int i = 0; i < 1_000_000; i++)
{
DoWork();
GC.Collect(); // NOPE!
}
// ✅ Deixe o GC decidir quando coletar
for (int i = 0; i < 1_000_000; i++)
{
DoWork();
}
6. Profile Antes de Otimizar
// Verificar se realmente há vaza
var before = GC.GetTotalMemory(true);
// Executa operação
DoWork();
var after = GC.GetTotalMemory(true);
if (after > before + threshold)
{
Console.WriteLine("Possível vaza!");
// Use dotMemory ou PerfView para investigar
}
Conclusão
O garbage collector do .NET Core é extraordinariamente sofisticado - generacional, concurrent, adaptativo. Para maioria dos casos, funciona optimamente sem intervenção manual.
Vazamentos reais de memória são quase sempre falha de aplicação - static event subscribers não unsubscribed, caches sem limite, IDisposable não implementado. Não é falha do GC, é falha de design.
GC.SuppressFinalize deve ser usado apenas em padrão IDisposable com finalizer explícito - caso extremamente raro. GC.Collect() deve ser evitado em código normal - reserved para diagnóstico ou shutdown.
Profiling adequado com ferramentas corretas revela rapidamente se memória está realmente vazando ou se é apenas comportamento normal de coleta geracional.
O conhecimento de como GC funciona não é pedantismo acadêmico - é diferença entre aplicação que escala elegantemente versus aplicação que "mysteriously" consome memória indefinidamente.
Insights & Takeaways
Garbage collector generacional coleta frequentemente Gen 0 (objetos jovens, rápido) e raramente Gen 2 (objetos antigos, lento), otimizando para realidade empírica que objetos jovens morrem rápido enquanto objetos velhos persistem.
As três fases do GC (Mark encontra objetos vivos, Sweep remove mortos, Compact move vivos juntos) permitem alocação de pointer-increment O(1) ao invés de busca em gaps como C malloc.
Vazamentos de memória raramente são falha do GC mas sim falha de aplicação: static event handlers não unsubscribed, caches sem limite, IDisposable não implementado - todas falhas de lógica, não falhas de coleta.
GC.SuppressFinalize é apenas necessário em classes que possuem ambos IDisposable implementado E finalizer (~) - cenário raro; chamar sem finalizer é noop inútil.
GC.Collect() deve ser evitado em código de produção normal porque força pausa síncrona enquanto GC é otimizado para rodar quando quiser; válido apenas após cleanup massivo one-time ou diagnóstico.


