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.