Garbage collector em C#: o que é e como funciona

O garbage collector do .NET é o gerenciador automático de memória que libera os objetos que o seu código C# deixou de usar. Por isso, o gerenciamento de memória em C# passa despercebido até o dia em que a aplicação consome memória demais ou apresenta pausas sob carga.

Este artigo explica o funcionamento do coletor para orientar decisões de código. Ou seja, o objetivo não é ajustar o coletor manualmente, e sim escrever código que colabora com ele.

Os comportamentos descritos aqui valem para o .NET 10, a versão LTS mais recente da plataforma.

O que é o garbage collector

No common language runtime (CLR), o garbage collector atua como gerenciador automático de memória. Ele controla tanto a alocação quanto a liberação de memória dos objetos gerenciados.

Com isso, o garbage collector elimina dois problemas clássicos: esquecer de liberar um objeto e acessar a memória de um objeto já liberado. Além disso, todo objeto novo nasce com a memória zerada.

A diferença entre pilha e heap

A pilha, ou stack, funciona por quadros. Cada chamada de método cria um quadro com os valores locais, e o runtime descarta o quadro inteiro quando o método termina.

Essa simplicidade vem de uma regra rígida: o que entra por último sai primeiro, e nada sobrevive à saída do método.

O heap, por outro lado, não tem essa garantia. Vários pontos do programa podem referenciar o mesmo objeto, que pode viver muito além do método que o criou.

É essa liberdade que torna a liberação difícil. Afinal, alguém precisa decidir quando um objeto do heap pode desaparecer, e esse alguém é o garbage collector.

Por que liberar memória no heap é um problema

Imagine um modelo de domínio chamado Customer instanciado uma única vez no heap.

Três classes guardam referências para essa instância, e o ponto de entrada guarda referências para as três classes. Nesse cenário, perguntar se Customer pode ser destruído não tem resposta local.

A pergunta correta, portanto, é se o objeto ainda é alcançável a partir de algum ponto vivo do programa.

Responder isso exige percorrer um grafo com milhões de arestas. De fato, a engenharia do garbage collector está em não precisar percorrer o grafo inteiro toda vez.

Raízes e alcançabilidade no garbage collector

O coletor não começa pelos objetos. Em vez disso, ele começa pelas raízes.

Uma raiz é qualquer referência que o runtime sabe estar viva independentemente de qualquer outro objeto. As principais são as variáveis na pilha reportadas pelo compilador JIT, os handles gerenciados pelo runtime e os dados estáticos.

A partir das raízes, o garbage collector percorre o grafo e marca tudo o que consegue alcançar. Então, o que ficou sem marcação é lixo, por definição.

Repare que um objeto pode estar logicamente inútil e, ainda assim, continuar vivo. Basta uma referência esquecida.

As três gerações do garbage collector

Percorrer o grafo inteiro a cada coleta seria proibitivo. Por isso, o .NET divide o heap gerenciado em três gerações e coleta apenas parte dele na maioria das vezes.

A geração 0 guarda objetos recém-criados, como uma variável temporária. A geração 1 funciona como amortecedor e guarda quem sobreviveu a uma coleta de geração 0.

Já a geração 2 guarda os objetos de vida longa, como caches, singletons e dados estáticos.

Essa divisão se apoia em três observações da Microsoft. Compactar parte do heap é mais rápido, objetos novos tendem a morrer cedo e objetos criados juntos tendem a ser usados juntos.

Coletar uma geração inclui as mais novas. Assim, a coleta de geração 2 recolhe o heap inteiro e recebe o nome de coleta completa.

Promoção e sobrevivência

Na coleta de geração 0, os objetos inalcançáveis desaparecem e o garbage collector promove os sobreviventes para a geração 1. Na coleta de geração 1, os sobreviventes sobem para a geração 2, onde permanecem.

Há um detalhe pouco conhecido. O código de usuário só aloca na geração 0 ou no large object heap, porque apenas o coletor povoa as gerações 1 e 2.

Você pode observar a promoção com GC.GetGeneration.

var order = new object();

// Recém-criado, o objeto nasce na geração 0
Console.WriteLine($"Generation: {GC.GetGeneration(order)}");

GC.Collect();
// Sobreviveu a uma coleta, então foi promovido
Console.WriteLine($"Generation: {GC.GetGeneration(order)}");

GC.Collect();
Console.WriteLine($"Generation: {GC.GetGeneration(order)}");

// Mantém a referência viva até aqui
GC.KeepAlive(order);

Esse código serve apenas para estudo, porque chamar GC.Collect em produção quase sempre é um erro.

O orçamento dinâmico

Cada geração tem um limite de alocação, chamado de orçamento. Quando as alocações excedem esse orçamento, o runtime dispara uma coleta.

Esses limites também mudam o tempo todo, conforme o comportamento da aplicação.

Quando o garbage collector detecta uma taxa alta de sobrevivência em uma geração, ele aumenta o limite de alocação daquela geração. Dessa forma, a próxima coleta recupera memória suficiente para justificar o custo.

