AAAlison AmorimBlog
alisonamorim.com ↗

Performance

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.

  • 6 min de leitura
  • Performance
Fonte do texto
Aspirador de pó neon avança numa estrada synthwave sugando crânios e entulho, como o GC recolhendo objetos mortos
Neste artigo · 11 seções
  1. Introdução
  2. O Modelo Generacional: Por que Existe
  3. Ciclo de Vida de um Objeto: Desde Alocação até Destruição
  4. Ciclo de Vida Completo - Mermaid Diagram
  5. Erros Comuns Que Cada Desenvolvedor Comete
  6. GC.SuppressFinalize: O Misconception Crítico
  7. Diferenciando Vazamento Real vs Pico Normal de Memória
  8. Problema vs Solução de Memory Leak - Mermaid
  9. Estratégias Práticas Para Prevenir Memory Leaks
  10. Conclusão
  11. 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

SIM NÃO SIM NÃO SIM NÃO Alocaçãovar obj = new MyClassGen 0 Objeto Criadorefcount implícito = vivo Gen 0 Fica CheiaTrigger: ~256 KB alocado Mark PhaseIdentifica objetos vivosSegue referências de roots Sweep PhaseMarca mortos como reciclávelObjetos vivos ainda em Gen 0 Compact PhaseMove vivos para cimaElimina fragmentação Sobreviveu? Promoção a Gen 1Próxima coleta Gen 1 DestruídoMemória Reciclada Sobrevive Gen 1? Promoção a Gen 2Coleta rara Gen 2Permanece até sair de escopo Com Finalizer? Adicionado a Finalization QueueEspera thread finalizer Coletado imediatamente Thread Finalizer Executa~ClassName chamado

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:

  1. IDisposable implementado
  2. 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:

  1. Você cria objeto e chama Dispose()
  2. Dispose libera memória nativa com Marshal.FreeHGlobal()
  3. Objeto é coletado por GC
  4. GC vê que tem finalizer e o executa
  5. Finalizer tenta liberar memória que já foi liberada
  6. Crash ou comportamento undefined

Com SuppressFinalize:

  1. Você cria objeto e chama Dispose()
  2. Dispose libera memória nativa
  3. SuppressFinalize diz ao GC: "não execute finalizer, tudo foi limpo"
  4. Quando objeto é coletado, finalizer não roda
  5. 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

❌ PROBLEMA: Static Event Subscreve sem unsubscribe Static event é root do GC Objeto nunca é coletado Memory Leak ✅ SOLUÇÃO: Unsubscribe Implementar IDisposable Unsubscribe em Dispose Objeto pode ser coletado Sem leak! ❌ PROBLEMA: Cache sem limite Dicionário static cresce Objetos nunca removidos ✅ SOLUÇÃO: Cache com limite Implementar MAX_SIZE Remove old entries

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.

Comente no LinkedIn

A conversa sobre este artigo acontece no LinkedIn, onde ele também foi publicado. Discordou de algo, viu de outro jeito em produção? Conta lá.

Leia também