O CLR equilibra o tempo todo duas pressões opostas. Coletar cedo demais desperdiça CPU, enquanto coletar tarde demais deixa o working set crescer além do necessário.

As três fases de uma coleta

Primeiro vem a marcação, que monta a lista de objetos vivos. Em seguida vem a realocação, que atualiza as referências dos objetos que vão mudar de lugar.

Por fim, a compactação recupera o espaço dos mortos e agrupa os sobreviventes.

Ela é necessária porque objetos têm tamanhos variáveis, e a remoção deixa lacunas irregulares. Sem compactação, uma alocação grande pode falhar mesmo havendo memória livre espalhada em pedaços pequenos.

Vale notar que o coletor só compacta quando encontra uma quantidade significativa de objetos inalcançáveis.

Por que os endereços precisam ser atualizados

Mover um objeto faz qualquer referência antiga apontar para o lugar errado. Por isso, a realocação atualiza cada referência para o novo endereço, sem que o seu código perceba.

Antes da coleta, o runtime suspende as threads gerenciadas, exceto a que disparou a operação. Threads em código nativo só param quando a chamada nativa retorna.

Por que a geração 2 é cara

Uma coleta de geração 0 encontra muitos mortos e poucos sobreviventes, então há pouco a mover. Já a geração 2 percorre todo o heap, com muitos sobreviventes e muitas referências a corrigir.

Portanto, o mesmo objeto pode ser barato ou caro para o garbage collector dependendo apenas de quanto tempo vive.

Isso não proíbe objetos de vida longa, já que caches e singletons são legítimos. A orientação é perguntar se o objeto realmente precisa sobreviver ao escopo atual.

O large object heap

Objetos com 85.000 bytes ou mais vão direto para o large object heap, o LOH. Esse valor veio de medições de performance, e não de convenção.

O LOH às vezes leva o nome de geração 3, mas o garbage collector o coleta junto com a geração 2. Ou seja, um objeto grande só desaparece em uma coleta completa.

Além disso, alocar um objeto grande já é caro, porque o CLR precisa zerar toda a memória nova.

O coletor também não compacta o LOH por padrão, já que mover objetos grandes custa caro. Ele apenas monta uma lista de blocos livres para reaproveitamento.

É possível forçar a compactação do LOH através de GCSettings.LargeObjectHeapCompactionMode, mas isso é uma medida de exceção. Em containers com limite de memória definido, o runtime já compacta o LOH automaticamente.

A documentação do ASP.NET Core dá um exemplo. Ler o corpo inteiro de uma requisição para um único byte[] ou string provoca coletas de geração 2 constantes e derruba o throughput.

Por esse motivo, o middleware de cache de resposta quebra suas entradas em blocos menores que o limite.

Quando o garbage collector executa

Não existe intervalo fixo. Em vez disso, três condições disparam cada execução.

A primeira é a pressão de alocação. Quando as alocações ultrapassam o limite da geração 0 ou do LOH, o runtime dispara uma coleta naquela geração.

A segunda é a pressão de memória no sistema. Quando o sistema operacional sinaliza pouca memória disponível, o garbage collector avalia se uma coleta de geração 2 seria produtiva.

A terceira é a chamada explícita a GC.Collect. Essa é a única das três que está sob controle direto do desenvolvedor e, justamente por isso, é a que não deveria ser usada.

Workstation, server e DATAS no garbage collector

O modo workstation é o padrão para aplicações standalone e o único possível com uma CPU lógica. Nele, a coleta acontece na própria thread que a disparou.

O modo server, por outro lado, mantém um heap e uma thread por CPU lógica, com coletas em paralelo. Ele é mais rápido, porém consome mais recursos, então o workstation pode ser melhor com muitos processos na mesma máquina.

A partir do .NET 9, entrou em cena um terceiro comportamento, habilitado por padrão. O DATAS, sigla para dynamic adaptation to application sizes, ajusta o tamanho do heap conforme o volume de dados de vida longa da aplicação.

Ele começa com um heap e aumenta ou reduz a quantidade conforme a demanda, como um híbrido entre os dois modos. Nos benchmarks publicados pela Microsoft, a redução de working set passou de 80%, com queda de throughput na casa de 2% a 3%.

Você configura o garbage collector no arquivo de projeto.

<PropertyGroup>
  <!-- Ativa o modo server -->
  <ServerGarbageCollection>true</ServerGarbageCollection>
  <!-- Desativa o DATAS caso o throughput caia -->
  <GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>
</PropertyGroup>

Só altere esses valores depois de medir. Para quem quer acompanhar a evolução mais recente, o artigo sobre as mudanças no coletor do .NET 10 complementa esta leitura.

Por que não chamar GC.Collect

A tentação de chamar GC.Collect surge quando o consumo de memória sobe. Contudo, essa decisão traz três problemas.

O primeiro é conceitual: coleta de lixo não resolve vazamento. Se existe uma referência viva para um objeto desnecessário, o garbage collector acerta ao mantê-lo.

O caso mais comum é uma coleção estática que recebe itens e nunca os remove. Nenhuma coleta forçada libera essa memória.

O segundo é de performance. Sem parâmetros, GC.Collect executa uma coleta bloqueante de todas as gerações.

Pior ainda, objetos da geração 0 que morreriam de forma barata sobrevivem e sobem para a geração 1 ou 2. Assim, você encarece o descarte deles e aumenta o tempo que ficam em memória.

O terceiro problema é competir com heurísticas melhores que a sua, baseadas em taxas de sobrevivência e orçamentos dinâmicos.

Por isso, a Microsoft afirma que aplicações ASP.NET Core em produção não devem chamar GC.Collect. O Visual Studio, inclusive, alerta quando detecta coletas induzidas em excesso.

As exceções são raras, como o fim de uma tela de carregamento em um jogo. Mesmo nesse caso, use GCCollectionMode.Optimized, que permite ao coletor ignorar o pedido.

// Sugere uma coleta, mas deixa o coletor decidir se vale a pena
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);

Em diagnóstico, forçar uma coleta antes de medir também ajuda a separar lixo pendente de objeto vivo. Ainda assim, nada justifica GC.Collect em caminho quente de produção.

Como ajudar o garbage collector a trabalhar menos

A resposta é alocar menos. Quanto menos objetos você cria, menos trabalho o garbage collector precisa fazer.

Mantenha objetos abaixo do limite do LOH e reutilize buffers com ArrayPool<T>.

using System.Buffers;

// Aluga um buffer do pool em vez de alocar um array novo
byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
    int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 64 * 1024));
    ProcessChunk(buffer.AsSpan(0, bytesRead));
}
finally
{
    // Devolve o buffer para que outra chamada possa reutilizá-lo
    ArrayPool<byte>.Shared.Return(buffer);
}

Prefira também Span<T> e Memory<T> para fatiar dados existentes em vez de copiá-los para novos arrays. O artigo sobre normalização de strings com Span mostra essa técnica na prática.

Libere referências assim que o objeto deixar de ser necessário, especialmente em campos de longa duração e coleções estáticas.

Implemente e chame Dispose, porque o coletor não gerencia memória nativa, como handles de arquivo, conexões e sockets.

Para cenários de cache, considere WeakReference, que permite ao coletor recolher o objeto sob pressão de memória. O tema de cache aparece com mais detalhes no artigo sobre cache em memória no ASP.NET.

Por fim, meça antes de mudar com dotnet-counters, dotnet-trace e dotnet-dump. O guia oficial Fundamentals of garbage collection aprofunda cada ponto.

O que ficou de fora

Este artigo trouxe uma visão simplificada do garbage collector. Por exemplo, um objeto de geração 2 pode referenciar um de geração 0, e a coleta de geração 0 precisa saber disso sem varrer o heap.

A solução usa card tables, mantidas por barreiras de escrita que o runtime insere.

Há ainda a coleta em background, as configurações de latência, os limites rígidos de heap para containers e a troca de segmentos por regiões, introduzida a partir do .NET 7.

A boa notícia é que você não precisa gerenciar nada disso. Essa é justamente a proposta de uma plataforma gerenciada.

Conclusão

O garbage collector do .NET é um sistema maduro, auto ajustável e projetado para funcionar bem sem intervenção.

Entender gerações, promoção e compactação serve para escrever código que não trabalhe contra ele.

As três ideias que valem a pena reter são diretas. Objetos de vida curta são baratos, objetos grandes e de vida longa são caros, e forçar coleta quase sempre piora o cenário que pretendia melhorar.

Portanto, foque em alocar menos, soltar referências no momento certo e medir antes de otimizar. Dessa forma, o garbage collector continua fazendo o trabalho dele sem que você perceba.

FAQ

O garbage collector resolve vazamento de memória?

Não, porque o vazamento em código gerenciado acontece quando existe uma referência viva para um objeto desnecessário. A correção passa por remover essa referência.

Com que frequência o coletor executa?

Não há intervalo fixo. Ele executa quando as alocações ultrapassam o orçamento de uma geração, quando o sistema sinaliza pouca memória ou quando o código chama GC.Collect.

Todo objeto começa na geração 0?

Não. Objetos com 85.000 bytes ou mais vão direto para o large object heap, que o coletor recolhe junto com a geração 2.

Existe algum caso em que chamar GC.Collect é aceitável?

Sim, mas são raros, como diagnóstico de vazamento ou o fim de uma tela de carregamento. Mesmo assim, prefira GCCollectionMode.Optimized e nunca use em caminho quente de produção.

Qual a diferença entre modo workstation e modo server?

O workstation coleta na thread que disparou a operação e é o padrão para aplicações standalone. Já o server mantém um heap e uma thread por CPU lógica e prioriza throughput.

O que muda com o DATAS?

O DATAS ajusta o número de heaps e o tamanho do heap conforme o volume de dados de vida longa. Ele surgiu como opcional no .NET 8 e virou padrão no .NET 9.

Preciso me preocupar com isso em uma aplicação pequena?

Provavelmente não, porque o comportamento padrão atende bem a maioria dos cenários. A preocupação com alocação faz diferença real em aplicações de alta carga ou com pouca memória.